pi-onlyne 1.2.1 → 2.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 +147 -240
- package/README.zh.md +79 -125
- package/package.json +2 -3
- package/src/activity.test.mjs +0 -6
- package/src/agent.live.test.mjs +15 -5
- package/src/agent.mjs +413 -291
- package/src/agent.test.mjs +466 -395
- package/src/background-subagents.mjs +192 -0
- package/src/background-subagents.test.mjs +166 -0
- package/src/config.mjs +4 -23
- package/src/config.test.mjs +0 -8
- package/src/index.ts +67 -54
- package/src/pi-surface.mjs +110 -30
- package/src/pi-surface.test.mjs +232 -0
- package/src/protocol.mjs +28 -47
- package/src/protocol.test.mjs +23 -69
- package/src/socket.mjs +270 -39
- package/src/socket.test.mjs +205 -47
- package/relay.toml.example +0 -18
- package/src/relay.mjs +0 -299
- package/src/relay.test.mjs +0 -210
package/src/relay.mjs
DELETED
|
@@ -1,299 +0,0 @@
|
|
|
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
|
-
}
|
package/src/relay.test.mjs
DELETED
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
// The relay guard's policy reader and its verdict.
|
|
2
|
-
//
|
|
3
|
-
// The reader is the half that decides whether a workspace guards anything at
|
|
4
|
-
// all: a missing file, a malformed line or an unknown key must leave the guard
|
|
5
|
-
// off (or leave only the sound half of it on) instead of refusing completions
|
|
6
|
-
// on a policy nobody wrote. The client's injected policy outranks the file, and
|
|
7
|
-
// the file is the fallback a manual installation still has.
|
|
8
|
-
|
|
9
|
-
import assert from "node:assert/strict";
|
|
10
|
-
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { tmpdir } from "node:os";
|
|
12
|
-
import { dirname, join } from "node:path";
|
|
13
|
-
import { afterEach, test } from "node:test";
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
DEFAULT_RELAY,
|
|
17
|
-
RELAY_ENV_COUNT,
|
|
18
|
-
RELAY_ENV_REQUIRED,
|
|
19
|
-
RELAY_FILE,
|
|
20
|
-
envRelay,
|
|
21
|
-
loadRelay,
|
|
22
|
-
parseRelay,
|
|
23
|
-
relayEnabled,
|
|
24
|
-
relayPath,
|
|
25
|
-
relayRefusal,
|
|
26
|
-
} from "./relay.mjs";
|
|
27
|
-
|
|
28
|
-
const cleanups = [];
|
|
29
|
-
afterEach(() => {
|
|
30
|
-
while (cleanups.length > 0) cleanups.pop()();
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
/** One temp directory holding a `relay.toml` body, when given one. */
|
|
34
|
-
function workspace(body) {
|
|
35
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-relay-"));
|
|
36
|
-
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
|
|
37
|
-
if (body !== undefined) {
|
|
38
|
-
writeFileSync(join(dir, RELAY_FILE), body);
|
|
39
|
-
}
|
|
40
|
-
return dir;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
test("a missing policy file leaves the guard off", () => {
|
|
44
|
-
const dir = workspace();
|
|
45
|
-
const relay = loadRelay({ path: join(dir, RELAY_FILE), env: {} });
|
|
46
|
-
assert.deepEqual(
|
|
47
|
-
{ required: relay.required, count: relay.count },
|
|
48
|
-
{ required: DEFAULT_RELAY.required, count: DEFAULT_RELAY.count },
|
|
49
|
-
);
|
|
50
|
-
assert.equal(relay.present, false);
|
|
51
|
-
assert.equal(relay.source, "none");
|
|
52
|
-
assert.equal(relay.warning, null);
|
|
53
|
-
assert.equal(relayEnabled(relay), false);
|
|
54
|
-
assert.equal(relay.path, join(dir, RELAY_FILE));
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("the default policy path is the one beside the plugin's package.json", () => {
|
|
58
|
-
const path = relayPath();
|
|
59
|
-
assert.ok(path.endsWith(`/${RELAY_FILE}`), path);
|
|
60
|
-
// A generated workspace loads the vendored copy of this package, so the
|
|
61
|
-
// policy has to travel inside it (`crates/onlyne-server/src/generate.rs`).
|
|
62
|
-
assert.ok(existsSync(join(dirname(path), "package.json")), dirname(path));
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("both policy keys are honoured, comments and blank lines included", () => {
|
|
66
|
-
const list = parseRelay('# which handoffs this session owes\nrelay_required = ["writer", "auditor"]\n');
|
|
67
|
-
assert.deepEqual(list.required, ["writer", "auditor"]);
|
|
68
|
-
assert.equal(list.count, null);
|
|
69
|
-
assert.equal(list.warning, null);
|
|
70
|
-
|
|
71
|
-
const counted = parseRelay("relay_required_count = 2 # distinct downstream roles\n");
|
|
72
|
-
assert.deepEqual(counted.required, []);
|
|
73
|
-
assert.equal(counted.count, 2);
|
|
74
|
-
assert.equal(counted.warning, null);
|
|
75
|
-
|
|
76
|
-
const both = parseRelay('relay_required = []\nrelay_required_count = 3\n');
|
|
77
|
-
assert.deepEqual(both.required, []);
|
|
78
|
-
assert.equal(both.count, 3);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("a body outside the closed subset warns and keeps the default", () => {
|
|
82
|
-
const unsupported = parseRelay(
|
|
83
|
-
["[relay]", 'relay_required = [', ' "writer",', "]", "relay_required_count = 0", "write = true"].join("\n"),
|
|
84
|
-
"relay.toml",
|
|
85
|
-
);
|
|
86
|
-
assert.deepEqual(unsupported.required, []);
|
|
87
|
-
assert.equal(unsupported.count, null);
|
|
88
|
-
assert.match(unsupported.warning, /relay\.toml:1: not a `key = value` line/);
|
|
89
|
-
assert.match(unsupported.warning, /relay\.toml:2: relay_required must be one line/);
|
|
90
|
-
assert.match(unsupported.warning, /relay\.toml:4: not a `key = value` line/);
|
|
91
|
-
assert.match(unsupported.warning, /relay\.toml:5: relay_required_count must be a positive integer/);
|
|
92
|
-
assert.match(unsupported.warning, /relay\.toml:6: unknown key "write"/);
|
|
93
|
-
|
|
94
|
-
// One bad line does not take the sound one with it.
|
|
95
|
-
const partial = parseRelay('relay_required = ["writer"]\nrelay_required_count = two\n');
|
|
96
|
-
assert.deepEqual(partial.required, ["writer"]);
|
|
97
|
-
assert.equal(partial.count, null);
|
|
98
|
-
assert.match(partial.warning, /relay_required_count must be a positive integer/);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("a policy file on disk reaches the caller with its warnings", () => {
|
|
102
|
-
const dir = workspace('relay_required = ["writer"]\nnonsense\n');
|
|
103
|
-
const relay = loadRelay({ path: join(dir, RELAY_FILE), env: {} });
|
|
104
|
-
assert.equal(relay.present, true);
|
|
105
|
-
assert.equal(relay.source, "file");
|
|
106
|
-
assert.deepEqual(relay.required, ["writer"]);
|
|
107
|
-
assert.equal(relayEnabled(relay), true);
|
|
108
|
-
assert.match(relay.warning, /relay\.toml:2:/);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("an injected policy is the one in force, and the file is not consulted", () => {
|
|
112
|
-
const dir = workspace('relay_required = ["legacy"]\n');
|
|
113
|
-
const relay = loadRelay({
|
|
114
|
-
path: join(dir, RELAY_FILE),
|
|
115
|
-
env: { [RELAY_ENV_REQUIRED]: "writer, auditor", [RELAY_ENV_COUNT]: "2" },
|
|
116
|
-
});
|
|
117
|
-
assert.deepEqual(relay.required, ["writer", "auditor"]);
|
|
118
|
-
assert.equal(relay.count, 2);
|
|
119
|
-
assert.equal(relay.source, "env");
|
|
120
|
-
assert.equal(relay.present, false, "the file is not where this policy came from");
|
|
121
|
-
assert.equal(relay.path, join(dir, RELAY_FILE));
|
|
122
|
-
assert.equal(relay.warning, null);
|
|
123
|
-
assert.equal(relayEnabled(relay), true);
|
|
124
|
-
// Both forms travel when the spec names both, and the list still decides.
|
|
125
|
-
assert.match(relayRefusal(relay, ["builder", "auditor"]), /missing handoff to: writer/);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("the environment spells the spec's two keys", () => {
|
|
129
|
-
const listed = envRelay({ [RELAY_ENV_REQUIRED]: "writer,, auditor ," });
|
|
130
|
-
assert.deepEqual(listed.required, ["writer", "auditor"]);
|
|
131
|
-
assert.equal(listed.count, null);
|
|
132
|
-
assert.equal(listed.specified, true);
|
|
133
|
-
assert.equal(listed.warning, null);
|
|
134
|
-
|
|
135
|
-
const counted = envRelay({ [RELAY_ENV_COUNT]: "2" });
|
|
136
|
-
assert.deepEqual(counted.required, []);
|
|
137
|
-
assert.equal(counted.count, 2);
|
|
138
|
-
assert.equal(counted.specified, true);
|
|
139
|
-
|
|
140
|
-
const nothing = envRelay({});
|
|
141
|
-
assert.deepEqual(nothing.required, []);
|
|
142
|
-
assert.equal(nothing.count, null);
|
|
143
|
-
assert.equal(nothing.specified, false, "an absent pair is not a policy");
|
|
144
|
-
assert.equal(nothing.warning, null);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("an unparsable variable is reported and the file keeps its turn", () => {
|
|
148
|
-
const dir = workspace('relay_required = ["legacy"]\n');
|
|
149
|
-
const relay = loadRelay({
|
|
150
|
-
path: join(dir, RELAY_FILE),
|
|
151
|
-
env: { [RELAY_ENV_COUNT]: "two" },
|
|
152
|
-
});
|
|
153
|
-
assert.equal(relay.source, "file");
|
|
154
|
-
assert.equal(relay.present, true);
|
|
155
|
-
assert.deepEqual(relay.required, ["legacy"]);
|
|
156
|
-
assert.equal(relay.count, null);
|
|
157
|
-
assert.equal(relayEnabled(relay), true);
|
|
158
|
-
assert.match(relay.warning, /ONLYNE_RELAY_COUNT must be a positive integer, got "two"/);
|
|
159
|
-
|
|
160
|
-
// A variable that names no role is not a policy either; with no file behind
|
|
161
|
-
// it the guard stays off, and the reason it is off is reported.
|
|
162
|
-
const off = loadRelay({ path: join(workspace(), RELAY_FILE), env: { [RELAY_ENV_REQUIRED]: ", ," } });
|
|
163
|
-
assert.deepEqual(off.required, []);
|
|
164
|
-
assert.equal(off.count, null);
|
|
165
|
-
assert.equal(off.source, "none");
|
|
166
|
-
assert.equal(off.present, false);
|
|
167
|
-
assert.equal(relayEnabled(off), false);
|
|
168
|
-
assert.match(off.warning, /ONLYNE_RELAY_REQUIRED: no role names in ", ,"/);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("the verdict names every missing edge, and the way out", () => {
|
|
172
|
-
const refusal = relayRefusal({ required: ["writer", "auditor"] }, ["auditor"]);
|
|
173
|
-
assert.match(refusal, /^relay guard: missing handoff to: writer \(/);
|
|
174
|
-
assert.match(refusal, /force:true and a non-empty reason/);
|
|
175
|
-
assert.match(refusal, /"relay-guard-forced: <reason>"/);
|
|
176
|
-
assert.equal(relayRefusal({ required: ["writer"] }, ["writer"]), null);
|
|
177
|
-
// The list wins when both keys are present, however many roles were reached.
|
|
178
|
-
assert.match(
|
|
179
|
-
relayRefusal({ required: ["writer"], count: 2 }, ["builder", "auditor"]),
|
|
180
|
-
/missing handoff to: writer/,
|
|
181
|
-
);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test("count mode wants distinct downstream roles, not echoes", () => {
|
|
185
|
-
const policy = { required: [], count: 2 };
|
|
186
|
-
const self = "planner";
|
|
187
|
-
const upstream = "supervisor";
|
|
188
|
-
// A send back to the role that assigned this task, and a note to itself, are
|
|
189
|
-
// not handoffs further down the cluster.
|
|
190
|
-
const echoed = relayRefusal(policy, [upstream, self], { role: self, upstream });
|
|
191
|
-
assert.match(echoed, /missing handoff: 2 of 2 required distinct downstream roles/);
|
|
192
|
-
assert.match(echoed, /delivered downstream: none/);
|
|
193
|
-
|
|
194
|
-
const one = relayRefusal(policy, [upstream, self, "builder"], { role: self, upstream });
|
|
195
|
-
assert.match(one, /missing handoff: 1 of 2 required distinct downstream roles/);
|
|
196
|
-
assert.match(one, /delivered downstream: builder/);
|
|
197
|
-
|
|
198
|
-
const enough = relayRefusal(policy, [upstream, self, "builder", "writer"], { role: self, upstream });
|
|
199
|
-
assert.equal(enough, null);
|
|
200
|
-
// Two handoffs to the same role are one edge, not two.
|
|
201
|
-
const repeated = relayRefusal(policy, ["builder", "builder"], { role: self, upstream });
|
|
202
|
-
assert.match(repeated, /missing handoff: 1 of 2/);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test("no policy, or a policy that guards nothing, never refuses", () => {
|
|
206
|
-
assert.equal(relayRefusal(DEFAULT_RELAY, []), null);
|
|
207
|
-
assert.equal(relayRefusal({ required: [], count: 0 }, []), null);
|
|
208
|
-
assert.equal(relayRefusal(undefined, []), null);
|
|
209
|
-
assert.equal(relayEnabled({ required: [], count: null }), false);
|
|
210
|
-
});
|