@jr2/orchestrator 0.2.0 → 0.2.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.
- package/package.json +3 -3
- package/src/actor.ts +8 -3
- package/src/harness-client.ts +28 -8
- package/src/http.ts +8 -5
- package/src/instance.ts +13 -10
- package/src/registration.ts +10 -2
- package/src/run-host.ts +19 -8
- package/src/sandbox-kubectl.ts +19 -11
- package/src/server.ts +1 -12
- package/src/tokens.ts +31 -1
- package/src/workspace.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jr2/orchestrator",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "The jr2 Orchestrator: xstate runtime, HTTP API and Console, and the kit pieces a Workflow imports.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
@@ -42,11 +42,11 @@
|
|
|
42
42
|
"ts-blank-space": "^0.9.0",
|
|
43
43
|
"xstate": "^5.18.0",
|
|
44
44
|
"zod": "^4.4.3",
|
|
45
|
-
"@jr2/agent-protocol": "0.2.
|
|
45
|
+
"@jr2/agent-protocol": "0.2.1"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^26.0.1",
|
|
49
|
-
"@jr2/harness": "0.2.
|
|
49
|
+
"@jr2/harness": "0.2.1"
|
|
50
50
|
},
|
|
51
51
|
"files": [
|
|
52
52
|
"bin",
|
package/src/actor.ts
CHANGED
|
@@ -257,8 +257,10 @@ export interface AgentRunPort {
|
|
|
257
257
|
* Machine edit that changed it. */
|
|
258
258
|
export type AgentAdmitOptions = { definition: AgentDefinition; signal?: AbortSignal };
|
|
259
259
|
|
|
260
|
-
/** Build a port for one invocation from its serializable input (ADR-0011 static-import doctrine)
|
|
261
|
-
|
|
260
|
+
/** Build a port for one invocation from its serializable input (ADR-0011 static-import doctrine),
|
|
261
|
+
* plus the Harness bearer for the placement that input resolved to (ADR-0058) — absent where no
|
|
262
|
+
* placement is named (the stub path) or the host derives none. */
|
|
263
|
+
export type AgentRunPortFactory = (endpoint: string, bearer?: string) => AgentRunPort;
|
|
262
264
|
|
|
263
265
|
/** Absorbed-turn-mechanics knobs (ADR-0016): defaulted, never Machine context. */
|
|
264
266
|
export type AgentRunOptions = {
|
|
@@ -504,7 +506,10 @@ export function agentActorWith(
|
|
|
504
506
|
);
|
|
505
507
|
registerSurface(currentIid);
|
|
506
508
|
|
|
507
|
-
|
|
509
|
+
// The Harness bearer (ADR-0058) is derived from the SAME name the registration records as its
|
|
510
|
+
// delivery scope: the pod hosting this Turn. One name, both directions — the Adapter there may
|
|
511
|
+
// speak for these picks, and only this bearer may drive the conversation there.
|
|
512
|
+
const client = portFactory(endpoint, sandbox === undefined ? undefined : binding.harnessBearer?.(sandbox));
|
|
508
513
|
const controller = new AbortController();
|
|
509
514
|
// Shared per run, created on demand so the ordering guarantee holds for any binding.
|
|
510
515
|
const pendingAborts = (binding.pendingAborts ??= new Map<string, Promise<void>>());
|
package/src/harness-client.ts
CHANGED
|
@@ -80,6 +80,10 @@ export type HarnessClient = {
|
|
|
80
80
|
export type HarnessClientOptions = {
|
|
81
81
|
/** The Harness base URL — what a workspace publishes, or a stub's `url`. */
|
|
82
82
|
baseUrl: string;
|
|
83
|
+
/** The Harness bearer for this placement (ADR-0058; `harnessToken` in tokens.ts) — sent on
|
|
84
|
+
* every call, the long-poll included. Absent only where no placement exists: an explicit-
|
|
85
|
+
* `endpoint` run against the stub Harness, which checks nothing. */
|
|
86
|
+
token?: string;
|
|
83
87
|
/** Injectable for socket-free tests. Default: global `fetch`. */
|
|
84
88
|
fetch?: typeof fetch;
|
|
85
89
|
/** Reconnect backoff floor/ceiling (capped exponential, jittered per rung — see `jittered`;
|
|
@@ -125,6 +129,7 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
|
|
|
125
129
|
const backoffMaxMs = options.backoffMaxMs ?? 5_000;
|
|
126
130
|
const admitWindowMs = options.admitWindowMs ?? 90_000;
|
|
127
131
|
const log = options.log ?? ((line: string) => console.warn(line));
|
|
132
|
+
const authorization: Record<string, string> = options.token ? { authorization: `Bearer ${options.token}` } : {};
|
|
128
133
|
|
|
129
134
|
const conversationUrl = (agentName: string, instanceId: string) =>
|
|
130
135
|
new URL(`/agents/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`, baseUrl).toString();
|
|
@@ -192,7 +197,7 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
|
|
|
192
197
|
url,
|
|
193
198
|
{
|
|
194
199
|
method: "POST",
|
|
195
|
-
headers: { "content-type": "application/json" },
|
|
200
|
+
headers: { "content-type": "application/json", ...authorization },
|
|
196
201
|
body: JSON.stringify({
|
|
197
202
|
message: sendOptions.message,
|
|
198
203
|
// The other half of the Frame (ADR-0057): where this Turn works. Omitted only when
|
|
@@ -238,13 +243,21 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
|
|
|
238
243
|
let events: StreamEvent[];
|
|
239
244
|
let nextOffset: string | null;
|
|
240
245
|
try {
|
|
241
|
-
const res = await fetchImpl(url.toString(), { signal });
|
|
246
|
+
const res = await fetchImpl(url.toString(), { headers: authorization, signal });
|
|
242
247
|
if (res.status === 404) {
|
|
243
248
|
throw new SettlementFault(
|
|
244
249
|
`conversation lost: the harness answered 404 for submission "${admission.submissionId}" — ` +
|
|
245
250
|
`a conversation lives as long as its Harness process (ADR-0027)`,
|
|
246
251
|
);
|
|
247
252
|
}
|
|
253
|
+
// Refused, not unreachable: the bearer is derived, so a Harness that rejects it now
|
|
254
|
+
// rejects it on every retry — reconnecting would park the Turn forever, silently.
|
|
255
|
+
if (res.status === 401 || res.status === 403) {
|
|
256
|
+
throw new SettlementFault(
|
|
257
|
+
`harness refused the stream read (${res.status}) for submission "${admission.submissionId}": ` +
|
|
258
|
+
`${await errorDetail(res)} — the bearer is not this placement's (ADR-0058)`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
248
261
|
if (!res.ok && res.status !== 204) {
|
|
249
262
|
throw new ReconnectableError(`harness stream read failed (${res.status})`);
|
|
250
263
|
}
|
|
@@ -274,6 +287,7 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
|
|
|
274
287
|
async abort(agentName, instanceId, opts) {
|
|
275
288
|
const res = await fetchImpl(`${conversationUrl(agentName, instanceId)}/abort`, {
|
|
276
289
|
method: "POST",
|
|
290
|
+
headers: authorization,
|
|
277
291
|
signal: opts?.signal,
|
|
278
292
|
});
|
|
279
293
|
if (!res.ok) {
|
|
@@ -334,16 +348,16 @@ export function createHarnessAgentRunClient(options: HarnessClientOptions): Agen
|
|
|
334
348
|
|
|
335
349
|
/**
|
|
336
350
|
* The run-narrative echo push (ADR-0023): `POST /echo { events }` against one Workspace Harness,
|
|
337
|
-
* bearing
|
|
338
|
-
*
|
|
351
|
+
* bearing that placement's Harness bearer (ADR-0058 — the Harness verifies it against a sha-256,
|
|
352
|
+
* never holding it at rest). The payload is the
|
|
339
353
|
* STRUCTURED feed events; the Harness renders. A non-OK answer rejects, and the CALLER treats
|
|
340
354
|
* that as log-and-continue — fire-and-forget lives in the tee (run-host.ts), not here, so a test
|
|
341
355
|
* can still assert a push failed.
|
|
342
356
|
*/
|
|
343
357
|
export function createEchoPush(options: {
|
|
344
358
|
baseUrl: string;
|
|
345
|
-
/** The
|
|
346
|
-
token
|
|
359
|
+
/** The Workspace's Harness bearer (ADR-0058). Absent only against the stub Harness. */
|
|
360
|
+
token?: string;
|
|
347
361
|
/** Injectable for socket-free tests. Default: global `fetch`. */
|
|
348
362
|
fetch?: typeof fetch;
|
|
349
363
|
}): (events: EchoEvent[]) => Promise<void> {
|
|
@@ -356,7 +370,10 @@ export function createEchoPush(options: {
|
|
|
356
370
|
// `fetch failed` in the pod log is what let that condition hide.
|
|
357
371
|
const res = await fetchImpl(url, {
|
|
358
372
|
method: "POST",
|
|
359
|
-
headers: {
|
|
373
|
+
headers: {
|
|
374
|
+
"content-type": "application/json",
|
|
375
|
+
...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
|
|
376
|
+
},
|
|
360
377
|
body: JSON.stringify({ events }),
|
|
361
378
|
}).catch((err: unknown) => {
|
|
362
379
|
throw new Error(`harness echo to ${url} failed: ${transportDetail(err)}`, { cause: err });
|
|
@@ -386,7 +403,10 @@ export function createEchoPush(options: {
|
|
|
386
403
|
* still an `AgentDefinition`: the actor narrows on start and refuses an Open one there.
|
|
387
404
|
*/
|
|
388
405
|
export function agent(declaration: AgentDeclaration): AgentLogic {
|
|
389
|
-
return agentActorWith(
|
|
406
|
+
return agentActorWith(
|
|
407
|
+
(endpoint, bearer) => createHarnessAgentRunClient({ baseUrl: endpoint, ...(bearer ? { token: bearer } : {}) }),
|
|
408
|
+
declaration,
|
|
409
|
+
);
|
|
390
410
|
}
|
|
391
411
|
|
|
392
412
|
/** A stream read worth retrying (server hiccup) — internal to the reconnect loop, never thrown out. */
|
package/src/http.ts
CHANGED
|
@@ -27,9 +27,10 @@
|
|
|
27
27
|
// control.
|
|
28
28
|
// authenticated any principal we minted a token for. The Agent's surface lives here, scoped
|
|
29
29
|
// further per-registration by `mayDeliverToAgent`.
|
|
30
|
-
// instanceOnly the Instance token ALONE. Run control
|
|
31
|
-
// token authenticates but is refused, because
|
|
32
|
-
//
|
|
30
|
+
// instanceOnly the Instance token ALONE. Run control — starting a run included (ADR-0058) — and
|
|
31
|
+
// full run state (`/runs*`): a Sandbox token authenticates but is refused, because
|
|
32
|
+
// starting work, reading another feature's context, or cancelling a run is not on
|
|
33
|
+
// the Agent's surface any more than a Gate is.
|
|
33
34
|
|
|
34
35
|
import { readFile, stat } from "node:fs/promises";
|
|
35
36
|
import { createRequire } from "node:module";
|
|
@@ -356,8 +357,10 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
356
357
|
// Push work: start a run of a registered workflow. Unknown workflow → host.start throws → 404.
|
|
357
358
|
// A body failing the machine's declared input schema (ADR-0033) → 400 naming the accepted
|
|
358
359
|
// shape — the same error class, and the same wire mapping, as a gate delivery failing its
|
|
359
|
-
// schema (`POST /runs/:id/gates/:gate/events` below).
|
|
360
|
-
|
|
360
|
+
// schema (`POST /runs/:id/gates/:gate/events` below). Instance-band (ADR-0058): starting work is
|
|
361
|
+
// run control, and a Sandbox token's scope is its own agent surface — no Adapter forwarding this
|
|
362
|
+
// route is what keeps an Agent from starting runs today, and that is not a control.
|
|
363
|
+
app.post("/workflows/:name/runs", instanceOnly, async (c) => {
|
|
361
364
|
const name = c.req.param("name");
|
|
362
365
|
const input = await readJson(c.req.text());
|
|
363
366
|
try {
|
package/src/instance.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { RunHost } from "./run-host.ts";
|
|
|
29
29
|
import type { RunRecord } from "./run-host.ts";
|
|
30
30
|
import { SqliteSnapshotStore } from "./snapshot-store.ts";
|
|
31
31
|
import type { SnapshotStore } from "./snapshot-store.ts";
|
|
32
|
-
import { createAuthenticator, loadSigningKey, mintInstanceToken } from "./tokens.ts";
|
|
32
|
+
import { createAuthenticator, harnessToken, loadSigningKey, mintInstanceToken } from "./tokens.ts";
|
|
33
33
|
import type { SandboxPort } from "./workspace.ts";
|
|
34
34
|
|
|
35
35
|
export type InstanceOptions = {
|
|
@@ -113,19 +113,25 @@ export async function startInstance(opts: InstanceOptions): Promise<RunningInsta
|
|
|
113
113
|
}
|
|
114
114
|
await store.init();
|
|
115
115
|
|
|
116
|
-
// Resolved BEFORE the host
|
|
117
|
-
//
|
|
116
|
+
// Resolved BEFORE the host, and the signing key with it: the host derives every Harness bearer
|
|
117
|
+
// from the key (ADR-0058) — admission, stream, abort, echo. The key is loaded from (or minted
|
|
118
|
+
// into) the instance folder, NOT generated per process: live Sandboxes outlive a restart, their
|
|
119
|
+
// Adapters still bear tokens this key signed, and their Harnesses still check bearers it derived.
|
|
120
|
+
// The Instance token is per-boot; the key is not. Both are served under in step 4 below.
|
|
118
121
|
const instanceToken = opts.instanceToken ?? mintInstanceToken();
|
|
122
|
+
const signingKey = opts.signingKey ?? (await loadSigningKey(opts.dir));
|
|
119
123
|
|
|
120
124
|
const host = new RunHost({
|
|
121
125
|
store,
|
|
122
126
|
reconcile: opts.reconcile,
|
|
123
127
|
sandbox: opts.sandbox,
|
|
124
128
|
instanceHarness: opts.instanceHarness,
|
|
129
|
+
harnessBearer: (placement) => harnessToken(signingKey, placement),
|
|
125
130
|
// The run-narrative echo (ADR-0023): tee a run's feed to its enclosing Workspace's Harness,
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
131
|
+
// bearing that Workspace's Harness bearer (ADR-0058) — never the Instance token, which would
|
|
132
|
+
// otherwise pass through a process the Agent executes code beside. The wire push is here and
|
|
133
|
+
// the fire-and-forget is the host's, so a Harness that refuses (or is gone) costs a log line.
|
|
134
|
+
echo: (endpoint, bearer) => createEchoPush({ baseUrl: endpoint, ...(bearer ? { token: bearer } : {}) }),
|
|
129
135
|
// A run left `live` to be retried is otherwise unexplained — the announce line names it, this
|
|
130
136
|
// says why (ADR-0030). stderr, because it is a fault, not the boot's structured result.
|
|
131
137
|
onRestoreError: (runId, err) =>
|
|
@@ -153,10 +159,7 @@ export async function startInstance(opts: InstanceOptions): Promise<RunningInsta
|
|
|
153
159
|
// discoverable by asking after a specific run id nobody knows to ask about.
|
|
154
160
|
const restored = await host.restore();
|
|
155
161
|
|
|
156
|
-
// 4. Serve, authenticated (ADR-0013)
|
|
157
|
-
// instance folder, NOT generated per process: live Sandboxes outlive a restart, and their
|
|
158
|
-
// Adapters still bear tokens this key signed. The Instance token is per-boot; the key is not.
|
|
159
|
-
const signingKey = opts.signingKey ?? (await loadSigningKey(opts.dir));
|
|
162
|
+
// 4. Serve, authenticated (ADR-0013) — the token and key resolved above.
|
|
160
163
|
const auth = createAuthenticator({ instanceToken, signingKey });
|
|
161
164
|
// The Repos are read PER REQUEST, never snapshotted here: a cache the agent cloned minutes
|
|
162
165
|
// after boot must show as present the next time anyone asks (ADR-0048/0051).
|
package/src/registration.ts
CHANGED
|
@@ -246,6 +246,13 @@ export type RunBinding = {
|
|
|
246
246
|
* absent, a `"none"` Agent without an explicit `endpoint` fails loudly.
|
|
247
247
|
*/
|
|
248
248
|
instanceHarness?: string;
|
|
249
|
+
/**
|
|
250
|
+
* The Harness bearer for a placement (ADR-0058): `harnessToken(signingKey, placement)`, closed
|
|
251
|
+
* over the key by the host. The Agent actor asks it for the pod its Turn resolved to; the echo
|
|
252
|
+
* attach asks it for the Workspace's. Absent (bare unit-test bindings, the stub path), no bearer
|
|
253
|
+
* is sent — which only a Harness that checks nothing accepts.
|
|
254
|
+
*/
|
|
255
|
+
harnessBearer?: (placement: string) => string;
|
|
249
256
|
/**
|
|
250
257
|
* Record an Agent invocation's durable admission in the host ledger (ADR-0016): persisted
|
|
251
258
|
* beside the snapshot in the same RunBlob save, keyed by iid (globally unique, so the map is
|
|
@@ -283,12 +290,13 @@ export type RunBinding = {
|
|
|
283
290
|
marker?: (event: TurnMarker) => void;
|
|
284
291
|
/**
|
|
285
292
|
* Attach the run-narrative echo to a Workspace's Harness (ADR-0023): the host replays the
|
|
286
|
-
* run's feed-so-far to `endpoint
|
|
293
|
+
* run's feed-so-far to `endpoint` — the Harness in `placement`'s pod, pushed with that
|
|
294
|
+
* placement's bearer (ADR-0058) — then tees live; returns the detach. Called by `workspace()`'s
|
|
287
295
|
* registrar — the same restore-safe seat the ambient handles ride — and scoped to the OWNING
|
|
288
296
|
* run by construction: the binding is per-run, so a sibling run's feed is unreachable.
|
|
289
297
|
* Host-supplied; absent = no echo.
|
|
290
298
|
*/
|
|
291
|
-
echo?: (endpoint: string) => () => void;
|
|
299
|
+
echo?: (endpoint: string, placement: string) => () => void;
|
|
292
300
|
/**
|
|
293
301
|
* The HOST is ending this run for its own reasons (ADR-0024). An Agent invocation ending
|
|
294
302
|
* normally ends the Agent's turn — the state stopped waiting — but `RunHost.stop()` is the one
|
package/src/run-host.ts
CHANGED
|
@@ -312,13 +312,18 @@ export type RunHostOptions = {
|
|
|
312
312
|
sandbox?: SandboxPort;
|
|
313
313
|
/** The Instance Harness base URL (ADR-0031) — where `workspace: "none"` Turns are admitted. */
|
|
314
314
|
instanceHarness?: string;
|
|
315
|
+
/** The Harness bearer for a placement (ADR-0058) — `startInstance` closes `harnessToken` over the
|
|
316
|
+
* signing key. Handed to every run's binding, and to the echo pusher below. Absent, no Harness
|
|
317
|
+
* call bears one: only the stub Harness answers such a host. */
|
|
318
|
+
harnessBearer?: (placement: string) => string;
|
|
315
319
|
/**
|
|
316
320
|
* Build the run-narrative echo pusher for one Workspace's Harness (ADR-0023) — the seam a fake
|
|
317
|
-
* echo server rides in tests; `startInstance` binds the real wire push (harness-client.ts)
|
|
318
|
-
*
|
|
319
|
-
* the echo is a courtesy view of the feed,
|
|
321
|
+
* echo server rides in tests; `startInstance` binds the real wire push (harness-client.ts).
|
|
322
|
+
* `bearer` is that Workspace's Harness bearer (ADR-0058), when the host derives one. Absent = no
|
|
323
|
+
* echo: a host without it runs identically, because the echo is a courtesy view of the feed,
|
|
324
|
+
* never a dependency of the run.
|
|
320
325
|
*/
|
|
321
|
-
echo?: (endpoint: string) => (events: EchoEvent[]) => Promise<void>;
|
|
326
|
+
echo?: (endpoint: string, bearer: string | undefined) => (events: EchoEvent[]) => Promise<void>;
|
|
322
327
|
};
|
|
323
328
|
|
|
324
329
|
/** What we persist per run: the machine snapshot wrapped with the run metadata restore needs.
|
|
@@ -383,7 +388,11 @@ export class RunHost {
|
|
|
383
388
|
private readonly onRestoreError?: (runId: string, err: unknown) => void;
|
|
384
389
|
private readonly sandbox?: SandboxPort;
|
|
385
390
|
private readonly instanceHarness?: string;
|
|
386
|
-
private readonly
|
|
391
|
+
private readonly harnessBearer?: (placement: string) => string;
|
|
392
|
+
private readonly echoFactory?: (
|
|
393
|
+
endpoint: string,
|
|
394
|
+
bearer: string | undefined,
|
|
395
|
+
) => (events: EchoEvent[]) => Promise<void>;
|
|
387
396
|
private readonly workflowDefs = new Map<string, WorkflowDef>();
|
|
388
397
|
private readonly runs = new Map<string, LiveRun>();
|
|
389
398
|
/**
|
|
@@ -401,6 +410,7 @@ export class RunHost {
|
|
|
401
410
|
this.onRestoreError = opts.onRestoreError;
|
|
402
411
|
this.sandbox = opts.sandbox;
|
|
403
412
|
this.instanceHarness = opts.instanceHarness;
|
|
413
|
+
this.harnessBearer = opts.harnessBearer;
|
|
404
414
|
this.echoFactory = opts.echo;
|
|
405
415
|
}
|
|
406
416
|
|
|
@@ -883,11 +893,11 @@ export class RunHost {
|
|
|
883
893
|
* Scoped to the OWNING run's lineage by construction: it reads one run's buffer and listeners
|
|
884
894
|
* and nothing else — a sibling run's events cannot reach this endpoint through here.
|
|
885
895
|
*/
|
|
886
|
-
private attachEcho(runId: string, endpoint: string): () => void {
|
|
896
|
+
private attachEcho(runId: string, endpoint: string, placement: string): () => void {
|
|
887
897
|
const run = this.runs.get(runId);
|
|
888
898
|
const factory = this.echoFactory;
|
|
889
899
|
if (!run || !factory) return () => {};
|
|
890
|
-
const push = factory(endpoint);
|
|
900
|
+
const push = factory(endpoint, this.harnessBearer?.(placement));
|
|
891
901
|
let chain = Promise.resolve();
|
|
892
902
|
let reported = false;
|
|
893
903
|
const enqueue = (events: EchoEvent[]): void => {
|
|
@@ -965,6 +975,7 @@ export class RunHost {
|
|
|
965
975
|
table: this.table,
|
|
966
976
|
sandbox: this.sandbox,
|
|
967
977
|
instanceHarness: this.instanceHarness,
|
|
978
|
+
...(this.harnessBearer ? { harnessBearer: this.harnessBearer } : {}),
|
|
968
979
|
// The admission ledger's write half (ADR-0016): the Agent actor reports the durable handle the
|
|
969
980
|
// moment the Harness admits it, and the ledger hits the store in the same RunBlob save. An
|
|
970
981
|
// admission arriving around stop/untrack still lands in `agents` but skips the save,
|
|
@@ -999,7 +1010,7 @@ export class RunHost {
|
|
|
999
1010
|
if (run && run.binding === binding) this.feed(run, event);
|
|
1000
1011
|
},
|
|
1001
1012
|
// The run-narrative echo attach (ADR-0023), called from `workspace()`'s registrar.
|
|
1002
|
-
echo: (endpoint) => this.attachEcho(record.runId, endpoint),
|
|
1013
|
+
echo: (endpoint, placement) => this.attachEcho(record.runId, endpoint, placement),
|
|
1003
1014
|
};
|
|
1004
1015
|
let bound = false;
|
|
1005
1016
|
const actor = createActor(machine, {
|
package/src/sandbox-kubectl.ts
CHANGED
|
@@ -69,7 +69,7 @@ import { readImageRefs, resolveSandboxImage, resolveUserImage, type ImageRefs }
|
|
|
69
69
|
import { CA_CONFIGMAP, IMAGES_KEY, IMAGES_MOUNT, REPOS_MOUNT } from "./names.ts";
|
|
70
70
|
import { repoIdentity } from "./repo-identity.ts";
|
|
71
71
|
import type { RepoResources } from "./repos.ts";
|
|
72
|
-
import { sandboxToken } from "./tokens.ts";
|
|
72
|
+
import { harnessTokenDigest, sandboxToken } from "./tokens.ts";
|
|
73
73
|
import type { ProvisionedRepo, SandboxPort, WorkspaceSpec } from "./workspace.ts";
|
|
74
74
|
|
|
75
75
|
/** Run one kubectl invocation to completion. `input` is piped to stdin (`apply -f -`). */
|
|
@@ -423,15 +423,23 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
|
423
423
|
];
|
|
424
424
|
|
|
425
425
|
// The Harness container's env: the instance's passthrough (`harness.env` — e.g. model
|
|
426
|
-
// config) first, then the mechanism-owned vars (the Adapter address, the CA trust path
|
|
427
|
-
// win on collision. Note the asymmetry stands (ADR-0013): user env/envFrom
|
|
428
|
-
// container only — never on the Adapter, whose env is minted here and
|
|
429
|
-
// credential.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
]
|
|
426
|
+
// config) first, then the mechanism-owned vars (the Adapter address, the CA trust path, the
|
|
427
|
+
// wire's gate), which win on collision. Note the asymmetry stands (ADR-0013): user env/envFrom
|
|
428
|
+
// land on the HARNESS container only — never on the Adapter, whose env is minted here and
|
|
429
|
+
// carries the pod's only credential.
|
|
430
|
+
//
|
|
431
|
+
// The gate rides LAST (ADR-0058): the digest of the bearer the Orchestrator derives for THIS
|
|
432
|
+
// Sandbox. A digest because the Agent reads this env; last because a `harness.env` entry of the
|
|
433
|
+
// same name must not be able to choose the Harness's credential.
|
|
434
|
+
const harnessEnv = (name: string): HarnessEnvVar[] => {
|
|
435
|
+
if (!opts.signingKey) throw new Error("kubectlSandbox: a Harness needs a signingKey to check its bearer");
|
|
436
|
+
return [
|
|
437
|
+
...(opts.env ?? []),
|
|
438
|
+
{ name: "JR2_ADAPTER_URL", value: `http://127.0.0.1:${adapterPort}` },
|
|
439
|
+
...(opts.caBundle ? [{ name: "NODE_EXTRA_CA_CERTS", value: `${CA_MOUNT}/ca.crt` }] : []),
|
|
440
|
+
{ name: "JR2_HARNESS_TOKEN_SHA256", value: harnessTokenDigest(opts.signingKey, name) },
|
|
441
|
+
];
|
|
442
|
+
};
|
|
435
443
|
|
|
436
444
|
/**
|
|
437
445
|
* The two init steps, in order (ADR-0037). They are plain container fragments the operator
|
|
@@ -573,7 +581,7 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
|
573
581
|
// this used to carry was unreachable. The seat's own vars (the fallback `HOME`) come
|
|
574
582
|
// FIRST, so the instance's `harness.env` can still override them the way it overrides
|
|
575
583
|
// anything the image set.
|
|
576
|
-
env: [...seat.env, ...harnessEnv()],
|
|
584
|
+
env: [...seat.env, ...harnessEnv(req.name)],
|
|
577
585
|
...(opts.envFrom?.length ? { envFrom: opts.envFrom } : {}),
|
|
578
586
|
// What the AGENT gets: an address on its own loopback, and no credential anywhere. This is
|
|
579
587
|
// the only thing in the pod that tells it how to reach its Machine (ADR-0013).
|
package/src/server.ts
CHANGED
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
// previous deploy bound — an Instance that drops its last `workspace()` leaves `jr2 gc` able to
|
|
26
26
|
// collect what it stopped using.
|
|
27
27
|
|
|
28
|
-
import { createHash } from "node:crypto";
|
|
29
28
|
import { join } from "node:path";
|
|
30
29
|
import { loadConfig } from "./config.ts";
|
|
31
30
|
import { loadWorkflows, startInstance, type RunningInstance } from "./instance.ts";
|
|
@@ -70,10 +69,7 @@ export async function serverMain(opts: ServerMainOptions): Promise<RunningInstan
|
|
|
70
69
|
// by nature, which is what lets live Sandboxes outlive orchestrator restarts (ADR-0013).
|
|
71
70
|
const namespace = env.JR2_NAMESPACE;
|
|
72
71
|
|
|
73
|
-
//
|
|
74
|
-
// sha-256 as its echo gate (ADR-0023, below), so the token must exist before the first
|
|
75
|
-
// provision. From the instance Secret when deployed; per-boot for a host-booted fixture,
|
|
76
|
-
// exactly as before.
|
|
72
|
+
// From the instance Secret when deployed; per-boot for a host-booted fixture.
|
|
77
73
|
const instanceToken = env.JR2_INSTANCE_TOKEN ?? mintInstanceToken();
|
|
78
74
|
|
|
79
75
|
// The data-plane switch (ADR-0012/0031/0051): a registered Machine COMPOSES a Sandbox — read off
|
|
@@ -124,13 +120,6 @@ export async function serverMain(opts: ServerMainOptions): Promise<RunningInstan
|
|
|
124
120
|
name: "JR2_HARNESS_JSON",
|
|
125
121
|
valueFrom: { configMapKeyRef: { name: HARNESS_CONFIGMAP, key: HARNESS_CONFIG_KEY } },
|
|
126
122
|
},
|
|
127
|
-
// The echo gate (ADR-0023): the Harness verifies echo bearers against this sha-256. The
|
|
128
|
-
// digest, never the token — the Agent executes code in the Harness container, and a
|
|
129
|
-
// digest inverts to nothing (the Instance token itself never enters a Sandbox, ADR-0013).
|
|
130
|
-
{
|
|
131
|
-
name: "JR2_ECHO_TOKEN_SHA256",
|
|
132
|
-
value: createHash("sha256").update(instanceToken).digest("base64url"),
|
|
133
|
-
},
|
|
134
123
|
...(config?.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
|
|
135
124
|
],
|
|
136
125
|
envFrom: [{ secretRef: { name: HARNESS_ENV_SECRET } }, ...(config?.harness?.envFrom ?? [])],
|
package/src/tokens.ts
CHANGED
|
@@ -21,13 +21,20 @@
|
|
|
21
21
|
// Harness's Adapter bears one signed for that placement's Service name
|
|
22
22
|
// (ADR-0031), scoping it to the Menu-only registrations placed there.
|
|
23
23
|
//
|
|
24
|
+
// And one bearer the other way (ADR-0058), which is no principal here because this server never
|
|
25
|
+
// checks it:
|
|
26
|
+
//
|
|
27
|
+
// Harness bearer what the Orchestrator presents to the Harness in one pod — admit, stream,
|
|
28
|
+
// abort, echo. Derived per placement (`harnessToken`); the Harness holds only
|
|
29
|
+
// its sha-256 (`harnessTokenDigest`), so it verifies and mints nothing.
|
|
30
|
+
//
|
|
24
31
|
// The Sandbox token is a SIGNED NAME, not a random string in a table: `<sandbox>.<hmac(key, name)>`,
|
|
25
32
|
// verified by recomputing. Three things fall out that a token table would have to work for — the
|
|
26
33
|
// Orchestrator holds no per-Sandbox state, `provision` stays idempotent (re-minting yields the same
|
|
27
34
|
// token, so a re-applied Secret is a no-op), and a token minted before a restart still verifies
|
|
28
35
|
// after one, which is exactly what ADR-0012's re-attach promises the still-running Adapter.
|
|
29
36
|
|
|
30
|
-
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
37
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
31
38
|
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
32
39
|
import { join } from "node:path";
|
|
33
40
|
|
|
@@ -46,6 +53,29 @@ export function sandboxToken(key: Buffer, sandbox: string): string {
|
|
|
46
53
|
return `${sandbox}.${sign(key, sandbox)}`;
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The Harness bearer for one placement (ADR-0058): what the Orchestrator presents on every Harness
|
|
58
|
+
* wire call — admit, stream, abort, echo — to the Harness in the pod `placement` names (a
|
|
59
|
+
* Sandbox, or `jr2-instance-harness`). The same signed-name idea as the Sandbox token, turned the
|
|
60
|
+
* other way: here the Orchestrator is the CALLER. Deterministic for the same reason — a restarted
|
|
61
|
+
* Orchestrator re-derives the bearer a live Harness already checks against.
|
|
62
|
+
*
|
|
63
|
+
* Domain-separated from `sandboxToken` by the `harness:` prefix, which no DNS-label name can
|
|
64
|
+
* produce, so no Sandbox token ever doubles as a Harness bearer or the reverse.
|
|
65
|
+
*/
|
|
66
|
+
export function harnessToken(key: Buffer, placement: string): string {
|
|
67
|
+
return sign(key, `harness:${placement}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What the Harness in `placement` holds to CHECK its bearer: the sha-256 of it, never the bearer
|
|
72
|
+
* (ADR-0058). The Agent executes code in that container, and a digest inverts to nothing — so the
|
|
73
|
+
* Harness can verify its own bearer and can mint none, for itself or for any other pod.
|
|
74
|
+
*/
|
|
75
|
+
export function harnessTokenDigest(key: Buffer, placement: string): string {
|
|
76
|
+
return createHash("sha256").update(harnessToken(key, placement)).digest("base64url");
|
|
77
|
+
}
|
|
78
|
+
|
|
49
79
|
/** Mint the per-boot Instance token (opaque and random — it names nothing, it just IS the trust). */
|
|
50
80
|
export function mintInstanceToken(): string {
|
|
51
81
|
return randomBytes(32).toString("base64url");
|
package/src/workspace.ts
CHANGED
|
@@ -688,7 +688,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
688
688
|
const wrapperRef = self._parent;
|
|
689
689
|
if (!wrapperRef) return;
|
|
690
690
|
const disposeHandles = registerAmbientHandles(wrapperRef, input.handles);
|
|
691
|
-
const detachEcho = runBindingOf(system as AnyActorSystem).echo?.(input.handles.endpoint);
|
|
691
|
+
const detachEcho = runBindingOf(system as AnyActorSystem).echo?.(input.handles.endpoint, input.handles.sandbox);
|
|
692
692
|
return () => {
|
|
693
693
|
detachEcho?.();
|
|
694
694
|
disposeHandles();
|