@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jr2/orchestrator",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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,
@@ -35,18 +35,17 @@
35
35
  "types": "./src/index.ts",
36
36
  "dependencies": {
37
37
  "@hono/node-server": "^2.0.6",
38
- "@modelcontextprotocol/sdk": "^1.29.0",
39
38
  "elkjs": "^0.11.1",
40
39
  "hono": "^4.12.27",
41
40
  "preact": "^10.29.8",
42
41
  "ts-blank-space": "^0.9.0",
43
42
  "xstate": "^5.18.0",
44
43
  "zod": "^4.4.3",
45
- "@jr2/agent-protocol": "0.2.1"
44
+ "@jr2/agent-protocol": "0.3.0"
46
45
  },
47
46
  "devDependencies": {
48
47
  "@types/node": "^26.0.1",
49
- "@jr2/harness": "0.2.1"
48
+ "@jr2/harness": "0.3.0"
50
49
  },
51
50
  "files": [
52
51
  "bin",
package/src/actor.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  // 1. REGISTERS the invocation's event surface: `tools` names are resolved against the
5
5
  // INVOKING MACHINE's vocabulary (per-Machine scoping — an unlisted name fails at invoke time)
6
6
  // and registered in the host's table under the instance's agent address, with a deliver
7
- // closure over THIS invocation's `sendBack`. The Agent's domain tool calls arrive from its
8
- // Adapter (`/agents/<iid>/events` — ADR-0013), are validated by the table, and land on the
7
+ // closure over THIS invocation's `sendBack`. The Agent's domain tool calls arrive through its
8
+ // pod's Custodian (`/agents/<iid>/events` — ADR-0013), are validated by the table, and land on the
9
9
  // state that invoked the agent — at any nesting depth, no routing, no `instanceId` on
10
10
  // domain events (the closure IS the provenance). `/agents/<iid>/surface` serves exactly
11
11
  // this registration, so menus are state-scoped by lifecycle (ADR-0006's dynamic
@@ -150,7 +150,7 @@ export type AgentTurnInput = {
150
150
  export type AgentTurnPlacement = {
151
151
  /** The Harness base URL this Turn is admitted over. */
152
152
  endpoint?: string;
153
- /** The Sandbox whose Adapter token may deliver this Turn's picks (ADR-0013). */
153
+ /** The Sandbox whose token (held by that pod's Custodian) may deliver this Turn's picks (ADR-0013). */
154
154
  sandbox?: string;
155
155
  };
156
156
 
@@ -435,7 +435,7 @@ export function agentActorWith(
435
435
  }
436
436
  endpoint = binding.instanceHarness;
437
437
  // The registration records the PLACEMENT's name as its delivery scope — ADR-0013's
438
- // doctrine, extended to the second placement: the Instance Harness Adapter bears a token
438
+ // doctrine, extended to the second placement: the Instance Harness's Custodian holds a token
439
439
  // signed for this name (deploy.ts), so it may speak for the Turns hosted there and for no
440
440
  // Workspace's. The Instance token still may (it is the operator, tokens.ts).
441
441
  sandbox = INSTANCE_HARNESS_SERVICE;
@@ -507,7 +507,7 @@ export function agentActorWith(
507
507
  registerSurface(currentIid);
508
508
 
509
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
510
+ // delivery scope: the pod hosting this Turn. One name, both directions — the Custodian there may
511
511
  // speak for these picks, and only this bearer may drive the conversation there.
512
512
  const client = portFactory(endpoint, sandbox === undefined ? undefined : binding.harnessBearer?.(sandbox));
513
513
  const controller = new AbortController();
package/src/agent.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // The Agent definition (ADR-0018/0027): the part of an Agent a user genuinely owns — model +
2
- // instructions + workspace access — as SERIALIZABLE DATA. Everything mechanical (the Adapter
3
- // leash, the Working-tool assembly, the wire) lives in the stock Harness image (`@jr2/harness`),
2
+ // instructions + workspace access — as SERIALIZABLE DATA. Everything mechanical (the Menu,
3
+ // the Working-tool assembly, the wire) lives in the stock Harness image (`@jr2/harness`),
4
4
  // which runs the definition it is handed and re-reads it per Submission.
5
5
  //
6
6
  // A definition is NOT an Instance roster entry: a Machine CARRIES it as an actor slot —
package/src/config.ts CHANGED
@@ -105,6 +105,8 @@ export type HarnessEnvVar = {
105
105
  /** A whole-Secret/ConfigMap env injection (corev1.EnvFromSource) for the Harness container —
106
106
  * e.g. `{ secretRef: { name: "anthropic" } }` to hand a real Harness its model API key. */
107
107
  export type HarnessEnvFromSource = {
108
+ /** Put before every key's name, as corev1 does. */
109
+ prefix?: string;
108
110
  secretRef?: { name: string };
109
111
  configMapRef?: { name: string };
110
112
  };
@@ -136,8 +138,9 @@ export type HarnessProvider = {
136
138
  /** The endpoint — reachable FROM PODS (`localhost` never is; a LAN address works on kind).
137
139
  * Deployment-varying → resolve from env (`.env`), never hardcode (ADR-0019). */
138
140
  baseUrl: string;
139
- /** API key, when the endpoint wants one. May read `process.env` — `jr2 up` materializes config
140
- * env values into the instance's Secret; the literal never lands in a manifest (ADR-0019).
141
+ /** API key, when the endpoint wants one. May read `process.env`. It is a held secret
142
+ * (ADR-0059): `jr2 up` gives the value to the Custodian alone, bound to `baseUrl`'s host, and the
143
+ * Harness container holds only its Stand-in. So `baseUrl` must be `https://` when this is set.
141
144
  * Genuinely optional: an unauthenticated endpoint needs none (the Harness sends the wire
142
145
  * library's placeholder, which vLLM/Ollama ignore). */
143
146
  apiKey?: string;
@@ -153,6 +156,41 @@ export type HarnessProvider = {
153
156
  models?: Record<string, HarnessProviderModel>;
154
157
  };
155
158
 
159
+ /** Where one of pi's catalog providers sends its calls (ADR-0059) — reach, not a model choice
160
+ * (ADR-0018). The catalog keeps every model fact (limits, cost, thinking levels); this changes only
161
+ * the endpoint, for a gateway that serves the provider's own API (LiteLLM, for example). */
162
+ export type CatalogReach = {
163
+ /** Replaces `model.baseUrl` for every model of this provider — the WHOLE base the provider's API
164
+ * appends to (`https://litellm.corp.example` for `anthropic`, which appends `/v1/messages`;
165
+ * `https://litellm.corp.example/v1` for `openai`). HTTPS only. Deployment-varying → `.env`. */
166
+ baseUrl: string;
167
+ };
168
+
169
+ /** A credential the Harness uses and never holds (ADR-0059). The Harness container's env carries
170
+ * its Stand-in, `jr2-held-<name>`; the Custodian puts the value into requests toward `hosts` alone. */
171
+ export type HeldSecret = {
172
+ /** The env var the Harness container sees — `[A-Z_][A-Z0-9_]*`. It holds the Stand-in. */
173
+ name: string;
174
+ /** The value as a literal, usually read from `process.env`/`.env`. `jr2 up` writes it into the
175
+ * `jr2-held-secrets` Secret, which only the Custodian mounts. Exactly one of `value`/`valueFrom`. */
176
+ value?: string;
177
+ /** Or a key of a Secret you manage (Sealed Secrets, External Secrets). `jr2 up` checks that the
178
+ * Secret and key exist; the Custodian mounts that one key as a file, and no other container does. */
179
+ valueFrom?: { secretKeyRef: { name: string; key: string } };
180
+ /** Where the value may go. Each entry is `host`, `host:port`, or an `https://` URL (its host and
181
+ * port are taken; the path is ignored). Exact DNS names or IP literals; no wildcards. Port
182
+ * default 443. Optional ONLY for a known model-key name (`MODEL_KEYS`), where it defaults to the
183
+ * one host that provider's calls go to. */
184
+ hosts?: readonly string[];
185
+ /** Request headers the Stand-in is swapped in, lowercase. Default
186
+ * `["authorization", "x-api-key", "x-goog-api-key", "api-key"]`. */
187
+ headers?: readonly string[];
188
+ /** Optional path prefixes the value may be sent to (`/v1/messages`). Absent → every path. A
189
+ * gateway key that can reach admin routes (a LiteLLM key that can mint keys) should narrow this:
190
+ * a key minted through the Custodian is a key the Agent can take away. */
191
+ paths?: readonly string[];
192
+ };
193
+
156
194
  /** The agent-runtime section (ADR-0018): what the stock Harness image consumes alongside the
157
195
  * Agent definition each Turn hands it (ADR-0049). Moved out of `sandbox` deliberately — `sandbox`
158
196
  * is pod transport (it still CARRIES this env to the Harness container), but model concerns are
@@ -164,20 +202,29 @@ export type HarnessProvider = {
164
202
  * One definition value may still be carried by several Machines, so the variation that matters is
165
203
  * per-definition and per-invocation, which one global default serves not at all. */
166
204
  export type HarnessConfig = {
167
- /** Custom model provider, preflighted from inside the cluster by `jr2 up` (ADR-0019). */
205
+ /** Custom model provider, preflighted from inside the cluster by `jr2 up` (ADR-0019). Its
206
+ * `apiKey` is a held secret, `JR2_PROVIDER_API_KEY`, bound to `baseUrl`'s host (ADR-0059). */
168
207
  provider?: HarnessProvider;
169
- /** Env vars for the Harness container (Agent creds, e.g. ANTHROPIC_API_KEY). Values read from
170
- * `process.env`/`.env` are materialized into the instance-owned Secret by `jr2 up`. */
208
+ /** pi catalog providers whose calls go somewhere else, keyed by the catalog provider id
209
+ * (`anthropic`, `openai`, ...) — a gateway that serves the provider's own API (ADR-0059). */
210
+ catalog?: Readonly<Record<string, CatalogReach>>;
211
+ /** Credentials the Harness uses and never holds (ADR-0059): the Harness container sees a
212
+ * Stand-in, and the Custodian puts the value into requests toward the bound hosts alone. A model
213
+ * API key belongs here, never in `env`. */
214
+ heldSecrets?: readonly HeldSecret[];
215
+ /** Env vars for the Harness container. Values read from `process.env`/`.env` are materialized
216
+ * into the instance-owned Secret by `jr2 up`. The Agent can read every one of them, so a known
217
+ * model key here is refused — it goes in `heldSecrets` (ADR-0059). */
171
218
  env?: readonly HarnessEnvVar[];
172
219
  /** Whole-Secret/ConfigMap env for the Harness container — `envFrom` refs to Secrets YOU manage
173
- * (Sealed Secrets etc.); `jr2 up` preflights that each referenced Secret exists (ADR-0019). */
220
+ * (Sealed Secrets etc.); `jr2 up` preflights that each referenced Secret exists (ADR-0019), and
221
+ * refuses one that carries a known model key (ADR-0059). */
174
222
  envFrom?: readonly HarnessEnvFromSource[];
175
223
  /** Path to a PEM CA bundle, RELATIVE to the instance folder — commit the file (CA certs are
176
224
  * public; e.g. an internal CA in front of a LAN vLLM). Only `jr2 up` reads it (host-side): it
177
225
  * materializes the `jr2-ca` ConfigMap and runs the provider preflight with the same trust. The
178
- * bundle lands on the Harness container and NOWHERE else — never the Adapter, whose Orchestrator
179
- * credential has no business behind the same trust store (the `harness.env` asymmetry,
180
- * ADR-0013/0020). */
226
+ * Harness container trusts it, and so does the Custodian when it verifies a bound host
227
+ * (ADR-0020, ADR-0059). */
181
228
  caBundle?: string;
182
229
  };
183
230
 
@@ -231,7 +278,7 @@ export type JR2Config = {
231
278
  *
232
279
  * Separate from `registry` on purpose: `registry` addresses images THIS converge builds,
233
280
  * `kitRegistry` addresses artifacts the kit already published. One key for both would make every
234
- * private-registry user mirror three images they could have pulled from the home. */
281
+ * private-registry user mirror the Kit images they could have pulled from the home. */
235
282
  kitRegistry?: string;
236
283
  /** What `jr2 up` builds its images FOR — docker platform strings, e.g. `["linux/arm64"]`
237
284
  * (deployment-varying — resolve from env). Absent → derived from the cluster's schedulable nodes
@@ -260,10 +307,13 @@ export function defineConfig(c: JR2Config): JR2Config {
260
307
  /**
261
308
  * Load an instance's `jr2.config.ts` (default export). Absent file → undefined (an instance
262
309
  * can boot configless); a file that fails to IMPORT throws — a broken config must be loud,
263
- * never silently treated as "no config". The one shape check lives here, on the path the host
264
- * CLI and the in-cluster Orchestrator share: `git.credentials` is checked at runtime, because an
265
- * instance is zero-build and nothing typechecks this file before Node strips its types and
266
- * imports it.
310
+ * never silently treated as "no config". The shape checks live here, on the path the host CLI
311
+ * and the in-cluster Orchestrator share: `git.credentials`, `harness.heldSecrets` and
312
+ * `harness.catalog` are checked at runtime, because an instance is zero-build and nothing
313
+ * typechecks this file before Node strips its types and imports it. SHAPE only: a held secret's
314
+ * `value` reads `.env`, which the in-cluster evaluation does not have, so an absent value is not
315
+ * this function's to refuse — `jr2 up` checks presence and every held-secret rule host-side
316
+ * (`heldSecretsOf`, ADR-0059).
267
317
  */
268
318
  export async function loadConfig(dir: string): Promise<JR2Config | undefined> {
269
319
  const file = join(dir, "jr2.config.ts");
@@ -271,6 +321,7 @@ export async function loadConfig(dir: string): Promise<JR2Config | undefined> {
271
321
  const mod = (await import(pathToFileURL(file).href)) as { default?: JR2Config };
272
322
  if (!mod.default) throw new Error(`${file} has no default export (use \`export default defineConfig({…})\`)`);
273
323
  if (mod.default.git !== undefined) checkGit(mod.default.git, file);
324
+ if (mod.default.harness !== undefined) checkHeld(mod.default.harness, file);
274
325
  return mod.default;
275
326
  }
276
327
 
@@ -295,3 +346,57 @@ function checkGit(git: unknown, where: string): void {
295
346
  }
296
347
 
297
348
  const GIT_HINT = "an entry is { match, token?, sshKey? } (ADR-0051)";
349
+
350
+ const OptionalText = z.string().optional();
351
+ const HeldSecretShape = z
352
+ .object({
353
+ name: NonEmpty,
354
+ // `.env` values are absent in-cluster, so `undefined` must pass here — presence is `jr2 up`'s.
355
+ value: OptionalText,
356
+ valueFrom: z
357
+ .object({ secretKeyRef: z.object({ name: NonEmpty, key: NonEmpty }).strict() })
358
+ .strict()
359
+ .optional(),
360
+ hosts: z.array(z.string()).optional(),
361
+ headers: z.array(z.string()).optional(),
362
+ paths: z.array(z.string()).optional(),
363
+ })
364
+ .strict();
365
+ const CatalogReachShape = z.object({ baseUrl: z.string() }).strict();
366
+
367
+ /** `harness.heldSecrets` and `harness.catalog`, SHAPE only (ADR-0059) — by index and field, the
368
+ * `checkGit` style, so a bad entry names itself (`harness.heldSecrets[1].hosts`). */
369
+ function checkHeld(harness: unknown, where: string): void {
370
+ if (typeof harness !== "object" || harness === null || Array.isArray(harness)) {
371
+ throw new Error(`${where} at harness: expected an object`);
372
+ }
373
+ const { heldSecrets, catalog } = harness as { heldSecrets?: unknown; catalog?: unknown };
374
+ if (heldSecrets !== undefined) {
375
+ if (!Array.isArray(heldSecrets)) {
376
+ throw new Error(`${where} at harness.heldSecrets: expected an array — ${HELD_HINT}`);
377
+ }
378
+ for (const [i, entry] of heldSecrets.entries()) {
379
+ const parsed = HeldSecretShape.safeParse(entry);
380
+ if (parsed.success) continue;
381
+ const issue = parsed.error.issues[0];
382
+ const at = ["", ...(issue?.path ?? [])].map(String).join(".");
383
+ throw new Error(`${where} at harness.heldSecrets[${i}]${at}: ${issue?.message ?? "invalid"} — ${HELD_HINT}`);
384
+ }
385
+ }
386
+ if (catalog !== undefined) {
387
+ if (typeof catalog !== "object" || catalog === null || Array.isArray(catalog)) {
388
+ throw new Error(`${where} at harness.catalog: expected an object keyed by catalog provider id — ${CATALOG_HINT}`);
389
+ }
390
+ for (const [id, reach] of Object.entries(catalog)) {
391
+ const parsed = CatalogReachShape.safeParse(reach);
392
+ if (parsed.success) continue;
393
+ const issue = parsed.error.issues[0];
394
+ const at = ["", ...(issue?.path ?? [])].map(String).join(".");
395
+ throw new Error(`${where} at harness.catalog.${id}${at}: ${issue?.message ?? "invalid"} — ${CATALOG_HINT}`);
396
+ }
397
+ }
398
+ }
399
+
400
+ const HELD_HINT =
401
+ "an entry is { name, value? | valueFrom?: { secretKeyRef: { name, key } }, hosts?, headers?, paths? } (ADR-0059)";
402
+ const CATALOG_HINT = "an entry is <provider id>: { baseUrl } (ADR-0059)";
@@ -0,0 +1,312 @@
1
+ -- The Custodian's decisions (ADR-0059), run by Envoy's Lua filter on every listener it owns.
2
+ --
3
+ -- Envoy carries the bytes: the CONNECT listener, TLS on both sides, the upstream pools, streaming.
4
+ -- This script makes every choice about a credential, and nothing else. `jr2 up` prepends a
5
+ -- generated `HELD` table (names, headers, paths, bound targets — never a value) and ships the
6
+ -- whole file in the `jr2-held` ConfigMap. Envoy loads it once per Lua state at start, so the
7
+ -- top-level code below runs before any listener serves: a value that is missing or cannot ride a
8
+ -- header stops the start-up with a line that names the secret, never the value.
9
+ --
10
+ -- Four roles, one per route family, read from the route's metadata:
11
+ --
12
+ -- control the Harness's Menu and the ask, plain HTTP on loopback, toward the Orchestrator
13
+ -- egress the CONNECT listener: dial guard, then tunnel or hand to an intercept listener
14
+ -- intercept one bound host, TLS already ended: swap the Stand-in, strip the rest
15
+ -- health the kubelet's probe
16
+
17
+ local VALUES = "/etc/jr2/custodian/values/"
18
+
19
+ -- The value of one held secret, read once. One trailing line end is taken off, because
20
+ -- `kubectl create secret --from-file` writes one; anything else a header cannot carry stops us.
21
+ local function load(name)
22
+ local f = io.open(VALUES .. name, "rb")
23
+ if f == nil then
24
+ error("jr2.custodian refuse secret=" .. name .. " reason=missing-value")
25
+ end
26
+ local v = f:read("*a")
27
+ f:close()
28
+ v = v:gsub("\r?\n$", "", 1)
29
+ if v == "" then
30
+ error("jr2.custodian refuse secret=" .. name .. " reason=empty-value")
31
+ end
32
+ if v:find("[^\32-\126]") then
33
+ error("jr2.custodian refuse secret=" .. name .. " reason=value-not-printable-ascii")
34
+ end
35
+ return v
36
+ end
37
+
38
+ local STAND_IN = {}
39
+ local VALUE = {}
40
+ for _, s in ipairs(HELD.secrets) do
41
+ STAND_IN[s.name] = "jr2-held-" .. s.name
42
+ VALUE[s.name] = load(s.name)
43
+ end
44
+
45
+ -- The headers every bound target treats as a credential (`CREDENTIAL_HEADERS`, held-secrets.ts).
46
+ local CREDENTIAL = {}
47
+ for _, h in ipairs(HELD.credential) do CREDENTIAL[h] = true end
48
+
49
+ -- Everything a target's requests may carry a credential in: the fixed set, plus every header a
50
+ -- secret bound to it names.
51
+ local function credentialHeaders(bound)
52
+ local set = {}
53
+ for h in pairs(CREDENTIAL) do set[h] = true end
54
+ for _, s in ipairs(bound) do
55
+ for _, h in ipairs(s.headers) do set[h] = true end
56
+ end
57
+ return set
58
+ end
59
+
60
+ local function tag(handle, key, value)
61
+ handle:streamInfo():dynamicMetadata():set("jr2", key, value)
62
+ end
63
+
64
+ local function refuse(handle, status, reason, body)
65
+ tag(handle, "action", "refuse")
66
+ tag(handle, "reason", reason)
67
+ tag(handle, "log", true)
68
+ handle:respond({ [":status"] = tostring(status), ["content-type"] = "text/plain" }, "jr2 custodian: " .. body .. "\n")
69
+ end
70
+
71
+ -- The swap, in the two forms a client writes a key in: the Stand-in alone (`x-api-key`), or one
72
+ -- auth scheme then the Stand-in (`Authorization: Bearer`). Nothing else is ever rewritten.
73
+ local function swapped(value, standIn, secret)
74
+ if value == standIn then return secret end
75
+ local scheme, rest = value:match("^([A-Za-z][A-Za-z0-9._~+/-]*) (.+)$")
76
+ if scheme ~= nil and rest == standIn then return scheme .. " " .. secret end
77
+ return nil
78
+ end
79
+
80
+ -- One request toward a target whose secrets are `bound`: every credential header is either the
81
+ -- product of a swap or gone, and a request that swapped nothing never leaves.
82
+ local function hold(handle, headers, bound, target, path)
83
+ local creds = credentialHeaders(bound)
84
+ for h in pairs(creds) do
85
+ if headers:getNumValues(h) > 1 then
86
+ return refuse(handle, 400, "credential", target .. " got header " .. h .. " more than once")
87
+ end
88
+ end
89
+ local done = {}
90
+ local names = {}
91
+ for _, s in ipairs(bound) do
92
+ for _, h in ipairs(s.headers) do
93
+ local v = headers:get(h)
94
+ if v ~= nil and not done[h] then
95
+ local out = swapped(v, STAND_IN[s.name], VALUE[s.name])
96
+ if out ~= nil then
97
+ if s.paths ~= nil then
98
+ local allowed = false
99
+ for _, p in ipairs(s.paths) do
100
+ if path == p or path:sub(1, #p) == p and (p:sub(-1) == "/" or path:sub(#p + 1, #p + 1) == "/") then
101
+ allowed = true
102
+ end
103
+ end
104
+ if not allowed then
105
+ return refuse(handle, 403, "path", s.name .. " may not be sent to this path of " .. target)
106
+ end
107
+ end
108
+ headers:replace(h, out)
109
+ done[h] = true
110
+ names[#names + 1] = s.name
111
+ end
112
+ end
113
+ end
114
+ end
115
+ for h in pairs(creds) do
116
+ if not done[h] then headers:remove(h) end
117
+ end
118
+ if #names == 0 then
119
+ local wanted, where = {}, {}
120
+ for _, s in ipairs(bound) do
121
+ wanted[#wanted + 1] = s.name
122
+ for _, h in ipairs(s.headers) do where[#where + 1] = h end
123
+ end
124
+ return refuse(handle, 403, "stand-in", target .. " needs the stand-in of " .. table.concat(wanted, ", ") ..
125
+ " in " .. table.concat(where, ", "))
126
+ end
127
+ tag(handle, "action", "intercept")
128
+ tag(handle, "secret", table.concat(names, ","))
129
+ tag(handle, "log", true)
130
+ end
131
+
132
+ -- A CONNECT target, one spelling: lowercase, no trailing dot on the name.
133
+ local function normal(authority)
134
+ local a = authority:lower()
135
+ local host, port = a:match("^(%[[^%]]+%]):(%d+)$")
136
+ if host == nil then host, port = a:match("^([^:]+):(%d+)$") end
137
+ if host == nil then return nil end
138
+ host = host:gsub("%.$", "")
139
+ return host, port
140
+ end
141
+
142
+ local function ipv4(host)
143
+ local a, b, c, d = host:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
144
+ if a == nil then return nil end
145
+ a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
146
+ if a > 255 or b > 255 or c > 255 or d > 255 then return nil end
147
+ return a, b, c, d
148
+ end
149
+
150
+ -- An IPv6 literal as eight numbers, or nil.
151
+ local function ipv6(host)
152
+ local s = host:match("^%[(.+)%]$")
153
+ if s == nil or not s:find(":") then return nil end
154
+ local v4 = s:match(":(%d+%.%d+%.%d+%.%d+)$")
155
+ if v4 ~= nil then
156
+ local a, b, c, d = ipv4(v4)
157
+ if a == nil then return nil end
158
+ s = s:sub(1, #s - #v4) .. string.format("%x:%x", a * 256 + b, c * 256 + d)
159
+ end
160
+ local head, rest = s:match("^(.-)::(.*)$")
161
+ local function groups(part)
162
+ local out = {}
163
+ if part == "" then return out end
164
+ for g in (part .. ":"):gmatch("([^:]*):") do
165
+ if not g:match("^%x%x?%x?%x?$") then return nil end
166
+ out[#out + 1] = tonumber(g, 16)
167
+ end
168
+ return out
169
+ end
170
+ local left, right
171
+ if head == nil then
172
+ left, right = groups(s), {}
173
+ if left == nil or #left ~= 8 then return nil end
174
+ else
175
+ left, right = groups(head), groups(rest)
176
+ if left == nil or right == nil or #left + #right > 7 then return nil end
177
+ end
178
+ local out = {}
179
+ for _, g in ipairs(left) do out[#out + 1] = g end
180
+ for _ = 1, 8 - #left - #right do out[#out + 1] = 0 end
181
+ for _, g in ipairs(right) do out[#out + 1] = g end
182
+ return out
183
+ end
184
+
185
+ -- The dial guard's first half: a tunnel never reaches loopback, link-local (the cloud metadata
186
+ -- address among it), or an unspecified address — nor an IPv4-mapped spelling of one. By NAME it
187
+ -- knows only `localhost`. The second half is Envoy's: an RBAC filter judges the address the
188
+ -- dynamic forward proxy resolved a name to, and `envoy_on_response` logs its refusal (ADR-0059).
189
+ local function guarded(host)
190
+ if host == "localhost" or host:sub(-10) == ".localhost" then return true end
191
+ local a, b = ipv4(host)
192
+ if a ~= nil then
193
+ return a == 127 or a == 0 or (a == 169 and b == 254)
194
+ end
195
+ local g = ipv6(host)
196
+ if g ~= nil then
197
+ local zero = true
198
+ for i = 1, 7 do if g[i] ~= 0 then zero = false end end
199
+ if zero and (g[8] == 0 or g[8] == 1) then return true end
200
+ if g[1] >= 0xfe80 and g[1] <= 0xfebf then return true end
201
+ if g[1] == 0xfd00 and g[2] == 0xec2 and g[3] == 0 and g[4] == 0 and g[5] == 0 and g[6] == 0 and g[7] == 0
202
+ and g[8] == 0x254 then return true end
203
+ local mapped = g[1] == 0 and g[2] == 0 and g[3] == 0 and g[4] == 0 and g[5] == 0 and g[6] == 0xffff
204
+ if mapped then
205
+ local x, y = math.floor(g[7] / 256), g[7] % 256
206
+ return x == 127 or x == 0 or (x == 169 and y == 254)
207
+ end
208
+ end
209
+ return false
210
+ end
211
+
212
+ local function egress(handle, headers)
213
+ if headers:get(":method") ~= "CONNECT" then
214
+ return refuse(handle, 405, "method", "this listener speaks CONNECT alone")
215
+ end
216
+ local authority = headers:get(":authority") or ""
217
+ local host, port = normal(authority)
218
+ if host == nil then
219
+ return refuse(handle, 400, "target", "a CONNECT names host:port")
220
+ end
221
+ local target = host .. ":" .. port
222
+ if target ~= authority then
223
+ headers:replace(":authority", target)
224
+ handle:clearRouteCache()
225
+ end
226
+ if HELD.targets[target] ~= nil then
227
+ -- Handed to the intercept listener, which logs each request it carries.
228
+ return
229
+ end
230
+ if guarded(host) then
231
+ return refuse(handle, 403, "guard", target .. " is loopback, link-local or unspecified")
232
+ end
233
+ tag(handle, "action", "tunnel")
234
+ tag(handle, "log", true)
235
+ end
236
+
237
+ -- What a bound host is sent. TRACE is not: a server that answers it echoes the request, the
238
+ -- swapped value among it, back to the Agent. Neither is a WebDAV or other extension method.
239
+ local METHODS = { GET = true, HEAD = true, POST = true, PUT = true, PATCH = true, DELETE = true, OPTIONS = true }
240
+
241
+ local function intercept(handle, headers, target)
242
+ if not METHODS[headers:get(":method")] then
243
+ return refuse(handle, 405, "method", target .. " takes GET, HEAD, POST, PUT, PATCH, DELETE or OPTIONS here")
244
+ end
245
+ if headers:get("upgrade") ~= nil then
246
+ return refuse(handle, 501, "upgrade", target .. " takes no protocol upgrade here")
247
+ end
248
+ local host = (headers:get(":authority") or ""):lower()
249
+ local want = target
250
+ if target:sub(-4) == ":443" then
251
+ if host == target:sub(1, -5) then host = target end
252
+ end
253
+ if host ~= want then
254
+ return refuse(handle, 421, "host", "Host does not name " .. target)
255
+ end
256
+ local path = (headers:get(":path") or "/"):match("^[^?]*")
257
+ if path:lower():find("%%2[ef]") then
258
+ return refuse(handle, 400, "path", "an encoded / or . in the path")
259
+ end
260
+ for _, h in ipairs({ "te", "trailer", "keep-alive", "proxy-connection" }) do headers:remove(h) end
261
+ return hold(handle, headers, HELD.targets[target], target, path)
262
+ end
263
+
264
+ local function control(handle, headers, route)
265
+ if route == "fetch" then
266
+ local sandbox = os.getenv("JR2_SANDBOX")
267
+ if sandbox == nil or sandbox == "" then
268
+ tag(handle, "action", "refuse")
269
+ tag(handle, "reason", "no-sandbox")
270
+ tag(handle, "log", true)
271
+ return handle:respond({ [":status"] = "404", ["content-type"] = "application/json" },
272
+ '{"error":"this pod serves no Sandbox, so it mounts no Repo to fetch (ADR-0053)"}')
273
+ end
274
+ headers:replace(":path", "/sandboxes/" .. sandbox .. "/fetch")
275
+ end
276
+ return hold(handle, headers, HELD.control, "orchestrator", headers:get(":path"))
277
+ end
278
+
279
+ local READY = false
280
+
281
+ -- A refusal by resolved address comes from the RBAC filter after this one: the one 403 a request
282
+ -- this script let through as a tunnel can get. It is logged as a refusal by spelling is.
283
+ function envoy_on_response(handle)
284
+ if handle:metadata():get("role") ~= "egress" then return end
285
+ if handle:headers():get(":status") ~= "403" then return end
286
+ local jr2 = handle:streamInfo():dynamicMetadata():get("jr2")
287
+ if jr2 == nil or jr2.action ~= "tunnel" then return end
288
+ tag(handle, "action", "refuse")
289
+ tag(handle, "reason", "guard")
290
+ end
291
+
292
+ function envoy_on_request(handle)
293
+ local meta = handle:metadata()
294
+ local role = meta:get("role")
295
+ local headers = handle:headers()
296
+ if role == "egress" then return egress(handle, headers) end
297
+ if role == "intercept" then return intercept(handle, headers, meta:get("target")) end
298
+ if role == "control" then return control(handle, headers, meta:get("route")) end
299
+ if role == "health" then
300
+ if not READY then
301
+ READY = true
302
+ local names = {}
303
+ for _, s in ipairs(HELD.secrets) do names[#names + 1] = s.name end
304
+ io.stdout:write("jr2.custodian ready secrets=" .. table.concat(names, ",") .. " hosts=" .. HELD.hosts .. "\n")
305
+ io.stdout:flush()
306
+ end
307
+ return handle:respond({ [":status"] = "200", ["content-type"] = "text/plain" }, "ok\n")
308
+ end
309
+ if role == "deny" then
310
+ return refuse(handle, 404, "route", "no such route")
311
+ end
312
+ end