omp-onlyne 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runtime.ts CHANGED
@@ -5,21 +5,30 @@ import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
5
5
  import { ensureKeyDir } from "@onlyne/node-host/keys";
6
6
  import { NetworkDiscovery } from "@onlyne/node-host/network";
7
7
  import { resolveLocale, setLocale } from "@onlyne/node-host/i18n";
8
- import { acquisitionOf } from "./acquire.ts";
8
+ import { acquisitionOf, evaluateAcquisition, type AcquireInput, type AcquireVerdict } from "@onlyne/node-host/acquire";
9
9
  import { AcquisitionLog, approvePeer, beginPeer, handshakeEntry, isTrustThread, judgeApproval, judgementFacts, revokePeer, trustThread, TRUST_THREAD, type ApprovalDeps, type ApprovalJudgement, type ApprovalRequest } from "./handshake.ts";
10
10
  import { commandText as t } from "./ui/command-text.ts";
11
11
  import { registerMsgTool } from "./tool.ts";
12
12
  import { currentDecision, JudgeDecider } from "./decider.ts";
13
13
  import { loadConfig, normalizeServiceUrl, saveConfig, shortNodeId } from "./config.ts";
14
14
  import { createRuntimeBridge } from "./messages.ts";
15
- import { advertisedHost, createOnlyneNode } from "./node.ts";
16
- import { canAnswer, ContextRegistry, SqliteStorage } from "./store.ts";
15
+ import { advertisedHost, createOnlyneNode } from "@onlyne/node-host/node";
16
+ import { canAnswer, ContextRegistry, SqliteStorage } from "@onlyne/node-host/store";
17
17
  import { resourceQuery } from "./resources.ts";
18
- import type { OnlyneConfig, OnlyneStatus, QueueItem, ViewerContact, ViewerDiscovery, ViewerMessage, ViewerSnapshot, ViewerThread } from "./types.ts";
18
+ import type { OnlyneConfig } from "@onlyne/node-host/types";
19
+ import type { AcquisitionAuditEntry, OnlyneStatus, QueueItem, ViewerContact, ViewerDiscovery, ViewerMessage, ViewerSnapshot, ViewerThread, ViewerTrustRow } from "./types.ts";
19
20
 
20
21
  /** The trust transition of RFC 0017 §5, as the host receives it. */
21
22
  type TrustNotify = Extract<NotifyEvent, { event: "trust" }>;
22
23
 
