feat(beehive): edit public metadata and deliberately publish with agent identity

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-10 22:46:03 -04:00
parent e43b0c2464
commit b577ff0112
14 changed files with 559 additions and 22 deletions

View File

@@ -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 <owner-state-directory>` (the directory containing
the owner TUI identity reference). Use `metadata-new`,
`metadata-edit <draft-id>`, `metadata-discard <draft-id>`, `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 <draft-id>`.
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 <id>`.
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.

View File

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

View File

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

View File

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

View File

@@ -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 <command> from any directory once installed (one-time user link
host --owner-present Default host and retained configured relay
host <relay> --owner-present Private foreground host on the default host folder; bounded OS key reads
host <host-directory> <relay> --owner-present Private foreground host on an explicit folder; bounded OS key reads
drafts <state-directory> Offline instruction drafts; no signer/network; same state directory as TUI
drafts <state-directory> Offline public metadata + instruction drafts; no signer/network; same state directory as TUI
tui discover <relay> <state-directory> Private availability discovery; no approval file
tui <catalog-file> [relay] Private owner UI; hidden existing owner signer
setup/add-agent accept optional <local-key-file> <public-genesis-file> 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 <id>, profile-discard <id>, 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 <id>, metadata-discard <id>, drafts, profile-new, profile-resume <id>, profile-discard <id>, 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<string,Message>();
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 <local-id>, configurations, config-new, config-select <name>, config-rename, config-remove <name>, profiles, drafts, profile-new, profile-edit <number>, profile-resume <id>, profile-discard <id>, apply <number|default>, operations, reconcile, retry <number>, hosts, agents, select <number or unique host>, 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 <id>, metadata-discard <id>, metadata-publish <id>, binding <local-id>, configurations, config-new, config-select <name>, config-rename, config-remove <name>, profiles, drafts, profile-new, profile-edit <number>, profile-resume <id>, profile-discard <id>, apply <number|default>, operations, reconcile, retry <number>, hosts, agents, select <number or unique host>, 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);

View File

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

View File

@@ -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<string, ActualRun>; assignment: Assignment; move?: { request: Message; prepare: Message }; preparations?: Record<string, { request: Message; token: string; reply: Message; candidate?: Selection; reservedRevision?: number }>; incoming?: Record<string, Message>; binding: { host: string; owner: string; agent: string }; revision: number; phase: 'stopped' | 'transitioning' | 'running' | 'quarantined'; selected: Selection; actual: null | ActualRun; operations: Record<string, { fingerprint: string; reply: Message }>; outbox: Message[] };
type State = { publicMetadata?: { operation: string; revision: string; event: import('nostr-tools/pure').Event }; configurations?: Configurations; runs?: Record<string, ActualRun>; assignment: Assignment; move?: { request: Message; prepare: Message }; preparations?: Record<string, { request: Message; token: string; reply: Message; candidate?: Selection; reservedRevision?: number }>; incoming?: Record<string, Message>; binding: { host: string; owner: string; agent: string }; revision: number; phase: 'stopped' | 'transitioning' | 'running' | 'quarantined'; selected: Selection; actual: null | ActualRun; operations: Record<string, { fingerprint: string; reply: Message }>; 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<Setup, 'ownerPublic' | 'ownerSecret'>): 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<Setup>, publish: (m: Message) => void, profiles: Profiles, setupId: string, bindings: Record<string, Setup>, retiredBindings: Record<string, string> = {}, privateSnapshots = false) {
function slot(setup: Setup, path: string, agent: string, currentSetup: (id: string, signal: AbortSignal) => Promise<Setup>, publish: (m: Message) => void, profiles: Profiles, setupId: string, bindings: Record<string, Setup>, retiredBindings: Record<string, string> = {}, 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<ReturnType<NonNullable<typeof metadataCustody>>>;
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();

View File

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

View File

@@ -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<string>();
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<typeof metadataDrafts>, question: (prompt: string) => Promise<string>, previous?: MetadataDraft): Promise<MetadataDraft> {
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);
}

View File

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

View File

@@ -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<string, unknown> };
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<string, unknown> };
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<string, unknown> {
@@ -20,7 +20,7 @@ export function fields(value: Record<string, unknown>, 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'); }

View File

@@ -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<string> {
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<unknown> {
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<Event | undefined> {
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}`;
}

View File

@@ -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<string>, access: { open?: boolean; denyAuth?: boolean; denyPublication?: boolean; denyReq?: boolean } = {}) {
export async function nostrFixture(owner: string, allowedIdentities?: ReadonlySet<string>, 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('{}');

View File

@@ -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<void>) => Promise<void>) {
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<string>();
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<ReturnType<typeof host>> | 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));
});