@executablemd/runtime 0.8.1 → 0.9.1

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ const dntGlobals = {};
2
+ export const dntGlobalThis = createMergeProxy(globalThis, dntGlobals);
3
+ function createMergeProxy(baseObj, extObj) {
4
+ return new Proxy(baseObj, {
5
+ get(_target, prop, _receiver) {
6
+ if (prop in extObj) {
7
+ return extObj[prop];
8
+ }
9
+ else {
10
+ return baseObj[prop];
11
+ }
12
+ },
13
+ set(_target, prop, value) {
14
+ if (prop in extObj) {
15
+ delete extObj[prop];
16
+ }
17
+ baseObj[prop] = value;
18
+ return true;
19
+ },
20
+ deleteProperty(_target, prop) {
21
+ let success = false;
22
+ if (prop in extObj) {
23
+ delete extObj[prop];
24
+ success = true;
25
+ }
26
+ if (prop in baseObj) {
27
+ delete baseObj[prop];
28
+ success = true;
29
+ }
30
+ return success;
31
+ },
32
+ ownKeys(_target) {
33
+ const baseKeys = Reflect.ownKeys(baseObj);
34
+ const extKeys = Reflect.ownKeys(extObj);
35
+ const extKeysSet = new Set(extKeys);
36
+ return [...baseKeys.filter((k) => !extKeysSet.has(k)), ...extKeys];
37
+ },
38
+ defineProperty(_target, prop, desc) {
39
+ if (prop in extObj) {
40
+ delete extObj[prop];
41
+ }
42
+ Reflect.defineProperty(baseObj, prop, desc);
43
+ return true;
44
+ },
45
+ getOwnPropertyDescriptor(_target, prop) {
46
+ if (prop in extObj) {
47
+ return Reflect.getOwnPropertyDescriptor(extObj, prop);
48
+ }
49
+ else {
50
+ return Reflect.getOwnPropertyDescriptor(baseObj, prop);
51
+ }
52
+ },
53
+ has(_target, prop) {
54
+ return prop in extObj || prop in baseObj;
55
+ },
56
+ });
57
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Exclusive ownership of one logical agent session
3
+ * (specs/native-agent-session-launch-spec.md §Ownership and concurrency).
4
+ *
5
+ * An advertised provider-returned session can be handed to a native UI, and a
6
+ * native UI is a live owner nothing on the XMD side can observe. So every
7
+ * operation that could act on such a session — establishing it, running a turn,
8
+ * launching into it — enters here first, and a host that cannot answer the
9
+ * ownership question refuses rather than guessing.
10
+ *
11
+ * This is a plain capability, injected directly by the host into the provider
12
+ * that needs it. It is deliberately not a contextual Api: ownership is a
13
+ * security decision, and a decision document middleware can replace is not one.
14
+ *
15
+ * Acquisition never waits. A native UI may stay open for hours, and a caller
16
+ * that queued would hold the reader's terminal while offering no way to reach
17
+ * the owner it waits for.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ /** Another live owner holds this session right now. */
21
+ export class AgentSessionBusy extends Error {
22
+ name = "AgentSessionBusy";
23
+ }
24
+ /**
25
+ * The last owner never proved it stopped.
26
+ *
27
+ * A crash releases the kernel lock but not this: nothing observable afterwards
28
+ * distinguishes a session whose owner died mid-turn from one it left cleanly,
29
+ * so the conservative answer stands until someone recovers it deliberately.
30
+ */
31
+ export class AgentSessionRecoveryRequired extends Error {
32
+ name = "AgentSessionRecoveryRequired";
33
+ }
34
+ /**
35
+ * The digest that names one session's sidecar and ownership record.
36
+ *
37
+ * Canonical, so every process derives the same name, and a digest, so the
38
+ * coordination namespace holds no agent name, session name, path or authored
39
+ * value.
40
+ */
41
+ export function agentSessionKeyDigest(key) {
42
+ return createHash("sha256")
43
+ .update(JSON.stringify([key.provider, key.agent, key.sessionKey]), "utf8")
44
+ .digest("hex");
45
+ }
46
+ const OWNER_KINDS = ["session", "prompt", "native-launch"];
47
+ function isRecord(value) {
48
+ return typeof value === "object" && value !== null && !Array.isArray(value);
49
+ }
50
+ /**
51
+ * Read an ownership record strictly.
52
+ *
53
+ * An unknown schema, a missing field, or a member this build cannot account for
54
+ * describes state it must not act on — so it is refused rather than read
55
+ * partially, and never repaired.
56
+ */
57
+ export function parseAgentSessionOwnership(value) {
58
+ if (!isRecord(value) || value.schema !== "agent-session-ownership.v1") {
59
+ return undefined;
60
+ }
61
+ const allowed = ["schema", "keyDigest", "state", "ownerKind", "operationId"];
62
+ for (const member of Object.keys(value)) {
63
+ if (!allowed.includes(member)) {
64
+ return undefined;
65
+ }
66
+ }
67
+ const { keyDigest, state, operationId } = value;
68
+ if (typeof keyDigest !== "string" || !/^[0-9a-f]{64}$/.test(keyDigest)) {
69
+ return undefined;
70
+ }
71
+ if (state !== "active" && state !== "idle") {
72
+ return undefined;
73
+ }
74
+ if (typeof operationId !== "string" || operationId.length === 0) {
75
+ return undefined;
76
+ }
77
+ const ownerKind = OWNER_KINDS.find((kind) => kind === value.ownerKind);
78
+ if (!ownerKind) {
79
+ return undefined;
80
+ }
81
+ return { schema: "agent-session-ownership.v1", keyDigest, state, ownerKind, operationId };
82
+ }
83
+ export function serializeAgentSessionOwnership(record) {
84
+ return `${JSON.stringify({
85
+ schema: record.schema,
86
+ keyDigest: record.keyDigest,
87
+ state: record.state,
88
+ ownerKind: record.ownerKind,
89
+ operationId: record.operationId,
90
+ }, null, 2)}\n`;
91
+ }
package/esm/apis.js CHANGED
@@ -75,12 +75,39 @@ import { join } from "node:path";
75
75
  import process from "node:process";
76
76
  import { realpath as fsRealpath, rename as fsRename } from "node:fs/promises";
77
77
  import { fetch as effectionFetch } from "@effectionx/fetch";
78
- import { ensureDir as fsEnsureDir, FsApi, globToRegExp, readTextFile as fsReadTextFile, rm as fsRm, stat as fsStat, writeTextFile as fsWriteTextFile, } from "@effectionx/fs";
78
+ import { ensureDir as fsEnsureDir, FsApi, globToRegExp, lstat as fsLstat, readTextFile as fsReadTextFile, rm as fsRm, stat as fsStat, writeTextFile as fsWriteTextFile, } from "@effectionx/fs";
79
79
  import { exec as processExec, Stdio } from "@effectionx/process";
80
80
  import { race, scoped, sleep, until } from "effection";
81
81
  import { timeoutFetch as contextualFetchTimeout } from "./config.js";
82
82
  import { Files } from "./files.js";
83
83
  import { Service } from "./service.js";
84
+ /**
85
+ * The headers of one response, detached from it.
86
+ *
87
+ * Taken while the live response is still in scope, so what a caller reads
88
+ * afterwards cannot depend on a response that has been disposed, and mutating
89
+ * the host's own `Headers` cannot change what was read. `get()` keeps the
90
+ * case-insensitive lookup callers already have, and joins repeated names in
91
+ * report order — the platform combines them before this sees them, so on the
92
+ * default adapter there is one entry per name to begin with.
93
+ */
94
+ function headerPair([name, value]) {
95
+ return [name, value];
96
+ }
97
+ function detachHeaders(headers) {
98
+ const entries = [];
99
+ for (const entry of headers) {
100
+ entries.push(headerPair(entry));
101
+ }
102
+ return {
103
+ get(key) {
104
+ const wanted = key.toLowerCase();
105
+ const found = entries.filter(([name]) => name.toLowerCase() === wanted);
106
+ return found.length === 0 ? null : found.map(([, value]) => value).join(", ");
107
+ },
108
+ entries: () => entries.map(headerPair),
109
+ };
110
+ }
84
111
  /**
85
112
  * The `errno` string a failed filesystem call carries, when it carries one.
86
113
  *
@@ -311,6 +338,23 @@ export const API = {
311
338
  throw err;
312
339
  }
313
340
  },
341
+ *lstat(path) {
342
+ try {
343
+ const s = yield* fsLstat(path);
344
+ return {
345
+ exists: true,
346
+ isFile: s.isFile(),
347
+ isDirectory: s.isDirectory(),
348
+ isSymbolicLink: s.isSymbolicLink(),
349
+ };
350
+ }
351
+ catch (err) {
352
+ if (errorCode(err) === "ENOENT") {
353
+ return { exists: false, isFile: false, isDirectory: false, isSymbolicLink: false };
354
+ }
355
+ throw err;
356
+ }
357
+ },
314
358
  *glob(options) {
315
359
  const { patterns, root, exclude = [] } = options;
316
360
  const matched = [];
@@ -356,18 +400,29 @@ export const API = {
356
400
  Fetch: createApi("runtime.fetch", {
357
401
  *fetch(input, init) {
358
402
  const timeout = init?.timeout ?? (yield* contextualFetchTimeout);
359
- const response = yield* withTimeout(`fetch(${input})`, timeout, effectionFetch(input, {
403
+ const request = effectionFetch(input, {
360
404
  method: init?.method,
361
405
  headers: init?.headers,
362
406
  body: init?.body,
363
- }));
364
- return {
407
+ });
408
+ const response = yield* withTimeout(`fetch(${input})`, timeout, init?.expect === true ? request.expect() : request);
409
+ // Read here, while the live response is still this scope's: a caller that
410
+ // keeps the value reads a snapshot rather than a handle on a response
411
+ // that has since been disposed. Collected through `forEach` rather than
412
+ // by iterating: the host `Headers` this reads is typed as iterable under
413
+ // some of the libs this package is built against and not others, and an
414
+ // array is iterable under all of them.
415
+ const entries = [];
416
+ response.headers.forEach((value, name) => entries.push([name, value]));
417
+ const headers = detachHeaders(entries);
418
+ const settled = {
365
419
  status: response.status,
366
- headers: response.headers,
420
+ headers,
367
421
  *text() {
368
422
  return yield* withTimeout(`fetch(${input}).text()`, timeout, response.text());
369
423
  },
370
424
  };
425
+ return settled;
371
426
  },
372
427
  }),
373
428
  /**
@@ -421,13 +476,31 @@ export function exec(options) {
421
476
  }
422
477
  export const readTextFile = API.Fs.operations.readTextFile;
423
478
  export const stat = API.Fs.operations.stat;
479
+ export const lstat = API.Fs.operations.lstat;
424
480
  export const glob = API.Fs.operations.glob;
425
481
  export const writeTextFile = API.Fs.operations.writeTextFile;
426
482
  export const ensureDir = API.Fs.operations.ensureDir;
427
483
  export const rename = API.Fs.operations.rename;
428
484
  export const remove = API.Fs.operations.remove;
429
485
  export const realpath = API.Fs.operations.realpath;
430
- export const fetch = API.Fetch.operations.fetch;
486
+ function chain(input, init) {
487
+ const settled = () => API.Fetch.operations.fetch(input, init);
488
+ return {
489
+ [Symbol.iterator]: () => settled()[Symbol.iterator](),
490
+ *text() {
491
+ return yield* (yield* settled()).text();
492
+ },
493
+ *json() {
494
+ return JSON.parse(yield* (yield* settled()).text());
495
+ },
496
+ // Recorded on the request rather than checked on the answer, so the whole
497
+ // middleware chain sees what the caller asked for.
498
+ expect: () => chain(input, { ...init, expect: true }),
499
+ };
500
+ }
501
+ export function fetch(input, init) {
502
+ return chain(input, init);
503
+ }
431
504
  export const env = API.Env.operations.env;
432
505
  export const cwd = API.Env.operations.cwd;
433
506
  export const platform = API.Env.operations.platform;
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The Deno implementation of session ownership.
3
+ *
4
+ * Constructed at a runtime-named boundary and handed to a provider by the host
5
+ * that built it. Shared modules never reach for it, and never ask what runtime
6
+ * they are on.
7
+ *
8
+ * Exclusivity is the kernel's, and durability is the filesystem's, because the
9
+ * two questions are different. The advisory lock answers "is another live
10
+ * process in this session right now", which a crash correctly ends. The
11
+ * ownership record answers "did the last owner prove it stopped", which a crash
12
+ * must *not* end — so the record is written active before any provider work and
13
+ * replaced with idle only after the body acknowledges quiescence. A process
14
+ * that dies in between releases the lock and leaves the active record, and the
15
+ * next owner refuses rather than inferring safety from a pid, an elapsed time,
16
+ * an empty transcript, or the lock being free.
17
+ *
18
+ * A process-local occupancy table sits in front of both. Advisory locks are
19
+ * per-process on some hosts, so two provider scopes in one process could
20
+ * otherwise both believe they hold the same session.
21
+ */
22
+ import * as dntShim from "./_dnt.shims.js";
23
+ import { ensure, Err, Ok, scoped, until } from "effection";
24
+ import { join } from "node:path";
25
+ import { AgentSessionBusy, agentSessionKeyDigest, AgentSessionRecoveryRequired, parseAgentSessionOwnership, serializeAgentSessionOwnership, } from "./agent-session-coordinator.js";
26
+ /** One of the host's methods, bound to it, or nothing when it has none. */
27
+ function callable(host, name) {
28
+ const member = Reflect.get(host, name);
29
+ if (typeof member !== "function") {
30
+ return undefined;
31
+ }
32
+ return (...args) => Reflect.apply(member, host, args);
33
+ }
34
+ function hostFile(value) {
35
+ if (typeof value !== "object" || value === null) {
36
+ return undefined;
37
+ }
38
+ const tryLock = callable(value, "tryLock");
39
+ const unlock = callable(value, "unlock");
40
+ const sync = callable(value, "sync");
41
+ const close = callable(value, "close");
42
+ if (!tryLock || !unlock || !sync || !close) {
43
+ return undefined;
44
+ }
45
+ return {
46
+ tryLock: (exclusive) => Promise.resolve(tryLock(exclusive)),
47
+ unlock: () => Promise.resolve(unlock()),
48
+ sync: () => Promise.resolve(sync()),
49
+ close: () => {
50
+ close();
51
+ },
52
+ };
53
+ }
54
+ function coordinatorHost() {
55
+ if (!Reflect.has(dntShim.dntGlobalThis, "Deno")) {
56
+ return undefined;
57
+ }
58
+ const host = Reflect.get(dntShim.dntGlobalThis, "Deno");
59
+ if (typeof host !== "object" || host === null) {
60
+ return undefined;
61
+ }
62
+ const names = ["mkdir", "open", "readTextFile", "writeTextFile", "rename", "remove"];
63
+ const calls = {};
64
+ for (const name of names) {
65
+ const call = callable(host, name);
66
+ if (!call) {
67
+ return undefined;
68
+ }
69
+ calls[name] = call;
70
+ }
71
+ return {
72
+ mkdir: (path, options) => Promise.resolve(calls.mkdir(path, options)),
73
+ open: (path, options) => Promise.resolve(calls.open(path, options)),
74
+ readTextFile: (path) => Promise.resolve(calls.readTextFile(path)).then((value) => String(value)),
75
+ writeTextFile: (path, data, options) => Promise.resolve(calls.writeTextFile(path, data, options)),
76
+ rename: (from, to) => Promise.resolve(calls.rename(from, to)),
77
+ remove: (path) => Promise.resolve(calls.remove(path)),
78
+ };
79
+ }
80
+ /** Whether this host can coordinate agent sessions at all. */
81
+ export function hasDenoAgentSessionCoordinator() {
82
+ return coordinatorHost() !== undefined;
83
+ }
84
+ function isMissing(cause) {
85
+ return ((typeof cause === "object" && cause !== null && Reflect.get(cause, "code") === "ENOENT") ||
86
+ (cause instanceof Error && cause.name === "NotFound"));
87
+ }
88
+ /**
89
+ * Build the coordinator rooted at `root`.
90
+ *
91
+ * Returns nothing on a host with no such filesystem: a host that cannot answer
92
+ * the ownership question installs no coordinator, and every advertised
93
+ * provider-returned operation refuses.
94
+ */
95
+ export function createDenoAgentSessionCoordinator(root) {
96
+ const found = coordinatorHost();
97
+ if (!found) {
98
+ return undefined;
99
+ }
100
+ const host = found;
101
+ // Sibling provider scopes in one process contend here before they contend in
102
+ // the kernel, because an advisory lock does not always separate them.
103
+ const occupied = new Set();
104
+ function* readOwnership(path) {
105
+ const text = yield* until(host.readTextFile(path).catch((cause) => {
106
+ if (isMissing(cause)) {
107
+ return undefined;
108
+ }
109
+ throw cause;
110
+ }));
111
+ if (text === undefined) {
112
+ return undefined;
113
+ }
114
+ let parsed;
115
+ try {
116
+ parsed = JSON.parse(text);
117
+ }
118
+ catch {
119
+ throw new AgentSessionRecoveryRequired("the retained agent session ownership record is not readable");
120
+ }
121
+ const record = parseAgentSessionOwnership(parsed);
122
+ if (!record) {
123
+ throw new AgentSessionRecoveryRequired("the retained agent session ownership record is not valid");
124
+ }
125
+ return record;
126
+ }
127
+ /**
128
+ * Replace the ownership record durably.
129
+ *
130
+ * Written whole, flushed, renamed over the destination, and the directory
131
+ * flushed behind it — so a reader sees the old record or the new one, and a
132
+ * crash cannot leave a half-written state that parses as something weaker.
133
+ */
134
+ function* publish(directory, destination, record) {
135
+ const staging = `${destination}.${record.operationId}.staging`;
136
+ yield* until(host.writeTextFile(staging, serializeAgentSessionOwnership(record), { mode: 0o600 }));
137
+ const staged = hostFile(yield* until(host.open(staging, { read: true })));
138
+ if (staged) {
139
+ yield* until(staged.sync());
140
+ staged.close();
141
+ }
142
+ yield* until(host.rename(staging, destination));
143
+ const opened = hostFile(yield* until(host.open(directory, { read: true })));
144
+ if (opened) {
145
+ yield* until(opened.sync());
146
+ opened.close();
147
+ }
148
+ }
149
+ return {
150
+ coordinate(key, owner, body) {
151
+ // Scoped, not a resource: ownership has to end when the body ends. A
152
+ // resource acquired in the caller's scope would hold the lock and the
153
+ // active record until *that* scope closed, which is a different — and
154
+ // much longer — lifetime than the work it protects.
155
+ return scoped(function* () {
156
+ const digest = agentSessionKeyDigest(key);
157
+ const leases = join(root, "leases");
158
+ const ownership = join(root, "ownership");
159
+ yield* until(host.mkdir(leases, { recursive: true, mode: 0o700 }));
160
+ yield* until(host.mkdir(ownership, { recursive: true, mode: 0o700 }));
161
+ const record = join(ownership, `${digest}.json`);
162
+ if (occupied.has(digest)) {
163
+ return Err(busy(key));
164
+ }
165
+ occupied.add(digest);
166
+ yield* ensure(() => {
167
+ occupied.delete(digest);
168
+ });
169
+ // Opened for the ownership scope and never unlinked: deleting it would
170
+ // let a second process create a fresh file and lock that instead.
171
+ const opened = hostFile(yield* until(host.open(join(leases, `${digest}.lease`), {
172
+ read: true,
173
+ write: true,
174
+ create: true,
175
+ mode: 0o600,
176
+ })));
177
+ if (!opened) {
178
+ throw new Error("this host opened a file with no advisory-lock operations");
179
+ }
180
+ let locked = false;
181
+ yield* ensure(function* () {
182
+ if (locked) {
183
+ yield* until(opened.unlock());
184
+ }
185
+ opened.close();
186
+ });
187
+ locked = (yield* until(opened.tryLock(true))) === true;
188
+ if (!locked) {
189
+ return Err(busy(key));
190
+ }
191
+ const retained = yield* readOwnership(record);
192
+ if (retained?.state === "active") {
193
+ // The lock is free and the record is not. Whoever held it last never
194
+ // said it had stopped, and nothing here can say it for them.
195
+ return Err(new AgentSessionRecoveryRequired(`session "${key.sessionKey}" was left owned by an XMD process that did not ` +
196
+ `finish. Nothing here can prove that owner stopped, so it stays owned until ` +
197
+ `it is recovered deliberately.`));
198
+ }
199
+ const active = {
200
+ schema: "agent-session-ownership.v1",
201
+ keyDigest: digest,
202
+ state: "active",
203
+ ownerKind: owner.kind,
204
+ operationId: owner.operationId,
205
+ };
206
+ yield* publish(ownership, record, active);
207
+ let quiesced = false;
208
+ // Registered before the body runs, so an idle record is published on
209
+ // every ordinary exit — and on none of the others.
210
+ yield* ensure(function* () {
211
+ if (!quiesced) {
212
+ return;
213
+ }
214
+ yield* publish(ownership, record, { ...active, state: "idle" });
215
+ });
216
+ return Ok(yield* body({
217
+ quiesced() {
218
+ quiesced = true;
219
+ },
220
+ }));
221
+ });
222
+ },
223
+ };
224
+ }
225
+ function busy(key) {
226
+ return new AgentSessionBusy(`another XMD owner is using session "${key.sessionKey}" — a native UI or a turn in ` +
227
+ `another process holds it. Run this again once that owner exits.`);
228
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The Deno implementation of executable observation.
3
+ *
4
+ * Constructed at a runtime-named boundary and handed to a provider by the host
5
+ * that built it, exactly as the session coordinator is. Shared modules never
6
+ * reach for it and never ask what runtime they are on.
7
+ *
8
+ * Resolution reads the real process environment rather than a contextual one.
9
+ * That is the whole point of building this here: PATH decides which file is
10
+ * observed, and a PATH document middleware could move is a PATH that can point
11
+ * the observation at one binary while the run spawns another. A controlled test
12
+ * substitutes the entire observer through the same constructor seam the host
13
+ * uses, so nothing needs a replaceable resolver to be testable.
14
+ */
15
+ import * as dntShim from "./_dnt.shims.js";
16
+ import { until } from "effection";
17
+ import { createHash } from "node:crypto";
18
+ import { readFile, realpath, stat } from "node:fs/promises";
19
+ import { delimiter, isAbsolute, join, resolve } from "node:path";
20
+ import { ExecutableObservationError } from "./executable-observer.js";
21
+ /** One of the host's methods, bound to it, or nothing when it has none. */
22
+ function callable(host, name) {
23
+ const member = Reflect.get(host, name);
24
+ if (typeof member !== "function") {
25
+ return undefined;
26
+ }
27
+ return (...args) => Reflect.apply(member, host, args);
28
+ }
29
+ function observerHost() {
30
+ const found = Reflect.get(dntShim.dntGlobalThis, "Deno");
31
+ if (typeof found !== "object" || found === null) {
32
+ return undefined;
33
+ }
34
+ const env = Reflect.get(found, "env");
35
+ const cwd = callable(found, "cwd");
36
+ const commandCtor = Reflect.get(found, "Command");
37
+ if (typeof commandCtor !== "function" || !cwd || typeof env !== "object" || env === null) {
38
+ return undefined;
39
+ }
40
+ const toObject = callable(env, "toObject");
41
+ if (!toObject) {
42
+ return undefined;
43
+ }
44
+ return {
45
+ command: (path, options) => Reflect.construct(commandCtor, [
46
+ path,
47
+ options,
48
+ ]),
49
+ env: { toObject: () => toObject() },
50
+ cwd: () => cwd(),
51
+ };
52
+ }
53
+ /** Whether this host can observe an executable at all. */
54
+ export function hasDenoExecutableObserver() {
55
+ return observerHost() !== undefined;
56
+ }
57
+ /** What the version invocation produced, decoded. */
58
+ function decode(value) {
59
+ if (typeof value !== "object" || value === null) {
60
+ return { code: -1, text: "" };
61
+ }
62
+ const code = Reflect.get(value, "code");
63
+ const stdout = Reflect.get(value, "stdout");
64
+ const decoder = new TextDecoder();
65
+ return {
66
+ code: typeof code === "number" ? code : -1,
67
+ text: stdout instanceof Uint8Array ? decoder.decode(stdout) : "",
68
+ };
69
+ }
70
+ /**
71
+ * Build an observer rooted in this process's real environment.
72
+ *
73
+ * `overrides` exist for the focused proof only: a test that wants to watch
74
+ * PATH search happen supplies its own search path and working directory rather
75
+ * than moving the ones every other thing in the process is using.
76
+ */
77
+ export function createDenoExecutableObserver(overrides) {
78
+ const found = observerHost();
79
+ if (!found) {
80
+ return undefined;
81
+ }
82
+ const host = found;
83
+ function* resolveCommand(command) {
84
+ if (command.length === 0) {
85
+ throw new ExecutableObservationError("no executable was named", { refusal: "not-found" });
86
+ }
87
+ const base = overrides?.cwd ?? host.cwd();
88
+ if (command.includes("/") || command.includes("\\") || isAbsolute(command)) {
89
+ return resolve(base, command);
90
+ }
91
+ const search = overrides?.path ?? host.env.toObject().PATH ?? "";
92
+ for (const entry of search.split(delimiter)) {
93
+ if (entry.length === 0) {
94
+ continue;
95
+ }
96
+ const candidate = join(entry, command);
97
+ const found = yield* until(stat(candidate).then(() => true, () => false));
98
+ if (found) {
99
+ return candidate;
100
+ }
101
+ }
102
+ throw new ExecutableObservationError(`no executable named ${command} was found on the search path`, { refusal: "not-found" });
103
+ }
104
+ return {
105
+ *observe(command, options) {
106
+ const resolved = yield* resolveCommand(command);
107
+ // Canonicalized before stat, hash and version: a symlinked launcher shim
108
+ // and the build it points at are one file, so the same build reached two
109
+ // ways produces one digest — and the version comes from that same file
110
+ // rather than from whatever the shim would have re-resolved.
111
+ const path = yield* until(realpath(resolved).catch(() => resolved));
112
+ const info = yield* until(stat(path).catch((cause) => {
113
+ throw new ExecutableObservationError(`${command} could not be inspected`, {
114
+ refusal: "not-found",
115
+ cause,
116
+ });
117
+ }));
118
+ if (!info.isFile()) {
119
+ throw new ExecutableObservationError(`${command} does not name a regular file`, {
120
+ refusal: "not-a-file",
121
+ });
122
+ }
123
+ // Any execute bit is enough: which one applies depends on who is asking,
124
+ // and a file with none of them is not a program under any of them.
125
+ if ((info.mode & 0o111) === 0) {
126
+ throw new ExecutableObservationError(`${command} is not executable`, {
127
+ refusal: "not-executable",
128
+ });
129
+ }
130
+ const bytes = yield* until(readFile(path).catch((cause) => {
131
+ throw new ExecutableObservationError(`${command} could not be read`, {
132
+ refusal: "unreadable",
133
+ cause,
134
+ });
135
+ }));
136
+ const versionArgs = options?.versionArgs ?? ["--version"];
137
+ const produced = yield* until(host
138
+ .command(path, { args: [...versionArgs], stdout: "piped", stderr: "null" })
139
+ .output()
140
+ .catch((cause) => {
141
+ throw new ExecutableObservationError(`${command} could not be asked its version`, {
142
+ refusal: "version-unavailable",
143
+ cause,
144
+ });
145
+ }));
146
+ const version = decode(produced);
147
+ if (version.code !== 0) {
148
+ throw new ExecutableObservationError(`${command} refused to report a version`, {
149
+ refusal: "version-unavailable",
150
+ });
151
+ }
152
+ return {
153
+ path,
154
+ digest: { algorithm: "sha256", value: createHash("sha256").update(bytes).digest("hex") },
155
+ versionOutput: version.text,
156
+ };
157
+ },
158
+ };
159
+ }