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/README.md +3 -3
- package/package.json +3 -3
- package/src/bridge-text.ts +4 -8
- package/src/config.ts +6 -4
- package/src/decider-text.ts +3 -6
- package/src/decider.ts +124 -670
- package/src/drafts.ts +68 -19
- package/src/guard.ts +107 -0
- package/src/handshake.ts +2 -2
- package/src/messages.ts +24 -6
- package/src/plan.ts +133 -0
- package/src/questions.ts +250 -0
- package/src/resolve.ts +387 -0
- package/src/resources.ts +1 -1
- package/src/runtime.ts +201 -25
- package/src/tool.ts +11 -2
- package/src/types.ts +60 -66
- package/src/ui/bootstrap-text.ts +24 -0
- package/src/ui/bootstrap.ts +77 -3
- package/src/ui/index.ts +6 -2
- package/src/ui/mail-message.ts +5 -1
- package/src/ui/viewer-text.ts +112 -4
- package/src/ui/viewer.ts +629 -57
- package/src/urls-text.ts +6 -0
- package/src/urls.ts +36 -4
- package/src/work.ts +10 -0
- package/src/acquire.ts +0 -182
- package/src/host-text.ts +0 -92
- package/src/node.ts +0 -139
- package/src/store.ts +0 -189
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 "
|
|
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 "
|
|
16
|
-
import { canAnswer, ContextRegistry, SqliteStorage } from "
|
|
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
|
|
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
|
-
|
|
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 =>
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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:
|
|
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
|
-
|
|
84
|
-
|
|
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,
|
|
2
|
-
import type {
|
|
3
|
-
import type {
|
|
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?:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
package/src/ui/bootstrap-text.ts
CHANGED
|
@@ -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}",
|