@jr2/orchestrator 0.1.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/bin/server.ts +23 -0
  4. package/console/canvas.ts +843 -0
  5. package/console/components/app.ts +79 -0
  6. package/console/components/drawer.ts +131 -0
  7. package/console/components/fleet.ts +117 -0
  8. package/console/components/machine-pane.ts +85 -0
  9. package/console/components/nav.ts +81 -0
  10. package/console/components/schema-form.ts +137 -0
  11. package/console/main.ts +383 -0
  12. package/console/page.html +28 -0
  13. package/console/store.ts +336 -0
  14. package/console/style.css +700 -0
  15. package/console/tsconfig.json +18 -0
  16. package/package.json +61 -0
  17. package/src/actor.ts +562 -0
  18. package/src/agent.ts +124 -0
  19. package/src/ambient.ts +50 -0
  20. package/src/config.ts +297 -0
  21. package/src/customize.ts +348 -0
  22. package/src/durability.ts +135 -0
  23. package/src/fingerprint.ts +92 -0
  24. package/src/gate.ts +76 -0
  25. package/src/harness-client.ts +503 -0
  26. package/src/http.ts +753 -0
  27. package/src/images.ts +303 -0
  28. package/src/index.ts +40 -0
  29. package/src/instance.ts +294 -0
  30. package/src/machine-doc.ts +334 -0
  31. package/src/names.ts +78 -0
  32. package/src/open.ts +17 -0
  33. package/src/parts.ts +500 -0
  34. package/src/pool.ts +284 -0
  35. package/src/registration.ts +340 -0
  36. package/src/repo-fetch.ts +259 -0
  37. package/src/repo-identity.ts +145 -0
  38. package/src/repos.ts +330 -0
  39. package/src/run-host.ts +1095 -0
  40. package/src/sandbox-kubectl.ts +1136 -0
  41. package/src/server.ts +220 -0
  42. package/src/setup.ts +360 -0
  43. package/src/snapshot-store.ts +150 -0
  44. package/src/stub-harness.ts +217 -0
  45. package/src/tokens.ts +126 -0
  46. package/src/vocabulary.ts +99 -0
  47. package/src/wire.ts +103 -0
  48. package/src/workspace.ts +874 -0
  49. package/tsconfig.instance.json +26 -0
