mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
feat(beehive): edit public metadata and deliberately publish with agent identity
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
96
beehive/PUBLIC_METADATA.md
Normal file
96
beehive/PUBLIC_METADATA.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
75
beehive/src/metadata-drafts.ts
Normal file
75
beehive/src/metadata-drafts.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'); }
|
||||
|
||||
83
beehive/src/public-metadata.ts
Normal file
83
beehive/src/public-metadata.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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('{}');
|
||||
|
||||
172
beehive/test/public-metadata.test.ts
Normal file
172
beehive/test/public-metadata.test.ts
Normal 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));
|
||||
});
|
||||
Reference in New Issue
Block a user