@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.
- package/LICENSE +21 -0
- package/README.md +23 -0
- package/bin/server.ts +23 -0
- package/console/canvas.ts +843 -0
- package/console/components/app.ts +79 -0
- package/console/components/drawer.ts +131 -0
- package/console/components/fleet.ts +117 -0
- package/console/components/machine-pane.ts +85 -0
- package/console/components/nav.ts +81 -0
- package/console/components/schema-form.ts +137 -0
- package/console/main.ts +383 -0
- package/console/page.html +28 -0
- package/console/store.ts +336 -0
- package/console/style.css +700 -0
- package/console/tsconfig.json +18 -0
- package/package.json +61 -0
- package/src/actor.ts +562 -0
- package/src/agent.ts +124 -0
- package/src/ambient.ts +50 -0
- package/src/config.ts +297 -0
- package/src/customize.ts +348 -0
- package/src/durability.ts +135 -0
- package/src/fingerprint.ts +92 -0
- package/src/gate.ts +76 -0
- package/src/harness-client.ts +503 -0
- package/src/http.ts +753 -0
- package/src/images.ts +303 -0
- package/src/index.ts +40 -0
- package/src/instance.ts +294 -0
- package/src/machine-doc.ts +334 -0
- package/src/names.ts +78 -0
- package/src/open.ts +17 -0
- package/src/parts.ts +500 -0
- package/src/pool.ts +284 -0
- package/src/registration.ts +340 -0
- package/src/repo-fetch.ts +259 -0
- package/src/repo-identity.ts +145 -0
- package/src/repos.ts +330 -0
- package/src/run-host.ts +1095 -0
- package/src/sandbox-kubectl.ts +1136 -0
- package/src/server.ts +220 -0
- package/src/setup.ts +360 -0
- package/src/snapshot-store.ts +150 -0
- package/src/stub-harness.ts +217 -0
- package/src/tokens.ts +126 -0
- package/src/vocabulary.ts +99 -0
- package/src/wire.ts +103 -0
- package/src/workspace.ts +874 -0
- 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 };
|