pi-onlyne 0.8.1 → 1.0.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 +325 -203
- package/README.zh.md +322 -0
- package/onlyne.json.example +6 -0
- package/package.json +19 -34
- package/relay.toml.example +18 -0
- package/src/agent.live.test.mjs +112 -0
- package/src/agent.mjs +923 -0
- package/src/agent.test.mjs +1079 -0
- package/src/config.mjs +74 -0
- package/src/config.test.mjs +88 -0
- package/src/frame.mjs +99 -0
- package/src/frame.test.mjs +97 -0
- package/src/index.ts +300 -0
- package/src/pi-surface.mjs +140 -0
- package/src/protocol.mjs +364 -0
- package/src/protocol.test.mjs +296 -0
- package/src/relay.mjs +299 -0
- package/src/relay.test.mjs +210 -0
- package/LICENSE +0 -21
- package/SPEC.md +0 -113
- package/dist/config.d.ts +0 -32
- package/dist/config.js +0 -19
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -576
- package/dist/onlyne.d.ts +0 -42
- package/dist/onlyne.js +0 -158
- package/dist/swarm-prompt.d.ts +0 -12
- package/dist/swarm-prompt.js +0 -45
- package/dist/swarm-slot.d.ts +0 -49
- package/dist/swarm-slot.js +0 -78
- package/dist/swarm.d.ts +0 -45
- package/dist/swarm.js +0 -197
- package/dist/workspace.d.ts +0 -6
- package/dist/workspace.js +0 -14
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// Protocol vocabulary tests. Every assertion about a frame's shape is anchored
|
|
2
|
+
// either on a wire vector the Rust encoder produced or on the reducer rules in
|
|
3
|
+
// `crates/onlyne-session/src/lifecycle.rs`.
|
|
4
|
+
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
IMAGE_DATA_MAX_BYTES,
|
|
12
|
+
MAX_HEAD_CHARS,
|
|
13
|
+
PROTOCOL_VERSION,
|
|
14
|
+
SEQ_BASE,
|
|
15
|
+
completeReport,
|
|
16
|
+
headOf,
|
|
17
|
+
heartbeatReport,
|
|
18
|
+
helloArgs,
|
|
19
|
+
hostBinding,
|
|
20
|
+
imagePart,
|
|
21
|
+
injectionText,
|
|
22
|
+
normalizeOutcome,
|
|
23
|
+
observationFor,
|
|
24
|
+
readPluginVersion,
|
|
25
|
+
readyReport,
|
|
26
|
+
sendEnvelope,
|
|
27
|
+
stdinTaskText,
|
|
28
|
+
welcomeFrom,
|
|
29
|
+
} from "./protocol.mjs";
|
|
30
|
+
|
|
31
|
+
const VECTOR_DIR = fileURLToPath(new URL("../../../crates/onlyne-proto/tests/wire_vectors/", import.meta.url));
|
|
32
|
+
const hasVectors = existsSync(VECTOR_DIR);
|
|
33
|
+
const vectorFrame = (name) => JSON.parse(JSON.parse(readFileSync(`${VECTOR_DIR}${name}`, "utf8")).frame);
|
|
34
|
+
const ASSIGN = () => vectorFrame("adapter_host_assign.json");
|
|
35
|
+
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
36
|
+
|
|
37
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
38
|
+
|
|
39
|
+
test("hello carries the package version and a flat agent mount", () => {
|
|
40
|
+
assert.equal(readPluginVersion(), packageJson.version);
|
|
41
|
+
const args = helloArgs({
|
|
42
|
+
role: "planner",
|
|
43
|
+
session: "8b1c",
|
|
44
|
+
taskId: "11111111-1111-4111-8111-111111111111",
|
|
45
|
+
pid: 4212,
|
|
46
|
+
capabilities: ["register", "report", "inject", "recycle"],
|
|
47
|
+
});
|
|
48
|
+
assert.deepEqual(args, {
|
|
49
|
+
protocol: PROTOCOL_VERSION,
|
|
50
|
+
plugin: "pi-onlyne",
|
|
51
|
+
version: packageJson.version,
|
|
52
|
+
kind: "agent",
|
|
53
|
+
capabilities: ["register", "report", "inject", "recycle"],
|
|
54
|
+
mount: {
|
|
55
|
+
role: "planner",
|
|
56
|
+
session: "8b1c",
|
|
57
|
+
task_id: "11111111-1111-4111-8111-111111111111",
|
|
58
|
+
pid: 4212,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("hello names exactly the keys the host's own vector names", { skip: !hasVectors }, () => {
|
|
64
|
+
const reference = vectorFrame("adapter_plugin_hello_plan_example.json");
|
|
65
|
+
const ours = { op: "hello", args: helloArgs({ role: "planner", session: "8b1c", capabilities: ["register"] }) };
|
|
66
|
+
assert.deepEqual(Object.keys(ours.args).sort(), Object.keys(reference.args).sort());
|
|
67
|
+
assert.deepEqual(Object.keys(ours.args.mount).sort(), ["role", "session"]);
|
|
68
|
+
assert.equal(reference.args.mount.role, ours.args.mount.role);
|
|
69
|
+
assert.equal(ours.args.kind, reference.args.kind);
|
|
70
|
+
assert.equal(ours.args.protocol, reference.args.protocol);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("welcome parses the host's response body and rejects junk", () => {
|
|
74
|
+
const args = {
|
|
75
|
+
protocol: 1,
|
|
76
|
+
role: "planner",
|
|
77
|
+
session_id: "s1",
|
|
78
|
+
generation: 1,
|
|
79
|
+
prose: "Read the incoming task",
|
|
80
|
+
server: { connected: true, cluster: "local", name: "server" },
|
|
81
|
+
host_capabilities: ["probe", "recycle"],
|
|
82
|
+
};
|
|
83
|
+
const welcome = welcomeFrom({ op: "welcome", args });
|
|
84
|
+
assert.deepEqual(welcome, {
|
|
85
|
+
protocol: 1,
|
|
86
|
+
role: "planner",
|
|
87
|
+
sessionId: "s1",
|
|
88
|
+
generation: 1,
|
|
89
|
+
prose: "Read the incoming task",
|
|
90
|
+
server: { connected: true, cluster: "local", name: "server" },
|
|
91
|
+
hostCapabilities: ["probe", "recycle"],
|
|
92
|
+
});
|
|
93
|
+
assert.deepEqual(welcomeFrom(args)?.role, "planner");
|
|
94
|
+
assert.equal(welcomeFrom(null), null);
|
|
95
|
+
assert.equal(welcomeFrom({ op: "welcome", args: { prose: "x" } }), null);
|
|
96
|
+
assert.deepEqual(welcomeFrom({ op: "welcome", args: { role: "builder" } }), {
|
|
97
|
+
protocol: 1,
|
|
98
|
+
role: "builder",
|
|
99
|
+
sessionId: null,
|
|
100
|
+
generation: 1,
|
|
101
|
+
prose: "",
|
|
102
|
+
server: null,
|
|
103
|
+
hostCapabilities: [],
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("ready matches the host's ready vector, which relays a cluster this plugin never speaks for", { skip: !hasVectors }, () => {
|
|
108
|
+
const reference = vectorFrame("adapter_plugin_report.json");
|
|
109
|
+
assert.equal(reference.op, "report");
|
|
110
|
+
assert.equal(reference.args.kind, "ready");
|
|
111
|
+
const { cluster_ref: relayed, ...local } = reference.args.data;
|
|
112
|
+
assert.equal(relayed, "cluster-b");
|
|
113
|
+
const ours = readyReport({ taskId: local.task_id, sessionId: local.session_id, generation: local.generation, seq: local.seq });
|
|
114
|
+
assert.deepEqual(ours, { kind: "ready", data: local });
|
|
115
|
+
});
|
|
116
|
+
test("heartbeat carries a full legal observation", () => {
|
|
117
|
+
const report = heartbeatReport({ taskId: "t1", generation: 2, seq: SEQ_BASE + 7, agent: "running" });
|
|
118
|
+
assert.equal(report.kind, "heartbeat");
|
|
119
|
+
assert.equal(report.data.task_id, "t1");
|
|
120
|
+
assert.equal(report.data.seq, SEQ_BASE + 7);
|
|
121
|
+
assert.deepEqual(report.data.observed, {
|
|
122
|
+
version: { generation: 2, seq: SEQ_BASE + 7 },
|
|
123
|
+
generation_live: true,
|
|
124
|
+
isolate_after: 1,
|
|
125
|
+
terminate_after: 3,
|
|
126
|
+
mismatch_count: 0,
|
|
127
|
+
agent: "running",
|
|
128
|
+
delivery: "none",
|
|
129
|
+
resource: "attached",
|
|
130
|
+
recovery: "none",
|
|
131
|
+
outcome: "pending",
|
|
132
|
+
public: "working",
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("every observation is a state tuple the reducer calls legal", () => {
|
|
137
|
+
// Mirrors onlyne-session's `project`: running works, idle/ready wait, booting creates.
|
|
138
|
+
const expected = { booting: "created", ready: "idle", running: "working", idle: "idle", gone: "created" };
|
|
139
|
+
for (const [agent, projected] of Object.entries(expected)) {
|
|
140
|
+
const observed = observationFor(agent, { generation: 1, seq: 1 });
|
|
141
|
+
assert.equal(observed.public, projected, `agent=${agent}`);
|
|
142
|
+
assert.notEqual(observed.isolate_after, 0);
|
|
143
|
+
assert.notEqual(observed.terminate_after, 0);
|
|
144
|
+
assert.equal(observed.outcome, "pending");
|
|
145
|
+
assert.equal(observed.delivery, "none");
|
|
146
|
+
assert.equal(observed.recovery, "none");
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const PANE_KEY = "45e603f7-0772-48aa-bcf6-832272747713:b6d067b6-9255-4f5c-a13f-24f194ea0560";
|
|
151
|
+
const PANE_ENV = {
|
|
152
|
+
ORCA_PANE_KEY: PANE_KEY,
|
|
153
|
+
ORCA_TAB_ID: "45e603f7-0772-48aa-bcf6-832272747713",
|
|
154
|
+
ORCA_LEAF_ID: "b6d067b6-9255-4f5c-a13f-24f194ea0560",
|
|
155
|
+
ORCA_TERMINAL_HANDLE: "term_1",
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
test("hostBinding names the Orca pane the environment exports", () => {
|
|
159
|
+
// The shape is `onlyne-session`'s `HostRef`: `{"orca":{…}}`, so the Rust side
|
|
160
|
+
// deserialises the observation without a second translation.
|
|
161
|
+
assert.deepEqual(hostBinding(PANE_ENV), {
|
|
162
|
+
orca: {
|
|
163
|
+
pane_key: PANE_KEY,
|
|
164
|
+
tab_id: "45e603f7-0772-48aa-bcf6-832272747713",
|
|
165
|
+
leaf_id: "b6d067b6-9255-4f5c-a13f-24f194ea0560",
|
|
166
|
+
handle: "term_1",
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("hostBinding reads the ids out of the pane key and invents nothing", () => {
|
|
172
|
+
// The pane key is `<tab_id>:<leaf_id>`; the handle is a separate export, and
|
|
173
|
+
// a missing one is absent rather than empty.
|
|
174
|
+
assert.deepEqual(hostBinding({ ORCA_PANE_KEY: PANE_KEY }), {
|
|
175
|
+
orca: {
|
|
176
|
+
pane_key: PANE_KEY,
|
|
177
|
+
tab_id: "45e603f7-0772-48aa-bcf6-832272747713",
|
|
178
|
+
leaf_id: "b6d067b6-9255-4f5c-a13f-24f194ea0560",
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
// An explicit id outranks the key's own spelling of it.
|
|
182
|
+
const explicit = hostBinding({ ...PANE_ENV, ORCA_TAB_ID: "aaaa1111-1111-4111-8111-111111111111" });
|
|
183
|
+
assert.equal(explicit.orca.tab_id, "aaaa1111-1111-4111-8111-111111111111");
|
|
184
|
+
assert.equal(explicit.orca.pane_key, "aaaa1111-1111-4111-8111-111111111111:b6d067b6-9255-4f5c-a13f-24f194ea0560");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("hostBinding is null when the environment names no pane", () => {
|
|
188
|
+
assert.equal(hostBinding({}), null, "a plain shell is not in a pane");
|
|
189
|
+
assert.equal(hostBinding(undefined), null);
|
|
190
|
+
// A pane key without a leaf names no pane, and one id alone is not a pane.
|
|
191
|
+
assert.equal(hostBinding({ ORCA_PANE_KEY: "not-a-pane-key" }), null);
|
|
192
|
+
assert.equal(hostBinding({ ORCA_TAB_ID: "tab-only" }), null);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("observationFor attaches the host only when there is one", () => {
|
|
196
|
+
const bare = observationFor("running", { generation: 1, seq: 2 });
|
|
197
|
+
assert.equal("host" in bare, false, "a tuple without a pane carries no host key");
|
|
198
|
+
|
|
199
|
+
const hosted = observationFor("running", { generation: 1, seq: 2, host: hostBinding(PANE_ENV) });
|
|
200
|
+
assert.deepEqual(hosted.host, hostBinding(PANE_ENV));
|
|
201
|
+
// The binding rides beside the dimensions: the tuple is still the same state.
|
|
202
|
+
const { host, ...dimensions } = hosted;
|
|
203
|
+
assert.deepEqual(dimensions, bare);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("heartbeatReport carries the host inside observed", () => {
|
|
207
|
+
const report = heartbeatReport({ taskId: "t1", generation: 1, seq: SEQ_BASE + 1, agent: "idle", host: hostBinding(PANE_ENV) });
|
|
208
|
+
assert.equal(report.data.observed.host.orca.pane_key, PANE_KEY);
|
|
209
|
+
assert.equal(report.data.observed.agent, "idle");
|
|
210
|
+
assert.equal(
|
|
211
|
+
"host" in heartbeatReport({ taskId: "t1", generation: 1, seq: SEQ_BASE + 2, agent: "idle" }).data.observed,
|
|
212
|
+
false
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("a completion names an outcome and a single-line head", () => {
|
|
217
|
+
assert.deepEqual(completeReport({ taskId: "t1", outcome: "failed", head: "broke\non line two" }), {
|
|
218
|
+
kind: "complete",
|
|
219
|
+
data: { task_id: "t1", outcome: "failed", head: "broke on line two" },
|
|
220
|
+
});
|
|
221
|
+
// No head key at all when there is nothing to summarise.
|
|
222
|
+
assert.deepEqual(completeReport({ taskId: "t1", outcome: "done", head: " " }), {
|
|
223
|
+
kind: "complete",
|
|
224
|
+
data: { task_id: "t1", outcome: "done" },
|
|
225
|
+
});
|
|
226
|
+
assert.equal(completeReport({ taskId: "t1", outcome: "weird" }).data.outcome, "done");
|
|
227
|
+
assert.equal(normalizeOutcome(undefined), "done");
|
|
228
|
+
assert.equal(normalizeOutcome("cancelled"), "cancelled");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("the ledger head is capped at the plan's 200 characters", () => {
|
|
232
|
+
assert.equal(MAX_HEAD_CHARS, 200);
|
|
233
|
+
assert.equal(headOf("x".repeat(500)).length, 200);
|
|
234
|
+
assert.equal(headOf(" spaced out \n text "), "spaced out text");
|
|
235
|
+
assert.equal(headOf(undefined), "");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("an assignment becomes one message naming its origin and payload", { skip: !hasVectors }, () => {
|
|
239
|
+
const assign = ASSIGN().args;
|
|
240
|
+
const text = injectionText({ assign, proseIsNew: true });
|
|
241
|
+
assert.match(text, /^\[onlyne\] task 11111111-1111-4111-8111-111111111111 from role:planner \(kind task\)/);
|
|
242
|
+
assert.match(text, /\[onlyne\] role prose from the spec:\nRead the incoming task/);
|
|
243
|
+
assert.match(text, /\nbuild it\n?$/);
|
|
244
|
+
|
|
245
|
+
const repeat = injectionText({ assign, proseIsNew: false });
|
|
246
|
+
assert.doesNotMatch(repeat, /role prose from the spec/);
|
|
247
|
+
assert.match(repeat, /build it/);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("an empty-bodied assignment still produces an instruction", () => {
|
|
251
|
+
const text = injectionText({
|
|
252
|
+
assign: { task_id: "t9", envelope: { id: "e9", kind: "note", from: { gateway: { gateway: "fg1", channel: "fake", conversation: "c1" } }, body: {} } },
|
|
253
|
+
proseIsNew: false,
|
|
254
|
+
attachmentPaths: ["/ws/.onlyne/tmp/attachments/a.png"],
|
|
255
|
+
});
|
|
256
|
+
assert.match(text, /from gateway:fg1:fake:c1/);
|
|
257
|
+
assert.match(text, /the task carried no text/);
|
|
258
|
+
assert.match(text, /\/ws\/\.onlyne\/tmp\/attachments\/a\.png/);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("a note carries no idempotency key and a task carries both", { skip: !hasVectors }, () => {
|
|
262
|
+
const note = sendEnvelope({ from: "planner", to: "builder", kind: "note", text: "ping" });
|
|
263
|
+
assert.deepEqual(Object.keys(note).sort(), ["admin", "body", "from", "id", "kind", "protocol", "to", "ts"].sort());
|
|
264
|
+
assert.deepEqual(
|
|
265
|
+
Object.keys(note).sort(),
|
|
266
|
+
Object.keys(vectorFrame("adapter_plugin_send_note.json").args).sort(),
|
|
267
|
+
);
|
|
268
|
+
assert.match(note.id, UUID_RE);
|
|
269
|
+
assert.deepEqual(note.from, { role: { role: "planner" } });
|
|
270
|
+
|
|
271
|
+
const task = sendEnvelope({ from: "planner", to: "builder", kind: "task", text: "build it" });
|
|
272
|
+
assert.match(task.op_id, /^o-[0-9a-f-]{36}$/);
|
|
273
|
+
assert.match(task.causality.task, UUID_RE);
|
|
274
|
+
assert.equal(task.causality.hop, 0);
|
|
275
|
+
assert.equal(task.causality.attempt, 0);
|
|
276
|
+
assert.throws(() => sendEnvelope({ from: "planner", to: "builder" }), /body requires text or image/);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("images are accepted only in the four core mimes and under the byte ceiling", () => {
|
|
280
|
+
const part = imagePart({ data: Buffer.from([1, 2, 3]), mime: "image/png", name: "shot.png" });
|
|
281
|
+
assert.equal(part.data_base64, Buffer.from([1, 2, 3]).toString("base64"));
|
|
282
|
+
assert.equal(part.mime, "image/png");
|
|
283
|
+
assert.equal(part.name, "shot.png");
|
|
284
|
+
assert.throws(() => imagePart({ data: Buffer.from([1]), mime: "image/svg+xml" }), /unsupported/);
|
|
285
|
+
assert.throws(
|
|
286
|
+
() => imagePart({ data: Buffer.alloc(IMAGE_DATA_MAX_BYTES + 1), mime: "image/png" }),
|
|
287
|
+
/exceeds/,
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("the stdin route recognises only the config_get overload", () => {
|
|
292
|
+
assert.deepEqual(stdinTaskText({ key: "stdin:do the thing" }), { text: "do the thing" });
|
|
293
|
+
assert.equal(stdinTaskText({ key: "model.name" }), null);
|
|
294
|
+
assert.equal(stdinTaskText({}), null);
|
|
295
|
+
assert.equal(stdinTaskText(undefined), null);
|
|
296
|
+
});
|
package/src/relay.mjs
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// The relay guard's policy: `<plugin package dir>/relay.toml`.
|
|
2
|
+
//
|
|
3
|
+
// The guard exists because a session narrated work in progress and then
|
|
4
|
+
// reported `done` with its todos untouched, leaving the downstream writer
|
|
5
|
+
// waiting on a handoff that never happened. The policy below is the minimum a
|
|
6
|
+
// session owes downstream before `onlyne_complete` may end it, expressed in
|
|
7
|
+
// delivery facts only: which roles this session handed something to, never
|
|
8
|
+
// what the text said (that is the critic layer's business, not the adapter's).
|
|
9
|
+
//
|
|
10
|
+
// The file sits next to `package.json`, so the policy travels with the plugin
|
|
11
|
+
// copy a generated workspace carries: `onlyne server generate` copies the
|
|
12
|
+
// package to `<ws>/.onlyne/agent/<pkg-name>/` and `.pi/settings.json` loads
|
|
13
|
+
// that copy (`crates/onlyne-server/src/generate.rs`). It is deliberately not
|
|
14
|
+
// `<ws>/.onlyne/config.toml`: the client parses that file as `ClientConfig`,
|
|
15
|
+
// which is `#[serde(deny_unknown_fields)]` and `additionalProperties: false`
|
|
16
|
+
// (`crates/onlyne-config/src/client.rs`, `schema/config-client.schema.json`),
|
|
17
|
+
// so a plugin-owned key there would make the client refuse to start. A `[local]`
|
|
18
|
+
// fragment merged into it has the same problem.
|
|
19
|
+
//
|
|
20
|
+
// The accepted body is a closed subset of TOML — flat `key = value` lines, the
|
|
21
|
+
// two keys below, one-line arrays of double-quoted strings — because this
|
|
22
|
+
// package parses its own files by hand and the runtime has no npm dependencies.
|
|
23
|
+
// Anything outside the subset is reported on stderr and ignored, the same
|
|
24
|
+
// degrade-don't-disable way `.pi/onlyne.json` behaves. A missing file is the
|
|
25
|
+
// default, which is "no guard": absent policy means the plugin behaves exactly
|
|
26
|
+
// as it did before this module existed.
|
|
27
|
+
//
|
|
28
|
+
// relay_required = ["writer"] # these roles must have received a handoff
|
|
29
|
+
// relay_required_count = 2 # ... or this many distinct downstream roles
|
|
30
|
+
//
|
|
31
|
+
// `relay_required` wins when both are present.
|
|
32
|
+
//
|
|
33
|
+
// The file is the manual installation's escape hatch. A generated workspace
|
|
34
|
+
// carries the same policy in its spec, and the client injects it into every
|
|
35
|
+
// session process it spawns, so the environment comes first:
|
|
36
|
+
//
|
|
37
|
+
// ONLYNE_RELAY_REQUIRED=writer,auditor # the spec's `relay_required`
|
|
38
|
+
// ONLYNE_RELAY_COUNT=2 # the spec's `relay_count`
|
|
39
|
+
//
|
|
40
|
+
// A variable that is set and unparsable is reported on stderr and ignored, and
|
|
41
|
+
// with nothing usable in the environment the file is read as before.
|
|
42
|
+
|
|
43
|
+
import { readFileSync } from "node:fs";
|
|
44
|
+
import { dirname, join } from "node:path";
|
|
45
|
+
import { fileURLToPath } from "node:url";
|
|
46
|
+
|
|
47
|
+
/** File name, resolved next to the plugin's `package.json`. */
|
|
48
|
+
export const RELAY_FILE = "relay.toml";
|
|
49
|
+
|
|
50
|
+
/** Fixed marker a waived completion's ledger head starts with. */
|
|
51
|
+
export const FORCED_PREFIX = "relay-guard-forced: ";
|
|
52
|
+
|
|
53
|
+
/** No policy: an empty list and no count, both frozen together. */
|
|
54
|
+
export const DEFAULT_RELAY = Object.freeze({ required: Object.freeze([]), count: null });
|
|
55
|
+
|
|
56
|
+
/** The client's injected policy variables, filled from the spec's entry. */
|
|
57
|
+
export const RELAY_ENV_REQUIRED = "ONLYNE_RELAY_REQUIRED";
|
|
58
|
+
export const RELAY_ENV_COUNT = "ONLYNE_RELAY_COUNT";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The policy file this module reads by default: beside `package.json`, the way
|
|
62
|
+
* `protocol.mjs` reads the plugin version.
|
|
63
|
+
*/
|
|
64
|
+
export function relayPath() {
|
|
65
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", RELAY_FILE);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether a loaded policy guards anything: a non-empty list, or a positive
|
|
70
|
+
* count. A malformed key leaves its own dimension off, so one bad line cannot
|
|
71
|
+
* silently arm the guard with the wrong rule.
|
|
72
|
+
*
|
|
73
|
+
* @param {{ required?: string[], count?: number | null } | null | undefined} config
|
|
74
|
+
*/
|
|
75
|
+
export function relayEnabled(config) {
|
|
76
|
+
if (!config) return false;
|
|
77
|
+
if (Array.isArray(config.required) && config.required.length > 0) return true;
|
|
78
|
+
return Number.isInteger(config.count) && config.count > 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse one `relay.toml` body.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} text
|
|
85
|
+
* @param {string} [name] file name for diagnostics
|
|
86
|
+
* @returns {{ required: string[], count: number | null, warning: string | null }}
|
|
87
|
+
*/
|
|
88
|
+
export function parseRelay(text, name = RELAY_FILE) {
|
|
89
|
+
const config = { required: [], count: null };
|
|
90
|
+
const warnings = [];
|
|
91
|
+
const warn = (line, detail) => warnings.push(`${name}:${line}: ${detail}`);
|
|
92
|
+
|
|
93
|
+
String(text)
|
|
94
|
+
.split(/\r?\n/)
|
|
95
|
+
.forEach((raw, index) => {
|
|
96
|
+
const line = index + 1;
|
|
97
|
+
const body = stripComment(raw).trim();
|
|
98
|
+
if (!body) return;
|
|
99
|
+
const assignment = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\S.*)$/.exec(body);
|
|
100
|
+
if (!assignment) {
|
|
101
|
+
warn(line, `not a \`key = value\` line (${JSON.stringify(raw.trim())}); ignored`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const [, key, value] = assignment;
|
|
105
|
+
if (key === "relay_required") {
|
|
106
|
+
const list = parseStringArray(value);
|
|
107
|
+
if (list === null) {
|
|
108
|
+
warn(line, 'relay_required must be one line of double-quoted names, e.g. ["writer"]; ignored');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
config.required = list;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (key === "relay_required_count") {
|
|
115
|
+
const count = /^[0-9]+$/.test(value) ? Number(value) : 0;
|
|
116
|
+
if (count < 1) {
|
|
117
|
+
warn(line, "relay_required_count must be a positive integer; ignored");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
config.count = count;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
warn(line, `unknown key ${JSON.stringify(key)}; ignored`);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return { ...config, warning: warnings.length > 0 ? warnings.join("; ") : null };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The policy the client injected from the spec, when it injected one.
|
|
131
|
+
*
|
|
132
|
+
* The list is one comma-joined variable, in the order the spec wrote it; blank
|
|
133
|
+
* entries are dropped, so a stray comma is not a role name. A variable that is
|
|
134
|
+
* set but unparsable is reported and ignored rather than adopted, which keeps a
|
|
135
|
+
* typo from arming the guard with a rule nobody wrote — and `specified` then
|
|
136
|
+
* says the environment supplied nothing, so the file still gets its turn.
|
|
137
|
+
*
|
|
138
|
+
* @param {Record<string, string | undefined>} [env]
|
|
139
|
+
* @returns {{ required: string[], count: number | null, specified: boolean, warning: string | null }}
|
|
140
|
+
*/
|
|
141
|
+
export function envRelay(env = process.env) {
|
|
142
|
+
const warnings = [];
|
|
143
|
+
let required = null;
|
|
144
|
+
let count = null;
|
|
145
|
+
|
|
146
|
+
const rawRequired = env[RELAY_ENV_REQUIRED];
|
|
147
|
+
if (rawRequired !== undefined) {
|
|
148
|
+
const names = String(rawRequired)
|
|
149
|
+
.split(",")
|
|
150
|
+
.map((name) => name.trim())
|
|
151
|
+
.filter(Boolean);
|
|
152
|
+
if (names.length > 0) required = names;
|
|
153
|
+
else warnings.push(`${RELAY_ENV_REQUIRED}: no role names in ${JSON.stringify(rawRequired)}; ignored`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const rawCount = env[RELAY_ENV_COUNT];
|
|
157
|
+
if (rawCount !== undefined) {
|
|
158
|
+
const text = String(rawCount).trim();
|
|
159
|
+
const parsed = /^[0-9]+$/.test(text) ? Number(text) : 0;
|
|
160
|
+
if (parsed > 0) count = parsed;
|
|
161
|
+
else warnings.push(`${RELAY_ENV_COUNT} must be a positive integer, got ${JSON.stringify(rawCount)}; ignored`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
required: required ?? [],
|
|
166
|
+
count,
|
|
167
|
+
specified: required !== null || count !== null,
|
|
168
|
+
warning: warnings.length > 0 ? warnings.join("; ") : null,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Read the policy: what the client injected from the spec first, then the file
|
|
174
|
+
* beside `package.json`.
|
|
175
|
+
*
|
|
176
|
+
* `source` names the winner, and `present` answers the narrower question the
|
|
177
|
+
* file itself raises: the environment winning means the file was never read, so
|
|
178
|
+
* a stale `relay.toml` cannot outlive the spec entry that replaced it.
|
|
179
|
+
*
|
|
180
|
+
* @param {{ readFile?: (path: string) => string, path?: string, env?: Record<string, string | undefined> }} [options]
|
|
181
|
+
* @returns {{ required: string[], count: number | null, path: string, present: boolean, source: "env" | "file" | "none", warning: string | null }}
|
|
182
|
+
*/
|
|
183
|
+
export function loadRelay(options = {}) {
|
|
184
|
+
const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
185
|
+
const path = options.path ?? relayPath();
|
|
186
|
+
const injected = envRelay(options.env ?? process.env);
|
|
187
|
+
if (injected.specified) {
|
|
188
|
+
return {
|
|
189
|
+
required: injected.required,
|
|
190
|
+
count: injected.count,
|
|
191
|
+
path,
|
|
192
|
+
present: false,
|
|
193
|
+
source: "env",
|
|
194
|
+
warning: injected.warning,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
let raw;
|
|
198
|
+
try {
|
|
199
|
+
raw = readFile(path);
|
|
200
|
+
} catch {
|
|
201
|
+
return {
|
|
202
|
+
...DEFAULT_RELAY,
|
|
203
|
+
path,
|
|
204
|
+
present: false,
|
|
205
|
+
source: "none",
|
|
206
|
+
warning: injected.warning,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const parsed = parseRelay(raw, path);
|
|
210
|
+
return {
|
|
211
|
+
required: parsed.required,
|
|
212
|
+
count: parsed.count,
|
|
213
|
+
path,
|
|
214
|
+
present: true,
|
|
215
|
+
source: "file",
|
|
216
|
+
warning: [injected.warning, parsed.warning].filter(Boolean).join("; ") || null,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The verdict for one `onlyne_complete`, as a refusal message or `null`.
|
|
222
|
+
*
|
|
223
|
+
* `delivered` is the set of roles this session's own successful `onlyne_send`
|
|
224
|
+
* calls reached. List mode is literal: every named role must be in it. Count
|
|
225
|
+
* mode counts distinct downstream roles, so a send to this role itself and a
|
|
226
|
+
* send back to the role that assigned the task (the upstream) do not count —
|
|
227
|
+
* neither of them hands work further down the cluster.
|
|
228
|
+
*
|
|
229
|
+
* @param {{ required?: string[], count?: number | null } | null} config
|
|
230
|
+
* @param {Iterable<string>} delivered
|
|
231
|
+
* @param {{ role?: string | null, upstream?: string | null }} [context]
|
|
232
|
+
* @returns {string | null}
|
|
233
|
+
*/
|
|
234
|
+
export function relayRefusal(config, delivered, context = {}) {
|
|
235
|
+
if (!relayEnabled(config)) return null;
|
|
236
|
+
const sent = new Set([...delivered].map((name) => String(name)));
|
|
237
|
+
|
|
238
|
+
if (Array.isArray(config.required) && config.required.length > 0) {
|
|
239
|
+
const missing = config.required.filter((name) => !sent.has(name));
|
|
240
|
+
if (missing.length === 0) return null;
|
|
241
|
+
return refusalText(
|
|
242
|
+
`missing handoff to: ${missing.join(", ")}`,
|
|
243
|
+
`this session delivered to: ${listOf([...sent])}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const { role = null, upstream = null } = context;
|
|
248
|
+
const downstream = [...sent].filter((name) => name !== role && name !== upstream);
|
|
249
|
+
if (downstream.length >= config.count) return null;
|
|
250
|
+
return refusalText(
|
|
251
|
+
`missing handoff: ${config.count - downstream.length} of ${config.count} required distinct downstream roles`,
|
|
252
|
+
`delivered downstream: ${listOf(downstream)}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The one refusal sentence: what is missing, what exists, and the way out. */
|
|
257
|
+
function refusalText(shortfall, evidence) {
|
|
258
|
+
return (
|
|
259
|
+
`relay guard: ${shortfall} (${evidence}); ` +
|
|
260
|
+
"send the missing edge with onlyne_send, then call onlyne_complete again — or call it with " +
|
|
261
|
+
'force:true and a non-empty reason to waive the guard and stamp the ledger head with ' +
|
|
262
|
+
`"${FORCED_PREFIX}<reason>"`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** A comma-joined list, or `none` when there is nothing to name. */
|
|
267
|
+
function listOf(names) {
|
|
268
|
+
return names.length > 0 ? names.join(", ") : "none";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Everything before an unquoted `#`; the subset has no multi-line strings. */
|
|
272
|
+
function stripComment(line) {
|
|
273
|
+
let quoted = false;
|
|
274
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
275
|
+
const char = line[index];
|
|
276
|
+
if (char === '"' && line[index - 1] !== "\\") quoted = !quoted;
|
|
277
|
+
else if (char === "#" && !quoted) return line.slice(0, index);
|
|
278
|
+
}
|
|
279
|
+
return line;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** One line of double-quoted strings, or `null` for anything else. */
|
|
283
|
+
function parseStringArray(body) {
|
|
284
|
+
const trimmed = body.trim();
|
|
285
|
+
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
|
|
286
|
+
const items = [];
|
|
287
|
+
let rest = trimmed.slice(1, -1).trim();
|
|
288
|
+
if (rest === "") return items;
|
|
289
|
+
for (;;) {
|
|
290
|
+
const item = /^"((?:[^"\\]|\\.)*)"\s*/.exec(rest);
|
|
291
|
+
if (!item) return null;
|
|
292
|
+
items.push(item[1].replace(/\\(.)/g, "$1"));
|
|
293
|
+
rest = rest.slice(item[0].length).trimStart();
|
|
294
|
+
if (rest === "") return items;
|
|
295
|
+
if (!rest.startsWith(",")) return null;
|
|
296
|
+
rest = rest.slice(1).trimStart();
|
|
297
|
+
if (rest === "") return null; // a trailing comma is not TOML
|
|
298
|
+
}
|
|
299
|
+
}
|