@@ -0,0 +1,150 @@
1
+ // Persistence for durable Machine snapshots (ADR-0007). A `SnapshotStore` is keyed by `runId`;
2
+ // `SqliteSnapshotStore` is the default backing for a deployed Orchestrator, with `:memory:` for
3
+ // tests. `markLost` records a run whose live state could not be re-hydrated (e.g. its flue
4
+ // handle is gone) without deleting its history.
5
+
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import type { StoredSnapshot } from "./durability.ts";
8
+
9
+ export interface SnapshotStore {
10
+ init(): Promise<void>;
11
+ load(runId: string): Promise<StoredSnapshot | null>;
12
+ /** Every persisted run, in insertion order. The host filters by `status` on restore. */
13
+ list(): Promise<StoredSnapshot[]>;
14
+ /**
15
+ * Persisted run ids sharing a prefix, sorted, capped at `limit` — the store half of abbreviated
16
+ * run ids (ADR-0009). Includes `lost` rows: the ambiguity set is the ids that EXIST, not the ones
17
+ * that are readable. Skipping a lost row would let a prefix it shares with a live run resolve
18
+ * silently to the live one, which makes resolution unsound.
19
+ */
20
+ findIdsByPrefix(prefix: string, limit: number): Promise<string[]>;
21
+ save(runId: string, snapshot: unknown, status?: string): Promise<void>;
22
+ markLost(runId: string, reason: string): Promise<void>;
23
+ /**
24
+ * Record a run whose Machine changed shape underneath it (ADR-0030) — refused, not resumed.
25
+ *
26
+ * Unlike {@link markLost} this KEEPS the snapshot. A lost run has nothing left to look at, so
27
+ * nulling it costs nothing; a drifted run is intact and merely unreadable by the Machine now
28
+ * loaded, and it is the one a human most needs to inspect. Nulling it would also hide the run
29
+ * entirely: `read()` returns undefined for a null blob, so `jr2 status <id>` would answer
30
+ * `no run` — a refusal indistinguishable from a run that never existed.
31
+ */
32
+ markDrifted(runId: string, reason: string): Promise<void>;
33
+ close(): Promise<void>;
34
+ }
35
+
36
+ type Row = {
37
+ run_id: string;
38
+ status: string;
39
+ snapshot: string | null;
40
+ reason: string | null;
41
+ updated_at: string;
42
+ };
43
+
44
+ export class SqliteSnapshotStore implements SnapshotStore {
45
+ private readonly db: DatabaseSync;
46
+
47
+ constructor(path: string) {
48
+ this.db = new DatabaseSync(path);
49
+ }
50
+
51
+ async init(): Promise<void> {
52
+ this.db.exec(`
53
+ CREATE TABLE IF NOT EXISTS machine_snapshots (
54
+ run_id TEXT PRIMARY KEY,
55
+ status TEXT NOT NULL DEFAULT 'live',
56
+ snapshot TEXT,
57
+ reason TEXT,
58
+ updated_at TEXT
59
+ )
60
+ `);
61
+ }
62
+
63
+ async load(runId: string): Promise<StoredSnapshot | null> {
64
+ const row = this.db
65
+ .prepare(`SELECT run_id, status, snapshot, reason, updated_at FROM machine_snapshots WHERE run_id = ?`)
66
+ .get(runId) as Row | undefined;
67
+ if (!row) return null;
68
+
69
+ const stored: StoredSnapshot = {
70
+ runId: row.run_id,
71
+ status: row.status,
72
+ snapshot: row.snapshot === null ? null : JSON.parse(row.snapshot),
73
+ };
74
+ if (row.reason !== null) stored.reason = row.reason;
75
+ return stored;
76
+ }
77
+
78
+ async list(): Promise<StoredSnapshot[]> {
79
+ const rows = this.db
80
+ .prepare(`SELECT run_id, status, snapshot, reason, updated_at FROM machine_snapshots ORDER BY rowid`)
81
+ .all() as Row[];
82
+ return rows.map((row) => {
83
+ const stored: StoredSnapshot = {
84
+ runId: row.run_id,
85
+ status: row.status,
86
+ snapshot: row.snapshot === null ? null : JSON.parse(row.snapshot),
87
+ };
88
+ if (row.reason !== null) stored.reason = row.reason;
89
+ return stored;
90
+ });
91
+ }
92
+
93
+ /**
94
+ * A range scan rather than `LIKE`/`GLOB`. `run_id` is the BINARY-collated PRIMARY KEY, but
95
+ * SQLite's `LIKE` is ASCII-case-insensitive by default, so the planner declines the index range
96
+ * and falls back to a full scan. `GLOB` would seek correctly but obliges every caller to escape
97
+ * `*?[]` first — a store shouldn't have to trust that. The `CHAR(0x10FFFF)` sentinel as the upper
98
+ * bound also avoids incrementing the prefix's last character, which has edge cases. `>=`/`<`
99
+ * carries to Postgres unchanged, where `LIKE 'x%'` would need `text_pattern_ops` to use an index.
100
+ */
101
+ async findIdsByPrefix(prefix: string, limit: number): Promise<string[]> {
102
+ const rows = this.db
103
+ .prepare(
104
+ `SELECT run_id FROM machine_snapshots
105
+ WHERE run_id >= ? AND run_id < ? || CHAR(0x10FFFF)
106
+ ORDER BY run_id LIMIT ?`,
107
+ )
108
+ .all(prefix, prefix, limit) as Array<{ run_id: string }>;
109
+ return rows.map((row) => row.run_id);
110
+ }
111
+
112
+ async save(runId: string, snapshot: unknown, status = "live"): Promise<void> {
113
+ this.db
114
+ .prepare(
115
+ `INSERT INTO machine_snapshots (run_id, status, snapshot, reason, updated_at)
116
+ VALUES (?, ?, ?, NULL, ?)
117
+ ON CONFLICT(run_id) DO UPDATE SET
118
+ status = excluded.status,
119
+ snapshot = excluded.snapshot,
120
+ reason = NULL,
121
+ updated_at = excluded.updated_at`,
122
+ )
123
+ .run(runId, status, JSON.stringify(snapshot), new Date().toISOString());
124
+ }
125
+
126
+ async markLost(runId: string, reason: string): Promise<void> {
127
+ this.db
128
+ .prepare(
129
+ `INSERT INTO machine_snapshots (run_id, status, snapshot, reason, updated_at)
130
+ VALUES (?, 'lost', NULL, ?, ?)
131
+ ON CONFLICT(run_id) DO UPDATE SET
132
+ status = 'lost',
133
+ reason = excluded.reason,
134
+ updated_at = excluded.updated_at`,
135
+ )
136
+ .run(runId, reason, new Date().toISOString());
137
+ }
138
+
139
+ /** An UPDATE, not an upsert: a drifted run is one we already have a snapshot for, and the whole
140
+ * point is to keep it. A row that is not there is not a drifted run — nothing to record. */
141
+ async markDrifted(runId: string, reason: string): Promise<void> {
142
+ this.db
143
+ .prepare(`UPDATE machine_snapshots SET status = 'drifted', reason = ?, updated_at = ? WHERE run_id = ?`)
144
+ .run(reason, new Date().toISOString(), runId);
145
+ }
146
+
147
+ async close(): Promise<void> {
148
+ this.db.close();
149
+ }
150
+ }
@@ -0,0 +1,217 @@
1
+ // The wire-compatible stub Harness (ADR-0011: dev stubbing happens at the wire, not in the
2
+ // actor). The e2e world hosts this on localhost so workspace-less test workflows can run without a
3
+ // data plane: an endpoint is just a URL, so the Agent actor keeps ONE code path and cannot tell it is
4
+ // talking to a fake. Semantics: ADMIT the agent (accept the prompt, mint an admission), hold
5
+ // the durable stream open, and never act — the Machine parks exactly as it would against a
6
+ // silent real Harness, and e2e drives it by playing the agent against `/mcp/<iid>` instead.
7
+ //
8
+ // Wire (ADR-0027 — the normative model of the five endpoints; `@jr2/harness` serves it for real):
9
+ // POST /agents/:name/:id {message, definition, model?, thinkingLevel?} → 200 { streamUrl, offset, submissionId }
10
+ // GET /agents/:name/:id?offset=…[&view=updates] → 200 `[]` + Stream-Next-Offset/Up-To-Date
11
+ // GET /agents/:name/:id?…&live=long-poll → parked; 204 + same headers on timeout
12
+ // POST /agents/:name/:id/abort → 200 { aborted }
13
+ // GET /agents/:name/:id?view=history → 200 { …, settlements }
14
+ // (the client's `wait(admission)` long-polls `streamUrl?view=updates` from the admission offset;
15
+ // an empty stream parks it — exactly the "admitted, never settles" semantics the mechanics tier
16
+ // needs.) The client then re-polls calmly at the long-poll cadence. `close()` severs parked polls.
17
+ //
18
+ // A stub submission therefore ends exactly one way: ABORTED, when the state that asked for the
19
+ // turn stops waiting (ADR-0024). That is what `history`'s `settlements` carries, and it is the
20
+ // only place a black-box test can see a turn end — the run itself is untouched by an abort.
21
+ //
22
+ // This can later grow scriptable behavior or be swapped for a real local Harness without
23
+ // touching actor code — it is only a different URL.
24
+ //
25
+ // The stub is INERT, with no seam for an agent to act through, and that is now the whole of it.
26
+ // The `onAdmit` hook this once carried had exactly one consumer — the containerized dev Harness
27
+ // image — and that image is gone (ADR-0038): the @kind tier runs the REAL `@jr2/harness` in the pod
28
+ // against a scripted model, so nothing needs the stub to originate tool calls any more. What
29
+ // survives is this tier's job (ADR-0031): the socket-free mechanics tier reaches the stub by
30
+ // explicit run-input `endpoint`, the Machine parks, and e2e plays the agent from outside.
31
+
32
+ import { createServer } from "node:http";
33
+ import type { AddressInfo, Socket } from "node:net";
34
+
35
+ /** One admission of an Agent: which slot, which durable exchange, the prompt, the DEFINITION the
36
+ * Machine's slot carried here (ADR-0049), and this Submission's dials (ADR-0018) — captured so a
37
+ * mechanics-tier test can assert which Agent a state ran and at what settings, exactly as it
38
+ * asserts the prompt. The stub is inert and never READS the definition: a real Harness runs it,
39
+ * this one only proves it rode the wire. */
40
+ export type Admission = {
41
+ agentName: string;
42
+ instanceId: string;
43
+ message?: string;
44
+ definition?: Record<string, unknown>;
45
+ model?: string;
46
+ thinkingLevel?: string;
47
+ };
48
+
49
+ /** One settled submission, in the shape `history()` reports it. The stub settles submissions for
50
+ * exactly one reason — an abort (ADR-0024) — so `outcome` has exactly one value here. */
51
+ export type StubSettlement = { submissionId: string; outcome: "aborted" };
52
+
53
+ export type StubHarnessOptions = {
54
+ /** Listen port. Default 0 → ephemeral (read back from `url`). */
55
+ port?: number;
56
+ /** Listen hostname. Default `127.0.0.1`. */
57
+ hostname?: string;
58
+ /** How long a live long-poll parks before answering "nothing yet". Default 25s. */
59
+ longPollMs?: number;
60
+ };
61
+
62
+ export type RunningStubHarness = {
63
+ /** The base URL — what a test workflow passes as its run-input `endpoint`. */
64
+ url: string;
65
+ /** Admissions seen so far (assertable in tests: which agents were admitted, with what). */
66
+ admissions: Admission[];
67
+ close: () => Promise<void>;
68
+ };
69
+
70
+ const AGENT_PATH = /^\/agents\/([^/]+)\/([^/]+)$/;
71
+ const ABORT_PATH = /^\/agents\/([^/]+)\/([^/]+)\/abort$/;
72
+
73
+ /** Start the stub Harness. Never acts: admit → hold the stream open → answer polls "empty". */
74
+ export async function startStubHarness(opts: StubHarnessOptions = {}): Promise<RunningStubHarness> {
75
+ const hostname = opts.hostname ?? "127.0.0.1";
76
+ const longPollMs = opts.longPollMs ?? 25_000;
77
+ const admissions: RunningStubHarness["admissions"] = [];
78
+ let submissionSeq = 0;
79
+
80
+ // Per-instance submission bookkeeping — the little that abort needs to mean anything (ADR-0024).
81
+ // A stub submission never settles on its own (that IS its semantics), so "unsettled" is simply
82
+ // "admitted and not yet aborted", and an abort sweeps ALL of them: the running Submission AND
83
+ // everything queued behind it (ADR-0024/0027).
84
+ const unsettled = new Map<string, string[]>();
85
+ const settlements = new Map<string, StubSettlement[]>();
86
+ const key = (agentName: string, instanceId: string) => `${agentName}/${instanceId}`;
87
+
88
+ // Parked long-polls hold sockets open; close() must sever them or it hangs on graceful close.
89
+ const sockets = new Set<Socket>();
90
+
91
+ const server = createServer((req, res) => {
92
+ const url = new URL(req.url ?? "/", "http://stub");
93
+
94
+ // End every in-flight and queued submission for one instance (`agents.abort` — ADR-0024).
95
+ // Answers `{ aborted }`: whether there was anything to end, exactly as the real Harness
96
+ // does for an idle instance. Settlement is recorded here rather than pushed on the stream, because nothing is
97
+ // listening — the actor that asked for the turn is already stopped.
98
+ const aborting = req.method === "POST" && ABORT_PATH.exec(url.pathname);
99
+ if (aborting) {
100
+ const [, agentName, instanceId] = aborting as unknown as [string, string, string];
101
+ const k = key(decodeURIComponent(agentName), decodeURIComponent(instanceId));
102
+ const ended = unsettled.get(k)?.splice(0) ?? [];
103
+ const settled = settlements.get(k) ?? [];
104
+ settled.push(...ended.map((submissionId) => ({ submissionId, outcome: "aborted" as const })));
105
+ settlements.set(k, settled);
106
+ res.writeHead(200, { "content-type": "application/json" });
107
+ res.end(JSON.stringify({ aborted: ended.length > 0 }));
108
+ return;
109
+ }
110
+
111
+ const match = AGENT_PATH.exec(url.pathname);
112
+ if (!match) {
113
+ res.writeHead(404, { "content-type": "application/json" });
114
+ res.end(JSON.stringify({ error: `stub harness: no route ${url.pathname}` }));
115
+ return;
116
+ }
117
+ const [, agentName, instanceId] = match as unknown as [string, string, string];
118
+
119
+ if (req.method === "POST") {
120
+ // Admission: accept the prompt and mint the durable handle. That is all — nothing acts.
121
+ let body = "";
122
+ req.on("data", (chunk: Buffer) => (body += chunk));
123
+ req.on("end", () => {
124
+ const sent = safeParse(body) as
125
+ | { message?: string; definition?: Record<string, unknown>; model?: string; thinkingLevel?: string }
126
+ | undefined;
127
+ const admission: Admission = {
128
+ agentName: decodeURIComponent(agentName),
129
+ instanceId: decodeURIComponent(instanceId),
130
+ message: sent?.message,
131
+ ...(sent?.definition ? { definition: sent.definition } : {}),
132
+ ...(sent?.model ? { model: sent.model } : {}),
133
+ ...(sent?.thinkingLevel ? { thinkingLevel: sent.thinkingLevel } : {}),
134
+ };
135
+ admissions.push(admission);
136
+ const submissionId = `stub-${++submissionSeq}`;
137
+ const k = key(admission.agentName, admission.instanceId);
138
+ unsettled.set(k, [...(unsettled.get(k) ?? []), submissionId]);
139
+ res.writeHead(200, { "content-type": "application/json" });
140
+ res.end(
141
+ JSON.stringify({
142
+ streamUrl: `http://${hostname}:${port}${url.pathname}`,
143
+ offset: "0_0",
144
+ submissionId,
145
+ }),
146
+ );
147
+ });
148
+ return;
149
+ }
150
+
151
+ if (req.method === "GET" && url.searchParams.get("view") === "history") {
152
+ // The conversation snapshot (`agents.history`). Messages are not modeled — the stub has no
153
+ // model — but SETTLEMENTS are, because `settlements[].outcome` is how a turn's end is
154
+ // observed from outside (ADR-0024) and what the @kind tier asserts.
155
+ const k = key(decodeURIComponent(agentName), decodeURIComponent(instanceId));
156
+ res.writeHead(200, { "content-type": "application/json" });
157
+ res.end(
158
+ JSON.stringify({
159
+ v: 1,
160
+ conversationId: decodeURIComponent(instanceId),
161
+ offset: "0_0",
162
+ messages: [],
163
+ settlements: settlements.get(k) ?? [],
164
+ }),
165
+ );
166
+ return;
167
+ }
168
+
169
+ if (req.method === "GET") {
170
+ // Durable-stream read. The stream never carries anything: a catch-up read answers
171
+ // "empty, up to date" immediately; a live long-poll parks until timeout, then 204s.
172
+ const offset = url.searchParams.get("offset") ?? "0_0";
173
+ const headers = { "stream-next-offset": offset, "stream-up-to-date": "true" };
174
+ if (url.searchParams.get("live") === "long-poll") {
175
+ const timer = setTimeout(() => {
176
+ res.writeHead(204, headers);
177
+ res.end();
178
+ }, longPollMs);
179
+ req.on("close", () => clearTimeout(timer));
180
+ return;
181
+ }
182
+ res.writeHead(200, { "content-type": "application/json", ...headers });
183
+ res.end("[]");
184
+ return;
185
+ }
186
+
187
+ res.writeHead(405, { "content-type": "application/json" });
188
+ res.end(JSON.stringify({ error: `stub harness: ${req.method} not supported` }));
189
+ });
190
+
191
+ server.on("connection", (socket) => {
192
+ sockets.add(socket);
193
+ socket.on("close", () => sockets.delete(socket));
194
+ });
195
+
196
+ const port = await new Promise<number>((resolve) => {
197
+ server.listen(opts.port ?? 0, hostname, () => resolve((server.address() as AddressInfo).port));
198
+ });
199
+
200
+ return {
201
+ url: `http://${hostname}:${port}`,
202
+ admissions,
203
+ close: () =>
204
+ new Promise<void>((resolve, reject) => {
205
+ server.close((err) => (err ? reject(err) : resolve()));
206
+ for (const socket of sockets) socket.destroy(); // sever parked long-polls
207
+ }),
208
+ };
209
+ }
210
+
211
+ function safeParse(text: string): unknown {
212
+ try {
213
+ return JSON.parse(text);
214
+ } catch {
215
+ return undefined;
216
+ }
217
+ }
package/src/tokens.ts ADDED
@@ -0,0 +1,126 @@
1
+ // Bearer tokens (ADR-0013), because the Sandbox boundary is otherwise theater. The Harness
2
+ // container shares the pod's network namespace, so an Agent can reach the Orchestrator directly;
3
+ // a per-pod NetworkPolicy cannot distinguish its packets from the Adapter's. Only authentication
4
+ // closes this, and only the token bounds what a caller may DO (as opposed to where it may talk).
5
+ //
6
+ // Two principals, and the asymmetry between them is the whole design:
7
+ //
8
+ // Instance token the human/CLI credential. Full trust: gates, run control, agent surfaces.
9
+ // From the instance Secret (`JR2_INSTANCE_TOKEN`); minted per boot when absent,
10
+ // announced once on stdout (fixtures capture it). An Agent never has it — it
11
+ // never enters a Sandbox.
12
+ //
13
+ // Sandbox token the Adapter's credential. Authorizes exactly: deliver to `kind: "agent"`
14
+ // registrations whose Sandbox is THIS one. Never a Gate (a compromised Agent
15
+ // must not approve its own review), never another Sandbox (`coding.ts`'s iids
16
+ // are derivable and feature ids are readable from the Work Source, so a merely
17
+ // run-scoped token would let one feature's coder inject a verdict into another
18
+ // feature's reviewer). Delivered as a Secret via the CR's `envFrom` into the
19
+ // Adapter container ALONE, which is the one place the Agent cannot read.
20
+ // The signed name is a POD hosting Turns, not a Sandbox CR per se: the Instance
21
+ // Harness's Adapter bears one signed for that placement's Service name
22
+ // (ADR-0031), scoping it to the Menu-only registrations placed there.
23
+ //
24
+ // The Sandbox token is a SIGNED NAME, not a random string in a table: `<sandbox>.<hmac(key, name)>`,
25
+ // verified by recomputing. Three things fall out that a token table would have to work for — the
26
+ // Orchestrator holds no per-Sandbox state, `provision` stays idempotent (re-minting yields the same
27
+ // token, so a re-applied Secret is a no-op), and a token minted before a restart still verifies
28
+ // after one, which is exactly what ADR-0012's re-attach promises the still-running Adapter.
29
+
30
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
31
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
32
+ import { join } from "node:path";
33
+
34
+ /** Who a request is, once its bearer token checks out. */
35
+ export type Principal =
36
+ /** The instance's own operator: the CLI, a human, a webhook translator holding the token. */
37
+ | { kind: "instance" }
38
+ /** One Sandbox's Adapter, speaking for the Agent in that pod — and for no one else. */
39
+ | { kind: "sandbox"; sandbox: string };
40
+
41
+ /** Resolve a bearer token to a principal; undefined = not a token we minted (→ 401). */
42
+ export type Authenticator = (bearer: string | undefined) => Principal | undefined;
43
+
44
+ /** The Sandbox token for one Sandbox: its name, signed. Same name + key → same token, always. */
45
+ export function sandboxToken(key: Buffer, sandbox: string): string {
46
+ return `${sandbox}.${sign(key, sandbox)}`;
47
+ }
48
+
49
+ /** Mint the per-boot Instance token (opaque and random — it names nothing, it just IS the trust). */
50
+ export function mintInstanceToken(): string {
51
+ return randomBytes(32).toString("base64url");
52
+ }
53
+
54
+ /**
55
+ * The instance's HMAC signing key, at `<dir>/.jr2/secret` (0600), created on first use.
56
+ *
57
+ * It MUST outlive the process: an Orchestrator restart leaves live Sandboxes running (ADR-0012 re-attach),
58
+ * and their Adapters still hold tokens minted by the process that died. A fresh key would reject
59
+ * every one of them — the Agent would silently lose its only route to its Machine.
60
+ */
61
+ export async function loadSigningKey(dir: string): Promise<Buffer> {
62
+ const path = join(dir, ".jr2", "secret");
63
+ try {
64
+ const existing = Buffer.from(await readFile(path, "utf8"), "base64");
65
+ if (existing.length >= 32) return existing;
66
+ } catch {
67
+ // absent (or unreadable) → mint below
68
+ }
69
+ const key = randomBytes(32);
70
+ await mkdir(join(dir, ".jr2"), { recursive: true });
71
+ await writeFile(path, key.toString("base64"), { mode: 0o600 });
72
+ await chmod(path, 0o600); // an existing file keeps its old mode through writeFile
73
+ return key;
74
+ }
75
+
76
+ /**
77
+ * The instance's authenticator: the Instance token, plus any Sandbox token this key signed.
78
+ * A token we did not mint resolves to nothing, and the caller is refused — there is no anonymous
79
+ * principal, and no route decides for itself whether it needs one.
80
+ */
81
+ export function createAuthenticator(opts: { instanceToken: string; signingKey: Buffer }): Authenticator {
82
+ return (bearer) => {
83
+ if (!bearer) return undefined;
84
+ if (constantTimeEqual(bearer, opts.instanceToken)) return { kind: "instance" };
85
+ const cut = bearer.lastIndexOf(".");
86
+ if (cut <= 0) return undefined;
87
+ const sandbox = bearer.slice(0, cut);
88
+ if (!constantTimeEqual(bearer.slice(cut + 1), sign(opts.signingKey, sandbox))) return undefined;
89
+ return { kind: "sandbox", sandbox };
90
+ };
91
+ }
92
+
93
+ /**
94
+ * May this principal deliver to this agent registration? The Instance token may (it is the
95
+ * operator). A Sandbox token may only when the registration records ITS name — which is why
96
+ * an Agent registration carries `sandbox` at all (ADR-0013). The recorded name is the pod hosting the Turn:
97
+ * a Workspace's Sandbox, or `jr2-instance-harness` for a Menu-only registration (ADR-0031). An
98
+ * agent registration with NO name at all is an explicit-`endpoint` run (the stub Harness on the
99
+ * host, in no pod): no Sandbox token can claim it.
100
+ */
101
+ export function mayDeliverToAgent(principal: Principal, registrationSandbox: string | undefined): boolean {
102
+ if (principal.kind === "instance") return true;
103
+ return registrationSandbox !== undefined && registrationSandbox === principal.sandbox;
104
+ }
105
+
106
+ /**
107
+ * May this principal ask for a fetch on this Sandbox (ADR-0053)? A Sandbox token may ask for its
108
+ * OWN pod and no other — the scope is the caches that pod mounts, and the route refuses a Repo it
109
+ * does not. The Instance token may, on the same grounds it may deliver to any agent surface: it is
110
+ * the operator. Nothing else can hold either, so there is no third case.
111
+ */
112
+ export function mayAskForSandbox(principal: Principal, sandbox: string): boolean {
113
+ if (principal.kind === "instance") return true;
114
+ return principal.sandbox === sandbox;
115
+ }
116
+
117
+ function sign(key: Buffer, value: string): string {
118
+ return createHmac("sha256", key).update(value).digest("base64url");
119
+ }
120
+
121
+ /** Compare without leaking the answer through timing. Unequal lengths are unequal, cheaply. */
122
+ function constantTimeEqual(a: string, b: string): boolean {
123
+ const left = Buffer.from(a);
124
+ const right = Buffer.from(b);
125
+ return left.length === right.length && timingSafeEqual(left, right);
126
+ }
@@ -0,0 +1,99 @@
1
+ // Vocabulary-on-the-machine (ADR-0011, ADR-0015): a Machine's event defs ride the machine, not a
2
+ // module export — and they are scoped to THAT Machine alone. `jr2Setup.createMachine` attaches
3
+ // them here; `gate` and the Agent slots read them back off the Machine that invoked them, and nothing ever
4
+ // merges two Machines' sets. That is what makes a Machine composable by plain `invoke`
5
+ // (ADR-0049): the importing Machine neither re-declares nor sees the nested one's events, so
6
+ // `coding`'s `approve` and `release`'s `approve` may differ and one run may hold both.
7
+ //
8
+ // The attachment is keyed on `machine.config` — the raw config object xstate's `.provide()`
9
+ // passes through unchanged (`new StateMachine(this.config, …)`, verified against the pinned
10
+ // xstate 5.32.2). So the host's per-run `provide` and a test's `.provide()` both keep the
11
+ // vocabulary, which is ADR-0049's rule that parts resolve at invoke time THROUGH THE LIVE ACTOR'S
12
+ // LOGIC rather than a build-time closure: whatever machine object an actor was invoked as, its
13
+ // config is the one the defs were attached to.
14
+ //
15
+ // A WeakMap (rather than a field) keeps the returned machine bit-identical — Stately-inspectable,
16
+ // constructible with no jr2 runtime — and preserves ADR-0011's anti-global-registry argument:
17
+ // attribution flows through the machine object, per-Machine by construction (a shared defs module
18
+ // can feed two machines; a dev reload's fresh machine gets a fresh entry).
19
+ //
20
+ // This module is a pure leaf (no wire client, no actors) so `run-host.ts` and `registration.ts`
21
+ // can read vocabularies without dragging the Harness wire client onto their test load path — the
22
+ // same isolation actor.ts keeps.
23
+
24
+ import type { AnyActorRef, AnyStateMachine } from "xstate";
25
+ import type { z } from "zod";
26
+ import type { EventDef } from "@jr2/agent-protocol";
27
+
28
+ /** The key both attachments use: the machine's raw config, which survives `.provide()`. */
29
+ type MachineKey = AnyStateMachine["config"];
30
+
31
+ const vocabularies = new WeakMap<MachineKey, Map<string, EventDef>>();
32
+
33
+ /** Attach a Machine's resolved vocabulary. jr2-internal: `jr2Setup` calls it, and `pool()` calls it
34
+ * for the one def it owns (its wake event). The machine factories do NOT propagate their body's
35
+ * or worker's defs onto the wrapper (ADR-0011): those belong to the nested Machine, which is
36
+ * where the actors that use them resolve. */
37
+ export function attachVocabulary(machine: AnyStateMachine, defs: Map<string, EventDef>): void {
38
+ vocabularies.set(machine.config, defs);
39
+ }
40
+
41
+ /** The vocabulary a Machine was built with — undefined for a Machine not built by `jr2Setup`
42
+ * (a plain `setup()` machine declares no workflow events and resolves to an empty scope). */
43
+ export function vocabularyOf(machine: AnyStateMachine): Map<string, EventDef> | undefined {
44
+ return vocabularies.get(machine.config);
45
+ }
46
+
47
+ /**
48
+ * The Machine that invoked this actor — `self._parent.logic`, public xstate API (ADR-0011). This
49
+ * is the resolution scope for `gate`'s `accepts` and an Agent turn's menu: the derived set came from
50
+ * THIS Machine's transitions, so its defs are the only ones a delivery may be validated against.
51
+ *
52
+ * Undefined for a rootless actor (`createActor(gate)` directly) and for a parent that is not a
53
+ * state machine — both read as an empty vocabulary, which fails the invoke loudly rather than
54
+ * silently accepting a name nobody declared.
55
+ */
56
+ export function invokingMachine(self: AnyActorRef): AnyStateMachine | undefined {
57
+ const logic = (self._parent as { logic?: unknown } | undefined)?.logic as AnyStateMachine | undefined;
58
+ return logic?.root ? logic : undefined;
59
+ }
60
+
61
+ // The declared run input (ADR-0033): the one piece of a machine's vocabulary the event defs
62
+ // missed — what a run of it is STARTED with. Same key choice as the vocabulary above, for the
63
+ // same reasons (per-machine attribution, no global registry, transparent across `.provide()`).
64
+
65
+ const inputSchemas = new WeakMap<MachineKey, z.ZodObject>();
66
+
67
+ /** Attach a machine's declared run-input schema. jr2-internal: `jr2Setup.createMachine({ input })`
68
+ * attaches it, and the machine factories (`workspace`, `pool`) attach their OWN — the `input` in
69
+ * their options. Like the vocabulary, the door does NOT propagate up from a body or a worker
70
+ * (ADR-0033): a wrapper feeds its child something other than the run input (the injected
71
+ * `workspace` handles; a source item), so the child's contract is not the door's. */
72
+ export function attachInputSchema(machine: AnyStateMachine, schema: z.ZodObject): void {
73
+ inputSchemas.set(machine.config, schema);
74
+ }
75
+
76
+ /** The run-input schema a machine declared — undefined for a machine that declared none, which
77
+ * is PERMISSIVE (ADR-0033): a run of it starts with anything, today's behavior. */
78
+ export function inputSchemaOf(machine: AnyStateMachine): z.ZodObject | undefined {
79
+ return inputSchemas.get(machine.config);
80
+ }
81
+
82
+ /**
83
+ * What the HOST adds to the input of the machine a run STARTS with, beside the door
84
+ * (`RunHost.start`). One field today: the run's seed Instance ID, minted with the run and
85
+ * reported by `jr2 status`, so an external caller can address the run's first conversation.
86
+ *
87
+ * It is deliberately NOT door material (ADR-0033): no caller sends it — `start` overwrites
88
+ * whatever arrived, after the parse — and it is never served as JSON Schema, so putting it in a
89
+ * door schema would publish it and demand of every caller a field the host supplies anyway.
90
+ *
91
+ * It is also PLACEMENT-DEPENDENT: only the ROOT machine is started with it. A machine invoked
92
+ * further down is fed by its parent, which passes what it chooses. No type can see where a machine
93
+ * sits, so the one contract that must reason about this — `workspace()`'s body guard — takes the
94
+ * permissive answer and counts these keys as PROVIDED, which lets a body declare the field
95
+ * honestly. Counting them as provided (rather than subtracting them from what the body demands)
96
+ * is what keeps their TYPE checked: `instanceId` is a string, and a body asking for anything else
97
+ * is still rejected.
98
+ */
99
+ export type HostInjectedInput = { instanceId: string };