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/socket.mjs
CHANGED
|
@@ -1,54 +1,285 @@
|
|
|
1
|
-
// The adapter socket a pi session dials.
|
|
1
|
+
// The adapter socket a pi session dials, resolved the v2 way.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// (
|
|
10
|
-
// file `<workspace>/.onlyne/run/socket`, one line holding the absolute served
|
|
11
|
-
// path. `onlyne-layout::SocketEndpoint::publish` writes that file; every reader
|
|
12
|
-
// in the product reaches one live socket through it.
|
|
3
|
+
// v2 keeps every local socket in one machine-level runtime directory —
|
|
4
|
+
// `$ONLYNE_RUNTIME_DIR` when the operator sets it and non-empty, `/tmp/onlyne-<uid>/`
|
|
5
|
+
// otherwise, `0700` — as `<digest>.sock`, `digest` the first 16 hex characters
|
|
6
|
+
// of `sha256` over the canonical workspace root. Nothing binds inside the tree
|
|
7
|
+
// any more: `<root>/.onlyne/run/s` and the `run/socket` marker v1 published the
|
|
8
|
+
// served path in are both deleted, and `run/s` survives only as the spelling
|
|
9
|
+
// operators print (`crates/onlyne-wire/src/socket.rs`).
|
|
13
10
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
11
|
+
// The client injects `ONLYNE_SOCKET` into every session process it spawns, so a
|
|
12
|
+
// pi started by a client needs nothing else and this module's first answer —
|
|
13
|
+
// that variable, verbatim — is what such a session uses. A pi started by hand
|
|
14
|
+
// (`pi -e <plugin>`) carries no such variable, and there is no path inside the
|
|
15
|
+
// tree to fall back on: it has to find the client the way the plan says an
|
|
16
|
+
// external runtime does (`docs/v2-PLAN.md` line 289), by reading the
|
|
17
|
+
// registration files in the runtime directory — `<digest>.json`, one per live
|
|
18
|
+
// endpoint, holding `kind`, `role`, `root`, `pid`, `version` and `runtime`.
|
|
19
|
+
//
|
|
20
|
+
// So a hand-started pi reads those files and the order of its answers is:
|
|
21
|
+
//
|
|
22
|
+
// 1. the registration whose `root` is the workspace this process runs in — the
|
|
23
|
+
// exact tree, or the nearest registered root containing it, which is the
|
|
24
|
+
// tree the CLI's own upward walk lands on (`owner_root` in
|
|
25
|
+
// `crates/onlyne-cli/src/socket.rs`). No arbitrary pick can arise on this
|
|
26
|
+
// key: the roots containing one directory are a chain, so "nearest" is
|
|
27
|
+
// stated by the paths themselves, and two registrations naming one tree
|
|
28
|
+
// name one socket, because the socket is its digest.
|
|
29
|
+
// 2. only when no registered root covers the working directory, the `runtime`
|
|
30
|
+
// field, which is the plan's discovery key for the external placement where
|
|
31
|
+
// one runtime serves several clients. Secondary in both senses: consulted
|
|
32
|
+
// last, and only a single matching client is an answer — several clients
|
|
33
|
+
// running pi sessions are refused by name rather than picked from, because
|
|
34
|
+
// a wrong pick dials a client that serves another role's sessions. (The
|
|
35
|
+
// field carries the session backend a client spawns under, `orca`/
|
|
36
|
+
// `acp`/`exec`, so today it names this plugin's runtime only where that
|
|
37
|
+
// backend is pi itself.)
|
|
38
|
+
// 3. nothing at all: `SocketResolutionError` naming the workspace, the
|
|
39
|
+
// runtime directory and the registration file — and every registration the
|
|
40
|
+
// directory does hold — so the operator reads what was missing rather than
|
|
41
|
+
// a raw `ENOENT` for a path v2 never creates.
|
|
42
|
+
//
|
|
43
|
+
// The derivation below has to agree byte for byte with `workspace_digest` in
|
|
44
|
+
// `crates/onlyne-wire/src/socket.rs` and `workspace_digest` in
|
|
45
|
+
// `crates/onlyne-testkit/e2e/lib.sh`; a digest one byte off dials a path no
|
|
46
|
+
// daemon holds.
|
|
47
|
+
|
|
48
|
+
import { createHash } from "node:crypto";
|
|
49
|
+
import { readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
50
|
+
import { tmpdir } from "node:os";
|
|
51
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
52
|
+
|
|
53
|
+
/** Environment variable holding the path a client injected, used verbatim. */
|
|
54
|
+
export const SOCKET_ENV = "ONLYNE_SOCKET";
|
|
55
|
+
|
|
56
|
+
/** Environment variable that replaces the default runtime directory; `RUNTIME_DIR_ENV` in `crates/onlyne-wire/src/socket.rs`. */
|
|
57
|
+
export const RUNTIME_DIR_ENV = "ONLYNE_RUNTIME_DIR";
|
|
58
|
+
|
|
59
|
+
/** Socket leaf inside a runtime directory: `<digest>.sock`. */
|
|
60
|
+
export const SOCKET_SUFFIX = ".sock";
|
|
61
|
+
|
|
62
|
+
/** Registration leaf inside a runtime directory: `<digest>.json`. */
|
|
63
|
+
export const REGISTRATION_SUFFIX = ".json";
|
|
23
64
|
|
|
24
|
-
|
|
25
|
-
|
|
65
|
+
/** The runtime this plugin is, the value it matches a client's `runtime` field against. */
|
|
66
|
+
export const RUNTIME_NAME = "pi";
|
|
26
67
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
68
|
+
/** How many hex characters of the digest name a runtime file; `workspace_digest` hashes 8 bytes. */
|
|
69
|
+
const DIGEST_LENGTH = 16;
|
|
29
70
|
|
|
30
|
-
/**
|
|
31
|
-
export
|
|
71
|
+
/** Raised when no client registration answers for a workspace, or several do. */
|
|
72
|
+
export class SocketResolutionError extends Error {
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} message what was looked for and what was found
|
|
75
|
+
* @param {{ path: string, root: string, kind: string | null, runtime: string | null }[]} candidates
|
|
76
|
+
*/
|
|
77
|
+
constructor(message, candidates = []) {
|
|
78
|
+
super(message);
|
|
79
|
+
this.name = "SocketResolutionError";
|
|
80
|
+
this.candidates = candidates;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
32
83
|
|
|
33
84
|
/**
|
|
34
|
-
* The
|
|
85
|
+
* The machine-level runtime directory: the override when set and non-empty,
|
|
86
|
+
* `/tmp/onlyne-<uid>` otherwise.
|
|
87
|
+
*
|
|
88
|
+
* Nothing is created or checked here, and the override is taken without
|
|
89
|
+
* trimming, both matching `runtime_dir_path` (an empty value is no override, a
|
|
90
|
+
* value holding only spaces is a directory name). The `/tmp` base is fixed
|
|
91
|
+
* rather than `os.tmpdir()` because a launchd-started daemon and an interactive
|
|
92
|
+
* shell see different `TMPDIR` values, and one base is what makes both compute
|
|
93
|
+
* the same path for the same root.
|
|
35
94
|
*
|
|
36
95
|
* @param {Record<string, string | undefined>} env the process environment
|
|
37
|
-
* @param {
|
|
38
|
-
* @
|
|
39
|
-
* @returns {string} an absolute path: the injected one, the published one, or `run/s`
|
|
96
|
+
* @param {{ uid?: number | null }} [options]
|
|
97
|
+
* @returns {string} an absolute directory path
|
|
40
98
|
*/
|
|
41
|
-
export function
|
|
99
|
+
export function runtimeDir(env, options = {}) {
|
|
100
|
+
const override = typeof env[RUNTIME_DIR_ENV] === "string" ? env[RUNTIME_DIR_ENV] : "";
|
|
101
|
+
if (override !== "") return override;
|
|
102
|
+
const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : null);
|
|
103
|
+
// Off unix the reference has no uid to name: `%TEMP%` already scopes the
|
|
104
|
+
// directory per user and the endpoint's own owner-only descriptor guards it.
|
|
105
|
+
return uid === null ? join(tmpdir(), "onlyne-user") : join("/tmp", `onlyne-${uid}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The canonical spelling of `root`, the one `absolute_path` takes.
|
|
110
|
+
*
|
|
111
|
+
* A root that exists is resolved through its symlinks, which is what keeps
|
|
112
|
+
* macOS's `/var` and `/private/var` one digest. A root that does not exist has
|
|
113
|
+
* nothing to canonicalize and falls back to the lexical absolute.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} root
|
|
116
|
+
* @param {{ realpath?: (path: string) => string }} [options]
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
export function canonicalRoot(root, options = {}) {
|
|
120
|
+
const realpath = options.realpath ?? ((path) => realpathSync(path));
|
|
121
|
+
try {
|
|
122
|
+
return realpath(root);
|
|
123
|
+
} catch {
|
|
124
|
+
return resolve(isAbsolute(root) ? root : join(process.cwd(), root));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The identity of one owner tree: `sha256` over its canonical spelling, the
|
|
130
|
+
* first 16 lowercase hex characters.
|
|
131
|
+
*
|
|
132
|
+
* Separators become `/` and the whole spelling is lowercased before hashing, so
|
|
133
|
+
* `C:\Work` and `c:/work` digest alike — the reason a daemon and every client
|
|
134
|
+
* derive one file name without talking to each other.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} root
|
|
137
|
+
* @param {{ realpath?: (path: string) => string }} [options]
|
|
138
|
+
* @returns {string} 16 hex characters
|
|
139
|
+
*/
|
|
140
|
+
export function workspaceDigest(root, options = {}) {
|
|
141
|
+
const spelling = canonicalRoot(root, options)
|
|
142
|
+
.replaceAll("\\", "/")
|
|
143
|
+
.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
|
144
|
+
return createHash("sha256").update(spelling, "utf8").digest("hex").slice(0, DIGEST_LENGTH);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The socket one owner root is bound to: `<runtime_dir>/<digest>.sock`.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} root the workspace root
|
|
151
|
+
* @param {Record<string, string | undefined>} env
|
|
152
|
+
* @param {{ readFile?: (path: string) => string, readDir?: (path: string) => string[], realpath?: (path: string) => string, uid?: number | null }} [options]
|
|
153
|
+
* @returns {string}
|
|
154
|
+
*/
|
|
155
|
+
export function socketPath(root, env, options = {}) {
|
|
156
|
+
return join(runtimeDir(env, options), `${workspaceDigest(root, options)}${SOCKET_SUFFIX}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The registration belonging to that socket: `<runtime_dir>/<digest>.json`.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} root the workspace root
|
|
163
|
+
* @param {Record<string, string | undefined>} env
|
|
164
|
+
* @param {{ readFile?: (path: string) => string, readDir?: (path: string) => string[], realpath?: (path: string) => string, uid?: number | null }} [options]
|
|
165
|
+
* @returns {string}
|
|
166
|
+
*/
|
|
167
|
+
export function registrationPath(root, env, options = {}) {
|
|
168
|
+
return join(runtimeDir(env, options), `${workspaceDigest(root, options)}${REGISTRATION_SUFFIX}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Every client registration the runtime directory holds, in file-name order.
|
|
173
|
+
*
|
|
174
|
+
* A missing runtime directory is no registrations rather than an error: a
|
|
175
|
+
* plugin started before any daemon should see an empty machine. A file that is
|
|
176
|
+
* not `.json`, does not parse, or names no root is skipped, because one stray
|
|
177
|
+
* file must not blind the reader to every other tree — the same rule
|
|
178
|
+
* `list_registrations` follows in `crates/onlyne-wire/src/socket.rs`. A
|
|
179
|
+
* registration of another kind (a server root's admin endpoint, say) is skipped
|
|
180
|
+
* here as well: this plugin speaks the adapter protocol of a role workspace's
|
|
181
|
+
* client, and an admin socket answers those frames with `unknown op`.
|
|
182
|
+
*
|
|
183
|
+
* @param {Record<string, string | undefined>} env
|
|
184
|
+
* @param {{ readFile?: (path: string) => string, readDir?: (path: string) => string[], realpath?: (path: string) => string, uid?: number | null }} [options]
|
|
185
|
+
* @returns {{ path: string, root: string, kind: string | null, runtime: string | null }[]}
|
|
186
|
+
*/
|
|
187
|
+
export function listClientRegistrations(env, options = {}) {
|
|
188
|
+
const readDir = options.readDir ?? ((path) => readdirSync(path));
|
|
42
189
|
const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const marker = join(cwd, SOCKET_MARKER_RELATIVE_PATH);
|
|
46
|
-
let published = "";
|
|
190
|
+
const dir = runtimeDir(env, options);
|
|
191
|
+
let names;
|
|
47
192
|
try {
|
|
48
|
-
|
|
193
|
+
names = readDir(dir);
|
|
49
194
|
} catch {
|
|
50
|
-
|
|
195
|
+
return [];
|
|
51
196
|
}
|
|
52
|
-
|
|
53
|
-
|
|
197
|
+
const found = [];
|
|
198
|
+
for (const name of [...names].sort()) {
|
|
199
|
+
if (typeof name !== "string" || !name.endsWith(REGISTRATION_SUFFIX)) continue;
|
|
200
|
+
let parsed;
|
|
201
|
+
try {
|
|
202
|
+
parsed = JSON.parse(readFile(join(dir, name)));
|
|
203
|
+
} catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
207
|
+
if (parsed.kind !== "client") continue;
|
|
208
|
+
if (typeof parsed.root !== "string" || parsed.root === "") continue;
|
|
209
|
+
found.push({
|
|
210
|
+
path: join(dir, name),
|
|
211
|
+
root: canonicalRoot(parsed.root, options),
|
|
212
|
+
kind: parsed.kind,
|
|
213
|
+
runtime: typeof parsed.runtime === "string" && parsed.runtime !== "" ? parsed.runtime : null,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return found;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The socket path to dial for the pi working directory `cwd`.
|
|
221
|
+
*
|
|
222
|
+
* @param {Record<string, string | undefined>} env the process environment
|
|
223
|
+
* @param {string} cwd the pi working directory, inside the role workspace
|
|
224
|
+
* @param {{ readFile?: (path: string) => string, readDir?: (path: string) => string[], realpath?: (path: string) => string, uid?: number | null, runtime?: string }} [options]
|
|
225
|
+
* @returns {string} an absolute path
|
|
226
|
+
* @throws {SocketResolutionError} when no client answers for the workspace, or several do
|
|
227
|
+
*/
|
|
228
|
+
export function resolveSocketPath(env, cwd, options = {}) {
|
|
229
|
+
const injected = typeof env[SOCKET_ENV] === "string" ? env[SOCKET_ENV].trim() : "";
|
|
230
|
+
if (injected) return injected;
|
|
231
|
+
|
|
232
|
+
const dir = runtimeDir(env, options);
|
|
233
|
+
const here = canonicalRoot(cwd, options);
|
|
234
|
+
const candidates = listClientRegistrations(env, options);
|
|
235
|
+
|
|
236
|
+
// The tree this process runs in answers first: the exact workspace, or the
|
|
237
|
+
// nearest registered root containing it — the same tree `owner_root` walks up
|
|
238
|
+
// to in `crates/onlyne-cli/src/socket.rs`, so a pi started in a subdirectory
|
|
239
|
+
// reaches the client of the workspace it belongs to. Roots containing one
|
|
240
|
+
// directory form a chain, so the longest one is this tree and never a coin
|
|
241
|
+
// toss; a second registration of that same tree names the same socket.
|
|
242
|
+
const covering = candidates.filter((entry) => covers(entry.root, here));
|
|
243
|
+
if (covering.length > 0) {
|
|
244
|
+
const nearest = covering.reduce((best, entry) => (entry.root.length > best.root.length ? entry : best));
|
|
245
|
+
return join(dir, `${workspaceDigest(nearest.root, options)}${SOCKET_SUFFIX}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// No registered root contains the working directory. The `runtime` field is
|
|
249
|
+
// the plan's discovery key for an external placement, where one runtime
|
|
250
|
+
// serves several clients (`docs/v2-PLAN.md` line 289); it stays secondary, so
|
|
251
|
+
// it is consulted only here, and only a single answer is an answer.
|
|
252
|
+
const runtime = (options.runtime ?? RUNTIME_NAME).toLowerCase();
|
|
253
|
+
const matching = candidates.filter((entry) => entry.runtime !== null && entry.runtime.toLowerCase() === runtime);
|
|
254
|
+
if (matching.length === 1) {
|
|
255
|
+
return join(dir, `${workspaceDigest(matching[0].root, options)}${SOCKET_SUFFIX}`);
|
|
256
|
+
}
|
|
257
|
+
const wanted = join(dir, `${workspaceDigest(here, options)}${REGISTRATION_SUFFIX}`);
|
|
258
|
+
let seen;
|
|
259
|
+
if (matching.length > 1) {
|
|
260
|
+
seen =
|
|
261
|
+
`and ${matching.length} clients there name runtime ${runtime} — ${describe(matching)} — ` +
|
|
262
|
+
"so which one serves this session is ambiguous";
|
|
263
|
+
} else if (candidates.length === 0) {
|
|
264
|
+
seen = "and the directory holds no client registration";
|
|
265
|
+
} else {
|
|
266
|
+
seen = `and the ${candidates.length} client registration(s) there belong to other roots: ${describe(candidates)}`;
|
|
267
|
+
}
|
|
268
|
+
throw new SocketResolutionError(
|
|
269
|
+
`onlyne: no client is registered for ${here}: looked for ${wanted} in ${dir}, ${seen}. ` +
|
|
270
|
+
"Start that workspace's client, or name the socket with ONLYNE_SOCKET",
|
|
271
|
+
matching.length > 0 ? matching : candidates,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** `true` when `root` is `here` or a directory containing it. */
|
|
276
|
+
function covers(root, here) {
|
|
277
|
+
return root === here || here.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** `<path> (root <root>, runtime <runtime>)` per entry, for an error message. */
|
|
281
|
+
function describe(entries) {
|
|
282
|
+
return entries
|
|
283
|
+
.map((entry) => `${entry.path} (root ${entry.root}, runtime ${entry.runtime ?? "-"})`)
|
|
284
|
+
.join(", ");
|
|
54
285
|
}
|
package/src/socket.test.mjs
CHANGED
|
@@ -1,79 +1,237 @@
|
|
|
1
1
|
// Which socket path a pi session dials.
|
|
2
2
|
//
|
|
3
|
-
// The
|
|
4
|
-
// the
|
|
5
|
-
//
|
|
6
|
-
// `
|
|
7
|
-
// and the marker is the only place inside the tree that names it.
|
|
3
|
+
// The two answers have one order: the path the client injected, then the client
|
|
4
|
+
// the runtime directory's registration files name for this workspace. Nothing
|
|
5
|
+
// falls back to a path inside the tree — v2 binds nothing there, so a test that
|
|
6
|
+
// expected `<workspace>/.onlyne/run/s` would pin a socket no daemon holds.
|
|
8
7
|
|
|
9
8
|
import assert from "node:assert/strict";
|
|
10
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
11
10
|
import { tmpdir } from "node:os";
|
|
12
11
|
import { join } from "node:path";
|
|
13
12
|
import { afterEach, test } from "node:test";
|
|
14
13
|
|
|
15
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
SOCKET_ENV,
|
|
16
|
+
SocketResolutionError,
|
|
17
|
+
canonicalRoot,
|
|
18
|
+
listClientRegistrations,
|
|
19
|
+
registrationPath,
|
|
20
|
+
resolveSocketPath,
|
|
21
|
+
runtimeDir,
|
|
22
|
+
socketPath,
|
|
23
|
+
workspaceDigest,
|
|
24
|
+
} from "./socket.mjs";
|
|
16
25
|
|
|
17
26
|
const cleanups = [];
|
|
18
27
|
afterEach(() => {
|
|
19
28
|
while (cleanups.length > 0) cleanups.pop()();
|
|
20
29
|
});
|
|
21
30
|
|
|
22
|
-
/** One temp
|
|
23
|
-
function
|
|
24
|
-
const dir = mkdtempSync(join(tmpdir(),
|
|
31
|
+
/** One temp directory, removed after the test. */
|
|
32
|
+
function scratch(prefix) {
|
|
33
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
25
34
|
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
|
|
26
|
-
mkdirSync(join(dir, ".onlyne", "run"), { recursive: true });
|
|
27
|
-
if (markerBody !== undefined) writeFileSync(join(dir, SOCKET_MARKER_RELATIVE_PATH), markerBody);
|
|
28
35
|
return dir;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
38
|
+
/** One temp runtime directory holding the registrations written into it. */
|
|
39
|
+
function runtime(registrations = []) {
|
|
40
|
+
const dir = scratch("pi-onlyne-runtime-");
|
|
41
|
+
for (const { name, body } of registrations) {
|
|
42
|
+
writeFileSync(join(dir, name), typeof body === "string" ? body : JSON.stringify(body));
|
|
43
|
+
}
|
|
44
|
+
return dir;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A registration file's name for `root`, the leaf `onlyne-wire` derives. */
|
|
48
|
+
function registrationName(root) {
|
|
49
|
+
return `${workspaceDigest(root)}.json`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The registration of one client whose sessions run under `runtime`. */
|
|
53
|
+
function client(root, runtimeName = "pi") {
|
|
54
|
+
// The writer's spelling is the canonical one (`RegistrationFile::new`).
|
|
55
|
+
return { kind: "client", role: "planner", root: canonicalRoot(root), pid: 4242, version: "0.2.0", runtime: runtimeName };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The env a client-spawned session carries, with the runtime directory pinned. */
|
|
59
|
+
function envFor(dir, extra = {}) {
|
|
60
|
+
return { ONLYNE_RUNTIME_DIR: dir, ...extra };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The refusal one resolve raises, which `assert.throws` does not hand back. */
|
|
64
|
+
function refusal(resolve) {
|
|
65
|
+
try {
|
|
66
|
+
resolve();
|
|
67
|
+
} catch (error) {
|
|
68
|
+
assert.ok(error instanceof SocketResolutionError, `expected a SocketResolutionError, got ${error}`);
|
|
69
|
+
return error;
|
|
70
|
+
}
|
|
71
|
+
assert.fail("expected a SocketResolutionError");
|
|
72
|
+
}
|
|
33
73
|
|
|
34
74
|
test("the injected environment variable decides first", () => {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
75
|
+
const workspace = scratch("pi-onlyne-ws-");
|
|
76
|
+
const dir = runtime([{ name: registrationName(workspace), body: client(workspace) }]);
|
|
77
|
+
const injected = join(dir, "some-other.sock");
|
|
78
|
+
|
|
79
|
+
assert.equal(resolveSocketPath(envFor(dir, { ONLYNE_SOCKET: injected }), workspace), injected);
|
|
39
80
|
// A value wrapped in space names the same socket.
|
|
40
|
-
assert.equal(resolveSocketPath({ ONLYNE_SOCKET: ` ${
|
|
81
|
+
assert.equal(resolveSocketPath(envFor(dir, { ONLYNE_SOCKET: ` ${injected} ` }), workspace), injected);
|
|
82
|
+
// It outranks a registration that answers for the same workspace.
|
|
83
|
+
assert.notEqual(injected, socketPath(workspace, envFor(dir)));
|
|
84
|
+
|
|
85
|
+
// A variable holding nothing answers nothing, so the registrations decide.
|
|
86
|
+
assert.equal(resolveSocketPath(envFor(dir, { ONLYNE_SOCKET: " " }), workspace), socketPath(workspace, envFor(dir)));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("the digest is the reference's, sixteen hex characters of sha256 over the canonical root", () => {
|
|
90
|
+
// Vectors from `crates/onlyne-wire/src/socket.rs::workspace_digest` and
|
|
91
|
+
// `crates/onlyne-testkit/e2e/lib.sh::workspace_digest`: python3 -c
|
|
92
|
+
// 'import hashlib;print(hashlib.sha256(PATH.encode()).hexdigest()[:16])'.
|
|
93
|
+
assert.equal(workspaceDigest("/srv/onlyne"), "8bc9cceef0a8586d");
|
|
94
|
+
assert.equal(workspaceDigest("/srv/onlyne/workspace"), "add3dd4d5bd2c91a");
|
|
95
|
+
// Separators become `/` and the spelling is lowercased before hashing.
|
|
96
|
+
assert.equal(workspaceDigest("/SRV/Onlyne"), "8bc9cceef0a8586d");
|
|
41
97
|
|
|
42
|
-
// A
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
98
|
+
// A root that exists is canonicalized, so one tree reached through two
|
|
99
|
+
// spellings digests once.
|
|
100
|
+
const real = scratch("pi-onlyne-ws-");
|
|
101
|
+
const link = join(scratch("pi-onlyne-link-"), "ws");
|
|
102
|
+
symlinkSync(real, link);
|
|
103
|
+
assert.equal(workspaceDigest(link), workspaceDigest(real));
|
|
46
104
|
});
|
|
47
105
|
|
|
48
|
-
test("
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
assert.
|
|
106
|
+
test("the runtime directory is the override, or /tmp/onlyne-<uid>", () => {
|
|
107
|
+
assert.equal(runtimeDir({ ONLYNE_RUNTIME_DIR: "/tmp/pinned" }, { uid: 501 }), "/tmp/pinned");
|
|
108
|
+
// An empty override is no override; a whitespace-only one is the reference's
|
|
109
|
+
// business, and it is a directory name there rather than an empty value.
|
|
110
|
+
assert.equal(runtimeDir({ ONLYNE_RUNTIME_DIR: "" }, { uid: 501 }), "/tmp/onlyne-501");
|
|
111
|
+
assert.equal(runtimeDir({}, { uid: 501 }), "/tmp/onlyne-501");
|
|
112
|
+
assert.equal(runtimeDir({}, { uid: 0 }), "/tmp/onlyne-0");
|
|
55
113
|
});
|
|
56
114
|
|
|
57
|
-
test("
|
|
58
|
-
const
|
|
59
|
-
|
|
115
|
+
test("the socket is derived from the workspace root in the runtime directory", () => {
|
|
116
|
+
const workspace = scratch("pi-onlyne-ws-");
|
|
117
|
+
const dir = runtime([{ name: registrationName(workspace), body: client(workspace) }]);
|
|
118
|
+
const env = envFor(dir);
|
|
119
|
+
|
|
120
|
+
const resolved = resolveSocketPath(env, workspace);
|
|
121
|
+
assert.equal(resolved, join(dir, `${workspaceDigest(workspace)}.sock`));
|
|
122
|
+
assert.equal(resolved, socketPath(workspace, env));
|
|
123
|
+
assert.equal(registrationPath(workspace, env), join(dir, registrationName(workspace)));
|
|
124
|
+
// Nothing inside the workspace serves anything in v2.
|
|
125
|
+
assert.ok(!resolved.startsWith(workspace));
|
|
126
|
+
|
|
127
|
+
// The override moves the whole derivation, socket and registration alike.
|
|
128
|
+
const pinned = runtime([{ name: registrationName(workspace), body: client(workspace) }]);
|
|
129
|
+
assert.equal(resolveSocketPath(envFor(pinned), workspace), join(pinned, `${workspaceDigest(workspace)}.sock`));
|
|
130
|
+
assert.equal(socketPath(workspace, envFor(pinned)), join(pinned, `${workspaceDigest(workspace)}.sock`));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a pi started below the workspace root resolves the workspace's client", () => {
|
|
134
|
+
const workspace = scratch("pi-onlyne-ws-");
|
|
135
|
+
const nested = join(workspace, ".onlyne", "workspaces", "topology", "planner");
|
|
136
|
+
mkdirSync(nested, { recursive: true });
|
|
137
|
+
const dir = runtime([{ name: registrationName(workspace), body: client(workspace) }]);
|
|
138
|
+
|
|
139
|
+
assert.equal(resolveSocketPath(envFor(dir), nested), socketPath(workspace, envFor(dir)));
|
|
140
|
+
// The nearest registered root wins over one that only contains it, so a
|
|
141
|
+
// client of the outer tree never answers for a session inside the inner one.
|
|
142
|
+
const outer = scratch("pi-onlyne-ws-");
|
|
143
|
+
const inner = join(outer, "inner");
|
|
144
|
+
mkdirSync(inner);
|
|
145
|
+
const both = runtime([
|
|
146
|
+
{ name: registrationName(outer), body: client(outer) },
|
|
147
|
+
{ name: registrationName(inner), body: client(inner) },
|
|
148
|
+
]);
|
|
149
|
+
assert.equal(resolveSocketPath({ ONLYNE_RUNTIME_DIR: both }, inner), socketPath(inner, { ONLYNE_RUNTIME_DIR: both }));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("a registration that is not this plugin's surface is not an answer", () => {
|
|
153
|
+
const workspace = scratch("pi-onlyne-ws-");
|
|
154
|
+
// A server root's admin socket speaks the admin vocabulary, not the adapter
|
|
155
|
+
// protocol, and `list_registrations` would hand it over just the same.
|
|
156
|
+
const dir = runtime([
|
|
157
|
+
{ name: registrationName(workspace), body: { kind: "server", role: null, root: workspace, pid: 1, version: "0.2.0", runtime: null } },
|
|
158
|
+
{ name: "broken.json", body: "{ not a registration" },
|
|
159
|
+
{ name: "notes.txt", body: "not a registration at all" },
|
|
160
|
+
{ name: "rootless.json", body: { kind: "client", pid: 2 } },
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
assert.deepEqual(listClientRegistrations(envFor(dir)), []);
|
|
164
|
+
const error = refusal(() => resolveSocketPath(envFor(dir), workspace));
|
|
165
|
+
assert.match(error.message, /no client is registered for/);
|
|
166
|
+
assert.match(error.message, new RegExp(registrationName(workspace).replace(/\./g, "\\.")));
|
|
167
|
+
assert.match(error.message, /holds no client registration/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a refused resolution names what was looked for", () => {
|
|
171
|
+
const workspace = scratch("pi-onlyne-ws-");
|
|
172
|
+
const empty = scratch("pi-onlyne-runtime-");
|
|
173
|
+
const nowhere = refusal(() => resolveSocketPath(envFor(empty), workspace));
|
|
174
|
+
assert.match(nowhere.message, new RegExp(workspaceDigest(workspace)));
|
|
175
|
+
assert.match(nowhere.message, new RegExp(empty));
|
|
176
|
+
assert.deepEqual(nowhere.candidates, []);
|
|
177
|
+
|
|
178
|
+
// Another tree's client is reported by name rather than dialled.
|
|
179
|
+
// An ACP client is another runtime's, so it is not this plugin's to dial and
|
|
180
|
+
// the refusal names it instead.
|
|
181
|
+
const other = scratch("pi-onlyne-ws-");
|
|
182
|
+
const dir = runtime([{ name: registrationName(other), body: client(other, "acp") }]);
|
|
183
|
+
const elsewhere = refusal(() => resolveSocketPath(envFor(dir), workspace));
|
|
184
|
+
assert.match(elsewhere.message, new RegExp(canonicalRoot(other).replace(/\./g, "\\.")));
|
|
185
|
+
assert.match(elsewhere.message, /belong to other roots/);
|
|
186
|
+
assert.match(elsewhere.message, /runtime acp/);
|
|
60
187
|
});
|
|
61
188
|
|
|
62
|
-
test("
|
|
63
|
-
const
|
|
64
|
-
|
|
189
|
+
test("several matching clients are refused, not picked from", () => {
|
|
190
|
+
const workspace = scratch("pi-onlyne-ws-");
|
|
191
|
+
const first = scratch("pi-onlyne-ws-");
|
|
192
|
+
const second = scratch("pi-onlyne-ws-");
|
|
65
193
|
|
|
66
|
-
|
|
67
|
-
|
|
194
|
+
// Two clients run pi sessions and neither root contains this workspace: the
|
|
195
|
+
// external placement's discovery key has no single answer.
|
|
196
|
+
const dir = runtime([
|
|
197
|
+
{ name: registrationName(first), body: client(first) },
|
|
198
|
+
{ name: registrationName(second), body: client(second) },
|
|
199
|
+
]);
|
|
200
|
+
const error = refusal(() => resolveSocketPath(envFor(dir), workspace));
|
|
201
|
+
assert.match(error.message, /name runtime pi/);
|
|
202
|
+
assert.match(error.message, /ambiguous/);
|
|
203
|
+
assert.equal(error.candidates.length, 2);
|
|
204
|
+
|
|
205
|
+
// Two registrations naming one tree are not ambiguous: the socket is the
|
|
206
|
+
// tree's digest, so both name one path and deduping is not a pick.
|
|
207
|
+
const duplicated = runtime([
|
|
208
|
+
{ name: registrationName(workspace), body: client(workspace) },
|
|
209
|
+
{ name: "hand-written.json", body: client(workspace) },
|
|
210
|
+
]);
|
|
211
|
+
assert.equal(resolveSocketPath(envFor(duplicated), workspace), socketPath(workspace, envFor(duplicated)));
|
|
212
|
+
|
|
213
|
+
// One runtime match with no root of its own is the plan's external case, and
|
|
214
|
+
// one answer is an answer.
|
|
215
|
+
const single = runtime([{ name: registrationName(first), body: client(first) }]);
|
|
216
|
+
const sole = resolveSocketPath(envFor(single), workspace);
|
|
217
|
+
assert.equal(sole, socketPath(first, envFor(single)));
|
|
218
|
+
assert.equal(sole, join(single, `${workspaceDigest(first)}.sock`));
|
|
219
|
+
});
|
|
68
220
|
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
221
|
+
test("a registration read through the injected filesystem names the same socket", () => {
|
|
222
|
+
const workspace = "/srv/onlyne/workspace";
|
|
223
|
+
const dir = "/tmp/onlyne-501";
|
|
224
|
+
const options = {
|
|
225
|
+
uid: 501,
|
|
226
|
+
realpath: (path) => path,
|
|
227
|
+
readDir: () => [registrationName(workspace), "unrelated.json"],
|
|
228
|
+
readFile: (path) => (path.endsWith(registrationName(workspace)) ? JSON.stringify(client(workspace)) : "{}"),
|
|
229
|
+
};
|
|
74
230
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
231
|
+
assert.equal(
|
|
232
|
+
resolveSocketPath({ ONLYNE_RUNTIME_DIR: dir }, workspace, options),
|
|
233
|
+
`${dir}/add3dd4d5bd2c91a.sock`,
|
|
234
|
+
);
|
|
235
|
+
// The env is what names the socket a client injected, and it is still first.
|
|
236
|
+
assert.equal(resolveSocketPath({ ONLYNE_RUNTIME_DIR: dir, [SOCKET_ENV]: "/tmp/served" }, workspace, options), "/tmp/served");
|
|
79
237
|
});
|
package/relay.toml.example
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
# Manual-installation escape hatch for the relay guard.
|
|
2
|
-
#
|
|
3
|
-
# A generated workspace gets its guard policy from spec.toml ([[client]] rows
|
|
4
|
-
# `relay_required = ["writer"]`, `relay_count = 2` — `relay_required_count` is
|
|
5
|
-
# accepted as the guard file's own spelling), which the client injects into
|
|
6
|
-
# every session it spawns. This file is for installations that manage their
|
|
7
|
-
# own workspace: drop it beside package.json and the guard reads it when the
|
|
8
|
-
# environment carries no policy. Environment wins over this file; no policy in
|
|
9
|
-
# either place leaves the guard off.
|
|
10
|
-
#
|
|
11
|
-
# relay_required wins over relay_count when both are present.
|
|
12
|
-
|
|
13
|
-
# Downstream roles one of this role's sessions must have handed work to before
|
|
14
|
-
# it may report a terminal outcome:
|
|
15
|
-
# relay_required = ["writer", "auditor"]
|
|
16
|
-
|
|
17
|
-
# ... or this many distinct downstream roles:
|
|
18
|
-
# relay_required_count = 2
|