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/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
- // macOS gives `sun_path` 104 bytes, so the kernel refuses a unix socket path
4
- // past 103 (`UNIX_SOCKET_PATH_MAX` in `crates/onlyne-layout/src/lib.rs`). A
5
- // generated role workspace nests three levels below its server root
6
- // (`<root>/.onlyne/ws/<topology>/<role>/.onlyne/run/s`), so a deep root carries
7
- // the canonical spelling past that bound. The client answers by serving such a
8
- // workspace from a short path under the temporary directory
9
- // (`<temp>/onlyne-<16hex>/s`) and publishing the choice it bound in the marker
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
- // So the plugin has three answers in order, cheapest first: the path the client
15
- // injected when it spawned this process (`ONLYNE_SOCKET`), the path the running
16
- // daemon published in the marker, and the canonical spelling. The third one is
17
- // a complete answer for every workspace short enough to serve from `run/s`,
18
- // because `bind_socket` writes the marker at every start and such a tree
19
- // publishes `run/s` in it; the two readings give one path. A marker that is
20
- // missing, unreadable, or blank holds no published override, so resolution
21
- // falls through silently. That keeps a hand-started pi in a workspace with a
22
- // running daemon on the right socket with no environment at all.
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
- import { readFileSync } from "node:fs";
25
- import { isAbsolute, join } from "node:path";
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
- /** The canonical socket leaf every role workspace names; `SOCKET_FILE_NAME` in `crates/onlyne-layout/src/lib.rs`. */
28
- export const SOCKET_RELATIVE_PATH = join(".onlyne", "run", "s");
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
- /** Marker beside it naming the path the daemon actually serves. */
31
- export const SOCKET_MARKER_RELATIVE_PATH = join(".onlyne", "run", "socket");
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 socket path to dial for the workspace at `cwd`.
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 {string} cwd the pi working directory, the role workspace itself
38
- * @param {{ readFile?: (path: string) => string }} [options]
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 resolveSocketPath(env, cwd, options = {}) {
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 injected = typeof env.ONLYNE_SOCKET === "string" ? env.ONLYNE_SOCKET.trim() : "";
44
- if (injected) return injected;
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
- published = readFile(marker).trim();
193
+ names = readDir(dir);
49
194
  } catch {
50
- published = "";
195
+ return [];
51
196
  }
52
- if (published && isAbsolute(published)) return published;
53
- return join(cwd, SOCKET_RELATIVE_PATH);
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
  }
@@ -1,79 +1,237 @@
1
1
  // Which socket path a pi session dials.
2
2
  //
3
- // The three answers have to stay in one order: what the client injected, what
4
- // the daemon published in `<run>/socket`, and the canonical `<run>/s`. A
5
- // workspace deep enough that the canonical spelling passes macOS' 103-byte
6
- // `sun_path` bound is served from a short path under the temporary directory,
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 { SOCKET_MARKER_RELATIVE_PATH, SOCKET_RELATIVE_PATH, resolveSocketPath } from "./socket.mjs";
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 workspace whose `run/` directory exists, optionally with a marker body. */
23
- function workspace(markerBody) {
24
- const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-socket-"));
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
- /** The short path a daemon serves an over-long workspace from. */
32
- const SERVED = join(tmpdir(), "onlyne-0123456789abcdef", "s");
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 dir = workspace(`${SERVED}\n`);
36
- assert.equal(resolveSocketPath({ ONLYNE_SOCKET: SERVED }, dir), SERVED);
37
- // Any other published answer loses to the environment.
38
- assert.equal(resolveSocketPath({ ONLYNE_SOCKET: join(dir, SOCKET_RELATIVE_PATH) }, dir), join(dir, SOCKET_RELATIVE_PATH));
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: ` ${SERVED} ` }, dir), SERVED);
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 variable holding nothing answers nothing, so the tree decides.
43
- const bare = workspace();
44
- assert.equal(resolveSocketPath({ ONLYNE_SOCKET: " " }, bare), join(bare, SOCKET_RELATIVE_PATH));
45
- assert.equal(resolveSocketPath({}, bare), join(bare, SOCKET_RELATIVE_PATH));
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("a published marker moves the session onto the served path", () => {
49
- const dir = workspace(`${SERVED}\n`);
50
- const resolved = resolveSocketPath({}, dir);
51
- assert.equal(resolved, SERVED);
52
- // The moved path lives outside the workspace, which is the whole point of the
53
- // marker: the tree's own `run/s` would be refused by the kernel here.
54
- assert.ok(!resolved.startsWith(dir));
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("a workspace with no marker keeps the canonical spelling", () => {
58
- const dir = workspace();
59
- assert.equal(resolveSocketPath({}, dir), join(dir, ".onlyne", "run", "s"));
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("a marker that is empty, relative, or unreadable falls through silently", () => {
63
- const empty = workspace("");
64
- assert.equal(resolveSocketPath({}, empty), join(empty, SOCKET_RELATIVE_PATH));
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
- const relative = workspace("run/s");
67
- assert.equal(resolveSocketPath({}, relative), join(relative, SOCKET_RELATIVE_PATH));
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
- // A `socket` leaf holding a directory makes the read itself fail.
70
- const unreadable = workspace();
71
- rmSync(join(unreadable, SOCKET_MARKER_RELATIVE_PATH), { force: true });
72
- mkdirSync(join(unreadable, SOCKET_MARKER_RELATIVE_PATH));
73
- assert.equal(resolveSocketPath({}, unreadable), join(unreadable, SOCKET_RELATIVE_PATH));
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
- // A workspace with no `run/` at all still names one canonical path.
76
- const bare = mkdtempSync(join(tmpdir(), "pi-onlyne-socket-"));
77
- cleanups.push(() => rmSync(bare, { recursive: true, force: true }));
78
- assert.equal(resolveSocketPath({}, bare), join(bare, SOCKET_RELATIVE_PATH));
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
  });
@@ -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