omp-onlyne 0.1.0 → 0.2.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 +13 -5
- package/package.json +3 -3
- package/src/acquire.ts +182 -0
- package/src/bridge-text.ts +36 -0
- package/src/config.ts +35 -3
- package/src/decider-text.ts +33 -15
- package/src/decider.ts +306 -55
- package/src/handshake.ts +291 -0
- package/src/host-text.ts +10 -4
- package/src/messages.ts +110 -10
- package/src/node.ts +15 -3
- package/src/runtime.ts +268 -8
- package/src/tool-text.ts +64 -30
- package/src/tool.ts +125 -19
- package/src/types.ts +14 -0
- package/src/ui/bootstrap.ts +6 -1
- package/src/ui/viewer-text.ts +4 -0
- package/src/ui/viewer.ts +21 -4
- package/src/urls-text.ts +18 -0
- package/src/urls.ts +170 -29
package/src/handshake.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { OnlyneError, type Json, type KeyExchange, type Message, type NodeId, type Storage, type ThreadRef, type TrustEntry, type TrustPhase } from "@onlyne/lib";
|
|
2
|
+
import { evaluateAcquisition, type AcquireInput, type AcquireVerdict, type AcquisitionPolicy } from "./acquire.ts";
|
|
3
|
+
import { bridgeText as t } from "./bridge-text.ts";
|
|
4
|
+
import { hostText } from "./host-text.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* RFC 0018 §3.2: the handshake entries live in one thread this node owns alone, so nothing in
|
|
8
|
+
* them ever leaves the node, and the business thread tree stays a record of conversations
|
|
9
|
+
* rather than a log of system events.
|
|
10
|
+
*/
|
|
11
|
+
export const TRUST_THREAD = "trust";
|
|
12
|
+
|
|
13
|
+
export function trustThread(nodeId: NodeId): ThreadRef {
|
|
14
|
+
return { owner: nodeId, id: TRUST_THREAD };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isTrustThread(nodeId: NodeId, ref: ThreadRef | undefined): boolean {
|
|
18
|
+
return ref !== undefined && ref.owner === nodeId && ref.id === TRUST_THREAD;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** RFC 0018 §2.1: where one peer statement's connection arrived from. */
|
|
22
|
+
export interface StatementObservation {
|
|
23
|
+
/** The address the connection came from; absent when this host could not observe one. */
|
|
24
|
+
observed?: string;
|
|
25
|
+
at: string;
|
|
26
|
+
cause: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const OBSERVED = "acquire/observed/";
|
|
30
|
+
const DELIVERED = "acquire/delivered/";
|
|
31
|
+
|
|
32
|
+
function observedKey(nodeId: NodeId, fingerprint: string): string {
|
|
33
|
+
return `${OBSERVED}${encodeURIComponent(nodeId)}/${encodeURIComponent(fingerprint)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function deliveredKey(nodeId: NodeId, fingerprint: string, phase: TrustPhase): string {
|
|
37
|
+
return `${DELIVERED}${encodeURIComponent(nodeId)}/${encodeURIComponent(fingerprint)}/${phase}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* RFC 0018 §2.1/§3: the two durable records this host keeps about a handshake. The observed
|
|
42
|
+
* address is written for every statement, before any verdict, so an approval decided later
|
|
43
|
+
* reads the address that statement itself arrived from and never the address of the call that
|
|
44
|
+
* asks (§2.1). The delivered records are one per `(node_id, fingerprint, phase)`, so a
|
|
45
|
+
* replayed event appends no second entry.
|
|
46
|
+
*/
|
|
47
|
+
export class AcquisitionLog {
|
|
48
|
+
constructor(private readonly storage: Storage) {}
|
|
49
|
+
|
|
50
|
+
async observe(nodeId: NodeId, fingerprint: string, observation: StatementObservation): Promise<void> {
|
|
51
|
+
await this.storage.transaction(tx => tx.put(observedKey(nodeId, fingerprint), observation));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A local transition is still an observed trust event, but it must not replace the address
|
|
56
|
+
* of the peer statement. Keep its timestamp and cause beside the original address instead.
|
|
57
|
+
*/
|
|
58
|
+
async transition(nodeId: NodeId, fingerprint: string, at: string, cause: string): Promise<void> {
|
|
59
|
+
await this.storage.transaction(async tx => {
|
|
60
|
+
const key = observedKey(nodeId, fingerprint);
|
|
61
|
+
const current = await tx.get<StatementObservation>(key);
|
|
62
|
+
await tx.put(key, { ...(current?.observed === undefined ? {} : { observed: current.observed }), at, cause });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async observation(nodeId: NodeId, fingerprint: string): Promise<StatementObservation | undefined> {
|
|
67
|
+
return this.storage.transaction(tx => tx.get<StatementObservation>(observedKey(nodeId, fingerprint)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async delivered(nodeId: NodeId, fingerprint: string, phase: TrustPhase): Promise<boolean> {
|
|
71
|
+
return (await this.storage.transaction(tx => tx.get(deliveredKey(nodeId, fingerprint, phase)))) !== undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async markDelivered(nodeId: NodeId, fingerprint: string, phase: TrustPhase, at: string): Promise<void> {
|
|
75
|
+
await this.storage.transaction(tx => tx.put(deliveredKey(nodeId, fingerprint, phase), { node_id: nodeId, fingerprint, phase, at }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* RFC 0018 §3: the reports an agent already acted on are settled. A revocation names no
|
|
80
|
+
* fingerprint, so it settles every report held for the node; a later statement opens its own.
|
|
81
|
+
*/
|
|
82
|
+
async settle(nodeId: NodeId, fingerprint?: string): Promise<void> {
|
|
83
|
+
const prefix = `${DELIVERED}${encodeURIComponent(nodeId)}/${fingerprint === undefined ? "" : `${encodeURIComponent(fingerprint)}/`}`;
|
|
84
|
+
await this.storage.transaction(async tx => {
|
|
85
|
+
for (const row of await tx.scan(prefix)) await tx.delete(row.key);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The machine facts a handshake entry carries beside its body (RFC 0018 §3). */
|
|
91
|
+
export interface HandshakeFacts {
|
|
92
|
+
node_id: NodeId;
|
|
93
|
+
fingerprint: string;
|
|
94
|
+
phase: TrustPhase;
|
|
95
|
+
/** Whether this entry owes an agent decision; a status update owes none. */
|
|
96
|
+
pending: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The facts one handshake entry carries, or undefined for an entry that is not one. */
|
|
100
|
+
export function handshakeFacts(meta: Message["meta"] | undefined): HandshakeFacts | undefined {
|
|
101
|
+
const value = meta?.data?.trust;
|
|
102
|
+
if (value === null || value === undefined || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
103
|
+
const row = value as Record<string, Json>;
|
|
104
|
+
const { node_id: node, fingerprint, phase, pending } = row;
|
|
105
|
+
if (typeof node !== "string" || typeof fingerprint !== "string" || typeof pending !== "boolean") return undefined;
|
|
106
|
+
if (phase !== "invited" && phase !== "requested" && phase !== "exchanged" && phase !== "revoked") return undefined;
|
|
107
|
+
return { node_id: node, fingerprint, phase, pending };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface HandshakeInput {
|
|
111
|
+
node_id: NodeId;
|
|
112
|
+
fingerprint: string;
|
|
113
|
+
phase: TrustPhase;
|
|
114
|
+
/** The address the peer wrote into its own statement. */
|
|
115
|
+
declared: string;
|
|
116
|
+
/** The address that statement arrived from; absent when this host observed none. */
|
|
117
|
+
observed?: string;
|
|
118
|
+
policy: AcquisitionPolicy;
|
|
119
|
+
/** The judgement made for this event, when one was made. */
|
|
120
|
+
judgement?: ApprovalJudgement;
|
|
121
|
+
/** Whether an approval was committed without an agent deciding first. */
|
|
122
|
+
approved: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* RFC 0018 §3: one handshake entry. The body carries what the judgement read and concluded;
|
|
127
|
+
* the action identifiers appear only when a decision is actually owed, and a status update
|
|
128
|
+
* says so instead. `pending` is the same fact for the host's todo hook.
|
|
129
|
+
*/
|
|
130
|
+
export function handshakeEntry(input: HandshakeInput): { msg: string; trust: { [key: string]: Json } } {
|
|
131
|
+
// RFC 0018 §3: only an invitation this host did not approve itself is work. A pending local
|
|
132
|
+
// approval waits on the peer, so there is nothing to do and nothing to wake a turn for.
|
|
133
|
+
const pending = input.phase === "invited" && !input.approved;
|
|
134
|
+
const lines = [
|
|
135
|
+
t("trustHeader", { phase: input.phase, node: input.node_id }),
|
|
136
|
+
t("trustFingerprint", { fingerprint: input.fingerprint }),
|
|
137
|
+
t("trustDeclared", { declared: input.declared }),
|
|
138
|
+
t("trustObserved", { observed: input.observed ?? t("trustUnobserved") }),
|
|
139
|
+
t("trustPolicy", { policy: input.policy.policy }),
|
|
140
|
+
];
|
|
141
|
+
if (input.judgement !== undefined) {
|
|
142
|
+
lines.push(t("trustJudgement", {
|
|
143
|
+
reason: input.judgement.verdict.reason,
|
|
144
|
+
conclusion: input.judgement.verdict.allowed ? t("trustAllowed") : t("trustRefused"),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
if (input.phase === "invited") lines.push(input.approved ? t("trustApproved") : t("trustInvited"));
|
|
148
|
+
else if (input.phase === "requested") lines.push(t("trustRequested"));
|
|
149
|
+
else if (input.phase === "exchanged") lines.push(t("trustExchanged"));
|
|
150
|
+
else lines.push(t("trustRevoked"));
|
|
151
|
+
if (input.phase === "invited" && !input.approved) {
|
|
152
|
+
// RFC 0018 §3: under `none` the agent may only look. Offering an action the policy
|
|
153
|
+
// refuses would ask for a decision that can never be made.
|
|
154
|
+
lines.push(input.policy.policy === "none"
|
|
155
|
+
? t("trustPanelOnly")
|
|
156
|
+
: t("trustAcceptAction", { node: input.node_id, fingerprint: input.fingerprint }));
|
|
157
|
+
}
|
|
158
|
+
if (pending) lines.push(t("trustRevokeAction", { node: input.node_id }));
|
|
159
|
+
return { msg: lines.join("\n"), trust: { node_id: input.node_id, fingerprint: input.fingerprint, phase: input.phase, pending } };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** RFC 0018 §2: one approval judgement and every input it read. */
|
|
163
|
+
export interface ApprovalJudgement {
|
|
164
|
+
at: string;
|
|
165
|
+
node_id: NodeId;
|
|
166
|
+
/** Absent when no fingerprint has been proved yet: an initiation reads the peer's key after the gate. */
|
|
167
|
+
fingerprint?: string;
|
|
168
|
+
policy: AcquisitionPolicy;
|
|
169
|
+
input: AcquireInput;
|
|
170
|
+
verdict: AcquireVerdict;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The judgement as the audit line records it: the time, the peer, and every input the verdict read. */
|
|
174
|
+
export function judgementFacts(judgement: ApprovalJudgement): Record<string, unknown> {
|
|
175
|
+
const { input, verdict } = judgement;
|
|
176
|
+
return {
|
|
177
|
+
at: judgement.at,
|
|
178
|
+
node: judgement.node_id,
|
|
179
|
+
fingerprint: judgement.fingerprint ?? null,
|
|
180
|
+
policy: judgement.policy.policy,
|
|
181
|
+
allow: [...judgement.policy.allow],
|
|
182
|
+
observed: input.observed ?? null,
|
|
183
|
+
declared: input.declared ?? null,
|
|
184
|
+
recorded: [...(input.recorded ?? [])],
|
|
185
|
+
allowed: verdict.allowed,
|
|
186
|
+
reason: verdict.reason,
|
|
187
|
+
...(verdict.matched === undefined ? {} : { matched: verdict.matched }),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Everything the judgement reads from the host, and the two calls it may then commit. */
|
|
192
|
+
export interface ApprovalDeps {
|
|
193
|
+
policy: AcquisitionPolicy;
|
|
194
|
+
log: AcquisitionLog;
|
|
195
|
+
/** RFC 0018 §2.1: the address the peer's last statement arrived from, as this host recorded it. */
|
|
196
|
+
observed(nodeId: NodeId, fingerprint: string): Promise<string | undefined>;
|
|
197
|
+
/** The address the peer wrote into its own statement, never the address of this call. */
|
|
198
|
+
declared(nodeId: NodeId): Promise<string | undefined>;
|
|
199
|
+
/** Every discovery address recorded for this node id; each is an outbound candidate (§2.2). */
|
|
200
|
+
recorded(nodeId: NodeId): Promise<string[]>;
|
|
201
|
+
/** The audit line, written whether or not the verdict allows the approval (§4.1). */
|
|
202
|
+
audit(judgement: ApprovalJudgement): void;
|
|
203
|
+
accept(nodeId: NodeId, fingerprint: string): Promise<TrustEntry>;
|
|
204
|
+
revoke(nodeId: NodeId): Promise<void>;
|
|
205
|
+
now(): string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface ApprovalRequest {
|
|
209
|
+
node_id: NodeId;
|
|
210
|
+
/** The fingerprint the peer proved. Omitted by an initiation, whose key read comes after the gate. */
|
|
211
|
+
fingerprint?: string;
|
|
212
|
+
/** The statement's own connection address, when the caller is holding it. */
|
|
213
|
+
observed?: string;
|
|
214
|
+
/**
|
|
215
|
+
* The discovery addresses as they stood when the statement arrived (RFC 0018 §2.2). A record
|
|
216
|
+
* this host derives from the statement itself is not part of the judgement.
|
|
217
|
+
*/
|
|
218
|
+
recorded?: string[];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* RFC 0018 §2: the one judgement, read by both the agent path and the auto-approval path.
|
|
223
|
+
* `observed` is the address a recorded statement arrived from — the caller's live connection
|
|
224
|
+
* address only when it is judging the statement it just received — and the declared address
|
|
225
|
+
* and every recorded address are checked with it (§2.1–2.2).
|
|
226
|
+
*/
|
|
227
|
+
export async function judgeApproval(deps: ApprovalDeps, request: ApprovalRequest): Promise<ApprovalJudgement> {
|
|
228
|
+
const observed = request.observed
|
|
229
|
+
?? (request.fingerprint === undefined ? undefined : await deps.observed(request.node_id, request.fingerprint));
|
|
230
|
+
const declared = await deps.declared(request.node_id);
|
|
231
|
+
const input: AcquireInput = {
|
|
232
|
+
...(observed === undefined || observed === "" ? {} : { observed }),
|
|
233
|
+
...(declared === undefined || declared === "" ? {} : { declared }),
|
|
234
|
+
recorded: request.recorded ?? await deps.recorded(request.node_id),
|
|
235
|
+
};
|
|
236
|
+
const judgement: ApprovalJudgement = {
|
|
237
|
+
at: deps.now(), node_id: request.node_id, fingerprint: request.fingerprint,
|
|
238
|
+
policy: deps.policy, input, verdict: await evaluateAcquisition(deps.policy, input),
|
|
239
|
+
};
|
|
240
|
+
deps.audit(judgement);
|
|
241
|
+
return judgement;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The refusal an agent-initiated approval gets. A refused approval is not a revocation: the peer's row is left as it is. */
|
|
245
|
+
export function approvalRefused(judgement: ApprovalJudgement): OnlyneError {
|
|
246
|
+
const { policy, verdict } = judgement;
|
|
247
|
+
return new OnlyneError("forbidden", hostText("acquireDenied", { policy: policy.policy, reason: verdict.reason }), {
|
|
248
|
+
policy_denied: verdict.reason,
|
|
249
|
+
policy: policy.policy,
|
|
250
|
+
...(judgement.input.observed === undefined ? {} : { observed: judgement.input.observed }),
|
|
251
|
+
...(judgement.input.declared === undefined ? {} : { declared: judgement.input.declared }),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** RFC 0018 §2/§3.1: the agent's `trust_accept`, judged before anything is committed. */
|
|
256
|
+
export async function approvePeer(deps: ApprovalDeps, nodeId: NodeId, fingerprint: string): Promise<TrustEntry> {
|
|
257
|
+
const judgement = await judgeApproval(deps, { node_id: nodeId, fingerprint });
|
|
258
|
+
if (!judgement.verdict.allowed) throw approvalRefused(judgement);
|
|
259
|
+
const entry = await deps.accept(nodeId, fingerprint);
|
|
260
|
+
await deps.log.settle(nodeId, fingerprint);
|
|
261
|
+
return entry;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* RFC 0018 §2/§3.1: the agent's `trust_begin`. The judgement reads stored facts only and runs
|
|
266
|
+
* before `exchange`, which is this path's first outbound request — the key read that starts the
|
|
267
|
+
* exchange — so a refusal sends nothing and leaves the peer's row untouched. On an allow the
|
|
268
|
+
* library commits the local approval and mails the statement, and the entry it returns carries
|
|
269
|
+
* the phase that commit produced.
|
|
270
|
+
*/
|
|
271
|
+
export async function beginPeer(
|
|
272
|
+
deps: ApprovalDeps,
|
|
273
|
+
exchange: (nodeId: NodeId) => Promise<KeyExchange>,
|
|
274
|
+
request: ApprovalRequest,
|
|
275
|
+
): Promise<{ session: KeyExchange; entry: TrustEntry }> {
|
|
276
|
+
const judgement = await judgeApproval(deps, request);
|
|
277
|
+
if (!judgement.verdict.allowed) throw approvalRefused(judgement);
|
|
278
|
+
const session = await exchange(request.node_id);
|
|
279
|
+
const entry = await session.confirm(session.fingerprint);
|
|
280
|
+
await deps.log.settle(request.node_id, entry.fingerprint);
|
|
281
|
+
return { session, entry };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* RFC 0018 §2: the agent's `trust_revoke`, never judged. The policy decides who may come in;
|
|
286
|
+
* it has no say over who may be turned away, which is also why this path reads no address.
|
|
287
|
+
*/
|
|
288
|
+
export async function revokePeer(deps: ApprovalDeps, nodeId: NodeId): Promise<void> {
|
|
289
|
+
await deps.revoke(nodeId);
|
|
290
|
+
await deps.log.settle(nodeId);
|
|
291
|
+
}
|
package/src/host-text.ts
CHANGED
|
@@ -14,8 +14,8 @@ const en = {
|
|
|
14
14
|
bindEmpty: "server.bind must not be empty.",
|
|
15
15
|
bindShape: "server.bind must be a hostname or IP address, got {value}.",
|
|
16
16
|
pageSizeRange: "threads.page_size must be an integer from 1 to 100, got {value}.",
|
|
17
|
-
retiredEndpoint: "node.endpoint is not supported; the endpoint comes from server.bind and the
|
|
18
|
-
|
|
17
|
+
retiredEndpoint: "node.endpoint is not supported; the endpoint comes from server.bind and the listening port.",
|
|
18
|
+
portRange: "server.port must be an integer from 0 to 65535, got {value}.",
|
|
19
19
|
retiredPeers: "peers is not supported; peers are discovered over the network and kept in the project SQLite.",
|
|
20
20
|
retiredOmp: "omp is not supported; delivery and spawning are decided by the OMP judge.",
|
|
21
21
|
retiredAttention: "threads.default_attention is not supported; reads default to the attention list.",
|
|
@@ -25,6 +25,9 @@ const en = {
|
|
|
25
25
|
stringArrayExpected: "{field} must be an array of non-empty strings.",
|
|
26
26
|
trustArray: "{path}: trust must be an array of tables.",
|
|
27
27
|
trustEntry: "{path}: a trust entry needs node_id and a 16-character fingerprint.",
|
|
28
|
+
acquirePolicy: "acquisition.policy must be all, segment or none, got {value}.",
|
|
29
|
+
acquireAllow: "acquisition.allow entries must be network/prefix blocks, got {value}.",
|
|
30
|
+
acquireDenied: "The acquisition policy {policy} refused this approval: {reason}.",
|
|
28
31
|
addressUnavailable: "No concrete network address is available for this bind host.",
|
|
29
32
|
keyProvider: "External credential references need a host key provider.",
|
|
30
33
|
loopbackBind: "The unauthenticated development binding requires a loopback bind host. Create a node key to serve a remote address.",
|
|
@@ -56,8 +59,8 @@ const zh: Record<keyof typeof en, string> = {
|
|
|
56
59
|
bindEmpty: "server.bind 不能为空。",
|
|
57
60
|
bindShape: "server.bind 必须是主机名或 IP 地址,收到的是 {value}。",
|
|
58
61
|
pageSizeRange: "threads.page_size 必须是 1 到 100 之间的整数,收到的是 {value}。",
|
|
59
|
-
retiredEndpoint: "不支持 node.endpoint;端点由 server.bind
|
|
60
|
-
|
|
62
|
+
retiredEndpoint: "不支持 node.endpoint;端点由 server.bind 和监听端口决定。",
|
|
63
|
+
portRange: "server.port 必须是 0 到 65535 之间的整数,收到的是 {value}。",
|
|
61
64
|
retiredPeers: "不支持 peers;对端通过网络发现,并保存在项目 SQLite 中。",
|
|
62
65
|
retiredOmp: "不支持 omp;投送与派生由 OMP judge 决定。",
|
|
63
66
|
retiredAttention: "不支持 threads.default_attention;读取默认使用关注列表。",
|
|
@@ -67,6 +70,9 @@ const zh: Record<keyof typeof en, string> = {
|
|
|
67
70
|
stringArrayExpected: "{field} 必须是非空字符串数组。",
|
|
68
71
|
trustArray: "{path}: trust 必须是表数组。",
|
|
69
72
|
trustEntry: "{path}: 每条 trust 记录需要 node_id 和 16 个字符的指纹。",
|
|
73
|
+
acquirePolicy: "acquisition.policy 必须是 all、segment 或 none,收到的是 {value}。",
|
|
74
|
+
acquireAllow: "acquisition.allow 的条目必须是 network/prefix 形式,收到的是 {value}。",
|
|
75
|
+
acquireDenied: "获取策略 {policy} 拒绝了这次批准:{reason}。",
|
|
70
76
|
addressUnavailable: "这个绑定主机没有可用的具体网络地址。",
|
|
71
77
|
keyProvider: "外部凭据引用需要主机密钥提供者。",
|
|
72
78
|
loopbackBind: "未认证的开发绑定需要回环绑定主机。要对外提供服务,请创建节点密钥。",
|
package/src/messages.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { ContextRecord } from "./types.ts";
|
|
|
8
8
|
import { AWAIT_CONSUMED, pendingAwaitResults } from "./tool.ts";
|
|
9
9
|
import { applyMessageTodo, type TodoPlanner } from "./todo.ts";
|
|
10
10
|
import { getRuntime } from "./runtime.ts";
|
|
11
|
+
import { handshakeFacts } from "./handshake.ts";
|
|
11
12
|
import { bridgeText as t } from "./bridge-text.ts";
|
|
12
13
|
|
|
13
14
|
export const INBOUND = "onlyne.inbound";
|
|
@@ -20,6 +21,8 @@ export interface BridgeDeps {
|
|
|
20
21
|
decider: JudgeDecider;
|
|
21
22
|
contexts: ContextRegistry;
|
|
22
23
|
nodeId: string;
|
|
24
|
+
/** Wall clock, so the escalation window can be tested without waiting for it. */
|
|
25
|
+
now?: () => number;
|
|
23
26
|
}
|
|
24
27
|
/**
|
|
25
28
|
* One inbound entry is judged once per process. A retry replays the recorded decision
|
|
@@ -31,8 +34,44 @@ interface InboundAttempt {
|
|
|
31
34
|
decision?: InboundDecision;
|
|
32
35
|
failure?: OnlyneError;
|
|
33
36
|
landed?: string;
|
|
37
|
+
/** Maintenance passes this entry has been reported landed without being confirmed. */
|
|
38
|
+
passes?: number;
|
|
39
|
+
/** Whether the held delivery has already been forced through as a trigger. */
|
|
40
|
+
escalated?: boolean;
|
|
41
|
+
/** Set once the second window passed with no confirmation; the warning is written once. */
|
|
42
|
+
unconfirmed?: boolean;
|
|
43
|
+
/** How the delivery was submitted: a trigger starts work, a next-turn fragment waits. */
|
|
44
|
+
delivery?: { via: "trigger" | "nextTurn"; at: number };
|
|
34
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How long a next-turn fragment may stay unread before the host offers it again. A turn takes at
|
|
49
|
+
* least one provider round trip, so a window measured in maintenance passes fires while the
|
|
50
|
+
* session is still working and duplicates the delivery (dpdp report, follow_up queue).
|
|
51
|
+
*/
|
|
52
|
+
const NEXT_TURN_ESCALATE_MS = 60_000;
|
|
53
|
+
/** Times an entry may be decided again after its binding went away before it is delivered instead. */
|
|
54
|
+
const MAX_REDECISIONS = 2;
|
|
35
55
|
const inboundAttempts = new Map<string, InboundAttempt>();
|
|
56
|
+
/**
|
|
57
|
+
* Deliveries attempted per entry, kept outside the attempt record: dropping that record is what
|
|
58
|
+
* makes the next pass decide again, and it must not reset the budget that bounds the deciding.
|
|
59
|
+
*/
|
|
60
|
+
const deliveryAttempts = new Map<string, number>();
|
|
61
|
+
|
|
62
|
+
function countedAttempt(key: string): number {
|
|
63
|
+
const next = (deliveryAttempts.get(key) ?? 0) + 1;
|
|
64
|
+
deliveryAttempts.set(key, next);
|
|
65
|
+
return next;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The end of the line for an entry nothing can hold: the main session takes it once, visibly. */
|
|
69
|
+
function escalate(deps: BridgeDeps, entry: Entry, key: string, reason: string) {
|
|
70
|
+
const token = registerPending(entry, deps.main.agent.id, "fragment");
|
|
71
|
+
deps.pi.sendMessage(payload(entry, INBOUND, token), { triggerTurn: true });
|
|
72
|
+
deps.pi.logger.info("onlyne.delivery.escalated", { entry: key, reason, attempts: deliveryAttempts.get(key) });
|
|
73
|
+
return { context: deps.main.agent.id, consumed: false, outcome: "delivered" as const };
|
|
74
|
+
}
|
|
36
75
|
|
|
37
76
|
function inboundKey(entry: Entry): string {
|
|
38
77
|
return `${entry.thread_ref.owner}/${entry.thread_ref.id}/${entry.seq}`;
|
|
@@ -41,6 +80,7 @@ function inboundKey(entry: Entry): string {
|
|
|
41
80
|
const TRACE_DEBUG = new Set(["inbound.inject", "inbound.duplicate"]);
|
|
42
81
|
|
|
43
82
|
export function createRuntimeBridge(deps: BridgeDeps): Runtime {
|
|
83
|
+
const now = deps.now ?? Date.now;
|
|
44
84
|
return {
|
|
45
85
|
/**
|
|
46
86
|
* Admission is the last cheap place to refuse a plan this host cannot honor (RFC 0005
|
|
@@ -87,23 +127,68 @@ export function createRuntimeBridge(deps: BridgeDeps): Runtime {
|
|
|
87
127
|
});
|
|
88
128
|
}
|
|
89
129
|
if (attempt.failure !== undefined) {
|
|
130
|
+
// A lost binding cannot be re-decided into existence: past the budget the entry is
|
|
131
|
+
// delivered to the main session instead, so the judgement stops burning model calls
|
|
132
|
+
// and the message stops being invisible.
|
|
133
|
+
if (attempt.failure.error.code === "context_lost" && countedAttempt(key) > MAX_REDECISIONS) {
|
|
134
|
+
return escalate(deps, entry, key, "context-lost");
|
|
135
|
+
}
|
|
90
136
|
deps.pi.logger.debug("onlyne.delivery.held", { entry: key, reason: "judge-failed", error: attempt.failure.message });
|
|
91
137
|
throw attempt.failure;
|
|
92
138
|
}
|
|
93
139
|
const decision = attempt.decision;
|
|
94
140
|
if (decision === undefined) throw new OnlyneError("decision_unavailable", t("noDecision"));
|
|
95
|
-
if (decision.action === "new_subagent") {
|
|
96
|
-
attempt.landed ??= await startSubagent(deps, entry);
|
|
97
|
-
// A spawn is a submission, not a read: the child's prompt carries the token and its
|
|
98
|
-
// first provider request is the proof, so nothing is consumed here.
|
|
99
|
-
return { context: attempt.landed, consumed: false, outcome: "delivered" as const };
|
|
100
|
-
}
|
|
101
141
|
// The body is already where the decision put it; re-sending it would append a
|
|
102
142
|
// second copy that the cursor can only confirm once.
|
|
103
143
|
if (attempt.landed !== undefined) {
|
|
144
|
+
// RFC 0008 §5: a delivery is landed only while the context holding it can still
|
|
145
|
+
// confirm it. A subagent session that ended leaves the entry unconfirmed forever,
|
|
146
|
+
// and reporting it delivered on every maintenance pass is a retry storm, not a
|
|
147
|
+
// delivery. Mark the dead binding lost and let the next pass decide again.
|
|
148
|
+
const holder = (await deps.contexts.list()).find(record => record.agent_id === attempt.landed);
|
|
149
|
+
if (holder !== undefined && holder.state !== "idle" && holder.state !== "running") {
|
|
150
|
+
if (countedAttempt(key) > MAX_REDECISIONS) return escalate(deps, entry, key, "context-lost");
|
|
151
|
+
await deps.contexts.updateState(holder.alias, "lost").catch(() => undefined);
|
|
152
|
+
inboundAttempts.delete(key);
|
|
153
|
+
throw new OnlyneError("context_lost", t("contextLost", { alias: holder.alias }), { context: holder.alias });
|
|
154
|
+
}
|
|
155
|
+
// A delivery nobody confirmed has two shapes and one bound. A next-turn fragment waits
|
|
156
|
+
// for an assembly the session may never reach. A trigger starts its own turn, and that
|
|
157
|
+
// turn can die before the model reads the payload — a provider response at or above 400
|
|
158
|
+
// drops the observation it recorded — leaving the entry unconfirmed while every pass
|
|
159
|
+
// reports it delivered. Both are retried once, after a window long enough to be sure the
|
|
160
|
+
// session is not simply working, and only while it is idle right now.
|
|
161
|
+
attempt.passes = (attempt.passes ?? 0) + 1;
|
|
162
|
+
const delivery = attempt.delivery;
|
|
163
|
+
const waiting = delivery !== undefined && now() - delivery.at >= NEXT_TURN_ESCALATE_MS;
|
|
164
|
+
if (attempt.landed === deps.main.agent.id && waiting && delivery !== undefined && deps.main.isIdle()) {
|
|
165
|
+
if (attempt.escalated !== true) {
|
|
166
|
+
attempt.escalated = true;
|
|
167
|
+
// The retry opens its own window; measuring the second one from the first send
|
|
168
|
+
// would report it as unconfirmed on the very next pass.
|
|
169
|
+
attempt.delivery = { via: "trigger", at: now() };
|
|
170
|
+
const token = registerPending(entry, deps.main.agent.id, "fragment");
|
|
171
|
+
deps.pi.sendMessage(payload(entry, INBOUND, token), { triggerTurn: true });
|
|
172
|
+
deps.pi.logger.info("onlyne.delivery.escalated", {
|
|
173
|
+
entry: key, reason: "unconfirmed", via: delivery.via, waited_ms: now() - delivery.at,
|
|
174
|
+
});
|
|
175
|
+
} else if (attempt.unconfirmed !== true) {
|
|
176
|
+
// One warning per entry: the reason is recorded once, and the pass stops shouting.
|
|
177
|
+
attempt.unconfirmed = true;
|
|
178
|
+
deps.pi.logger.warn("onlyne.delivery.unconfirmed", {
|
|
179
|
+
entry: key, via: delivery.via, passes: attempt.passes, waited_ms: now() - delivery.at, context: attempt.landed,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
104
183
|
deps.pi.logger.debug("onlyne.delivery.held", { entry: key, reason: "already-delivered", context: attempt.landed });
|
|
105
184
|
return { context: attempt.landed, consumed: false, outcome: "delivered" as const };
|
|
106
185
|
}
|
|
186
|
+
if (decision.action === "new_subagent") {
|
|
187
|
+
attempt.landed = await startSubagent(deps, entry);
|
|
188
|
+
// A spawn is a submission, not a read: the child's prompt carries the token and its
|
|
189
|
+
// first provider request is the proof, so nothing is consumed here.
|
|
190
|
+
return { context: attempt.landed, consumed: false, outcome: "delivered" as const };
|
|
191
|
+
}
|
|
107
192
|
if (decision.action === "context") {
|
|
108
193
|
const alias = decision.context;
|
|
109
194
|
if (alias === undefined) throw new OnlyneError("decision_unavailable", t("noContextNamed"));
|
|
@@ -137,8 +222,15 @@ export function createRuntimeBridge(deps: BridgeDeps): Runtime {
|
|
|
137
222
|
payload(entry, INBOUND, token),
|
|
138
223
|
mode === undefined ? { triggerTurn: true } : { deliverAs: mode === "steer" ? "steer" : "followUp" },
|
|
139
224
|
);
|
|
225
|
+
attempt.delivery = { via: "trigger", at: now() };
|
|
140
226
|
} else {
|
|
141
|
-
|
|
227
|
+
// RFC 0008 §7: a fragment enters the next context assembly without starting a turn.
|
|
228
|
+
// An idle session never reaches that assembly by itself, so the entry would sit in
|
|
229
|
+
// the queue as `injected: false` forever; idle means there is no turn to wait for.
|
|
230
|
+
const idle = deps.main.isIdle();
|
|
231
|
+
deps.pi.sendMessage(payload(entry, INBOUND, token), idle ? { triggerTurn: true } : { deliverAs: "nextTurn" });
|
|
232
|
+
if (idle) deps.pi.logger.info("onlyne.delivery.escalated", { entry: key, reason: "idle-fragment" });
|
|
233
|
+
attempt.delivery = { via: idle ? "trigger" : "nextTurn", at: now() };
|
|
142
234
|
}
|
|
143
235
|
attempt.landed = deps.main.agent.id;
|
|
144
236
|
deps.pi.logger.info("onlyne.delivery.sent", {
|
|
@@ -149,6 +241,13 @@ export function createRuntimeBridge(deps: BridgeDeps): Runtime {
|
|
|
149
241
|
},
|
|
150
242
|
todo: async (entry, context) => {
|
|
151
243
|
inboundAttempts.delete(inboundKey(entry));
|
|
244
|
+
deliveryAttempts.delete(inboundKey(entry));
|
|
245
|
+
// RFC 0018 §3: a handshake status update is delivered for the record, not as work.
|
|
246
|
+
// Only an entry that owes a decision takes the todo decision path.
|
|
247
|
+
if (handshakeFacts(entry.message.meta)?.pending === false) {
|
|
248
|
+
refreshMailCards();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
152
251
|
const planner: TodoPlanner = {
|
|
153
252
|
decideTodo: (incoming, current) => deps.decider.decideTodo(deps.main, incoming, current),
|
|
154
253
|
};
|
|
@@ -172,8 +271,7 @@ const pending = new Map<string, PendingObservation>();
|
|
|
172
271
|
const toolResults = new Map<string, PendingObservation>();
|
|
173
272
|
const observed = new Map<string, string[]>();
|
|
174
273
|
|
|
175
|
-
function registerPending(entry: Entry, context: string, kind: PendingObservation["kind"]): string {
|
|
176
|
-
const token = `onlyne-${crypto.randomUUID()}`;
|
|
274
|
+
function registerPending(entry: Entry, context: string, kind: PendingObservation["kind"], token = `onlyne-${crypto.randomUUID()}`): string {
|
|
177
275
|
pending.set(token, { entry, context, kind });
|
|
178
276
|
return token;
|
|
179
277
|
}
|
|
@@ -283,7 +381,9 @@ async function startSubagent(deps: BridgeDeps, entry: Entry): Promise<string> {
|
|
|
283
381
|
await deps.contexts.bind(record.alias, { agent_id: child, ...(parentId === undefined ? {} : { parent_id: parentId }) });
|
|
284
382
|
// The token is already in the child's prompt, so it can only be registered once the child's
|
|
285
383
|
// identity is known; until then there is no provider boundary to observe it at.
|
|
286
|
-
|
|
384
|
+
// It is registered as the token the prompt carries: a fresh one would never appear in any
|
|
385
|
+
// provider payload, and the delivery could never be confirmed.
|
|
386
|
+
registerPending(entry, child, "fragment", token);
|
|
287
387
|
return child;
|
|
288
388
|
}
|
|
289
389
|
async function findContextBySource(contexts: ContextRegistry, entry: Entry): Promise<ContextRecord | undefined> {
|
package/src/node.ts
CHANGED
|
@@ -45,9 +45,21 @@ export function createOnlyneNode(deps: NodeDeps): OnlyneNode {
|
|
|
45
45
|
return { actor };
|
|
46
46
|
};
|
|
47
47
|
const resolve = async (id: string): Promise<Target> => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
:
|
|
48
|
+
let target: Target;
|
|
49
|
+
if (id === config.node.id) {
|
|
50
|
+
target = { node_id: id, base_url: deps.endpoint };
|
|
51
|
+
} else {
|
|
52
|
+
try {
|
|
53
|
+
target = await node.discovery.resolve(id);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const trustEntry = await node.trust.entry(id).catch(() => undefined);
|
|
56
|
+
if (trustEntry?.base_url) {
|
|
57
|
+
target = { node_id: id, base_url: trustEntry.base_url };
|
|
58
|
+
} else {
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
51
63
|
return { ...target, headers: { "Onlyne-Actor": config.node.id } };
|
|
52
64
|
};
|
|
53
65
|
/**
|