24
+ /** Which actor a judgement in flight belongs to: the agent that asked, or the policy approving on its own. */
25
+ interface AuditScope {
26
+ actor: AcquisitionAuditEntry["actor"];
27
+ action: string;
28
+ /** The judgement this scope produced, kept because a refusal throws away the value the caller sees. */
29
+ capture?: (judgement: ApprovalJudgement) => void;
30
+ }
31
+
23
32
  export interface PluginRuntime {
24
33
  readonly config: OnlyneConfig;
25
34
  readonly endpoint: string;
@@ -83,6 +92,16 @@ export async function stopRuntime(): Promise<void> {
83
92
  /** How often the discovery catalog is re-observed; well inside the library's 120 s freshness window. */
84
93
  const DISCOVERY_REFRESH_MS = 30_000;
85
94
 
95
+ /**
96
+ * The discovery layer's loopback switch. `createOnlyneNode` passes no `plugins.allow_loopback`,
97
+ * so this host always runs the documented default; the panel reports it beside the policy.
98
+ */
99
+ const ALLOW_LOOPBACK = false;
100
+ /** Audit rows kept in the store; the panel renders the newest 20, pruning keeps the file bounded. */
101
+ const AUDIT_PREFIX = "acquisition/audit/";
102
+ const AUDIT_KEEP = 64;
103
+ const AUDIT_VISIBLE = 20;
104
+
86
105
  /** The alias the context registry gives this node's main session (`ContextRegistry.ensureMain`). */
87
106
  const MAIN_CONTEXT = "main";
88
107
 
@@ -112,6 +131,10 @@ class OmpRuntime implements PluginRuntime {
112
131
  endpoint = "";
113
132
  /** RFC 0018 §2.1: where the connection being served came from, for the library's own call into `notify`. */
114
133
  private readonly connection = new AsyncLocalStorage<string | undefined>();
134
+ /** RFC 0018 §5: the actor a judgement in flight belongs to, so the audit can tell an agent from the policy. */
135
+ private readonly auditScope = new AsyncLocalStorage<AuditScope>();
136
+ /** Two audit rows can share a millisecond; the tick keeps their stored order equal to their arrival. */
137
+ private auditTick = 0;
115
138
  private readonly log: AcquisitionLog;
116
139
  /** The host side of one approval, built once with the node in `start`. */
117
140
  private peers!: ApprovalDeps;
@@ -169,6 +192,21 @@ class OmpRuntime implements PluginRuntime {
169
192
  if (this.config.server.public_url === undefined && this.config.server.port !== bound) {
170
193
  await this.rememberPort(bound);
171
194
  }
195
+ // RFC 0018 §2.3/§5.1: a trust seed is a human pre-authorization the policy never judges,
196
+ // so the bypass is written where it can be re-read: one line per seed naming the config
197
+ // file it came from, and the policy status beside it. A fingerprint is the audit fact,
198
+ // never a credential. The discovery axis is separate and, like the policy, reported here.
199
+ const policy = acquisitionOf(this.config);
200
+ this.pi.logger.info("onlyne.acquisition.policy", {
201
+ policy: policy.policy, allow: [...policy.allow],
202
+ allow_loopback: ALLOW_LOOPBACK,
203
+ });
204
+ for (const seed of this.config.trust ?? []) {
205
+ this.pi.logger.info("onlyne.acquisition.seed", {
206
+ node: seed.node_id, fingerprint: seed.fingerprint, source: this.config.path,
207
+ actor: "human", conclusion: "seed_authorized",
208
+ });
209
+ }
172
210
  const instanceId = crypto.randomUUID();
173
211
  // The decider probes peers for their execution contexts, so it must use the node's own
174
212
  // transport: an unsigned probe is refused by every keyed peer.
@@ -201,7 +239,15 @@ class OmpRuntime implements PluginRuntime {
201
239
  .filter(thread => !isTrustThread(this.config.node.id, thread.thread_ref)),
202
240
  listJoinedThreads: async () => (await allPages(cursor => this.node.threads.joined(this.node.auth, { page_size: 100, cursor })))
203
241
  .filter(thread => !isTrustThread(this.config.node.id, thread.thread_ref)),
204
- listMessages: (thread, cursor) => this.node.messages.list(this.node.auth, thread, { attention: "thread", page_size: 100, cursor }),
242
+ // A joined thread's entries live on its owner; this node holds no local copy, so the
243
+ // owner's message route is the only source. Both legs keep the same page shape, and
244
+ // resolving a joined `{thread, seq}` reference now reads the entries it names.
245
+ listMessages: async (thread, cursor) => {
246
+ const query = { attention: "thread" as const, page_size: 100, ...(cursor === undefined ? {} : { cursor }) };
247
+ return thread.owner === this.config.node.id
248
+ ? this.node.messages.list(this.node.auth, thread, query)
249
+ : this.node.client.threadEntries(thread, query);
250
+ },
205
251
  listInbox: async () => (await allPages(cursor => this.node.messages.inbox(this.node.auth, { page_size: 100, cursor })))
206
252
  .map(row => ({ context: row.context, attention: row.attention })),
207
253
  threadMembers: async thread => (await this.node.threads.describe(this.node.auth, thread)).members.map(member => member.peer),
@@ -237,7 +283,11 @@ class OmpRuntime implements PluginRuntime {
237
283
  observed: async (nodeId, fingerprint) => (await this.log.observation(nodeId, fingerprint))?.observed,
238
284
  declared: async nodeId => (await this.node.trust.entry(nodeId))?.base_url,
239
285
  recorded: nodeId => this.recorded(nodeId),
240
- audit: judgement => this.pi.logger.info("onlyne.acquire.decided", judgementFacts(judgement)),
286
+ audit: judgement => {
287
+ this.pi.logger.info("onlyne.acquire.decided", judgementFacts(judgement));
288
+ // The caller decided the actor; a judgement made with no scope is the policy's own.
289
+ this.auditScope.getStore()?.capture?.(judgement);
290
+ },
241
291
  accept: (nodeId, fingerprint) => this.node.trust.accept(this.node.auth, nodeId, fingerprint),
242
292
  revoke: nodeId => this.node.trust.revoke(this.node.auth, nodeId),
243
293
  now: () => new Date().toISOString(),
@@ -248,7 +298,7 @@ class OmpRuntime implements PluginRuntime {
248
298
  if (!current || current.domain_id !== this.config.region) {
249
299
  let expected_revision: string | undefined;
250
300
  try { expected_revision = (await this.node.domains.describe(this.node.auth, this.config.region)).revision; }
251
- catch (error) { if (!(error instanceof OnlyneError && error.error.code === "not_found")) throw error; }
301
+ catch (error) { if (!isNotFoundError(error)) throw error; }
252
302
  await this.node.domains.join(this.node.auth, this.config.region, { ...mutation(), expected_revision });
253
303
  }
254
304
  handler = request => this.node.fetch(request);
@@ -318,6 +368,17 @@ class OmpRuntime implements PluginRuntime {
318
368
  ...(observed === undefined ? {} : { observed }), at, cause: event.cause,
319
369
  });
320
370
  } else await this.log.transition(node, fingerprint, at, event.cause);
371
+ // RFC 0018 §2.3/§5: a pinned seed is a human pre-authorization the policy never judges.
372
+ // The startup log lists the seeds; this row records the statement that actually used one.
373
+ const seed = (this.config.trust ?? []).find(row => row.node_id === node && row.fingerprint === fingerprint);
374
+ if (event.cause === "peer-statement" && seed !== undefined) {
375
+ await this.recordAudit({
376
+ at, actor: "seed", action: "seed_authorized", node_id: node, fingerprint,
377
+ ...(observed === undefined ? {} : { observed }),
378
+ ...(event.entry.base_url === "" ? {} : { declared: event.entry.base_url }),
379
+ policy: this.peers.policy.policy, allowed: true, reason: "seed_authorized", override: true, source: this.config.path,
380
+ });
381
+ }
321
382
  // RFC 0018 §2.2: the verdict reads the catalog as it stood when the statement arrived.
322
383
  // The record this host derives from that statement is not part of the judgement, or the
323
384
  // two conditions would collapse into one.
@@ -333,6 +394,8 @@ class OmpRuntime implements PluginRuntime {
333
394
  const judgement = event.phase === "invited" && event.cause === "peer-statement"
334
395
  ? await judgeApproval(this.peers, { node_id: node, fingerprint, observed, recorded })
335
396
  : undefined;
397
+ // §4.1: a verdict is audited whether it allows the approval or refuses it.
398
+ if (judgement !== undefined) await this.recordAudit(this.auditEntry(judgement, "policy", "approve"));
336
399
  if (judgement?.verdict.allowed === true) this.approveDeferred(node, fingerprint);
337
400
  await this.deliverHandshake(event, observed, judgement);
338
401
  }
@@ -382,7 +445,7 @@ class OmpRuntime implements PluginRuntime {
382
445
  await this.node.threads.describe(this.node.auth, ref);
383
446
  return;
384
447
  } catch (error) {
385
- if (!(error instanceof OnlyneError && error.error.code === "not_found")) throw error;
448
+ if (!isNotFoundError(error)) throw error;
386
449
  }
387
450
  await this.node.threads.create(this.node.auth, { title: TRUST_THREAD, id: ref.id, ...mutation() });
388
451
  }
@@ -391,6 +454,52 @@ class OmpRuntime implements PluginRuntime {
391
454
  const records = await this.node.discovery.listRecords();
392
455
  return [...new Set(records.filter(record => record.node_id === node).map(record => record.base_url))];
393
456
  }
457
+ /**
458
+ * The persisted acquisition audit. One row per fact, keyed by its own time so the scan is the
459
+ * history; the oldest rows are pruned past `AUDIT_KEEP`, and the panel reads the newest
460
+ * `AUDIT_VISIBLE` back out. A failed write is logged and never doubts a committed decision.
461
+ */
462
+ private async recordAudit(entry: AcquisitionAuditEntry): Promise<void> {
463
+ if (this.closed) return;
464
+ try {
465
+ await this.storage.transaction(async tx => {
466
+ const rows = await tx.scan<AcquisitionAuditEntry>(AUDIT_PREFIX);
467
+ await tx.put(`${AUDIT_PREFIX}${entry.at}/${String(this.auditTick++).padStart(12, "0")}-${crypto.randomUUID()}`, entry);
468
+ const excess = rows.length + 1 - AUDIT_KEEP;
469
+ for (const row of rows.slice(0, Math.max(0, excess))) await tx.delete(row.key);
470
+ });
471
+ } catch (error) {
472
+ this.pi.logger.warn("onlyne.acquisition.audit_failed", { error: errorText(error) });
473
+ }
474
+ }
475
+ /** One judgement as the audit stores it: the time, the actor, and every input the verdict read. */
476
+ private auditEntry(judgement: ApprovalJudgement, actor: AcquisitionAuditEntry["actor"], action: string): AcquisitionAuditEntry {
477
+ return {
478
+ at: judgement.at, actor, action, node_id: judgement.node_id,
479
+ ...(judgement.fingerprint === undefined ? {} : { fingerprint: judgement.fingerprint }),
480
+ ...(judgement.input.observed === undefined ? {} : { observed: judgement.input.observed }),
481
+ ...(judgement.input.declared === undefined ? {} : { declared: judgement.input.declared }),
482
+ policy: judgement.policy.policy, allowed: judgement.verdict.allowed, reason: judgement.verdict.reason,
483
+ };
484
+ }
485
+ /**
486
+ * What the policy would say about this peer right now, read from the same stored facts a
487
+ * judgement reads. The human path asks only to mark an override; it never gates the action.
488
+ */
489
+ private async policyVerdict(node: string, fingerprint?: string): Promise<{ observed?: string; declared?: string; verdict: AcquireVerdict }> {
490
+ const observed = fingerprint === undefined ? undefined : (await this.log.observation(node, fingerprint))?.observed;
491
+ const declared = (await this.node.trust.entry(node))?.base_url;
492
+ const input: AcquireInput = {
493
+ ...(observed === undefined || observed === "" ? {} : { observed }),
494
+ ...(declared === undefined || declared === "" ? {} : { declared }),
495
+ recorded: await this.recorded(node),
496
+ };
497
+ return {
498
+ ...(observed === undefined ? {} : { observed }),
499
+ ...(declared === undefined ? {} : { declared }),
500
+ verdict: await evaluateAcquisition(this.peers.policy, input),
501
+ };
502
+ }
394
503
  /**
395
504
  * A user-typed address. It is normalized once, read for its own identity and everything it
396
505
  * declares, and only a successful read becomes a seed. The node keeps running: a seed is a
@@ -449,41 +558,78 @@ class OmpRuntime implements PluginRuntime {
449
558
  }
450
559
  /** Stage one of one exchange: the peer's proved fingerprint, and the session that commits it. */
451
560
  async trustBegin(node: string): Promise<KeyExchange> {
452
- return this.node.trust.begin(this.node.auth, node);
561
+ const session = await this.node.trust.begin(this.node.auth, node);
562
+ await this.recordAudit({
563
+ at: new Date().toISOString(), actor: "human", action: "begin",
564
+ node_id: node, fingerprint: session.fingerprint, declared: session.base_url,
565
+ });
566
+ return session;
453
567
  }
454
568
  async trustAccept(node: string, fingerprint: string): Promise<void> {
569
+ // The panel keeps the human gate outside the policy (RFC 0018 §2). Read the verdict first
570
+ // so a row the policy would have refused is recorded as the override it is.
571
+ const admission = await this.policyVerdict(node, fingerprint);
455
572
  await this.node.trust.accept(this.node.auth, node, fingerprint);
573
+ await this.recordAudit({
574
+ at: new Date().toISOString(), actor: "human", action: "accept", node_id: node, fingerprint,
575
+ ...(admission.observed === undefined ? {} : { observed: admission.observed }),
576
+ ...(admission.declared === undefined ? {} : { declared: admission.declared }),
577
+ policy: this.peers.policy.policy, allowed: admission.verdict.allowed, reason: admission.verdict.reason,
578
+ ...(admission.verdict.allowed ? {} : { override: true }),
579
+ });
456
580
  }
457
581
  async trustRevoke(node: string): Promise<void> {
582
+ const row = await this.node.trust.entry(node);
583
+ const observed = row === undefined ? undefined : (await this.log.observation(node, row.fingerprint))?.observed;
458
584
  await this.node.trust.revoke(this.node.auth, node);
585
+ await this.recordAudit({
586
+ at: new Date().toISOString(), actor: "human", action: "revoke", node_id: node,
587
+ ...(row === undefined ? {} : { fingerprint: row.fingerprint, declared: row.base_url }),
588
+ ...(observed === undefined ? {} : { observed }),
589
+ });
459
590
  }
460
591
  /** RFC 0018 §2/§3.1: the agent's approval, refused with `policy_denied` when the policy does not allow it. */
461
592
  async acquireAccept(node: string, fingerprint: string): Promise<TrustEntry> {
462
- return approvePeer(this.peers, node, fingerprint);
593
+ let judged: ApprovalJudgement | undefined;
594
+ try {
595
+ return await this.auditScope.run({ actor: "agent", action: "accept", capture: judgement => { judged = judgement; } },
596
+ () => approvePeer(this.peers, node, fingerprint));
597
+ } finally {
598
+ if (judged !== undefined) await this.recordAudit(this.auditEntry(judged, "agent", "accept"));
599
+ }
463
600
  }
464
601
  /** RFC 0018 §2/§3.1: the agent's revocation, which no policy judges. */
465
602
  async acquireRevoke(node: string): Promise<void> {
466
603
  await revokePeer(this.peers, node);
604
+ await this.recordAudit({ at: new Date().toISOString(), actor: "agent", action: "revoke", node_id: node });
467
605
  }
468
606
  /**
469
607
  * RFC 0018 §2/§3.1: the agent's initiation of one exchange. The judgement reads only what this
470
608
  * host already stores; the peer's key is read after it, so a refusal sends nothing.
471
609
  */
472
610
  async acquireBegin(node: string): Promise<TrustEntry> {
473
- const row = await this.node.trust.entry(node);
474
- const statement = row === undefined ? undefined : (await this.log.observation(node, row.fingerprint))?.observed;
475
- const records = (await this.node.discovery.listRecords()).filter(record => record.node_id === node);
476
- const observed = outboundObserved({ statement, records, declared: row?.base_url });
477
- const request: ApprovalRequest = {
478
- node_id: node,
479
- ...(row === undefined ? {} : { fingerprint: row.fingerprint }),
480
- ...(observed === undefined ? {} : { observed }),
481
- };
482
- const committed = await beginPeer(this.peers, target => this.node.trust.begin(this.node.auth, target), request);
483
- return committed.entry;
611
+ let judged: ApprovalJudgement | undefined;
612
+ try {
613
+ return await this.auditScope.run({ actor: "agent", action: "begin", capture: judgement => { judged = judgement; } }, async () => {
614
+ const row = await this.node.trust.entry(node);
615
+ const statement = row === undefined ? undefined : (await this.log.observation(node, row.fingerprint))?.observed;
616
+ const records = (await this.node.discovery.listRecords()).filter(record => record.node_id === node);
617
+ const observed = outboundObserved({ statement, records, declared: row?.base_url });
618
+ const request: ApprovalRequest = {
619
+ node_id: node,
620
+ ...(row === undefined ? {} : { fingerprint: row.fingerprint }),
621
+ ...(observed === undefined ? {} : { observed }),
622
+ };
623
+ const committed = await beginPeer(this.peers, target => this.node.trust.begin(this.node.auth, target), request);
624
+ return committed.entry;
625
+ });
626
+ } finally {
627
+ if (judged !== undefined) await this.recordAudit(this.auditEntry(judged, "agent", "begin"));
628
+ }
484
629
  }
485
630
  async trustRotate(): Promise<{ fingerprint: string }> {
486
631
  const identity = await this.node.trust.rotate(this.node.auth);
632
+ await this.recordAudit({ at: new Date().toISOString(), actor: "human", action: "rotate", fingerprint: identity.fingerprint });
487
633
  return { fingerprint: identity.fingerprint };
488
634
  }
489
635
  async refreshDiscovery(): Promise<void> {
@@ -543,7 +689,7 @@ class OmpRuntime implements PluginRuntime {
543
689
  const entries = await allPages(cursor => this.node.messages.list(this.node.auth, ref, { attention: "thread", page_size: 100, cursor }));
544
690
  views.push({ ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: thread.title, state: thread.state,
545
691
  unread: inbox.filter(item => !item.injected && item.attention.thread_ref.owner === ref.owner && item.attention.thread_ref.id === ref.id).length,
546
- last_seq: entries.at(-1)?.seq ?? "0", messages: entries.map(entry => ({ ref: { sender: entry.message.sender, id: entry.message.id }, seq: entry.seq, direction: entry.message.sender === this.config.node.id ? "out" : "in", author: entry.authenticated_author, at: entry.at, text: entry.message.msg, relation: entry.message.meta?.relation })),
692
+ last_seq: entries.at(-1)?.seq ?? "0", messages: entries.map(entry => ({ ref: { sender: entry.message.sender, id: entry.message.id }, seq: entry.seq, direction: entry.message.sender === this.config.node.id ? "out" : "in", author: entry.authenticated_author, at: entry.at, text: entry.message.msg, relation: entry.message.meta?.relation, ...(entry.message.meta?.work === undefined ? {} : { work: entry.message.meta.work }) })),
547
693
  });
548
694
  }
549
695
  // A member's own message never comes back through its inbox, so the message that
@@ -570,7 +716,7 @@ class OmpRuntime implements PluginRuntime {
570
716
  if (!item.injected) view.unread++;
571
717
  const entry = item.attention.entry;
572
718
  if (entry !== undefined) {
573
- view.messages.push({ ref: { sender: entry.message.sender, id: entry.message.id }, seq: entry.seq, direction: entry.message.sender === this.config.node.id ? "out" : "in", author: entry.authenticated_author, at: entry.at, text: entry.message.msg, relation: entry.message.meta?.relation });
719
+ view.messages.push({ ref: { sender: entry.message.sender, id: entry.message.id }, seq: entry.seq, direction: entry.message.sender === this.config.node.id ? "out" : "in", author: entry.authenticated_author, at: entry.at, text: entry.message.msg, relation: entry.message.meta?.relation, ...(entry.message.meta?.work === undefined ? {} : { work: entry.message.meta.work }) });
574
720
  view.last_seq = entry.seq;
575
721
  }
576
722
  }
@@ -583,7 +729,7 @@ class OmpRuntime implements PluginRuntime {
583
729
  const view = seen.get(key) ?? { ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: t("thread", { node: shortNodeId(ref.owner) }), state: "open", unread: 0, last_seq: "0", messages: [], read_only: true };
584
730
  seen.set(key, view);
585
731
  const message = row.value.request.message;
586
- view.messages.push({ ref: { sender: message.sender, id: message.id }, seq: receipt.seq, direction: "out", author: message.sender, at: row.value.created_at ?? "", text: message.msg, relation: message.meta?.relation });
732
+ view.messages.push({ ref: { sender: message.sender, id: message.id }, seq: receipt.seq, direction: "out", author: message.sender, at: row.value.created_at ?? "", text: message.msg, relation: message.meta?.relation, ...(message.meta?.work === undefined ? {} : { work: message.meta.work }) });
587
733
  if (Number(receipt.seq) > Number(view.last_seq)) view.last_seq = receipt.seq;
588
734
  }
589
735
  for (const view of seen.values()) view.messages.sort((left, right) => Number(left.seq) - Number(right.seq));
@@ -619,9 +765,28 @@ class OmpRuntime implements PluginRuntime {
619
765
  last_seen: record.last_seen,
620
766
  }));
621
767
  const trust = this.node.keys.enabled ? await this.node.trust.list(this.node.auth) : [];
768
+ // RFC 0018 §2.1: the declared address is what the peer wrote; the observed one is the
769
+ // connection this host recorded for its last statement. Both are shown, never merged.
770
+ const trustRows: ViewerTrustRow[] = [];
771
+ for (const row of trust) {
772
+ const observation = await this.log.observation(row.node_id, row.fingerprint);
773
+ trustRows.push({
774
+ node_id: row.node_id, fingerprint: row.fingerprint, phase: trustPhase(row), base_url: row.base_url,
775
+ ...(observation?.observed === undefined ? {} : { observed: observation.observed }),
776
+ });
777
+ }
778
+ // Newest first: the panel renders this array in order and never sorts it.
779
+ const auditRows = await this.storage.transaction(tx => tx.scan<AcquisitionAuditEntry>(AUDIT_PREFIX));
780
+ const audit = auditRows
781
+ .sort((left, right) => right.value.at.localeCompare(left.value.at) || right.key.localeCompare(left.key))
782
+ .slice(0, AUDIT_VISIBLE)
783
+ .map(row => row.value);
784
+ const policy = acquisitionOf(this.config);
622
785
  const identity = this.node.keys.enabled ? await this.node.keys.describe() : undefined;
623
786
  return { region: this.config.region, endpoint: this.endpoint, contexts, queue, threads: views, discovery, contacts,
624
- trust: trust.map(row => ({ node_id: row.node_id, fingerprint: row.fingerprint, phase: trustPhase(row), base_url: row.base_url })),
787
+ trust: trustRows,
788
+ acquisition: { policy: policy.policy, allow: [...policy.allow], allow_loopback: ALLOW_LOOPBACK },
789
+ audit,
625
790
  ...(identity === undefined ? {} : { identity: { public_key: identity.public_key, fingerprint: identity.fingerprint } }),
626
791
  ...(this.discoveryError === undefined ? {} : { discovery_error: this.discoveryError }) };
627
792
  }
@@ -649,6 +814,17 @@ export async function allPages<T>(read: (cursor?: string) => Promise<Page<T>>):
649
814
 
650
815
  function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); }
