From b577ff0112bef00f96da724048abd1f97050da0e Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 22:46:03 -0400 Subject: [PATCH] feat(beehive): edit public metadata and deliberately publish with agent identity Signed-off-by: Logan Johnson --- beehive/PUBLIC_METADATA.md | 96 +++++++++++++++ beehive/README.md | 4 + beehive/TESTING.md | 15 +++ beehive/src/catalog-transport.ts | 2 +- beehive/src/cli.ts | 56 ++++++++- beehive/src/host-transport.ts | 2 +- beehive/src/host.ts | 57 +++++++-- beehive/src/intents.ts | 8 +- beehive/src/metadata-drafts.ts | 75 ++++++++++++ beehive/src/nostr-codec.ts | 2 +- beehive/src/protocol.ts | 4 +- beehive/src/public-metadata.ts | 83 +++++++++++++ beehive/test/nostr-fixture.ts | 5 +- beehive/test/public-metadata.test.ts | 172 +++++++++++++++++++++++++++ 14 files changed, 559 insertions(+), 22 deletions(-) create mode 100644 beehive/PUBLIC_METADATA.md create mode 100644 beehive/src/metadata-drafts.ts create mode 100644 beehive/src/public-metadata.ts create mode 100644 beehive/test/public-metadata.test.ts diff --git a/beehive/PUBLIC_METADATA.md b/beehive/PUBLIC_METADATA.md new file mode 100644 index 0000000000..14dc92f8b4 --- /dev/null +++ b/beehive/PUBLIC_METADATA.md @@ -0,0 +1,96 @@ +# Public agent metadata (separate from private instructions) + +Public kind0 contains **only** `display_name`, `picture`, `about`. Do not put +behavior instructions, private inventory, provider details or credentials in +these fields. Empty fields clear the previous public value; `about` is at most +280 Unicode characters. Picture is an HTTPS URL, not an upload operation. + +## Edit without a host, network or key + +Run `beehive drafts ` (the directory containing +the owner TUI identity reference). Use `metadata-new`, +`metadata-edit `, `metadata-discard `, `metadata-drafts`, `quit`. +Discard only removes a local draft, never a submitted operation or public event. Enter the exact retained +relay URL and agent **hex public key**, then the three public fields. The final +valid about line + Enter atomically saves the whole form. Partial forms are not +saved; closing mid-form retains the previous saved version. Blank input clears a +field, not “keep previous.” Resume prints the saved values before prompting. + +Drafts live in `metadata-drafts/drafts.json` (owner-only 0700/0600) under owner +control state, **not on the current host**. The separate `profile-drafts` store +and immutable private instruction library are untouched. Concurrent stale edits +fail instead of overwriting another save. There are at most 100 drafts. The +short directory edit lock is the same mechanism as instruction drafts: after a +crash, confirm no live editor before locally removing a leftover `edit.lock`. + +## Deliberate publication while the model is stopped + +In the private owner TUI, the same draft commands work. Select the exact agent +on its currently assigned eligible host, then `metadata-publish `. +Review the complete PUBLIC payload and confirm `yes`. No Start, Restart, ACP +executable, model or provider readiness is required. Stale/unreachable host +inventory prevents submission and retains the draft. Host disappearance after +submission leaves an owner journal operation pending/unknown. + +The private management command is owner-signed and encrypted. The host consumer +checks the signer (transport), host/agent/relay binding, retained assignment, +operation identity, host CAS revision and exact metadata hash before reading the +selected agent credential. The host key is infrastructure, never the public +profile signer. Only the existing agent key signs both real kind0 and the NIP98 +request authentication for the relay's supported `POST /events` and `/query`. +The retained conversation relay must equal the management relay, and a valid +**existing** agent-owner NIP-OA association must be present in that local +conversation binding. Its signature, owner, agent and conditions are checked; +no attestation is created, copied from another agent, or repaired. Conditions +restricted to a single kind cannot authorize both kind0 and kind27235 and are +refused. Missing association/key, removed key or denied credential access is an +explicit recoverable error; no key regeneration or owner-key access occurs. + +## Draft, pending, published, failed/unknown + +`metadata-drafts` lists saved desired values and matching submitted-operation +statuses; `operations` shows all attempts. A saved draft is never evidence of +publication. The existing owner `management-intents` journal durably retains the +exact operation before sending. The host retains the exact agent-signed candidate +in its per-agent journal's `publicMetadata` field before submitting it. This is +an attempt record, **not a new editable agent definition**. + +Only a positive matching event ACK **and** a signature-verified latest kind0 +readback with that exact event ID produce `published; signed latest kind0 `. +A receipt records a historical observation; later external edits may supersede +it. The published representation lives on the community relay as the latest +kind0 by the agent pubkey. Public readback never replaces the owner's draft. + +`reconcile` reopens management transport and retrieves retained receipts. Duplicate +operations never repeat the public side effect, including after a host crash. +If interruption loses the terminal observation, status remains unknown; this +bounded slice does not automatically republish or turn queue persistence into +success. Check the desired draft, wait for fresh host state, then deliberately +submit a new publish attempt if needed. A same-second retained signed attempt or same-second/future-dated latest kind0 +is refused rather than inventing a timestamp or gambling on Nostr event-ID tie +ordering; wait until its timestamp is past before retrying. Stale host revisions +and old operation IDs cannot overwrite a newer accepted edit. ACK refusal, +readback mismatch and network failures remain visible, not silently retried. + +Publication changes no selected instructions/configuration, actual model run or +sibling agent profile. It consumes the host operation revision on success, like +other serialized owner actions; use refreshed inventory for the next action. +No generic Move, key recovery, membership, invite or provider subsystem is added. + +## Source and validation scope + +Native contract reference is immutable Buzz `051c3a270be9c73da9ab06700bcab7d5552fceaa`: +Desktop `relay.rs` `build_profile_event`, `sync_managed_agent_profile`, +`query_relay_at_with_keys`, `query_agent_profile`; `commands/agents_profile.rs`; +SDK `nip_oa.rs`; relay `api/bridge.rs`. At this pin Desktop's managed-agent +builder/query actually use name/picture, not about. Beehive adds the requested +explicit about field through the supported kind0 JSON contract, not through +behavior text. + +`test/public-metadata.test.ts` drives the real offline CLI, private management +consumer and signature-enforcing loopback HTTP consumer with synthetic keys and +an explicitly isolated credential adapter. It checks stopped publication with +nonexistent ACP/provider executables, negative ACK/readback, wrong owner/relay, +malformed public payload, stale revision, missing key and timestamp ordering. +This is source-shaped relay evidence, not a production relay, OS credential, +installed-native or live-person profile publication claim. diff --git a/beehive/README.md b/beehive/README.md index c3e80c5e61..165fa49735 100644 --- a/beehive/README.md +++ b/beehive/README.md @@ -857,3 +857,7 @@ Restart/Move prerequisite evidence is sealed after probe teardown before old act teardown. Installed acceptance uses real Buzz transport/CLI with source-shaped TypeScript provider fixtures, not live vendor authentication or inference. See CHECKPOINT's MODEL_PROVIDER_CORRECTION entry for validation and observation limits. + +Public identity editing/publication is separate from private instructions: see +[PUBLIC_METADATA.md](PUBLIC_METADATA.md) for offline metadata drafts, deliberate +agent-signed kind0 publication while stopped, and pending/error recovery. diff --git a/beehive/TESTING.md b/beehive/TESTING.md index 8f19f15167..d7814cb8d8 100644 --- a/beehive/TESTING.md +++ b/beehive/TESTING.md @@ -671,3 +671,18 @@ These are synthetic local credential/relay/provider fixtures using installed binaries, not live OS credential, community admission, policy acceptance or vendor readiness evidence. Authority foundation 91d2246 received a scoped independent no-blocker review; it must not be represented as review of this later setup slice. + +## Deliberate public metadata + +`public-metadata.test.ts` uses the actual offline draft CLI and private host +management consumer plus a NIP98/kind0 signature-enforcing loopback HTTP relay. +The isolated selected-agent credential adapter is explicit (not HOME isolation). +ACP/provider executables do not exist: publication succeeds while stopped, with +private selected instructions and sibling state preserved. Controls cover foreign +owner, wrong relay, private fields, retained assignment, invalid owner association, +stale revision, duplicate ID, removed key, negative ACK, missing latest readback, +signed timestamp conflicts and host reopen without automatic public side effects. +The fixture reuses the private NIP42/59 relay via an optional HTTP handler; it does +not change that relay's existing default admission behavior. No production relay, +OS credential or installed-native public metadata evidence is claimed. +See PUBLIC_METADATA.md for the editing and explicit publication path and limits. diff --git a/beehive/src/catalog-transport.ts b/beehive/src/catalog-transport.ts index 51777c2ab5..a63e8476ad 100644 --- a/beehive/src/catalog-transport.ts +++ b/beehive/src/catalog-transport.ts @@ -40,7 +40,7 @@ export function catalogTransport(catalog: HostCatalog) { void wire.publish({ ...m, body: { relay: url, profile: m.body } }, retained.owner).catch(() => { if (!closed && current === generation) disconnected?.(1008); }); return; } - if (!['inspect', 'save', 'start', 'restart', 'stop'].includes(m.type)) throw Error('Operation not integrated with private host transport'); + if (!['metadata', 'inspect', 'save', 'start', 'restart', 'stop'].includes(m.type)) throw Error('Operation not integrated with private host transport'); const host = catalogHost(retained, m.host, Math.floor(Date.now() / 1000), offers); const current = generation; void wire.publish(m, host.request.host).catch(() => { if (!closed && current === generation) disconnected?.(1008); }); diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index b6aaebcfb0..eea64869e2 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -1,3 +1,5 @@ +import { metadataDrafts, editMetadataDraft } from './metadata-drafts.ts'; +import { metadataRevision } from './public-metadata.ts'; import { setupPath } from './setup-path.ts'; import { randomUUID } from 'node:crypto'; import { credentialHelperReader } from './credential-helper.ts'; @@ -69,7 +71,7 @@ Command: beehive from any directory once installed (one-time user link host --owner-present Default host and retained configured relay host --owner-present Private foreground host on the default host folder; bounded OS key reads host --owner-present Private foreground host on an explicit folder; bounded OS key reads - drafts Offline instruction drafts; no signer/network; same state directory as TUI + drafts Offline public metadata + instruction drafts; no signer/network; same state directory as TUI tui discover Private availability discovery; no approval file tui [relay] Private owner UI; hidden existing owner signer setup/add-agent accept optional for standby import. @@ -80,13 +82,27 @@ Move is fixture-only experimental; containment acceptance remains gated. No prov async function main() { if (command === 'drafts') { const drafts = profileDrafts(setupPath(text(args[0]))); + const metadata = metadataDrafts(setupPath(text(args[0]))); const ui = createInterface({ input: stdin, output: stdout }); - console.log('Offline instruction drafts. Commands: drafts, profile-new, profile-resume , profile-discard , quit. Saved after a complete valid instructions line (Enter), not per keystroke. No publication, signer, host or model needed.'); + console.log('Offline instruction drafts. Commands: metadata-drafts, metadata-new, metadata-edit , metadata-discard , drafts, profile-new, profile-resume , profile-discard , quit. Saved after a complete valid instructions line (Enter), not per keystroke. No publication, signer, host or model needed.'); try { for (;;) { const line = (await ui.question('drafts> ')).trim(); if (line === 'quit') break; try { + if (line === 'metadata-drafts') { for (const d of metadata.list()) console.log(`${d.id} | draft | ${d.relay} | ${d.agent} | ${JSON.stringify(d.value)}`); continue; } + if (line.startsWith('metadata-discard ')) { + const previous = metadata.list().find(d => d.id === line.split(' ')[1]); + if (!previous) throw Error('Unknown metadata draft'); + if (await ui.question('Discard local metadata draft only (publications remain)? [yes/no]: ') === 'yes') metadata.discard(previous); + continue; + } + if (line === 'metadata-new' || line.startsWith('metadata-edit ')) { + const previous = metadata.list().find(d => d.id === line.split(' ')[1]); + if (line !== 'metadata-new' && !previous) throw Error('Unknown metadata draft'); + const saved = await editMetadataDraft(metadata, prompt => ui.question(prompt), previous); + console.log(`Saved public metadata draft ${saved.id}; publication is separate: metadata-publish ${saved.id}`); continue; + } if (line === 'drafts') { for (const d of drafts.list()) console.log(`${d.id} | saved draft | ${d.value.name} | ${d.value.instructions}`); continue; } const previous = drafts.list().find(d => d.id === line.split(' ')[1]); if (line.startsWith('profile-discard ')) { @@ -413,6 +429,7 @@ async function main() { const offers = new Map(); const hostFresh = (host: string) => !catalog || (offers.has(host) && Date.now() - Number(offers.get(host)!.body.observedAt) <= 6000) || catalog.registrations.some(r => r.request.host === host && Date.now() < r.expires * 1000); const drafts = profileDrafts(dirname(setupPath(text(args[0])))); + const metadata = metadataDrafts(dirname(setupPath(text(args[0])))); const profiles = new Profiles(); const client = managementClient(join(dirname(setupPath(text(args[0]))), 'management-intents'),text(args[1]),secret,m => { profiles.receive(m); @@ -432,7 +449,7 @@ async function main() { }, catalog ? { catalog } : undefined); try { await client.ready; } catch (error) { client.close(); throw error; } const ui = createInterface({ input: stdin, output: stdout }); - console.log('Beehive | Hosts → assigned agent → selected-next / actual run\nCommands: binding , configurations, config-new, config-select , config-rename, config-remove , profiles, drafts, profile-new, profile-edit , profile-resume , profile-discard , apply , operations, reconcile, retry , hosts, agents, select , show, save, start, restart, stop, move, quit. Closing this UI does not stop hosts.'); + console.log('Beehive | Hosts → assigned agent → selected-next / actual run\nCommands: metadata-drafts, metadata-new, metadata-edit , metadata-discard , metadata-publish , binding , configurations, config-new, config-select , config-rename, config-remove , profiles, drafts, profile-new, profile-edit , profile-resume , profile-discard , apply , operations, reconcile, retry , hosts, agents, select , show, save, start, restart, stop, move, quit. Closing this UI does not stop hosts.'); let selected = ''; let profileRows: Profile[] = []; try { @@ -482,6 +499,39 @@ async function main() { for (const p of profiles.incomplete()) console.log(`INCOMPLETE ${p.name} @ ${p.revision.slice(0, 12)}: missing/conflicting lineage; cannot apply`); console.log('Immutable versions; concurrent edits remain branches. Publishing never applies to agents. No destructive deletion; apply default explicitly to clear.'); continue; } + try { + if (line === 'metadata-drafts') { + for (const d of metadata.list()) { + const operations = client.status().filter(s => s.request.type === 'metadata' && s.request.agent === d.agent && s.request.body.relay === d.relay && s.request.body.revision === metadataRevision(d.value)); + console.log(`${d.id} | draft | ${d.relay} | ${d.agent} | ${JSON.stringify(d.value)}`); + for (const op of operations) console.log(` ${op.request.id} | ${op.state} | ${op.result ?? 'pending/unknown; no signed readback yet'}`); + } + continue; + } + if (line.startsWith('metadata-discard ')) { + const previous = metadata.list().find(d => d.id === line.split(' ')[1]); + if (!previous) throw Error('Unknown metadata draft'); + if (await ui.question('Discard local metadata draft only (publications remain)? [yes/no]: ') === 'yes') metadata.discard(previous); + continue; + } + if (line === 'metadata-new' || line.startsWith('metadata-edit ')) { + const previous = metadata.list().find(d => d.id === line.split(' ')[1]); + if (line !== 'metadata-new' && !previous) throw Error('Unknown metadata draft'); + const saved = await editMetadataDraft(metadata, prompt => ui.question(prompt), previous); + console.log(`Saved public metadata draft ${saved.id}; publication is separate: metadata-publish ${saved.id}`); continue; + } + if (line.startsWith('metadata-publish ')) { + if (!catalog) throw Error('Metadata publication requires private owner management'); + const d = metadata.list().find(d => d.id === line.split(' ')[1]); + const current = inventory.get(selected); + if (!d || !current || current.agent !== d.agent || d.relay !== text(args[1])) throw Error('Select the exact agent on an eligible host for this draft relay'); + if (current.body.assignedHost !== current.host || Date.now() - Number(current.body.observedAt) > 6000 || !hostFresh(current.host)) throw Error('Host stale/not assigned; draft retained; no publication sent'); + console.log(`PUBLIC kind0: ${JSON.stringify(d.value)}. Agent ${d.agent} signs; private instructions and runtime remain unchanged.`); + if (await ui.question('Deliberately publish this public metadata? [yes/no]: ') !== 'yes') continue; + client.submit(message('metadata', current.host, d.agent, current.revision, { relay: d.relay, value: d.value, revision: metadataRevision(d.value) })); + console.log('Metadata pending/unknown until signed latest readback receipt. Use operations/reconcile; draft is retained.'); continue; + } + } catch (error) { console.log(error instanceof Error ? error.message : 'Metadata action failed'); continue; } if (line === 'drafts') { for (const d of drafts.list()) { const op = client.status().find(o => o.request.type === 'profile' && o.request.body.revision === d.value.revision); diff --git a/beehive/src/host-transport.ts b/beehive/src/host-transport.ts index a817cc0114..2321d10b1a 100644 --- a/beehive/src/host-transport.ts +++ b/beehive/src/host-transport.ts @@ -19,7 +19,7 @@ export function privateHostTransport(configuration: HostPairing | HostRegistrati this.validate(url); const wire = connectNostr(url, Buffer.from(secret, 'hex'), input => { const m = input.message; - if (input.sender !== retained.request.owner || m.host !== retained.request.host || !['inspect', 'save', 'start', 'restart', 'stop'].includes(m.type)) return; + if (input.sender !== retained.request.owner || m.host !== retained.request.host || !['metadata', 'inspect', 'save', 'start', 'restart', 'stop'].includes(m.type)) return; receive(m); }, () => {}); // Existing durable host outbox survives; no automatic launch retry. return { diff --git a/beehive/src/host.ts b/beehive/src/host.ts index 3a5f3061e5..f1d0e2f773 100644 --- a/beehive/src/host.ts +++ b/beehive/src/host.ts @@ -1,3 +1,5 @@ +import { publicMetadata, metadataRevision, metadataAuthorization, publishPublicMetadata } from './public-metadata.ts'; +import { readCredentialManifest } from './credential-slots.ts'; import { systemCredentials, type CredentialBackend } from './credential-store.ts'; import { trace } from './latency-trace.ts'; import { validateBuzzProvider, type BuzzProvider } from './buzz-provider.ts'; @@ -28,7 +30,7 @@ export function bindingFingerprint(setup: Setup): string { } type Selection = import('./handoff.ts').Selection; type ActualRun = { harnessSetup?: { id: string; fingerprint: string }; appliedInstructions?: { source: 'profile' | 'upstream-default'; revision: string | null; hash: string | null }; selection: Selection; executableHash: string; run: string; evidence?: Evidence; preparedInputHash?: string }; -type State = { configurations?: Configurations; runs?: Record; assignment: Assignment; move?: { request: Message; prepare: Message }; preparations?: Record; incoming?: Record; binding: { host: string; owner: string; agent: string }; revision: number; phase: 'stopped' | 'transitioning' | 'running' | 'quarantined'; selected: Selection; actual: null | ActualRun; operations: Record; outbox: Message[] }; +type State = { publicMetadata?: { operation: string; revision: string; event: import('nostr-tools/pure').Event }; configurations?: Configurations; runs?: Record; assignment: Assignment; move?: { request: Message; prepare: Message }; preparations?: Record; incoming?: Record; binding: { host: string; owner: string; agent: string }; revision: number; phase: 'stopped' | 'transitioning' | 'running' | 'quarantined'; selected: Selection; actual: null | ActualRun; operations: Record; outbox: Message[] }; /** Resolve owner authority without requiring a private owner identity on a host. * Legacy diagnostic manifests remain readable; contradictory identities refuse. */ export function setupOwner(setup: Pick): string { @@ -115,7 +117,7 @@ export function loadSlotState(setup: Setup, path: string, agent: string): State return state; } /** Hosts consult durable assignment, never key presence or relay inventory, for authority. */ -function slot(setup: Setup, path: string, agent: string, currentSetup: (id: string, signal: AbortSignal) => Promise, publish: (m: Message) => void, profiles: Profiles, setupId: string, bindings: Record, retiredBindings: Record = {}, privateSnapshots = false) { +function slot(setup: Setup, path: string, agent: string, currentSetup: (id: string, signal: AbortSignal) => Promise, publish: (m: Message) => void, profiles: Profiles, setupId: string, bindings: Record, retiredBindings: Record = {}, privateSnapshots = false, metadataCustody?: (signal: AbortSignal) => Promise<{ secret: string; relay: string; authTag: string }>, managementRelay?: string) { // Optional execution credential: a public-only slot (local key copy deliberately // removed) has none. There is no shadow identity and no reconstruction; execution // paths must load it explicitly and fail closed when absent. @@ -167,7 +169,7 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri function receive(m: Message) { trace('slot.receive', { id: m.id, type: m.type, revision: m.revision }); // Cancellation is signalled outside the serialized mutation queue. Admission is // still exact-authority/revision and never bypasses durable receipt processing. - const operation = m.host === setup.host && ['save','start','restart','stop','move'].includes(m.type); + const operation = m.host === setup.host && ['metadata','save','start','restart','stop','move'].includes(m.type); const first = operation && !queuedOperations.has(m.id); if (operation) queuedOperations.add(m.id); // Reserve receive order too: a conflicting queued ID is not a Stop authority. @@ -410,8 +412,8 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri } } async function handle(m: Message) { trace('slot.handle', { id: m.id, type: m.type, revision: m.revision }); - if (m.host === setup.host && ['save','start','restart','stop','move'].includes(m.type)) queuedOperations.delete(m.id); - if (closing || persistenceFailed || m.host !== setup.host || !['inspect','save','start','restart','stop','move','prepare','prepared','grant'].includes(m.type)) return; + if (m.host === setup.host && ['metadata','save','start','restart','stop','move'].includes(m.type)) queuedOperations.delete(m.id); + if (closing || persistenceFailed || m.host !== setup.host || !['metadata','inspect','save','start','restart','stop','move','prepare','prepared','grant'].includes(m.type)) return; if (['prepare','prepared','grant'].includes(m.type)) { await exchange(m); return; } // Serialized Stop processing durably resolves that Stop's own retraction. if (m.type === 'stop' || m.type === 'save') retracted.delete(m.id); @@ -436,7 +438,23 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri state.operations[m.id] = { fingerprint, reply: pending }; save(); try { if (state.move && m.type === 'stop') { fields(m.body, []); finishMove('Move cancelled before grant by stop'); } - if (m.type === 'move') { + if (m.type === 'metadata') { + fields(m.body, ['relay', 'value', 'revision']); + const value = publicMetadata(m.body.value); + if (!privateSnapshots || !metadataCustody || m.body.relay !== managementRelay || m.body.revision !== metadataRevision(value)) throw Error('Invalid private metadata authorization/binding'); + if (state.move) throw Error('Move reservation busy'); + if (state.publicMetadata && state.publicMetadata.event.created_at >= Math.floor(Date.now() / 1000)) throw Error('Retained kind0 attempt occupies this second; deliberately retry later'); + const revision = state.revision, assignment = hash(state.assignment); + const check = () => { if (closing || persistenceFailed || state.revision !== revision || hash(state.assignment) !== assignment || state.assignment.assignedHost !== setup.host) throw Error('Metadata authority invalidated'); }; + check(); + const controller = new AbortController(); credentialRead = controller; + let custody: Awaited>>; + try { custody = await metadataCustody(controller.signal); controller.signal.throwIfAborted(); } + finally { if (credentialRead === controller) credentialRead = undefined; } + check(); + if (custody.relay !== managementRelay) throw Error('Metadata relay differs from retained agent relay'); + result = await publishPublicMetadata({ ...custody, agent, owner: state.binding.owner, value, check, retain(event) { state.publicMetadata = { operation: m.id, revision: metadataRevision(value), event }; save(); } }); + } else if (m.type === 'move') { fields(m.body, ['target','targetRevision','selection']); const target = text(m.body.target); const destination = selection(m.body.selection); if (destination.profile !== state.selected.profile || hash(destination.behavior ?? null) !== hash(state.selected.behavior ?? null)) throw Error('Move must preserve source selected behavior'); @@ -597,7 +615,30 @@ export async function host(directory: string, url: string, signal?: AbortSignal, if (!value) throw Error('Binding removed'); if (current.retiredBindings?.[id]) throw Error('Binding retired'); return value; - }, publish, profiles, entry.setupId, entry.bindings, entry.retiredBindings, !!transport)); + }, publish, profiles, entry.setupId, entry.bindings, entry.retiredBindings, !!transport, async readSignal => { + // Public binding and existing OA checked before the one selected credential read. + const raw = object(readPrivate(join(directory, 'setup.json'))); + if (raw.version === 3) { + const manifest = readCredentialManifest(directory), snapshot = JSON.stringify(manifest); + const row = manifest.agents[entry.agent]; + if (!row || manifest.host !== hostKeyForMetadata() || manifest.ownerPublic !== setupOwner(entry.setup)) throw Error('Metadata custody binding changed'); + const conversation = manifest.conversation ?? manifest.setups[row.setup]?.conversation; + if (!conversation?.authTag || conversation.relay !== url) throw Error('Existing agent-owner association/relay required'); + metadataAuthorization(conversation.authTag, entry.agent, manifest.ownerPublic, Math.floor(Date.now() / 1000)); + let secret: string | null; + try { secret = credentials.readAsync ? await credentials.readAsync(row.key, readSignal) : credentials.read(row.key); } + catch { throw Error('Agent credential unavailable; publication not attempted'); } + readSignal.throwIfAborted(); + if (JSON.stringify(readCredentialManifest(directory)) !== snapshot) throw Error('Metadata custody changed during read'); + if (!secret || publicKey(secret) !== entry.agent) throw Error('Local agent key missing; metadata publication not repaired automatically'); + return { secret, relay: conversation.relay, authTag: conversation.authTag }; + } + // Legacy diagnostic fixtures retain keys in the existing installation format. + const current = (await installationSlotsAsync(directory, credentials, readSignal)).find(e => e.agent === entry.agent)?.setup; + if (!current || current.host !== entry.setup.host || setupOwner(current) !== setupOwner(entry.setup) || !current.agentSecret || !current.conversation?.authTag || current.conversation.relay !== url) throw Error('Existing agent key/owner association/relay required'); + return { secret: current.agentSecret, relay: current.conversation.relay, authTag: current.conversation.authTag }; + function hostKeyForMetadata() { return transport?.binding.host ?? entry.setup.host; } + }, url)); } const setup = entries[0]?.setup; const hostKey = transport?.binding.host ?? setup!.host; @@ -611,7 +652,7 @@ export async function host(directory: string, url: string, signal?: AbortSignal, const selected = slots.get(m.agent); trace('host.route', { id: m.id, type: m.type, selected: !!selected }); if (selected) selected.receive(m); else if (m.type === 'inspect') { for (const s of slots.values()) s.replay(); } - else if (['save','start','restart','stop','move'].includes(m.type)) publish(message('receipt',hostKey,m.agent,0,{ operation: m.id, fingerprint: digest(JSON.stringify(m)).toString('hex'), result: 'not-authority' })); + else if (['metadata','save','start','restart','stop','move'].includes(m.type)) publish(message('receipt',hostKey,m.agent,0,{ operation: m.id, fingerprint: digest(JSON.stringify(m)).toString('hex'), result: 'not-authority' })); }, () => { if (initialized) for (const s of slots.values()) s.replay(); }); await client.ready; signal?.throwIfAborted(); diff --git a/beehive/src/intents.ts b/beehive/src/intents.ts index da3ca66fdd..59e7851e81 100644 --- a/beehive/src/intents.ts +++ b/beehive/src/intents.ts @@ -37,7 +37,7 @@ export function managementClient(root: string, url: string, secret: string, rece const value = object(readPrivate(path)); fields(value, ['scope', 'envelope']); if (value.scope !== scope) throw Error('Wrong journal scope'); const request = open(value.envelope, secret); - if (!['profile','save','start','restart','stop','move'].includes(request.type) || name !== `${request.id}.intent`) throw Error('Invalid intent'); + if (!['metadata','profile','save','start','restart','stop','move'].includes(request.type) || name !== `${request.id}.intent`) throw Error('Invalid intent'); const intent: Intent = { envelope: value.envelope as Envelope, request, published: false }; const receiptPath = join(dir, `${request.id}.receipt`); if (existsSync(receiptPath)) { @@ -102,8 +102,8 @@ export function managementClient(root: string, url: string, secret: string, rece get socket() { return transport.socket; }, submit(request: Message) { if (closed) throw Error('UI closed'); - if (!['profile','save','start','restart','stop','move'].includes(request.type)) throw Error('Invalid operation'); - if (privateHosts && !['profile', 'save', 'start', 'restart', 'stop'].includes(request.type)) throw Error('Private Move authority is not integrated; no operation prepared'); + if (!['metadata','profile','save','start','restart','stop','move'].includes(request.type)) throw Error('Invalid operation'); + if (privateHosts && !['metadata', 'profile', 'save', 'start', 'restart', 'stop'].includes(request.type)) throw Error('Private Move authority is not integrated; no operation prepared'); if (request.type === 'profile') { profile(request.body); if (request.host !== 'profiles' || request.agent !== 'profiles' || request.revision !== 0) throw Error('Invalid profile publication'); @@ -129,7 +129,7 @@ export function managementClient(root: string, url: string, secret: string, rece }, status() { return [...intents.values()].map(i => ({ request: structuredClone(i.request), - state: i.request.type === 'profile' && i.published ? 'completed' : i.receipt ? (['accepted','saved; running configuration unchanged'].includes(String(i.receipt.body.result)) ? 'completed' : i.receipt.body.result === 'interrupted-reconcile-locally' ? 'unknown' : 'failed') : i.blocked ? 'unknown' : 'pending', + state: i.request.type === 'profile' && i.published ? 'completed' : i.receipt ? ((['accepted','saved; running configuration unchanged'].includes(String(i.receipt.body.result)) || (i.request.type === 'metadata' && /^published; signed latest kind0 [a-f0-9]{64}$/.test(String(i.receipt.body.result)))) ? 'completed' : i.receipt.body.result === 'interrupted-reconcile-locally' ? 'unknown' : 'failed') : i.blocked ? 'unknown' : 'pending', retryAvailable: !i.receipt && !(i.request.type === 'profile' && i.published) && !!i.blocked, publication: i.request.type === 'profile' && i.published ? 'immutable publication observed; no agent application' : i.receipt ? 'host terminal receipt' : i.blocked ? 'relay policy failure; automatic retry disabled; reconcile, then retry after policy repair' : i.published ? 'relay observed; not host admission' : 'unconfirmed', result: i.receipt?.body.result })); diff --git a/beehive/src/metadata-drafts.ts b/beehive/src/metadata-drafts.ts new file mode 100644 index 0000000000..91eff747a0 --- /dev/null +++ b/beehive/src/metadata-drafts.ts @@ -0,0 +1,75 @@ +import { randomUUID } from 'node:crypto'; +import { existsSync, lstatSync, mkdirSync, rmdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fields, object } from './protocol.ts'; +import { publicMetadata, metadataRevision, metadataRelay, type PublicMetadata } from './public-metadata.ts'; +import { readPrivate, writePrivate } from './storage.ts'; + +export type MetadataDraft = { id: string; relay: string; agent: string; value: PublicMetadata }; +/** Local editable text only; no signer, relay, host, model or key access. Each + * completed input is an atomic save, NOT keystroke durability. A short directory + * lock refuses concurrent editors rather than silently overwriting their saves. + */ +export function metadataDrafts(stateDirectory: string) { + const directory = join(stateDirectory, 'metadata-drafts'); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + for (const path of [stateDirectory, directory]) { + const stat = lstatSync(path); + if (!stat.isDirectory() || (stat.mode & 0o077)) throw Error('Draft directory must be owner-only'); + } + const path = join(directory, 'drafts.json'); + function list(): MetadataDraft[] { + if (!existsSync(path)) return []; + if (lstatSync(path).size > 1000000) throw Error('Draft storage exceeds limit'); + const stored = object(readPrivate(path)); fields(stored, ['version', 'drafts']); + if (stored.version !== 1 || !Array.isArray(stored.drafts) || stored.drafts.length > 100) throw Error('Invalid draft storage'); + const ids = new Set(); + return stored.drafts.map(input => { + const d = object(input); fields(d, ['id', 'relay', 'agent', 'value']); + if (typeof d.id !== 'string' || !/^[0-9a-f-]{36}$/.test(d.id) || ids.has(d.id)) throw Error('Invalid draft identity'); + if (typeof d.relay !== 'string' || typeof d.agent !== 'string' || !/^[a-f0-9]{64}$/.test(d.agent)) throw Error('Invalid draft binding'); + metadataRelay(d.relay); + ids.add(d.id); return { id: d.id, relay: d.relay, agent: d.agent, value: publicMetadata(d.value) }; + }); + } + function change(update: (drafts: MetadataDraft[]) => MetadataDraft[]) { + const lock = join(directory, 'edit.lock'); + mkdirSync(lock, { mode: 0o700 }); + try { writePrivate(path, { version: 1, drafts: update(list()) }); } + finally { rmdirSync(lock); } + } + return { + list, + save(relay: string, agent: string, value: PublicMetadata, previous?: MetadataDraft): MetadataDraft { + metadataRelay(relay); if (!/^[a-f0-9]{64}$/.test(agent)) throw Error('Agent must be a hex public key'); + if (previous && (previous.relay !== relay || previous.agent !== agent)) throw Error('Draft binding cannot change'); + const draft = { id: previous?.id ?? randomUUID(), relay, agent, value: publicMetadata(value) }; + change(drafts => { + if (previous) { + const index = drafts.findIndex(d => d.id === previous.id); + if (index < 0 || metadataRevision(drafts[index]!.value) !== metadataRevision(previous.value)) throw Error('Draft changed; resume again'); + drafts[index] = draft; + } else { if (drafts.length >= 100) throw Error('Draft storage full; discard unused drafts'); drafts.push(draft); } + return drafts; + }); + return draft; + }, + discard(previous: MetadataDraft) { + change(drafts => { + if (!drafts.some(d => d.id === previous.id && metadataRevision(d.value) === metadataRevision(previous.value))) throw Error('Draft changed; resume again'); + return drafts.filter(d => d.id !== previous.id); + }); + }, + }; +} + +/** Owner-only completed-form save; blank values deliberately clear fields. */ +export async function editMetadataDraft(store: ReturnType, question: (prompt: string) => Promise, previous?: MetadataDraft): Promise { + if (previous) console.log(`Saved public draft ${previous.id}: ${JSON.stringify(previous.value)}`); + const relay = previous?.relay ?? await question('Public relay (exact retained ws/wss URL): '); + const agent = previous?.agent ?? await question('Agent hex public key: '); + const display_name = await question('Public display name (blank clears): '); + const picture = await question('Public HTTPS picture URL (blank clears): '); + const about = await question('Public about (NOT private instructions; blank clears): '); + return store.save(relay, agent, { display_name, picture, about }, previous); +} diff --git a/beehive/src/nostr-codec.ts b/beehive/src/nostr-codec.ts index ba36d731cc..31f7e65823 100644 --- a/beehive/src/nostr-codec.ts +++ b/beehive/src/nostr-codec.ts @@ -43,7 +43,7 @@ export function authorizeManagement(input: AuthenticatedMessage, owner: string, if (role === 'owner') { if (!['inventory', 'receipt'].includes(message.type) || hosts[message.host] !== sender) throw Error('Untrusted host response'); } else { - if (message.host !== role.host || sender !== owner || !['profile', 'inspect', 'save', 'start', 'restart', 'stop', 'move'].includes(message.type)) throw Error('Unauthorized owner command'); + if (message.host !== role.host || sender !== owner || !['metadata', 'profile', 'inspect', 'save', 'start', 'restart', 'stop', 'move'].includes(message.type)) throw Error('Unauthorized owner command'); } return message; } diff --git a/beehive/src/protocol.ts b/beehive/src/protocol.ts index e377d48086..41c88fa480 100644 --- a/beehive/src/protocol.ts +++ b/beehive/src/protocol.ts @@ -3,7 +3,7 @@ import { schnorr } from '@noble/curves/secp256k1'; /** Experimental private wire protocol; deliberately not a Nostr kind allocation. * Host-level availability has agent='' and revision=0, never an agent inventory row. */ -export type Message = { v: 1; id: string; host: string; agent: string; type: 'availability' | 'profile' | 'inspect' | 'save' | 'start' | 'restart' | 'stop' | 'move' | 'prepare' | 'prepared' | 'grant' | 'inventory' | 'receipt'; revision: number; body: Record }; +export type Message = { v: 1; id: string; host: string; agent: string; type: 'metadata' | 'availability' | 'profile' | 'inspect' | 'save' | 'start' | 'restart' | 'stop' | 'move' | 'prepare' | 'prepared' | 'grant' | 'inventory' | 'receipt'; revision: number; body: Record }; export type Envelope = { v: 1; owner: string; nonce: string; ciphertext: string; tag: string; signature: string }; /** Reject unknown envelope/message fields, oversized values and ambiguous revisions. */ export function object(value: unknown): Record { @@ -20,7 +20,7 @@ export function fields(value: Record, names: string[]) { export function parseMessage(value: unknown): Message { const m = object(value); fields(m, ['v','id','host','agent','type','revision','body']); - if (m.v !== 1 || !['availability','profile','inspect','save','start','restart','stop','move','prepare','prepared','grant','inventory','receipt'].includes(String(m.type)) || !Number.isSafeInteger(m.revision) || Number(m.revision) < 0) throw Error('Invalid message'); + if (m.v !== 1 || !['metadata','availability','profile','inspect','save','start','restart','stop','move','prepare','prepared','grant','inventory','receipt'].includes(String(m.type)) || !Number.isSafeInteger(m.revision) || Number(m.revision) < 0) throw Error('Invalid message'); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(text(m.id))) throw Error('Invalid operation ID'); text(m.host); if (m.type === 'availability') { if (m.agent !== '' || m.revision !== 0) throw Error('Availability is not an agent report'); } diff --git a/beehive/src/public-metadata.ts b/beehive/src/public-metadata.ts new file mode 100644 index 0000000000..88a008b57f --- /dev/null +++ b/beehive/src/public-metadata.ts @@ -0,0 +1,83 @@ +import { randomUUID } from 'node:crypto'; +import { schnorr } from '@noble/curves/secp256k1'; +import { finalizeEvent, verifyEvent, type Event } from 'nostr-tools/pure'; +import { digest, fields, object, publicKey } from './protocol.ts'; + +/** Public identity only. Never derive about from a behavior profile. Empty fields clear. */ +export type PublicMetadata = { display_name: string; picture: string; about: string }; +/** Strict public-field allowlist; unknown/private fields fail closed. */ +export function publicMetadata(input: unknown): PublicMetadata { + const v = object(input); fields(v, ['display_name', 'picture', 'about']); + for (const [key, limit] of [['display_name', 128], ['picture', 2048], ['about', 280]] as const) { + if (typeof v[key] !== 'string' || [...v[key]].length > limit || /[\x00-\x1f\x7f]/.test(v[key])) throw Error('Invalid public metadata'); + } + if (v.picture) { const u = new URL(String(v.picture)); if (u.protocol !== 'https:' || u.username || u.password) throw Error('Picture must be a public HTTPS URL'); } + return { display_name: String(v.display_name), picture: String(v.picture), about: String(v.about) }; +} +/** Canonical desired-content hash, independent of host placement and instructions. */ +export function metadataRevision(value: PublicMetadata): string { return digest(JSON.stringify(publicMetadata(value))).toString('hex'); } +/** Native NIP-OA preimage/conditions from buzz-sdk nip_oa.rs at 051c3a270. + * Consume an existing association only; never manufacture owner authorization. + * Check both kind0 and request kind27235 against its conditions. + */ +export function metadataAuthorization(raw: string, agent: string, owner: string, now: number): string[] { + const a: unknown = JSON.parse(raw); + if (!Array.isArray(a) || a.length !== 4 || !a.every(v => typeof v === 'string') || a[0] !== 'auth' || a[1] !== owner || owner === agent || !/^[a-f0-9]{64}$/.test(owner) || !/^[a-f0-9]{128}$/.test(a[3])) throw Error('Invalid existing agent-owner association'); + const conditions = a[2] as string; + for (const clause of conditions ? conditions.split('&') : []) { + const match = /^(kind=|created_at<|created_at>)(0|[1-9][0-9]*)$/.exec(clause); + if (!match) throw Error('Unsupported owner association conditions'); + const n = Number(match[2]); + if (!Number.isSafeInteger(n) || n > (match[1] === 'kind=' ? 65535 : 4294967295)) throw Error('Invalid owner association conditions'); + if (match[1] === 'kind=' || (match[1] === 'created_at<' ? now >= n : now <= n)) throw Error('Owner association does not authorize metadata and request authentication'); + } + if (!schnorr.verify(a[3], digest(`nostr:agent-auth:${agent}:${conditions}`), owner)) throw Error('Invalid owner association signature'); + return a as string[]; +} +/** Exact supported generic HTTP relay path, not a host-authenticated socket. */ +export function metadataRelay(relay: string): string { + const u = new URL(relay); + if (!['ws:', 'wss:'].includes(u.protocol) || u.username || u.password || u.search || u.hash || (u.protocol === 'ws:' && !['127.0.0.1', '[::1]', 'localhost'].includes(u.hostname))) throw Error('Invalid metadata relay'); + u.protocol = u.protocol === 'wss:' ? 'https:' : 'http:'; + return u.href.replace(/\/$/, ''); +} +/** Publish only after governing consumer authorization. Query before signing to + * avoid kind0 same-second tie ordering; exact signed readback is separate from ACK. + * No retry loop, provider, ACP, or credential backend is opened here. + */ +export async function publishPublicMetadata(input: { relay: string; agent: string; owner: string; secret: string; authTag: string; value: PublicMetadata; check: () => void; retain: (event: Event) => void }): Promise { + const value = publicMetadata(input.value), base = metadataRelay(input.relay); + if (publicKey(input.secret) !== input.agent) throw Error('Agent signer mismatch'); + const tags = metadataAuthorization(input.authTag, input.agent, input.owner, Math.floor(Date.now() / 1000)); + async function request(path: string, body: unknown): Promise { + input.check(); + const content = JSON.stringify(body), url = `${base}/${path}`, now = Math.floor(Date.now() / 1000); + metadataAuthorization(input.authTag, input.agent, input.owner, now); + const auth = finalizeEvent({ kind: 27235, created_at: now, tags: [['u', url], ['method', 'POST'], ['payload', digest(content).toString('hex')], ['nonce', randomUUID()]], content: '' }, Buffer.from(input.secret, 'hex')); + const response = await fetch(url, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(5000), headers: { 'Content-Type': 'application/json', Authorization: `Nostr ${Buffer.from(JSON.stringify(auth)).toString('base64')}`, 'x-auth-tag': input.authTag }, body: content }); + if (!response.ok) { await response.body?.cancel(); throw Error(`Public relay refused ${path} (HTTP ${response.status}); publication unknown`); } + const reader = response.body?.getReader(); if (!reader) throw Error('Missing public relay response'); + const chunks: Uint8Array[] = []; let size = 0; + try { for (;;) { const { done, value: part } = await reader.read(); if (done) break; size += part.length; if (size > 65536) throw Error('Public relay response too large'); chunks.push(part); } } finally { await reader.cancel(); } + input.check(); return JSON.parse(Buffer.concat(chunks).toString()); + } + async function latest(): Promise { + const rows = await request('query', [{ authors: [input.agent], kinds: [0], limit: 1 }]); + if (!Array.isArray(rows) || rows.length > 1) throw Error('Invalid public metadata readback'); + if (!rows.length) return undefined; + const event = rows[0] as Event; + if (!verifyEvent(event) || event.pubkey !== input.agent || event.kind !== 0) throw Error('Invalid public metadata signature/readback'); + return event; + } + const prior = await latest(); + const now = Math.floor(Date.now() / 1000); + if (prior && prior.created_at >= now) throw Error('Latest kind0 occupies this second or is future-dated; deliberately retry later'); + input.check(); metadataAuthorization(input.authTag, input.agent, input.owner, now); + const event = finalizeEvent({ kind: 0, created_at: now, tags: [tags], content: JSON.stringify(value) }, Buffer.from(input.secret, 'hex')); + input.retain(event); // Durable exact candidate before network effect, including unknown outcome. + const ack = object(await request('events', event)); + if (ack.accepted !== true || ack.event_id !== event.id) throw Error('Public relay did not acknowledge this event; publication unknown'); + const observed = await latest(); + if (observed?.id !== event.id) throw Error('Public event submitted but latest readback differs; publication unknown'); + return `published; signed latest kind0 ${event.id}`; +} diff --git a/beehive/test/nostr-fixture.ts b/beehive/test/nostr-fixture.ts index fd50ed71ad..c1f489f628 100644 --- a/beehive/test/nostr-fixture.ts +++ b/beehive/test/nostr-fixture.ts @@ -1,4 +1,4 @@ -import { createServer } from 'node:http'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { randomUUID } from 'node:crypto'; import { WebSocketServer, type WebSocket } from 'ws'; import { verifyEvent, type Event } from 'nostr-tools/pure'; @@ -7,11 +7,12 @@ import { verifyEvent, type Event } from 'nostr-tools/pure'; * recipient-scoped REQ, timestamp/size and publication rules. Optional allowed * identities model real server AUTH refusal, not a Beehive membership preflight. */ -export async function nostrFixture(owner: string, allowedIdentities?: ReadonlySet, access: { open?: boolean; denyAuth?: boolean; denyPublication?: boolean; denyReq?: boolean } = {}) { +export async function nostrFixture(owner: string, allowedIdentities?: ReadonlySet, access: { open?: boolean; denyAuth?: boolean; denyPublication?: boolean; denyReq?: boolean } = {}, publicHttp?: (req: IncomingMessage, res: ServerResponse) => boolean) { const allowed = new Set(allowedIdentities ?? [owner]); const httpRequests: string[] = []; const http = createServer((req, res) => { httpRequests.push(`${req.method} ${req.url}`); + if (publicHttp?.(req, res)) return; res.setHeader('Content-Type', 'application/json'); if (req.method === 'GET' && req.url === '/') res.end(JSON.stringify({ supported_nips: [1,2,10,11,16,17,23,25,29,33,38,42,50,56] })); else res.writeHead(404).end('{}'); diff --git a/beehive/test/public-metadata.test.ts b/beehive/test/public-metadata.test.ts new file mode 100644 index 0000000000..cd429e0e96 --- /dev/null +++ b/beehive/test/public-metadata.test.ts @@ -0,0 +1,172 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, realpathSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { setTimeout as delay } from 'node:timers/promises'; +import { schnorr } from '@noble/curves/secp256k1'; +import { finalizeEvent, verifyEvent, type Event } from 'nostr-tools/pure'; +import { digest, newKey, publicKey, message, type Message } from '../src/protocol.ts'; +import { publicMetadata, metadataRevision, metadataAuthorization } from '../src/public-metadata.ts'; +import { metadataDrafts } from '../src/metadata-drafts.ts'; +import { profile, profileRevision } from '../src/profiles.ts'; +import { profileDrafts } from '../src/profile-drafts.ts'; +import { host, initialState, type Setup } from '../src/host.ts'; +import { createGenesis } from '../src/assignment.ts'; +import { writePrivate } from '../src/storage.ts'; +import { credentialReference, type CredentialBackend } from '../src/credential-store.ts'; +import { privateHostTransport } from '../src/host-transport.ts'; +import { hostPairing } from '../src/host-registration.ts'; +import { managementClient } from '../src/intents.ts'; +import { connectNostr } from '../src/nostr-client.ts'; +import { nostrFixture } from './nostr-fixture.ts'; + +async function until(p: () => boolean) { for (let i = 0; i < 400; i++) { if (p()) return; await delay(20); } throw Error('Metadata evidence missing'); } +const value = { display_name: 'Synthetic agent', picture: 'https://example.invalid/avatar.png', about: 'Public test identity only' }; +function association(owner: string, agent: string, conditions = '') { return JSON.stringify(['auth', publicKey(owner), conditions, Buffer.from(schnorr.sign(digest(`nostr:agent-auth:${agent}:${conditions}`), owner)).toString('hex')]); } + +test('offline actual editor resumes public draft without network, signer or instruction mixing', async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-metadata-'))), agent = publicKey(newKey()); + const forbidden = join(root, 'forbidden'); writeFileSync(forbidden, 'INVALID: any credential read fails'); + const store = metadataDrafts(root); + async function session(run: (send: (s: string, prompt: string) => Promise) => Promise) { + const child = spawn(process.execPath, ['--import', resolve('test/isolated-credentials-loader.ts'), 'src/cli.ts', 'drafts', root], { env: { PATH: '/usr/bin:/bin', HOME: root, BEEHIVE_TEST_CREDENTIAL_FILE: forbidden }, stdio: ['pipe', 'pipe', 'pipe'] }); + let output = ''; child.stdout.on('data', b => output += b); child.stderr.on('data', b => output += b); + const exit = new Promise(resolve => child.on('close', resolve)); + const send = async (s: string, prompt: string) => { const at = output.length; child.stdin.write(`${s}\n`); await until(() => { if (child.exitCode !== null) throw Error(output); return output.slice(at).includes(prompt); }); }; + try { await until(() => output.includes('drafts> ')); await run(send); } finally { child.kill('SIGTERM'); await exit; } + } + try { + await session(async send => { await send('metadata-new', 'Public relay'); await send('wss://relay.example.invalid', 'Agent hex'); await send(agent, 'Public display'); await send(value.display_name, 'Public HTTPS'); await send(value.picture, 'Public about'); await send(value.about, 'Saved public metadata draft'); }); + const draft = store.list()[0]!; assert.deepEqual(draft.value, value); + await session(async send => { await send(`metadata-edit ${draft.id}`, 'Public display'); await send('unfinished', 'Public HTTPS'); }); + assert.deepEqual(store.list()[0], draft, 'partial form does not tear saved snapshot'); + await session(async send => { await send(`metadata-edit ${draft.id}`, 'Public display'); await send('Edited', 'Public HTTPS'); await send('', 'Public about'); await send('', 'Saved public metadata draft'); }); + assert.deepEqual(store.list()[0]!.value, { display_name: 'Edited', picture: '', about: '' }); + assert.throws(() => store.save(draft.relay, agent, value, draft), /changed/); + await session(async send => { await send(`metadata-discard ${draft.id}`, 'Discard local metadata'); await send('no', 'drafts> '); }); + assert.equal(store.list().length, 1); + await session(async send => { await send(`metadata-discard ${draft.id}`, 'Discard local metadata'); await send('yes', 'drafts> '); }); + assert.equal(store.list().length, 0); + assert.deepEqual(profileDrafts(root).list(), []); + assert.equal(readFileSync(forbidden, 'utf8'), 'INVALID: any credential read fails'); + assert.throws(() => publicMetadata({ ...value, instructions: 'private' }), /fields/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('private owner authorizes stopped-agent kind0 with agent NIP98, exact readback, custody and stale fences', async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-metadata-host-'))); + const owner = newKey(), hostSecret = newKey(), agentSecret = newKey(), siblingSecret = newKey(), stranger = newKey(); + const ownerKey = publicKey(owner), hostKey = publicKey(hostSecret), agent = publicKey(agentSecret), sibling = publicKey(siblingSecret); + const authTag = association(owner, agent); const httpErrors: unknown[] = [], events: Event[] = [], authIds = new Set(); + let url = '', latest: Event | undefined, deny = false, hideReadback = false, reads = 0; + const relay = await nostrFixture(ownerKey, new Set([ownerKey, hostKey, publicKey(stranger)]), {}, (req, res) => { + if (req.method !== 'POST') return false; + let body = ''; req.on('data', b => body += b); + req.on('end', () => { + try { + const auth = JSON.parse(Buffer.from(String(req.headers.authorization).slice(6), 'base64').toString()) as Event; + assert(verifyEvent(auth)); assert.equal(auth.pubkey, agent); assert.equal(auth.kind, 27235); + assert(!authIds.has(auth.id), 'native request replay protection'); authIds.add(auth.id); + assert(auth.tags.some(t => t[0] === 'u' && t[1] === url.replace('ws:', 'http:') + req.url)); + assert(auth.tags.some(t => t[0] === 'method' && t[1] === 'POST')); + assert(auth.tags.some(t => t[0] === 'payload' && t[1] === digest(body).toString('hex'))); + assert.equal(req.headers['x-auth-tag'], authTag); assert(schnorr.verify(JSON.parse(authTag)[3], digest(`nostr:agent-auth:${agent}:`), ownerKey)); metadataAuthorization(authTag, agent, ownerKey, auth.created_at); + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/query') { assert.deepEqual(JSON.parse(body), [{ authors: [agent], kinds: [0], limit: 1 }]); res.end(JSON.stringify(latest && !hideReadback ? [latest] : [])); } + else { + assert.equal(req.url, '/events'); const e = JSON.parse(body) as Event; + assert(verifyEvent(e)); assert.equal(e.kind, 0); assert.equal(e.pubkey, agent); assert.deepEqual(e.tags, [JSON.parse(authTag)]); + assert.deepEqual(Object.keys(JSON.parse(e.content)).sort(), ['about', 'display_name', 'picture']); assert(!body.includes('PRIVATE INSTRUCTIONS')); + if (deny) { res.end(JSON.stringify({ event_id: e.id, accepted: false, message: 'refused' })); return; } + latest = e; events.push(e); res.end(JSON.stringify({ event_id: e.id, accepted: true, message: 'saved' })); + } + } catch (e) { httpErrors.push(e); res.writeHead(400).end('{}'); } + }); return true; + }); + url = relay.url; + const directory = join(root, 'host'), journalPath = (key: string) => join(directory, 'agents', key, 'journal.json'); + const harness = { runner: '/not-installed/no-provider-or-model', args: [], workspace: root, mode: 'fixture' as const, conversation: { executable: '/not-installed/no-acp', relay: url, authTag } }; + for (const [key, secret] of [[agent, agentSecret], [sibling, siblingSecret]]) { + const setup: Setup = { ...harness, host: hostKey, ownerPublic: ownerKey, agentSecret: secret }; + const state = initialState(setup, createGenesis(ownerKey, key!, key === sibling ? publicKey(stranger) : hostKey)); + if (key === agent) { + const behavior = profile({ name: 'Private', parent: null, instructions: 'PRIVATE INSTRUCTIONS', revision: profileRevision('Private', null, 'PRIVATE INSTRUCTIONS') }); + state.selected = { ...state.selected, profile: behavior.revision, behavior }; + } + writePrivate(journalPath(key!), state); + } + writePrivate(join(directory, 'setup.json'), { version: 3, host: hostKey, ownerPublic: ownerKey, setups: { default: harness }, agents: { [agent]: { key: credentialReference('agent', agent), setup: 'default' }, [sibling]: { key: credentialReference('agent', sibling), setup: 'default' } } }); + const secrets = new Map([[agent, agentSecret], [sibling, siblingSecret]]); + const credentials: CredentialBackend = { read(ref) { reads++; return secrets.get(ref.publicKey) ?? null; }, create() { throw Error('No credential creation'); }, remove() { throw Error('No credential removal'); } }; + const seen: Message[] = [], catalog = { version: 1 as const, owner: ownerKey, relay: url, registrations: [] }; + const client = managementClient(join(root, 'intents'), url, owner, m => seen.push(m), () => {}, { catalog }); + const attacker = connectNostr(url, Buffer.from(stranger, 'hex'), () => {}, () => {}); + let service: Awaited> | undefined; + try { + await client.ready; await attacker.ready; + const pairing = hostPairing({ version: 1, purpose: 'beehive-host-registration', host: hostKey, owner: ownerKey, label: 'synthetic host', relay: url, nonce: newKey() }); + service = await host(directory, url, undefined, privateHostTransport(pairing, hostSecret), credentials); + await until(() => seen.some(m => m.type === 'inventory' && m.agent === agent)); + const state = () => JSON.parse(readFileSync(journalPath(agent), 'utf8')); + let other = readFileSync(journalPath(sibling), 'utf8'); const selected = state().selected; + reads = 0; + const draft = metadataDrafts(root).save(url, agent, value); + const op = (v = value, revision = state().revision) => message('metadata', hostKey, agent, revision, { relay: url, value: v, revision: metadataRevision(v) }); + async function request(m: Message) { client.submit(m); await until(() => client.status().some(s => s.request.id === m.id && s.state !== 'pending')); return client.status().find(s => s.request.id === m.id)!.result; } + assert.equal(await request({ ...op(), agent: sibling }), 'not-authority'); + assert.equal(reads, 0, 'standby assignment refuses before credential access'); + other = readFileSync(journalPath(sibling), 'utf8'); + await attacker.publish(op(), hostKey); + assert.equal(await request({ ...op(), body: { ...op().body, relay: 'wss://wrong.invalid' } }), 'Invalid private metadata authorization/binding'); + assert.match(String(await request({ ...op(), body: { ...op().body, value: { ...value, instructions: 'PRIVATE INSTRUCTIONS' } } })), /Unexpected fields/); + assert.equal(reads, 0); assert.equal(events.length, 0); + const manifestBytes = readFileSync(join(directory, 'setup.json'), 'utf8'); + const invalidAssociation = JSON.parse(manifestBytes); + invalidAssociation.setups.default.conversation.authTag = association(stranger, agent); + writePrivate(join(directory, 'setup.json'), invalidAssociation); + assert.match(String(await request(op())), /Invalid existing agent-owner association/); + assert.equal(reads, 0, 'invalid retained association refuses before agent credential access'); + writeFileSync(join(directory, 'setup.json'), manifestBytes); + const first = op(); assert.match(String(await request(first)), /^published; signed latest kind0 /); + assert.equal(client.status().find(s => s.request.id === first.id)!.state, 'completed'); + assert.equal(reads, 1, 'only selected agent credential read'); assert.equal(events.length, 1); + assert.equal(latest!.content, JSON.stringify(draft.value)); assert.equal(state().phase, 'stopped'); assert.equal(state().actual, null); assert.deepEqual(state().selected, selected); + assert.equal(readFileSync(journalPath(sibling), 'utf8'), other); + assert.equal(await request(op({ ...value, about: 'stale' }, 0)), 'revision-conflict'); assert.equal(reads, 1); + const ownerWire = connectNostr(url, Buffer.from(owner, 'hex'), () => {}, () => {}); await ownerWire.ready; + try { await ownerWire.publish(first, hostKey); await ownerWire.publish(message('inspect', hostKey, agent), hostKey); } finally { ownerWire.close(); } + assert.equal(events.length, 1); + await until(() => Math.floor(Date.now() / 1000) > state().publicMetadata.event.created_at); + secrets.delete(agent); assert.match(String(await request(op())), /key missing/); assert.equal(events.length, 1); + secrets.set(agent, agentSecret); + // A valid signed same-second/future event cannot be overwritten by a guessed timestamp. + latest = finalizeEvent({ kind: 0, created_at: Math.floor(Date.now() / 1000) + 5, tags: [JSON.parse(authTag)], content: JSON.stringify(value) }, Buffer.from(agentSecret, 'hex')); + assert.match(String(await request(op())), /occupies this second or is future-dated/); assert.equal(events.length, 1); + latest = events[0]; + await delay(1100); deny = true; + assert.match(String(await request(op({ ...value, about: 'denied' }))), /did not acknowledge/); assert.equal(events.length, 1); + // Even a rejected/unknown attempt reserves its signed second locally: a delayed + // remote ingest must not race a new same-second event with arbitrary ID ordering. + await until(() => Math.floor(Date.now() / 1000) > state().publicMetadata.event.created_at); + deny = false; hideReadback = true; + assert.match(String(await request(op({ ...value, about: 'unknown' }))), /readback differs/); assert.equal(events.length, 2); + assert(state().publicMetadata.event.id === events[1]!.id, 'unknown candidate survives durably'); + const persisted = state().publicMetadata; + await service.close(); service = undefined; + service = await host(directory, url, undefined, privateHostTransport(pairing, hostSecret), credentials); + assert.deepEqual(state().publicMetadata, persisted); assert.equal(events.length, 2, 'host reopen never silently retries unknown public side effects'); + assert.deepEqual(httpErrors, []); assert.equal(readFileSync(journalPath(sibling), 'utf8'), other); + assert.equal(metadataDrafts(root).list()[0]!.value.about, value.about); + } finally { client.close(); attacker.close(); await service?.close(); await relay.close(); rmSync(root, { recursive: true, force: true }); } +}); + +test('existing OA association rejects wrong owner, agent, conditions and forged authorization', () => { + const owner = newKey(), agent = publicKey(newKey()), tag = association(owner, agent), now = Math.floor(Date.now() / 1000); + assert.equal(metadataAuthorization(tag, agent, publicKey(owner), now)[1], publicKey(owner)); + assert.throws(() => metadataAuthorization(tag, publicKey(newKey()), publicKey(owner), now)); + assert.throws(() => metadataAuthorization(tag, agent, publicKey(newKey()), now)); + assert.throws(() => metadataAuthorization(association(owner, agent, 'kind=0'), agent, publicKey(owner), now)); + assert.throws(() => metadataAuthorization(association(owner, agent, 'created_at<1'), agent, publicKey(owner), now)); +});