@jr2/orchestrator 0.2.1 → 0.3.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/package.json +3 -4
- package/src/actor.ts +5 -5
- package/src/agent.ts +2 -2
- package/src/config.ts +119 -14
- package/src/custodian.lua +312 -0
- package/src/custodian.ts +899 -0
- package/src/harness-client.ts +2 -2
- package/src/held-secrets.ts +587 -0
- package/src/http.ts +19 -16
- package/src/images.ts +11 -10
- package/src/index.ts +6 -2
- package/src/instance.ts +1 -1
- package/src/model-keys.ts +92 -0
- package/src/names.ts +30 -6
- package/src/registration.ts +1 -1
- package/src/repo-fetch.ts +1 -1
- package/src/run-host.ts +15 -13
- package/src/sandbox-kubectl.ts +71 -107
- package/src/server.ts +4 -5
- package/src/tokens.ts +9 -8
- package/src/workspace.ts +1 -1
package/src/sandbox-kubectl.ts
CHANGED
|
@@ -40,21 +40,22 @@
|
|
|
40
40
|
// initContainer preflight the USER'S image + that volume → ADR-0037's probe, the thing that
|
|
41
41
|
// proves a registry ref, whose first appearance is this provision
|
|
42
42
|
// container harness the Sandbox Image, command overridden, /work + /opt/jr2 mounted
|
|
43
|
-
// container
|
|
43
|
+
// container custodian jr2-composed, the pod's only credential holder (below)
|
|
44
44
|
// container user optional, the image's own entrypoint, the checkouts (/work, plus
|
|
45
45
|
// /repos and /opt/jr2 read-only) and NOTHING else
|
|
46
46
|
//
|
|
47
|
-
// This is also where the
|
|
48
|
-
// ADR-0001 made `Sidecars` generic container fragments it schedules WITHOUT
|
|
49
|
-
//
|
|
50
|
-
// builds is the pod's asymmetry:
|
|
47
|
+
// This is also where the CUSTODIAN is composed (ADR-0013, ADR-0059). The operator needs no change
|
|
48
|
+
// to carry it: ADR-0001 made `Sidecars` generic container fragments it schedules WITHOUT
|
|
49
|
+
// understanding, so the Custodian is exactly that — a container with an image, its mounts, and a
|
|
50
|
+
// readiness probe the pod's Ready waits on. What this module builds is the pod's asymmetry:
|
|
51
51
|
//
|
|
52
|
-
// harness container
|
|
53
|
-
//
|
|
52
|
+
// harness container JR2_CUSTODIAN_URL + the Stand-in of every credential (no credential)
|
|
53
|
+
// custodian container the Sandbox token and every held secret, as files (the credentials)
|
|
54
54
|
//
|
|
55
55
|
// The Agent has code execution in the first and none in the second. The token is minted here — a
|
|
56
56
|
// signed Sandbox name (see tokens.ts), so re-provisioning after a restart yields the SAME token and
|
|
57
|
-
// the Secret re-applies as a no-op.
|
|
57
|
+
// the Secret re-applies as a no-op. What else the Custodian holds is `jr2 up`'s resolution, read
|
|
58
|
+
// per provision from the `jr2-held` mount (custodian.ts builds both placements' pods alike).
|
|
58
59
|
|
|
59
60
|
import { execFile } from "node:child_process";
|
|
60
61
|
import { join } from "node:path";
|
|
@@ -65,8 +66,10 @@ import {
|
|
|
65
66
|
type HarnessEnvVar,
|
|
66
67
|
type SandboxPlacement,
|
|
67
68
|
} from "./config.ts";
|
|
69
|
+
import { custodianComposition, type CustodianComposition } from "./custodian.ts";
|
|
70
|
+
import { readHeldManifest } from "./held-secrets.ts";
|
|
68
71
|
import { readImageRefs, resolveSandboxImage, resolveUserImage, type ImageRefs } from "./images.ts";
|
|
69
|
-
import {
|
|
72
|
+
import { HELD_KEY, HELD_MOUNT, IMAGES_KEY, IMAGES_MOUNT, REPOS_MOUNT } from "./names.ts";
|
|
70
73
|
import { repoIdentity } from "./repo-identity.ts";
|
|
71
74
|
import type { RepoResources } from "./repos.ts";
|
|
72
75
|
import { harnessTokenDigest, sandboxToken } from "./tokens.ts";
|
|
@@ -75,9 +78,6 @@ import type { ProvisionedRepo, SandboxPort, WorkspaceSpec } from "./workspace.ts
|
|
|
75
78
|
/** Run one kubectl invocation to completion. `input` is piped to stdin (`apply -f -`). */
|
|
76
79
|
export type KubectlExec = (args: string[], opts?: { input?: string }) => Promise<{ stdout: string; stderr: string }>;
|
|
77
80
|
|
|
78
|
-
/** Where the Harness container sees the instance's CA bundle (ADR-0020). */
|
|
79
|
-
const CA_MOUNT = "/etc/jr2/ca";
|
|
80
|
-
|
|
81
81
|
/** Where jr2's runtime lands in every container that gets it (ADR-0037). `/opt/jr2` and not `/app`
|
|
82
82
|
* because a stranger's base may already use `/app`, and one layout must serve both the stock
|
|
83
83
|
* Harness image and an arbitrary Sandbox Image. It is a PUBLISHED surface: `bin/` beside `lib/`
|
|
@@ -97,10 +97,6 @@ const HARNESS_COMMAND = [`${RUNTIME_MOUNT}/bin/node`, `${RUNTIME_MOUNT}/src/main
|
|
|
97
97
|
* checkouts must hold this volume — which is why the User Container mounts it (ADR-0005). */
|
|
98
98
|
const UPLOAD_PACK = `${RUNTIME_MOUNT}/bin/jr2-upload-pack`;
|
|
99
99
|
|
|
100
|
-
/** The Adapter's port on the pod's loopback. The program defaults to this address too, so the
|
|
101
|
-
* fetch url names it only when the composition moved it (attachScript). */
|
|
102
|
-
const DEFAULT_ADAPTER_PORT = 8081;
|
|
103
|
-
|
|
104
100
|
/** ADR-0005's default work group. Convention, not config: the pod's `fsGroup` is granted to every
|
|
105
101
|
* container as a supplemental group, so the Harness writes `/work` whatever the number and no
|
|
106
102
|
* image's `/etc/group` needs to know it. The one override is the spec's `workGroup`. */
|
|
@@ -263,6 +259,10 @@ export type KubectlSandboxOptions = {
|
|
|
263
259
|
* off it at invoke time, handed to `provision()` as a string — and resolved against this map at
|
|
264
260
|
* provision. The per-run spec never names one (ADR-0051). */
|
|
265
261
|
imagesPath?: string;
|
|
262
|
+
/** The held-secret manifest `jr2 up` resolved (ADR-0059) — read per provision like the image map,
|
|
263
|
+
* so a held-secret edit reaches future Sandboxes without rolling this process. Default: the
|
|
264
|
+
* `jr2-held` ConfigMap's mount. */
|
|
265
|
+
heldPath?: string;
|
|
266
266
|
/** Extra env for the HARNESS container (`harness.env`) — merged ahead of the
|
|
267
267
|
* mechanism-owned vars, which win on collision. */
|
|
268
268
|
env?: HarnessEnvVar[];
|
|
@@ -273,20 +273,11 @@ export type KubectlSandboxOptions = {
|
|
|
273
273
|
* `sandbox.tolerations`, written on the CR verbatim and copied onto the pod by the operator, which
|
|
274
274
|
* merges nothing with them. Absent → wherever an ordinary pod lands. */
|
|
275
275
|
placement?: SandboxPlacement;
|
|
276
|
-
/** The instance ships a private-CA bundle (ADR-0020):
|
|
277
|
-
*
|
|
278
|
-
* HTTP to the Orchestrator's Service (the same asymmetry as env/envFrom above). */
|
|
276
|
+
/** The instance ships a private-CA bundle (ADR-0020): the HARNESS container trusts it, and the
|
|
277
|
+
* Custodian verifies a bound host with it (custodian.ts). */
|
|
279
278
|
caBundle?: boolean;
|
|
280
|
-
/**
|
|
281
|
-
* Where the Adapter reaches the Orchestrator FROM INSIDE THE CLUSTER — the orchestrator's own
|
|
282
|
-
* Service DNS (derived from JR2_NAMESPACE by the entrypoint). The Agent is never told it.
|
|
283
|
-
* A thunk is still accepted for callers that resolve their address late.
|
|
284
|
-
*/
|
|
285
|
-
orchestratorUrl?: string | (() => string | undefined);
|
|
286
279
|
/** The key Sandbox tokens are signed with — from the instance Secret (ADR-0013/0019). */
|
|
287
280
|
signingKey?: Buffer;
|
|
288
|
-
/** The Adapter's port on the pod's loopback. Default 8081. */
|
|
289
|
-
adapterPort?: number;
|
|
290
281
|
/** Kube namespace for Sandbox CRs. Default `default`. */
|
|
291
282
|
namespace?: string;
|
|
292
283
|
/** kubectl `--context` override. Default: the current context (ADR-0009). */
|
|
@@ -339,6 +330,7 @@ export type KubectlSandboxOptions = {
|
|
|
339
330
|
export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
340
331
|
const ns = opts.namespace ?? "default";
|
|
341
332
|
const imagesPath = opts.imagesPath ?? join(IMAGES_MOUNT, IMAGES_KEY);
|
|
333
|
+
const heldPath = opts.heldPath ?? join(HELD_MOUNT, HELD_KEY);
|
|
342
334
|
const workRoot = opts.workRoot ?? "/work";
|
|
343
335
|
const readyTimeoutMs = opts.readyTimeoutMs ?? 120_000;
|
|
344
336
|
const repoTimeoutMs = opts.repoTimeoutMs ?? 25 * 60_000;
|
|
@@ -346,32 +338,12 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
|
346
338
|
const exec = opts.exec ?? defaultKubectlExec;
|
|
347
339
|
const credentials = opts.credentials ?? [];
|
|
348
340
|
|
|
349
|
-
const adapterPort = opts.adapterPort ?? DEFAULT_ADAPTER_PORT;
|
|
350
341
|
const leaseIntervalMs = opts.leaseIntervalMs ?? 5 * 60_000;
|
|
351
342
|
const base = ["--namespace", ns, ...(opts.context ? ["--context", opts.context] : [])];
|
|
352
343
|
|
|
353
|
-
/** The Sandbox's token Secret —
|
|
344
|
+
/** The Sandbox's token Secret — mounted into the Custodian container, and nothing else in the pod. */
|
|
354
345
|
const secretName = (name: string) => `${name}-token`;
|
|
355
346
|
|
|
356
|
-
/** Resolved at provision time (see the option's doc): the Orchestrator's in-cluster address. */
|
|
357
|
-
const orchestratorUrl = (): string | undefined =>
|
|
358
|
-
typeof opts.orchestratorUrl === "function" ? opts.orchestratorUrl() : opts.orchestratorUrl;
|
|
359
|
-
|
|
360
|
-
/** The Adapter, as the operator sees it: an opaque container fragment (ADR-0001). */
|
|
361
|
-
const adapterSidecar = (name: string, refs: ImageRefs) => ({
|
|
362
|
-
name: "adapter",
|
|
363
|
-
image: refs.adapter,
|
|
364
|
-
env: [
|
|
365
|
-
{ name: "JR2_ORCHESTRATOR_URL", value: orchestratorUrl() },
|
|
366
|
-
{ name: "JR2_SANDBOX", value: name },
|
|
367
|
-
{ name: "JR2_ADAPTER_PORT", value: String(adapterPort) },
|
|
368
|
-
],
|
|
369
|
-
// The credential, and the reason this is a separate container: `local()` tools give the Agent
|
|
370
|
-
// code execution in the HARNESS container, so anything mounted there is the Agent's. Here, it
|
|
371
|
-
// is out of reach — different container, no shared process namespace.
|
|
372
|
-
envFrom: [{ secretRef: { name: secretName(name) } }],
|
|
373
|
-
});
|
|
374
|
-
|
|
375
347
|
/**
|
|
376
348
|
* The User Container (ADR-0005): the opt-in third seat, composed only when the wrapper's static
|
|
377
349
|
* `user` option names an image (ADR-0049). The ZERO-CONTRACT seat — jr2 injects nothing, probes
|
|
@@ -395,8 +367,8 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
|
395
367
|
* Agent, with no credential of their own. Nothing else follows it in: `ext::` names the program
|
|
396
368
|
* by absolute path, so this seat still gets no env, no command, and no probe. The repo volumes
|
|
397
369
|
* are the operator's — it defines `repo-<key>` for every key the CR names — so this seat mounts
|
|
398
|
-
* them by name. The
|
|
399
|
-
* container holding the pod's
|
|
370
|
+
* them by name. The Custodian is deliberately not given any of the three: it reads no worktree, and it is the
|
|
371
|
+
* container holding the pod's credentials, so it gets the narrowest mount set that works.
|
|
400
372
|
* It also carries no `securityContext`, which the operator reads as the exemption — root is
|
|
401
373
|
* ALLOWED here, because hardening a seat whose identity is "what jr2 does not own" is an opinion,
|
|
402
374
|
* and the standard managed-access shape (a root sshd that setuids sessions down) must run
|
|
@@ -413,30 +385,28 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
|
413
385
|
});
|
|
414
386
|
|
|
415
387
|
/** The pod's sidecar list (ADR-0001: opaque fragments the operator schedules verbatim). The
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
adapterSidecar(name, refs),
|
|
388
|
+
* Custodian is ALWAYS here: a Sandbox without one is a pod that comes up Ready and then parks its
|
|
389
|
+
* Machine forever on a Menu it cannot read (ADR-0013). The User Container joins it only when the
|
|
390
|
+
* spec named one. */
|
|
391
|
+
const sidecarsFor = async (custodian: CustodianComposition, refs: ImageRefs, keys: string[], user?: string) => [
|
|
392
|
+
custodian.custodianContainer,
|
|
422
393
|
...(user !== undefined ? [await userSidecar(refs, user, keys)] : []),
|
|
423
394
|
];
|
|
424
395
|
|
|
425
396
|
// The Harness container's env: the instance's passthrough (`harness.env` — e.g. model
|
|
426
|
-
// config) first, then the mechanism-owned vars (the
|
|
427
|
-
//
|
|
428
|
-
// land on the HARNESS container only — never on the
|
|
429
|
-
//
|
|
397
|
+
// config) first, then the mechanism-owned vars (the Custodian's address and every Stand-in, the
|
|
398
|
+
// proxy and the trust bundles — custodian.ts), which win on collision. Note the asymmetry stands
|
|
399
|
+
// (ADR-0013): user env/envFrom land on the HARNESS container only — never on the Custodian, whose
|
|
400
|
+
// mounts are composed here and carry the pod's credentials.
|
|
430
401
|
//
|
|
431
402
|
// The gate rides LAST (ADR-0058): the digest of the bearer the Orchestrator derives for THIS
|
|
432
403
|
// Sandbox. A digest because the Agent reads this env; last because a `harness.env` entry of the
|
|
433
404
|
// same name must not be able to choose the Harness's credential.
|
|
434
|
-
const harnessEnv = (name: string): HarnessEnvVar[] => {
|
|
405
|
+
const harnessEnv = (name: string, custodian: CustodianComposition): HarnessEnvVar[] => {
|
|
435
406
|
if (!opts.signingKey) throw new Error("kubectlSandbox: a Harness needs a signingKey to check its bearer");
|
|
436
407
|
return [
|
|
437
408
|
...(opts.env ?? []),
|
|
438
|
-
|
|
439
|
-
...(opts.caBundle ? [{ name: "NODE_EXTRA_CA_CERTS", value: `${CA_MOUNT}/ca.crt` }] : []),
|
|
409
|
+
...custodian.harnessEnv,
|
|
440
410
|
{ name: "JR2_HARNESS_TOKEN_SHA256", value: harnessTokenDigest(opts.signingKey, name) },
|
|
441
411
|
];
|
|
442
412
|
};
|
|
@@ -531,10 +501,11 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
|
531
501
|
req: { name: string; runId: string; workflow: string; image?: string; user?: string; workGroup?: number },
|
|
532
502
|
refs: ImageRefs,
|
|
533
503
|
repos: FencedRepo[],
|
|
504
|
+
custodian: CustodianComposition,
|
|
534
505
|
) => {
|
|
535
506
|
const seat = await seatFor(refs, req.image);
|
|
536
507
|
const sidecars = await sidecarsFor(
|
|
537
|
-
|
|
508
|
+
custodian,
|
|
538
509
|
refs,
|
|
539
510
|
repos.map((r) => r.key),
|
|
540
511
|
req.user,
|
|
@@ -577,14 +548,13 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
|
577
548
|
fsGroup: req.workGroup ?? DEFAULT_WORK_GROUP,
|
|
578
549
|
// Ordered, and before any container starts: populate `/opt/jr2`, then prove the image on it.
|
|
579
550
|
initContainers: initContainersFor(refs, seat),
|
|
580
|
-
// Never empty
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
env: [...seat.env, ...harnessEnv(req.name)],
|
|
551
|
+
// Never empty: the Custodian's address is unconditional. The seat's own vars (the fallback
|
|
552
|
+
// `HOME`) come FIRST, so the instance's `harness.env` can still override them the way it
|
|
553
|
+
// overrides anything the image set.
|
|
554
|
+
env: [...seat.env, ...harnessEnv(req.name, custodian)],
|
|
585
555
|
...(opts.envFrom?.length ? { envFrom: opts.envFrom } : {}),
|
|
586
|
-
// What the AGENT gets: an address on its own loopback
|
|
587
|
-
//
|
|
556
|
+
// What the AGENT gets: an address on its own loopback and a Stand-in for every credential,
|
|
557
|
+
// and no credential anywhere. The Custodian beside it holds them (ADR-0013, ADR-0059).
|
|
588
558
|
sidecars,
|
|
589
559
|
// The Repos this Sandbox attaches, by cache key (ADR-0051). The operator does the rest: a
|
|
590
560
|
// `repo-<key>` volume per entry — the node's cache, hostPath, read-only in the primary
|
|
@@ -607,18 +577,19 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
|
607
577
|
{ name: "runtime", emptyDir: {} },
|
|
608
578
|
// Only for ADR-0037's fallback seat: uid 1000 on a stranger's base has no home at all.
|
|
609
579
|
...seat.homeVolume,
|
|
610
|
-
|
|
580
|
+
// The Custodian's (its values, leaves and config), and the trust ConfigMap (ADR-0020).
|
|
581
|
+
...custodian.volumes,
|
|
611
582
|
],
|
|
612
|
-
// CR-level volumeMounts land on the HARNESS container only (the operator's contract)
|
|
613
|
-
//
|
|
614
|
-
//
|
|
583
|
+
// CR-level volumeMounts land on the HARNESS container only (the operator's contract): the
|
|
584
|
+
// trust bundles, and none of the Custodian's volumes. The Repo caches are not listed: the
|
|
585
|
+
// operator mounts each `repo-<key>` into this container itself.
|
|
615
586
|
volumeMounts: [
|
|
616
587
|
{ name: "work", mountPath: workRoot },
|
|
617
588
|
// Read-only: nothing writes under `/opt/jr2` at runtime, and the Agent has code execution
|
|
618
589
|
// in this container — leaving its own runtime writable would let a turn edit it.
|
|
619
590
|
{ name: "runtime", mountPath: RUNTIME_MOUNT, readOnly: true },
|
|
620
591
|
...seat.homeMount,
|
|
621
|
-
...
|
|
592
|
+
...custodian.harnessMounts,
|
|
622
593
|
],
|
|
623
594
|
},
|
|
624
595
|
};
|
|
@@ -676,23 +647,13 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
|
676
647
|
|
|
677
648
|
/**
|
|
678
649
|
* Mint this Sandbox's token into a Secret, BEFORE the CR exists — the operator creates the pod
|
|
679
|
-
* the moment it sees the CR, and a pod whose
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
650
|
+
* the moment it sees the CR, and a pod whose volume names an absent Secret never starts.
|
|
651
|
+
* Idempotent by construction: the token is the Sandbox's name, signed (tokens.ts), so a
|
|
652
|
+
* re-provision after an orchestrator restart re-applies the SAME value, and the Custodian that has
|
|
653
|
+
* been holding it all along stays valid.
|
|
683
654
|
*/
|
|
684
655
|
const applyTokenSecret = async (name: string): Promise<void> => {
|
|
685
|
-
if (!opts.signingKey) throw new Error("kubectlSandbox:
|
|
686
|
-
// Fail the provision rather than ship an Adapter that cannot reach the Orchestrator. A mute
|
|
687
|
-
// Adapter is the worst possible outcome: the pod comes up Ready, the Agent is admitted, its
|
|
688
|
-
// tool call dies on `localhost`, and the Machine simply parks forever — a hang with no error.
|
|
689
|
-
if (!orchestratorUrl()) {
|
|
690
|
-
throw new Error(
|
|
691
|
-
"kubectlSandbox: the Adapter has no route to the Orchestrator (no orchestratorUrl — deployed " +
|
|
692
|
-
"instances derive Service DNS from JR2_NAMESPACE). An Agent with no Adapter cannot drive its " +
|
|
693
|
-
"Machine at all (ADR-0013).",
|
|
694
|
-
);
|
|
695
|
-
}
|
|
656
|
+
if (!opts.signingKey) throw new Error("kubectlSandbox: a Custodian needs a signingKey to mint its Sandbox token");
|
|
696
657
|
const secret = {
|
|
697
658
|
apiVersion: "v1",
|
|
698
659
|
kind: "Secret",
|
|
@@ -783,7 +744,17 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
|
783
744
|
// reaches future Sandboxes without rolling the Orchestrator. Reading before the Secret apply
|
|
784
745
|
// also means an unknown image name costs nothing: no Secret, no CR, nothing to clean up.
|
|
785
746
|
const refs = await readImageRefs(imagesPath);
|
|
786
|
-
|
|
747
|
+
// The same stance for what the Custodian holds (ADR-0059): `jr2 up` resolved it host-side,
|
|
748
|
+
// where `.env` exists, and this process reads the result — never its own config, whose
|
|
749
|
+
// `.env` values are absent in-cluster.
|
|
750
|
+
const manifest = await readHeldManifest(heldPath);
|
|
751
|
+
const custodian = custodianComposition(manifest, {
|
|
752
|
+
image: refs.custodian,
|
|
753
|
+
token: { secret: secretName(req.name), key: "JR2_SANDBOX_TOKEN" },
|
|
754
|
+
sandbox: req.name,
|
|
755
|
+
caBundle: opts.caBundle === true,
|
|
756
|
+
});
|
|
757
|
+
const cr = await crFor(req, refs, repos, custodian);
|
|
787
758
|
|
|
788
759
|
// The Repo resources, BEFORE the CR names them (ADR-0051): a bound one already exists from
|
|
789
760
|
// the boot and only its eviction clock moves — this run's spelling never rewrites the spec
|
|
@@ -794,7 +765,7 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
|
794
765
|
// a Sandbox whose Repo could not be recorded.
|
|
795
766
|
for (const repo of repos) await opts.repos.ensure(repo);
|
|
796
767
|
|
|
797
|
-
await applyTokenSecret(req.name); // before the CR: the pod's
|
|
768
|
+
await applyTokenSecret(req.name); // before the CR: the pod's Custodian mounts it at start
|
|
798
769
|
await exec(["apply", ...base, "-f", "-"], { input: JSON.stringify(cr) });
|
|
799
770
|
|
|
800
771
|
const applied = Date.now();
|
|
@@ -868,12 +839,7 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
|
868
839
|
},
|
|
869
840
|
|
|
870
841
|
async attach(req) {
|
|
871
|
-
const { script, repos, review } = attachScript(req.spec, req.repos, {
|
|
872
|
-
reposMount: REPOS_MOUNT,
|
|
873
|
-
workRoot,
|
|
874
|
-
// The fetch url names the Adapter only when it is somewhere unexpected (ADR-0053).
|
|
875
|
-
...(adapterPort !== DEFAULT_ADAPTER_PORT ? { adapterUrl: `http://127.0.0.1:${adapterPort}` } : {}),
|
|
876
|
-
});
|
|
842
|
+
const { script, repos, review } = attachScript(req.spec, req.repos, { reposMount: REPOS_MOUNT, workRoot });
|
|
877
843
|
// `-c harness` is unchanged and still correct after ADR-0037: the primary container runs the
|
|
878
844
|
// Sandbox Image, so `git` here is the git the user chose. Never `-c user` — that seat is
|
|
879
845
|
// zero-contract, may hold no git at all, and jr2 commands nothing in it (ADR-0005).
|
|
@@ -1002,7 +968,7 @@ function staleSlots(
|
|
|
1002
968
|
export function attachScript(
|
|
1003
969
|
spec: WorkspaceSpec,
|
|
1004
970
|
repos: Array<{ slot: string; url: string; ref?: string }>,
|
|
1005
|
-
paths: { reposMount: string; workRoot: string
|
|
971
|
+
paths: { reposMount: string; workRoot: string },
|
|
1006
972
|
): { script: string; repos: Record<string, string>; review?: Record<string, string> } {
|
|
1007
973
|
const worktrees: Record<string, string> = {};
|
|
1008
974
|
const review: Record<string, string> = {};
|
|
@@ -1048,7 +1014,7 @@ export function attachScript(
|
|
|
1048
1014
|
// (ADR-0051). Push still succeeds only with a caller-supplied credential (a forwarded agent
|
|
1049
1015
|
// in the User Container); the pod itself holds none. `--` keeps the url an operand, never an
|
|
1050
1016
|
// option.
|
|
1051
|
-
`git -C ${sq(dflt)} remote set-url origin -- ${sq(fetchUrl(identity
|
|
1017
|
+
`git -C ${sq(dflt)} remote set-url origin -- ${sq(fetchUrl(identity))}`,
|
|
1052
1018
|
`git -C ${sq(dflt)} remote set-url --push origin -- ${sq(repo.url)}`,
|
|
1053
1019
|
// `ext` is on git's own "known scary" list, so its built-in default is `never` and the url
|
|
1054
1020
|
// above would die with `fatal: transport 'ext' not allowed` before the program ever ran.
|
|
@@ -1086,13 +1052,11 @@ export function attachScript(
|
|
|
1086
1052
|
|
|
1087
1053
|
/**
|
|
1088
1054
|
* `origin`'s fetch url for one Repo (ADR-0053): the `ext::` transport, the program's absolute path
|
|
1089
|
-
* on the runtime volume, the service git asks for, and the Repo's identity.
|
|
1090
|
-
*
|
|
1091
|
-
* the program reads `$JR2_ADAPTER_URL` and then falls back to that same address, so spelling it out
|
|
1092
|
-
* unconditionally would put a number in every `git remote -v` that says nothing.
|
|
1055
|
+
* on the runtime volume, the service git asks for, and the Repo's identity. No address: the
|
|
1056
|
+
* program asks the Custodian at its one loopback address, which every Harness pod has (ADR-0059).
|
|
1093
1057
|
*/
|
|
1094
|
-
function fetchUrl(identity: string
|
|
1095
|
-
return `ext::${UPLOAD_PACK} %S ${extArg(identity)}
|
|
1058
|
+
function fetchUrl(identity: string): string {
|
|
1059
|
+
return `ext::${UPLOAD_PACK} %S ${extArg(identity)}`;
|
|
1096
1060
|
}
|
|
1097
1061
|
|
|
1098
1062
|
/**
|
package/src/server.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// JR2_SIGNING_KEY base64 key Sandbox tokens are signed with; from the Secret so live Sandboxes
|
|
12
12
|
// survive a pod restart. Absent → minted into `<dir>/.jr2/secret` (dev-grade).
|
|
13
13
|
// JR2_NAMESPACE the pod's own namespace (Deployment fieldRef) — presence = "deployed":
|
|
14
|
-
// Sandboxes are driven in it, and the
|
|
14
|
+
// Sandboxes are driven in it, and the Custodians' route home is Service DNS.
|
|
15
15
|
// <git.credentials[].token>
|
|
16
16
|
// each token env var the config names rides the instance Secret (ADR-0051):
|
|
17
17
|
// the Orchestrator materializes it into the Repo's credential Secret.
|
|
@@ -36,7 +36,6 @@ import {
|
|
|
36
36
|
IMAGES_MOUNT,
|
|
37
37
|
INSTANCE_HARNESS_PORT,
|
|
38
38
|
INSTANCE_HARNESS_SERVICE,
|
|
39
|
-
ORCHESTRATOR_SERVICE,
|
|
40
39
|
} from "./names.ts";
|
|
41
40
|
import { partsOf, type CarriedRepo } from "./parts.ts";
|
|
42
41
|
import { kubectlRepoFetches, type RepoFetches } from "./repo-fetch.ts";
|
|
@@ -65,8 +64,9 @@ export async function serverMain(opts: ServerMainOptions): Promise<RunningInstan
|
|
|
65
64
|
const signingKey =
|
|
66
65
|
env.JR2_SIGNING_KEY !== undefined ? Buffer.from(env.JR2_SIGNING_KEY, "base64") : await loadSigningKey(opts.dir);
|
|
67
66
|
|
|
68
|
-
// Deployed (JR2_NAMESPACE set):
|
|
69
|
-
// by nature, which is what lets live Sandboxes outlive orchestrator restarts (ADR-0013).
|
|
67
|
+
// Deployed (JR2_NAMESPACE set): Custodians dial the orchestrator at its own Service DNS — stable
|
|
68
|
+
// by nature, which is what lets live Sandboxes outlive orchestrator restarts (ADR-0013). `jr2 up`
|
|
69
|
+
// writes that address into the Custodian's bootstrap; nothing here passes it on.
|
|
70
70
|
const namespace = env.JR2_NAMESPACE;
|
|
71
71
|
|
|
72
72
|
// From the instance Secret when deployed; per-boot for a host-booted fixture.
|
|
@@ -128,7 +128,6 @@ export async function serverMain(opts: ServerMainOptions): Promise<RunningInstan
|
|
|
128
128
|
caBundle: config?.harness?.caBundle !== undefined,
|
|
129
129
|
// Which nodes are Sandbox nodes (ADR-0052) — the CR carries it, the operator reads no config.
|
|
130
130
|
...(config?.sandbox ? { placement: config.sandbox } : {}),
|
|
131
|
-
orchestratorUrl: `http://${ORCHESTRATOR_SERVICE}.${namespace}.svc:${port}`,
|
|
132
131
|
signingKey,
|
|
133
132
|
namespace,
|
|
134
133
|
...(opts.exec ? { exec: opts.exec } : {}),
|
package/src/tokens.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Bearer tokens (ADR-0013), because the Sandbox boundary is otherwise theater. The Harness
|
|
2
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
|
|
3
|
+
// a per-pod NetworkPolicy cannot distinguish its packets from the Custodian's. Only authentication
|
|
4
4
|
// closes this, and only the token bounds what a caller may DO (as opposed to where it may talk).
|
|
5
5
|
//
|
|
6
6
|
// Two principals, and the asymmetry between them is the whole design:
|
|
@@ -10,15 +10,16 @@
|
|
|
10
10
|
// announced once on stdout (fixtures capture it). An Agent never has it — it
|
|
11
11
|
// never enters a Sandbox.
|
|
12
12
|
//
|
|
13
|
-
// Sandbox token the
|
|
13
|
+
// Sandbox token the Custodian's credential. Authorizes exactly: deliver to `kind: "agent"`
|
|
14
14
|
// registrations whose Sandbox is THIS one. Never a Gate (a compromised Agent
|
|
15
15
|
// must not approve its own review), never another Sandbox (`coding.ts`'s iids
|
|
16
16
|
// are derivable and feature ids are readable from the Work Source, so a merely
|
|
17
17
|
// run-scoped token would let one feature's coder inject a verdict into another
|
|
18
|
-
// feature's reviewer). Delivered as a Secret
|
|
19
|
-
//
|
|
18
|
+
// feature's reviewer). Delivered as a Secret mounted into the Custodian
|
|
19
|
+
// container ALONE, which the Agent cannot read; the Harness holds its
|
|
20
|
+
// Stand-in (ADR-0059).
|
|
20
21
|
// The signed name is a POD hosting Turns, not a Sandbox CR per se: the Instance
|
|
21
|
-
// Harness's
|
|
22
|
+
// Harness's Custodian holds one signed for that placement's Service name
|
|
22
23
|
// (ADR-0031), scoping it to the Menu-only registrations placed there.
|
|
23
24
|
//
|
|
24
25
|
// And one bearer the other way (ADR-0058), which is no principal here because this server never
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
// verified by recomputing. Three things fall out that a token table would have to work for — the
|
|
33
34
|
// Orchestrator holds no per-Sandbox state, `provision` stays idempotent (re-minting yields the same
|
|
34
35
|
// token, so a re-applied Secret is a no-op), and a token minted before a restart still verifies
|
|
35
|
-
// after one, which is exactly what ADR-0012's re-attach promises the still-running
|
|
36
|
+
// after one, which is exactly what ADR-0012's re-attach promises the still-running Custodian.
|
|
36
37
|
|
|
37
38
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
38
39
|
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
@@ -42,7 +43,7 @@ import { join } from "node:path";
|
|
|
42
43
|
export type Principal =
|
|
43
44
|
/** The instance's own operator: the CLI, a human, a webhook translator holding the token. */
|
|
44
45
|
| { kind: "instance" }
|
|
45
|
-
/** One Sandbox's
|
|
46
|
+
/** One Sandbox's Custodian, speaking for the Agent in that pod — and for no one else. */
|
|
46
47
|
| { kind: "sandbox"; sandbox: string };
|
|
47
48
|
|
|
48
49
|
/** Resolve a bearer token to a principal; undefined = not a token we minted (→ 401). */
|
|
@@ -85,7 +86,7 @@ export function mintInstanceToken(): string {
|
|
|
85
86
|
* The instance's HMAC signing key, at `<dir>/.jr2/secret` (0600), created on first use.
|
|
86
87
|
*
|
|
87
88
|
* It MUST outlive the process: an Orchestrator restart leaves live Sandboxes running (ADR-0012 re-attach),
|
|
88
|
-
* and their
|
|
89
|
+
* and their Custodians still hold tokens minted by the process that died. A fresh key would reject
|
|
89
90
|
* every one of them — the Agent would silently lose its only route to its Machine.
|
|
90
91
|
*/
|
|
91
92
|
export async function loadSigningKey(dir: string): Promise<Buffer> {
|
package/src/workspace.ts
CHANGED
|
@@ -805,7 +805,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
805
805
|
endpoint: ctx.endpoint!,
|
|
806
806
|
// Derived, not remembered: the same function every port operation names the CR
|
|
807
807
|
// with, so the Sandbox the registrar publishes — and the Agent actor records on its
|
|
808
|
-
// registration — is the one the
|
|
808
|
+
// registration — is the one the Custodian's token is scoped to, by construction
|
|
809
809
|
// (ADR-0013).
|
|
810
810
|
sandbox: workspaceName(runBindingOf(system as AnyActorSystem).runId, ctx.wsId),
|
|
811
811
|
repos: out.repos,
|