651
816
 
817
+ /** Check if an error represents an Onlyne not_found condition, independent of instanceof identity. */
818
+ function isNotFoundError(error: unknown): boolean {
819
+ if (error instanceof OnlyneError) return error.error.code === "not_found";
820
+ if (typeof error === "object" && error !== null) {
821
+ const failure = (error as { error?: { code?: string } }).error;
822
+ if (failure?.code === "not_found") return true;
823
+ if ((error as { message?: string }).message === "The requested record is unavailable.") return true;
824
+ }
825
+ return false;
826
+ }
827
+
652
828
  /**
653
829
  * RFC 0018 §2.1–2.2: the address an outbound judgement reads for a peer this host may initiate
654
830
  * with. The arrival address this host recorded for the peer's last statement is the strongest
package/src/tool.ts CHANGED
@@ -80,8 +80,17 @@ export function registerMsgTool(pi: ExtensionAPI): void {
80
80
  details: { result: entry },
81
81
  };
82
82
  }
83
- if (typeof parsed.data.meta?.draft_id === "string") {
84
- const result = await sendDraft(runtime, ctx, parsed.data.meta.draft_id, signal);
83
+ const draftId = typeof parsed.data.meta?.draft_id === "string" ? parsed.data.meta.draft_id : undefined;
84
+ // `send_draft` names the act, not the row: the draft is found by the message it
85
+ // answers, or by the newest one this session created. `meta.draft_id` alone keeps
86
+ // working because the handoff from a drafting child reports exactly that id.
87
+ if (op === "send_draft" || draftId !== undefined) {
88
+ const result = await sendDraft(runtime, ctx, {
89
+ ...(draftId === undefined ? {} : { id: draftId }),
90
+ ...(parsed.data.meta?.in_reply_to === undefined
91
+ ? {}
92
+ : { in_reply_to: parsed.data.meta.in_reply_to as Json }),
93
+ }, signal);
85
94
  recordAwaitResult(pi, toolCallId, result, ctx);
86
95
  return { content: [{ type: "text", text: `${summary(result, spelling(parsed.data.meta?.to))}\n\n${JSON.stringify(visibleResult(result), null, 2)}` }], details: { result: visibleResult(result) } };
87
96
  }
package/src/types.ts CHANGED
@@ -1,63 +1,6 @@
1
- import type { MessageRef, NodeId, ThreadRef, TrustPhase, TrustSeed } from "@onlyne/lib";
2
- import type { Locale } from "@onlyne/node-host/i18n";
3
- import type { AcquisitionPolicy } from "./acquire.ts";
4
-
5
- /**
6
- * Project-scoped configuration in `<project>/.onlyne/config.toml`.
7
- *
8
- * Delivery decisions are made by OMP's `judge` model role, so no model
9
- * endpoint or model name belongs here.
10
- */
11
- export interface OnlyneConfig {
12
- path: string;
13
- language?: Locale;
14
- /** Protocol membership, independent from the node identity. */
15
- region: string;
16
- node: {
17
- id: NodeId;
18
- key_ref?: string;
19
- /** Where the node identity lives: the project's `.onlyne/keys`, the user's key store, or no key at all. */
20
- key_scope?: "project" | "global" | "none";
21
- };
22
- server: {
23
- bind: string;
24
- /**
25
- * The port this node answers on. Absent asks the operating system for one, and the
26
- * chosen port is written back here: a node whose address changes on every start
27
- * leaves every peer that recorded it calling a port nobody serves.
28
- */
29
- port?: number;
30
- /** The externally reachable base URL when a reverse proxy fronts the node. */
31
- public_url?: string;
32
- };
33
- threads: {
34
- page_size: number;
35
- };
36
- /** Bootstrap contact seeds the node reaches at every start. */
37
- discovery?: {
38
- seeds?: string[];
39
- };
40
- /** Pinned peer fingerprints; an arriving key that matches is trusted without a prompt. */
41
- trust?: TrustSeed[];
42
- /** RFC 0018 §2: peers this node may approve without a human confirming first. */
43
- acquisition?: AcquisitionPolicy;
44
- }
45
-
46
- /** One execution context the plugin registered for a delivered message. */
47
- export interface ContextRecord {
48
- alias: string;
49
- node_id: NodeId;
50
- main_agent_id: string;
51
- source?: { sender: NodeId; id: string };
52
- agent_id?: string;
53
- session_file?: string;
54
- parent_id?: string;
55
- thread_ref?: ThreadRef;
56
- summary: string;
57
- state: "unbound" | "idle" | "running" | "parked" | "lost";
58
- created_at: string;
59
- bound_at?: string;
60
- }
1
+ import type { MessageRef, NodeId, ThreadRef, TrustPhase, WorkRef } from "@onlyne/lib";
2
+ import type { AcquirePolicy } from "@onlyne/node-host/acquire";
3
+ import type { ContextRecord } from "@onlyne/node-host/types";
61
4
 
62
5
  /** One row of the viewer's queue page. */
63
6
  export interface QueueItem {
@@ -77,6 +20,8 @@ export interface ViewerMessage {
77
20
  at: string;
78
21
  text: string;
79
22
  relation?: string;
23
+ /** The work unit the sender named (RFC 0003 §1), when the message carries one. */
24
+ work?: WorkRef;
80
25
  }
81
26
 
82
27
  /** One thread row in the viewer's chat page. */
@@ -118,6 +63,56 @@ export interface ViewerContact {
118
63
  last_seen: string;
119
64
  }
120
65
 
66
+ /** RFC 0018 §5: the acquisition settings this runtime actually runs with, read back for the panel. */
67
+ export interface AcquisitionSnapshot {
68
+ policy: AcquirePolicy;
69
+ allow: string[];
70
+ /**
71
+ * The discovery layer's separate loopback switch (RFC 0018 §5.1). It decides what may enter
72
+ * the catalog, not what may be approved without a human, and the panel shows both values
73
+ * together so loosening one is never mistaken for loosening the other.
74
+ */
75
+ allow_loopback: boolean;
76
+ }
77
+
78
+ /**
79
+ * One acquisition audit row. The actors stay distinguishable (RFC 0018 §5): a human's panel
80
+ * action, an agent's decision, a configured seed's pre-authorization, and the policy's own
81
+ * automatic verdict. `allowed` is the verdict the gate reached; `override` marks a human
82
+ * approval that committed although the policy refused it, and a seed authorization that
83
+ * bypasses the policy by design.
84
+ */
85
+ export interface AcquisitionAuditEntry {
86
+ at: string;
87
+ actor: "human" | "agent" | "seed" | "policy";
88
+ action: string;
89
+ node_id?: NodeId;
90
+ fingerprint?: string;
91
+ /** The address the peer's statement arrived from, as this host observed it. */
92
+ observed?: string;
93
+ /** The address the peer wrote into its own statement. */
94
+ declared?: string;
95
+ policy?: AcquirePolicy;
96
+ allowed?: boolean;
97
+ reason?: string;
98
+ override?: boolean;
99
+ /** The configuration file a seed authorization came from. */
100
+ source?: string;
101
+ }
102
+
103
+ /**
104
+ * One trust row with both addresses the panel needs. `base_url` is what the peer declared;
105
+ * `observed` is where its last statement actually arrived from, and the two need not agree
106
+ * (RFC 0018 §2.1).
107
+ */
108
+ export interface ViewerTrustRow {
109
+ node_id: NodeId;
110
+ fingerprint: string;
111
+ phase: TrustPhase;
112
+ base_url: string;
113
+ observed?: string;
114
+ }
115
+
121
116
  /** Read-only snapshot the viewer renders. */
122
117
  export interface ViewerSnapshot {
123
118
  region?: string;
@@ -129,12 +124,11 @@ export interface ViewerSnapshot {
129
124
  contacts: ViewerContact[];
130
125
  discovery_error?: string;
131
126
  /** Every trust row, projected through `trustPhase`: the only stage this package renders. */
132
- trust?: Array<{
133
- node_id: NodeId;
134
- fingerprint: string;
135
- phase: TrustPhase;
136
- base_url: string;
137
- }>;
127
+ trust?: ViewerTrustRow[];
128
+ /** RFC 0018 §5: the acquisition panel's current settings. */
129
+ acquisition?: AcquisitionSnapshot;
130
+ /** The persisted acquisition audit, newest first; the panel renders the first 20. */
131
+ audit?: AcquisitionAuditEntry[];
138
132
  identity?: { public_key: string; fingerprint: string };
139
133
  }
140
134
 
@@ -15,6 +15,8 @@ const en = {
15
15
  "bootstrap.field.keyScope": "Node key (project/global/none)",
16
16
  "bootstrap.field.pageSize": "Page size",
17
17
  "bootstrap.field.contact": "Node addresses (optional)",
18
+ "bootstrap.field.policy": "Acquisition policy (all/segment/none)",
19
+ "bootstrap.field.allow": "Acquisition allow set",
18
20
  "bootstrap.hint.region": "A short name is stored as urn:onlyne:region:<name>",
19
21
  "bootstrap.hint.nodeId": "A short name is stored as urn:onlyne:agent:<name>",
20
22
  "bootstrap.hint.bind": "127.0.0.1",
@@ -23,6 +25,16 @@ const en = {
23
25
  "bootstrap.hint.pageSize": "1..100, default 50",
24
26
  "bootstrap.hint.contact":
25
27
  "Nodes discovery cannot reach are contacted by address; stored as discovery.seeds and contacted on every start. Separate addresses with commas",
28
+ "bootstrap.hint.policy":
29
+ "segment: only addresses inside the allow set are approved automatically; all: any peer; none: only a human approves",
30
+ "bootstrap.hint.allow":
31
+ "CIDR blocks or host names, comma separated. The default is loopback only; a private range lets every host in it pair without asking you",
32
+ "bootstrap.policyInvalid": "Acquisition policy must be all, segment or none.",
33
+ "bootstrap.allowInvalid": "Allow entries must be network/prefix blocks or host names, got {value}.",
34
+ "bootstrap.policy.all": "all",
35
+ "bootstrap.policy.segment": "segment",
36
+ "bootstrap.policy.none": "none",
37
+ "bootstrap.policy.title": "Set the acquisition policy to {value}",
26
38
  "bootstrap.empty": "[empty]",
27
39
  "bootstrap.regionMissing": "Region is required; a short name is stored as urn:onlyne:region:<name>",
28
40
  "bootstrap.regionPreview": "Will be stored as {uri}",
@@ -53,6 +65,7 @@ const en = {
53
65
  "mail.thread": "Thread",
54
66
  "mail.relation": "Relation",
55
67
  "mail.inReplyTo": "In-Reply-To",
68
+ "mail.work": "Work",
56
69
  "mail.delivery": "Delivery",
57
70
  "mail.status": "Status",
58
71
  "mail.consumed": " · consumed {state}",
@@ -73,6 +86,8 @@ const zh: Record<keyof typeof en, string> = {
73
86
  "bootstrap.field.keyScope": "Node key (project/global/none)",
74
87
  "bootstrap.field.pageSize": "Page size",
75
88
  "bootstrap.field.contact": "节点地址(可选)",
89
+ "bootstrap.field.policy": "获取策略(all/segment/none)",
90
+ "bootstrap.field.allow": "获取允许集合",
76
91
  "bootstrap.hint.region": "短名保存为 urn:onlyne:region:<短名>",
77
92
  "bootstrap.hint.nodeId": "短名保存为 urn:onlyne:agent:<短名>",
78
93
  "bootstrap.hint.bind": "127.0.0.1",
@@ -80,6 +95,14 @@ const zh: Record<keyof typeof en, string> = {
80
95
  "project:密钥存 .onlyne/keys(git 内需 ignore);global:存 ~/.config/onlyne/keys;none:回环开发绑定",
81
96
  "bootstrap.hint.pageSize": "1..100,默认 50",
82
97
  "bootstrap.hint.contact": "自动发现到不了的节点按地址联系;保存为 discovery.seeds,每次启动都会联系。多个地址用逗号分隔",
98
+ "bootstrap.hint.policy": "segment:只自动批准允许集合内的地址;all:任意对端;none:只有人类能批准",
99
+ "bootstrap.hint.allow": "CIDR 网段或主机名,逗号分隔。默认只允许回环;填私有网段等于允许其中每台主机不经你确认完成配对",
100
+ "bootstrap.policyInvalid": "获取策略只能是 all、segment 或 none。",
101
+ "bootstrap.allowInvalid": "允许条目必须是 network/prefix 网段或主机名,收到的是 {value}。",
102
+ "bootstrap.policy.all": "all",
103
+ "bootstrap.policy.segment": "segment",
104
+ "bootstrap.policy.none": "none",
105
+ "bootstrap.policy.title": "把获取策略设为 {value}",
83
106
  "bootstrap.empty": "[空]",
84
107
  "bootstrap.regionMissing": "Region 必填;短名将保存为 urn:onlyne:region:<短名>",
85
108
  "bootstrap.regionPreview": "将保存为 {uri}",
@@ -110,6 +133,7 @@ const zh: Record<keyof typeof en, string> = {
110
133
  "mail.thread": "线程",
111
134
  "mail.relation": "关系",
112
135
  "mail.inReplyTo": "回复",
136
+ "mail.work": "工作单元",
113
137
  "mail.delivery": "投送",
114
138
  "mail.status": "状态",
115
139
  "mail.consumed": " · 已消费 {state}",