nylorun 0.6.0-beta → 0.8.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +77 -73
  3. package/dist/cli.js +7 -2
  4. package/dist/doctor.d.ts +2 -4
  5. package/dist/doctor.js +12 -19
  6. package/dist/sandbox/cluster-file.d.ts +47 -0
  7. package/dist/sandbox/cluster-file.js +55 -0
  8. package/dist/sandbox/commands.d.ts +41 -0
  9. package/dist/sandbox/commands.js +281 -0
  10. package/dist/sandbox/kubectl.d.ts +39 -0
  11. package/dist/sandbox/kubectl.js +70 -0
  12. package/dist/sandbox/manifests.d.ts +41 -0
  13. package/dist/sandbox/manifests.js +138 -0
  14. package/dist/sandbox/probe.d.ts +44 -0
  15. package/dist/sandbox/probe.js +137 -0
  16. package/dist/stack/commands.d.ts +56 -9
  17. package/dist/stack/commands.js +167 -123
  18. package/dist/stack/compose-file.d.ts +42 -5
  19. package/dist/stack/compose-file.js +208 -21
  20. package/dist/stack/env-file.d.ts +48 -6
  21. package/dist/stack/env-file.js +81 -9
  22. package/dist/stack/host-files.js +11 -1
  23. package/dist/stack/images.d.ts +8 -4
  24. package/dist/stack/images.js +8 -3
  25. package/dist/stack/index.d.ts +4 -2
  26. package/dist/stack/index.js +7 -3
  27. package/dist/stack/paths.d.ts +15 -0
  28. package/dist/stack/paths.js +7 -0
  29. package/dist/stack/ports.d.ts +0 -2
  30. package/dist/stack/ports.js +0 -2
  31. package/dist/stack/prepare.d.ts +10 -4
  32. package/dist/stack/prepare.js +20 -3
  33. package/dist/stack/sandbox.d.ts +4 -0
  34. package/dist/stack/sandbox.js +106 -0
  35. package/dist/stack/stacks.d.ts +8 -0
  36. package/dist/stack/stacks.js +19 -0
  37. package/dist/stack/studio-login.d.ts +2 -2
  38. package/dist/stack/studio-login.js +2 -2
  39. package/package.json +5 -5
  40. package/dist/stack/proxy.d.ts +0 -78
  41. package/dist/stack/proxy.js +0 -224
@@ -0,0 +1,281 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { CliError } from "../errors.js";
4
+ import { parseStackFlags, reservedSandboxPorts, restartTenant, selectTenant, } from "../stack/commands.js";
5
+ import { dockerPreflight } from "../stack/docker.js";
6
+ import { stackImages } from "../stack/images.js";
7
+ import { choosePort } from "../stack/ports.js";
8
+ import { readStackEnv } from "../stack/prepare.js";
9
+ import { apiRoute, readClusterFile, removeClusterFiles, writeClusterFiles, } from "./cluster-file.js";
10
+ import { contexts, kube } from "./kubectl.js";
11
+ import { CONTROLLER_DEPLOYMENT, CONTROLLER_NAMESPACE, CONTROLLER_SHA256, CONTROLLER_URL, CONTROLLER_VERSION, networkPolicy, SANDBOX_API_VERSION, SANDBOX_CRD, tenantLabel, tenantManifests, tenantNamespace, TOKEN_SECRET, } from "./manifests.js";
12
+ import { assertIPv4, runProbe } from "./probe.js";
13
+ export const sandboxUsage = ` sandbox enable --context <name> [--tenant <name>] [--host-address <ip>] [--bind-address <ip>] [--no-pull]
14
+ run the Tenant's sandboxes as pods on that kubeconfig context (agent-sandbox ${CONTROLLER_VERSION})
15
+ sandbox disable [--tenant <name>] [--delete-namespace]
16
+ stop the sandboxes service and forget the cluster
17
+ sandbox status [--tenant <name>] [--json]
18
+ the cluster, the sandboxes service's readiness and what it reports`;
19
+ const ENABLE_USAGE = "nylorun sandbox enable --context <name> [--tenant <name>] [--host-address <ip>] [--bind-address <ip>] [--no-pull]";
20
+ /** The default workload image (D36), pre-pulled with the Runtime image. */
21
+ export const DEFAULT_SANDBOX_IMAGE = "python:3.13-slim";
22
+ /** Preferred host ports for the Harness API, gates and egress (pods reach them). */
23
+ const PREFERRED_PORTS = { harness: 8790, gates: 8791, egress: 8792 };
24
+ const usageError = (message) => new CliError(message, 2);
25
+ /** The context's API server and CA, from the kubeconfig. */
26
+ async function readKubeconfig(k) {
27
+ const config = JSON.parse(await k.check("Reading the kubeconfig", ["config", "view", "--minify", "--raw", "-o", "json"]));
28
+ const cluster = config.clusters?.[0]?.cluster;
29
+ if (!cluster?.server)
30
+ throw new CliError(`Context ${k.context} has no API server.`, 6);
31
+ let caData = cluster["certificate-authority-data"];
32
+ if (!caData && cluster["certificate-authority"])
33
+ caData = (await readFile(cluster["certificate-authority"])).toString("base64");
34
+ if (!caData)
35
+ throw new CliError(`Context ${k.context} has no cluster CA: the sandboxes service only talks to an API server it can verify.`, 6);
36
+ return { server: cluster.server, caData };
37
+ }
38
+ /**
39
+ * agent-sandbox: install the pinned release when absent; accept it when the pinned version
40
+ * serves v1beta1; refuse anything else. Never upgrades or uninstalls.
41
+ */
42
+ async function ensureController(k, fetch, err) {
43
+ const crd = await k.getJson(["crd", SANDBOX_CRD]);
44
+ if (crd) {
45
+ if (!crd.spec?.versions?.some((v) => v.name === SANDBOX_API_VERSION && v.served))
46
+ throw new CliError(`Refusing context ${k.context}: its agent-sandbox does not serve agents.x-k8s.io/${SANDBOX_API_VERSION}. Nylorun needs agent-sandbox ${CONTROLLER_VERSION}.`, 6);
47
+ const deployment = await k.getJson(["deployment", CONTROLLER_DEPLOYMENT, "-n", CONTROLLER_NAMESPACE]);
48
+ const image = deployment?.spec?.template?.spec?.containers?.[0]?.image ?? "";
49
+ const version = /:(v[^@:]+)(?:@.*)?$/.exec(image)?.[1];
50
+ if (version !== CONTROLLER_VERSION)
51
+ throw new CliError(`Refusing context ${k.context}: agent-sandbox ${version ?? "(controller not found)"} is installed; Nylorun needs ${CONTROLLER_VERSION} and never upgrades or removes it.`, 6);
52
+ return;
53
+ }
54
+ err(`Installing agent-sandbox ${CONTROLLER_VERSION} into ${k.context}.`);
55
+ const response = await fetch(CONTROLLER_URL, { signal: AbortSignal.timeout(60_000) });
56
+ if (!response.ok)
57
+ throw new CliError(`Downloading ${CONTROLLER_URL} failed (HTTP ${response.status}).`, 1);
58
+ const manifest = await response.text();
59
+ const digest = createHash("sha256").update(manifest).digest("hex");
60
+ if (digest !== CONTROLLER_SHA256)
61
+ throw new CliError(`${CONTROLLER_URL} has sha256 ${digest}, not the pinned ${CONTROLLER_SHA256}; not applying it.`, 1);
62
+ await k.check("Installing agent-sandbox", ["apply", "--server-side", "-f", "-"], { input: manifest });
63
+ await k.check("Waiting for the Sandbox CRD", ["wait", "--for=condition=Established", `crd/${SANDBOX_CRD}`, "--timeout=60s"]);
64
+ await k.check("Waiting for the agent-sandbox controller", ["rollout", "status", `deployment/${CONTROLLER_DEPLOYMENT}`, "-n", CONTROLLER_NAMESPACE, "--timeout=180s"], { timeoutMs: 200_000 });
65
+ }
66
+ /** The ServiceAccount token the token controller writes into the Secret. */
67
+ async function serviceAccountToken(k, namespace, pollMs) {
68
+ const deadline = Date.now() + 60_000;
69
+ for (;;) {
70
+ const secret = await k.getJson(["secret", TOKEN_SECRET, "-n", namespace]);
71
+ if (secret?.data?.token)
72
+ return Buffer.from(secret.data.token, "base64").toString("utf8");
73
+ if (Date.now() > deadline)
74
+ throw new CliError(`The ServiceAccount token in ${namespace}/${TOKEN_SECRET} was not issued.`, 6);
75
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
76
+ }
77
+ }
78
+ /**
79
+ * Steps 2–8 of `enable` against one context: controller, namespace and RBAC, NetworkPolicy
80
+ * probe, the namespace's policy, pre-pull. Returns what cluster.json records.
81
+ */
82
+ export async function installCluster(deps, input) {
83
+ const { stack } = deps;
84
+ const k = kube(deps.kubectl, input.context);
85
+ const pollMs = deps.pollMs ?? 1000;
86
+ const { server, caData } = await readKubeconfig(k);
87
+ let route;
88
+ try {
89
+ route = apiRoute(server);
90
+ }
91
+ catch (error) {
92
+ throw new CliError(`Context ${input.context}: ${error instanceof Error ? error.message : String(error)}`, 6);
93
+ }
94
+ await ensureController(k, deps.fetch ?? stack.fetch, stack.err);
95
+ const namespace = tenantNamespace(input.tenant);
96
+ const existing = await k.getJson(["namespace", namespace]);
97
+ const owner = existing?.metadata?.labels?.["dev.nylorun.tenant"];
98
+ if (existing && owner !== tenantLabel(input.tenant))
99
+ throw new CliError(`Namespace ${namespace} exists in ${input.context} but belongs to ${owner ?? "something else"}.`, 6);
100
+ await k.apply("Creating the Tenant's namespace and RBAC", tenantManifests(namespace, input.tenant));
101
+ const token = await serviceAccountToken(k, namespace, pollMs);
102
+ stack.err(`Probing NetworkPolicy enforcement in ${input.context}.`);
103
+ const probe = await runProbe({
104
+ kube: k,
105
+ docker: stack.docker,
106
+ ports: stack.ports,
107
+ err: stack.err,
108
+ pollMs,
109
+ ...(deps.policyTimeoutMs !== undefined ? { policyTimeoutMs: deps.policyTimeoutMs } : {}),
110
+ }, {
111
+ ...(input.hostAddress ? { hostAddress: input.hostAddress } : {}),
112
+ ...(input.bindAddress ? { bindAddress: input.bindAddress } : {}),
113
+ prePull: input.prePull,
114
+ });
115
+ await k.apply("Applying the namespace's NetworkPolicy", [
116
+ networkPolicy(namespace, probe.hostAddress, [input.ports.harness, input.ports.gates, input.ports.egress]),
117
+ ]);
118
+ return {
119
+ cluster: {
120
+ context: input.context,
121
+ server,
122
+ dial: route.dial,
123
+ tlsServerName: route.tlsServerName,
124
+ caData,
125
+ namespace,
126
+ controllerVersion: CONTROLLER_VERSION,
127
+ hostAddress: probe.hostAddress,
128
+ bindAddress: probe.bindAddress,
129
+ ports: input.ports,
130
+ networkPolicy: { enforced: probe.enforced, probedAt: probe.probedAt },
131
+ enabledAt: new Date().toISOString(),
132
+ },
133
+ token,
134
+ notPulled: probe.notPulled,
135
+ };
136
+ }
137
+ async function enable(deps, args) {
138
+ const { stack } = deps;
139
+ const flags = parseStackFlags(args, { booleans: ["--no-pull"], values: ["--context", "--tenant", "--host-address", "--bind-address"] }, ENABLE_USAGE);
140
+ if (flags.rest.length)
141
+ throw usageError(`Usage: ${ENABLE_USAGE}`);
142
+ const context = flags.values.get("--context");
143
+ if (!context)
144
+ throw usageError(`nylorun sandbox enable needs --context <name>: the kubeconfig context to run pods in (never the current one implicitly). Usage: ${ENABLE_USAGE}`);
145
+ const hostAddress = flags.values.get("--host-address");
146
+ const bindAddress = flags.values.get("--bind-address");
147
+ if (hostAddress)
148
+ assertIPv4(hostAddress, "--host-address");
149
+ if (bindAddress)
150
+ assertIPv4(bindAddress, "--bind-address");
151
+ const name = flags.values.get("--tenant");
152
+ const tenant = await selectTenant(stack, name === undefined ? {} : { name });
153
+ await dockerPreflight(stack.docker);
154
+ const known = await contexts(deps.kubectl);
155
+ if (!known.includes(context))
156
+ throw usageError(`Context ${context} is not in your kubeconfig. Contexts: ${known.join(", ") || "(none)"}.`);
157
+ const persisted = (await readStackEnv(tenant.paths))?.sandboxes;
158
+ const reserved = await reservedSandboxPorts(stack, tenant.paths.root);
159
+ const taken = new Set();
160
+ const pick = async (preferred, kept) => {
161
+ const port = await choosePort(stack.ports, preferred, kept, new Set([...taken, ...(kept === undefined ? reserved : [])]));
162
+ taken.add(port);
163
+ return port;
164
+ };
165
+ const ports = {
166
+ harness: await pick(PREFERRED_PORTS.harness, persisted?.harnessPort),
167
+ gates: await pick(PREFERRED_PORTS.gates, persisted?.gatesPort),
168
+ egress: await pick(PREFERRED_PORTS.egress, persisted?.egressPort),
169
+ };
170
+ const images = stackImages(stack.env, { runtime: stack.runtimeVersion, studio: stack.studioVersion });
171
+ const installed = await installCluster(deps, {
172
+ context,
173
+ tenant: tenant.name,
174
+ ...(hostAddress ? { hostAddress } : {}),
175
+ ...(bindAddress ? { bindAddress } : {}),
176
+ prePull: flags.booleans.has("--no-pull") ? [] : [DEFAULT_SANDBOX_IMAGE, images.runtime],
177
+ ports,
178
+ });
179
+ for (const image of installed.notPulled)
180
+ stack.err(`Warning: could not pre-pull ${image} on the cluster's nodes; the first sandbox using it pulls it (or load it, e.g. kind load docker-image ${image}).`);
181
+ await writeClusterFiles(tenant.paths.root, installed.cluster, installed.token);
182
+ const settings = {
183
+ token: persisted?.token ?? randomBytes(32).toString("hex"),
184
+ image: images.sandboxes,
185
+ harnessPort: ports.harness,
186
+ gatesPort: ports.gates,
187
+ egressPort: ports.egress,
188
+ hostAddress: installed.cluster.hostAddress,
189
+ bind: installed.cluster.bindAddress,
190
+ };
191
+ await restartTenant(stack, { name: tenant.name, sandboxes: settings });
192
+ const ready = await stack.docker.stream(tenant.compose("up", "--detach", "--wait", "--wait-timeout", "120", "sandboxes"));
193
+ if (ready !== 0)
194
+ throw new CliError(`The sandboxes service did not become ready (image ${images.sandboxes}). See "nylorun logs sandboxes" and "nylorun sandbox status".`, 7);
195
+ const c = installed.cluster;
196
+ stack.out(`Sandboxes ${tenant.name} enabled on ${c.context} (namespace ${c.namespace}, agent-sandbox ${c.controllerVersion})`);
197
+ stack.out(`Pods reach ${c.hostAddress} on ports ${c.ports.harness}, ${c.ports.gates}, ${c.ports.egress}; NetworkPolicy enforced (probed ${c.networkPolicy.probedAt})`);
198
+ return 0;
199
+ }
200
+ async function disable(deps, args) {
201
+ const { stack } = deps;
202
+ const usage = "nylorun sandbox disable [--tenant <name>] [--delete-namespace]";
203
+ const flags = parseStackFlags(args, { booleans: ["--delete-namespace"], values: ["--tenant"] }, usage);
204
+ if (flags.rest.length)
205
+ throw usageError(`Usage: ${usage}`);
206
+ const name = flags.values.get("--tenant");
207
+ const tenant = await selectTenant(stack, name === undefined ? {} : { name });
208
+ const cluster = await readClusterFile(tenant.paths.root);
209
+ const persisted = (await readStackEnv(tenant.paths))?.sandboxes;
210
+ if (!cluster && !persisted) {
211
+ stack.out(`Sandboxes are not enabled for Tenant ${tenant.name}.`);
212
+ return 0;
213
+ }
214
+ await dockerPreflight(stack.docker);
215
+ // While compose.yaml still names the service; absent after an earlier failed disable.
216
+ await stack.docker.run(tenant.compose("rm", "--stop", "--force", "sandboxes"));
217
+ await removeClusterFiles(tenant.paths.root);
218
+ await restartTenant(stack, { name: tenant.name, sandboxes: null });
219
+ if (flags.booleans.has("--delete-namespace") && cluster) {
220
+ await kube(deps.kubectl, cluster.context).check("Deleting the namespace", [
221
+ "delete", "namespace", cluster.namespace, "--ignore-not-found", "--wait=false",
222
+ ]);
223
+ stack.out(`Deleting namespace ${cluster.namespace} in ${cluster.context}.`);
224
+ }
225
+ stack.out(`Sandboxes disabled for Tenant ${tenant.name}.${cluster && !flags.booleans.has("--delete-namespace") ? ` Namespace ${cluster.namespace} in ${cluster.context} is kept (--delete-namespace removes it and every sandbox in it).` : ""}`);
226
+ return 0;
227
+ }
228
+ /** Asks the sandboxes service from inside the runtime container, which holds the token. */
229
+ const INFO_SCRIPT = "fetch(process.env.NYLORUN_SANDBOXES_URL+'/v1/info',{headers:{authorization:'Bearer '+process.env.NYLORUN_SANDBOXES_TOKEN},signal:AbortSignal.timeout(5000)}).then(async r=>{process.stdout.write(await r.text());process.exit(r.ok?0:1)},()=>process.exit(1))";
230
+ async function status(deps, args) {
231
+ const { stack } = deps;
232
+ const usage = "nylorun sandbox status [--tenant <name>] [--json]";
233
+ const flags = parseStackFlags(args, { booleans: ["--json"], values: ["--tenant"] }, usage);
234
+ if (flags.rest.length)
235
+ throw usageError(`Usage: ${usage}`);
236
+ const name = flags.values.get("--tenant");
237
+ const tenant = await selectTenant(stack, name === undefined ? {} : { name });
238
+ const cluster = await readClusterFile(tenant.paths.root);
239
+ if (!cluster) {
240
+ if (flags.booleans.has("--json"))
241
+ stack.out(JSON.stringify({ enabled: false }, null, 2));
242
+ else
243
+ stack.out(`Sandboxes are not enabled for Tenant ${tenant.name}. Run "nylorun sandbox enable --context <name>".`);
244
+ return 3;
245
+ }
246
+ await dockerPreflight(stack.docker);
247
+ const ready = (await stack.docker.run(tenant.compose("exec", "-T", "sandboxes", "/sandboxes", "healthcheck"))).code === 0;
248
+ const answer = await stack.docker.run(tenant.compose("exec", "-T", "runtime", "node", "-e", INFO_SCRIPT));
249
+ let info;
250
+ try {
251
+ info = answer.code === 0 ? JSON.parse(answer.stdout) : undefined;
252
+ }
253
+ catch {
254
+ info = undefined;
255
+ }
256
+ const { caData: _ca, ...recorded } = cluster;
257
+ if (flags.booleans.has("--json")) {
258
+ stack.out(JSON.stringify({ enabled: true, cluster: recorded, ready, ...(info ? { info } : {}) }, null, 2));
259
+ }
260
+ else {
261
+ stack.out(`Context ${cluster.context} (${cluster.server}, dialled at ${cluster.dial})`);
262
+ stack.out(`Namespace ${cluster.namespace} agent-sandbox ${cluster.controllerVersion}`);
263
+ stack.out(`Pods reach ${cluster.hostAddress} ports ${cluster.ports.harness}, ${cluster.ports.gates}, ${cluster.ports.egress} (bound on ${cluster.bindAddress})`);
264
+ stack.out(`Policy ${cluster.networkPolicy.enforced ? "enforced" : "NOT enforced"} (probed ${cluster.networkPolicy.probedAt})`);
265
+ stack.out(`Service ${ready ? "ready" : "not ready"}${info ? "" : " (no /v1/info answer)"}`);
266
+ }
267
+ return ready ? 0 : 3;
268
+ }
269
+ const COMMANDS = {
270
+ enable,
271
+ disable,
272
+ status,
273
+ };
274
+ /** `nylorun sandbox <enable|disable|status>`. */
275
+ export async function runSandboxCommand(args, deps) {
276
+ const [name, ...rest] = args;
277
+ const command = name === undefined ? undefined : COMMANDS[name];
278
+ if (!command)
279
+ throw usageError(`Usage:\n${sandboxUsage}`);
280
+ return await command(deps, rest);
281
+ }
@@ -0,0 +1,39 @@
1
+ export interface KubectlResult {
2
+ code: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ /** The kubectl binary was not found. */
6
+ missing?: boolean;
7
+ }
8
+ /** Runs `kubectl`; replaced by a fake in tests. */
9
+ export interface KubectlRunner {
10
+ run(args: readonly string[], options?: {
11
+ input?: string;
12
+ timeoutMs?: number;
13
+ }): Promise<KubectlResult>;
14
+ }
15
+ /** The real `kubectl` on PATH. */
16
+ export declare function spawnKubectl(env: Readonly<Record<string, string | undefined>>, binary?: string): KubectlRunner;
17
+ /**
18
+ * kubectl bound to one context: every call passes `--context`, so the current context is
19
+ * never used.
20
+ */
21
+ export interface Kube {
22
+ context: string;
23
+ run(args: readonly string[], options?: {
24
+ input?: string;
25
+ timeoutMs?: number;
26
+ }): Promise<KubectlResult>;
27
+ /** Run and fail with a CliError naming `what` on a non-zero exit. */
28
+ check(what: string, args: readonly string[], options?: {
29
+ input?: string;
30
+ timeoutMs?: number;
31
+ }): Promise<string>;
32
+ /** `get -o json`; undefined when the object does not exist. */
33
+ getJson<T = unknown>(args: readonly string[]): Promise<T | undefined>;
34
+ /** `apply --server-side` of manifests. */
35
+ apply(what: string, manifests: readonly object[]): Promise<void>;
36
+ }
37
+ export declare function kube(runner: KubectlRunner, context: string): Kube;
38
+ /** The kubeconfig's contexts (`kubectl config get-contexts -o name`). */
39
+ export declare function contexts(runner: KubectlRunner): Promise<string[]>;
@@ -0,0 +1,70 @@
1
+ import { spawn } from "node:child_process";
2
+ import { CliError } from "../errors.js";
3
+ /** The real `kubectl` on PATH. */
4
+ export function spawnKubectl(env, binary = "kubectl") {
5
+ const childEnv = Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== undefined));
6
+ return {
7
+ run(args, options = {}) {
8
+ return new Promise((resolve) => {
9
+ let stdout = "";
10
+ let stderr = "";
11
+ const child = spawn(binary, [...args], { env: childEnv, stdio: ["pipe", "pipe", "pipe"] });
12
+ const timer = setTimeout(() => child.kill("SIGKILL"), options.timeoutMs ?? 300_000);
13
+ child.stdout.on("data", (chunk) => (stdout += chunk));
14
+ child.stderr.on("data", (chunk) => (stderr += chunk));
15
+ child.once("error", (error) => {
16
+ clearTimeout(timer);
17
+ resolve({
18
+ code: 127,
19
+ stdout,
20
+ stderr: error.message,
21
+ ...(error.code === "ENOENT" ? { missing: true } : {}),
22
+ });
23
+ });
24
+ child.once("close", (code) => {
25
+ clearTimeout(timer);
26
+ resolve({ code: code ?? 1, stdout, stderr });
27
+ });
28
+ child.stdin.on("error", () => { });
29
+ child.stdin.end(options.input ?? "");
30
+ });
31
+ },
32
+ };
33
+ }
34
+ export function kube(runner, context) {
35
+ const run = (args, options) => runner.run(["--context", context, ...args], options);
36
+ const check = async (what, args, options) => {
37
+ const result = await run(args, options);
38
+ if (result.missing)
39
+ throw new CliError("kubectl is not installed or not on PATH. Install it (https://kubernetes.io/docs/tasks/tools/).", 1);
40
+ if (result.code !== 0)
41
+ throw new CliError(`${what} failed (kubectl --context ${context} ${args.join(" ")}): ${result.stderr.trim() || result.stdout.trim()}`, 1);
42
+ return result.stdout;
43
+ };
44
+ return {
45
+ context,
46
+ run,
47
+ check,
48
+ async getJson(args) {
49
+ const result = await run(["get", ...args, "-o", "json"]);
50
+ if (result.code !== 0) {
51
+ if (/NotFound|not found/i.test(result.stderr))
52
+ return undefined;
53
+ throw new CliError(`kubectl --context ${context} get ${args.join(" ")}: ${result.stderr.trim()}`, 1);
54
+ }
55
+ return JSON.parse(result.stdout);
56
+ },
57
+ async apply(what, manifests) {
58
+ await check(what, ["apply", "--server-side", "--field-manager", "nylorun", "--force-conflicts", "-f", "-"], { input: JSON.stringify({ apiVersion: "v1", kind: "List", items: manifests }) });
59
+ },
60
+ };
61
+ }
62
+ /** The kubeconfig's contexts (`kubectl config get-contexts -o name`). */
63
+ export async function contexts(runner) {
64
+ const result = await runner.run(["config", "get-contexts", "-o", "name"]);
65
+ if (result.missing)
66
+ throw new CliError("kubectl is not installed or not on PATH. Install it (https://kubernetes.io/docs/tasks/tools/).", 1);
67
+ if (result.code !== 0)
68
+ throw new CliError(`kubectl config get-contexts failed: ${result.stderr.trim()}`, 1);
69
+ return result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
70
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * What `nylorun sandbox enable` installs into a cluster. The agent-sandbox controller is
3
+ * pinned (D32, Experiment 2): v1.0.5, API agents.x-k8s.io/v1beta1, its release manifest
4
+ * verified by sha256 before it is applied. It is never upgraded or uninstalled here.
5
+ */
6
+ export declare const CONTROLLER_VERSION = "v1.0.5";
7
+ export declare const CONTROLLER_URL = "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/v1.0.5/sandbox.yaml";
8
+ export declare const CONTROLLER_SHA256 = "e89fd95c0aa57609fa24be4112bd52ce67fe8939ecf6f3c17edf2f1e8f1eb860";
9
+ export declare const CONTROLLER_NAMESPACE = "agent-sandbox-system";
10
+ export declare const CONTROLLER_DEPLOYMENT = "agent-sandbox-controller";
11
+ export declare const SANDBOX_CRD = "sandboxes.agents.x-k8s.io";
12
+ export declare const SANDBOX_API_VERSION = "v1beta1";
13
+ export declare const SERVICE_ACCOUNT = "nylorun-sandboxes";
14
+ export declare const TOKEN_SECRET = "nylorun-sandboxes-token";
15
+ export declare const NETWORK_POLICY = "nylorun-sandboxes";
16
+ /**
17
+ * The Tenant's namespace, `nylorun-sbx-<tenant>`: a DNS label, so `_` becomes `-`; a name
18
+ * that had to change (or is too long) gets a short hash so two Tenants never share one.
19
+ */
20
+ export declare function tenantNamespace(tenant: string): string;
21
+ /** The `dev.nylorun.tenant` label value: a Tenant name, cut to a label's 63 characters. */
22
+ export declare function tenantLabel(tenant: string): string;
23
+ /** Namespace, ServiceAccount, Role, RoleBinding and the ServiceAccount's token Secret. */
24
+ export declare function tenantManifests(namespace: string, tenant: string): object[];
25
+ /**
26
+ * Every pod in the namespace: no ingress, and egress only to the Docker host on the
27
+ * Harness API, gates and egress ports. No DNS: pods address the host by IP (D42).
28
+ */
29
+ export declare function networkPolicy(namespace: string, hostAddress: string, ports: readonly number[], options?: {
30
+ name?: string;
31
+ podSelector?: Record<string, string>;
32
+ }): object;
33
+ export declare const PROBE_IMAGE = "busybox:1.37.0";
34
+ /** A small busybox pod; `nodeName` pins it (pre-pull), `serve` runs httpd on 8080. */
35
+ export declare function probePod(namespace: string, name: string, options: {
36
+ role: string;
37
+ serve?: boolean;
38
+ image?: string;
39
+ nodeName?: string;
40
+ command?: string[];
41
+ }): object;
@@ -0,0 +1,138 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * What `nylorun sandbox enable` installs into a cluster. The agent-sandbox controller is
4
+ * pinned (D32, Experiment 2): v1.0.5, API agents.x-k8s.io/v1beta1, its release manifest
5
+ * verified by sha256 before it is applied. It is never upgraded or uninstalled here.
6
+ */
7
+ export const CONTROLLER_VERSION = "v1.0.5";
8
+ export const CONTROLLER_URL = `https://github.com/kubernetes-sigs/agent-sandbox/releases/download/${CONTROLLER_VERSION}/sandbox.yaml`;
9
+ export const CONTROLLER_SHA256 = "e89fd95c0aa57609fa24be4112bd52ce67fe8939ecf6f3c17edf2f1e8f1eb860";
10
+ export const CONTROLLER_NAMESPACE = "agent-sandbox-system";
11
+ export const CONTROLLER_DEPLOYMENT = "agent-sandbox-controller";
12
+ export const SANDBOX_CRD = "sandboxes.agents.x-k8s.io";
13
+ export const SANDBOX_API_VERSION = "v1beta1";
14
+ export const SERVICE_ACCOUNT = "nylorun-sandboxes";
15
+ export const TOKEN_SECRET = "nylorun-sandboxes-token";
16
+ export const NETWORK_POLICY = "nylorun-sandboxes";
17
+ const TENANT_LABEL = "dev.nylorun.tenant";
18
+ const MANAGED = { "app.kubernetes.io/managed-by": "nylorun" };
19
+ /**
20
+ * The Tenant's namespace, `nylorun-sbx-<tenant>`: a DNS label, so `_` becomes `-`; a name
21
+ * that had to change (or is too long) gets a short hash so two Tenants never share one.
22
+ */
23
+ export function tenantNamespace(tenant) {
24
+ const base = tenant.replace(/_/g, "-").slice(0, 40).replace(/-+$/, "");
25
+ const suffix = base === tenant ? "" : `-${createHash("sha256").update(tenant).digest("hex").slice(0, 6)}`;
26
+ return `nylorun-sbx-${base}${suffix}`;
27
+ }
28
+ /** The `dev.nylorun.tenant` label value: a Tenant name, cut to a label's 63 characters. */
29
+ export function tenantLabel(tenant) {
30
+ return tenant.slice(0, 63).replace(/[^a-z0-9]+$/, "");
31
+ }
32
+ /** Namespace, ServiceAccount, Role, RoleBinding and the ServiceAccount's token Secret. */
33
+ export function tenantManifests(namespace, tenant) {
34
+ const labels = { ...MANAGED, [TENANT_LABEL]: tenantLabel(tenant) };
35
+ return [
36
+ { apiVersion: "v1", kind: "Namespace", metadata: { name: namespace, labels } },
37
+ {
38
+ apiVersion: "v1",
39
+ kind: "ServiceAccount",
40
+ metadata: { name: SERVICE_ACCOUNT, namespace, labels },
41
+ automountServiceAccountToken: false,
42
+ },
43
+ {
44
+ apiVersion: "rbac.authorization.k8s.io/v1",
45
+ kind: "Role",
46
+ metadata: { name: SERVICE_ACCOUNT, namespace, labels },
47
+ // Lifecycle only: no NetworkPolicies (enable owns the policy) and no pods/exec.
48
+ rules: [
49
+ {
50
+ apiGroups: ["agents.x-k8s.io"],
51
+ resources: ["sandboxes", "sandboxes/status"],
52
+ verbs: ["get", "list", "watch", "create", "update", "patch", "delete"],
53
+ },
54
+ {
55
+ apiGroups: [""],
56
+ resources: ["pods", "persistentvolumeclaims", "events"],
57
+ verbs: ["get", "list", "watch"],
58
+ },
59
+ {
60
+ apiGroups: [""],
61
+ resources: ["secrets"],
62
+ verbs: ["get", "create", "update", "patch", "delete"],
63
+ },
64
+ ],
65
+ },
66
+ {
67
+ apiVersion: "rbac.authorization.k8s.io/v1",
68
+ kind: "RoleBinding",
69
+ metadata: { name: SERVICE_ACCOUNT, namespace, labels },
70
+ roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: SERVICE_ACCOUNT },
71
+ subjects: [{ kind: "ServiceAccount", name: SERVICE_ACCOUNT, namespace }],
72
+ },
73
+ {
74
+ apiVersion: "v1",
75
+ kind: "Secret",
76
+ type: "kubernetes.io/service-account-token",
77
+ metadata: {
78
+ name: TOKEN_SECRET,
79
+ namespace,
80
+ labels,
81
+ annotations: { "kubernetes.io/service-account.name": SERVICE_ACCOUNT },
82
+ },
83
+ },
84
+ ];
85
+ }
86
+ /**
87
+ * Every pod in the namespace: no ingress, and egress only to the Docker host on the
88
+ * Harness API, gates and egress ports. No DNS: pods address the host by IP (D42).
89
+ */
90
+ export function networkPolicy(namespace, hostAddress, ports, options = {}) {
91
+ return {
92
+ apiVersion: "networking.k8s.io/v1",
93
+ kind: "NetworkPolicy",
94
+ metadata: { name: options.name ?? NETWORK_POLICY, namespace, labels: MANAGED },
95
+ spec: {
96
+ podSelector: options.podSelector ? { matchLabels: options.podSelector } : {},
97
+ policyTypes: ["Ingress", "Egress"],
98
+ ingress: [],
99
+ egress: [
100
+ {
101
+ to: [{ ipBlock: { cidr: `${hostAddress}/32` } }],
102
+ ports: ports.map((port) => ({ protocol: "TCP", port })),
103
+ },
104
+ ],
105
+ },
106
+ };
107
+ }
108
+ export const PROBE_IMAGE = "busybox:1.37.0";
109
+ /** A small busybox pod; `nodeName` pins it (pre-pull), `serve` runs httpd on 8080. */
110
+ export function probePod(namespace, name, options) {
111
+ const command = options.command ??
112
+ (options.serve
113
+ ? ["sh", "-c", "echo ok > /tmp/index.html; exec httpd -f -p 8080 -h /tmp"]
114
+ : ["sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"]);
115
+ return {
116
+ apiVersion: "v1",
117
+ kind: "Pod",
118
+ metadata: { name, namespace, labels: { ...MANAGED, "nylorun.dev/probe": options.role } },
119
+ spec: {
120
+ automountServiceAccountToken: false,
121
+ enableServiceLinks: false,
122
+ terminationGracePeriodSeconds: 1,
123
+ restartPolicy: options.command ? "Never" : "Always",
124
+ ...(options.nodeName ? { nodeName: options.nodeName } : {}),
125
+ securityContext: { runAsNonRoot: true, runAsUser: 1000, seccompProfile: { type: "RuntimeDefault" } },
126
+ containers: [
127
+ {
128
+ name: "probe",
129
+ image: options.image ?? PROBE_IMAGE,
130
+ imagePullPolicy: "IfNotPresent",
131
+ command,
132
+ securityContext: { allowPrivilegeEscalation: false, capabilities: { drop: ["ALL"] } },
133
+ resources: { limits: { cpu: "200m", memory: "64Mi" } },
134
+ },
135
+ ],
136
+ },
137
+ };
138
+ }
@@ -0,0 +1,44 @@
1
+ import type { DockerRunner } from "../stack/docker.js";
2
+ import type { PortProbe } from "../stack/ports.js";
3
+ import type { Kube } from "./kubectl.js";
4
+ /**
5
+ * The NetworkPolicy probe `nylorun sandbox enable` runs before it trusts a cluster (D32), in
6
+ * a throwaway namespace `nylorun-sbx-probe-<random>`:
7
+ *
8
+ * - a `control` pod (no policy) must reach a `peer` pod and the allowed host port, which
9
+ * proves the routes exist;
10
+ * - the `restricted` pod, under the same policy shape as the Tenant's namespace (egress only
11
+ * to `<host>/32` on one port), must reach the allowed host port and nothing else: not the
12
+ * peer pod, not another port on the host.
13
+ *
14
+ * The host ports are served by a temporary busybox container published on the bind address.
15
+ * The namespace and the container are always removed.
16
+ */
17
+ export interface ProbeDeps {
18
+ kube: Kube;
19
+ docker: DockerRunner;
20
+ ports: PortProbe;
21
+ err(line: string): void;
22
+ /** Delay between polls; tests shorten it. */
23
+ pollMs?: number;
24
+ /** How long the policy may take to apply. */
25
+ policyTimeoutMs?: number;
26
+ }
27
+ export interface ProbeResult {
28
+ hostAddress: string;
29
+ bindAddress: string;
30
+ enforced: true;
31
+ probedAt: string;
32
+ /** Images the pre-pull could not pull (warnings, not failures). */
33
+ notPulled: string[];
34
+ }
35
+ /** Docker Desktop and OrbStack forward the host address to this machine's loopback. */
36
+ export declare function defaultBind(context: string, hostAddress: string): string;
37
+ export declare function assertIPv4(value: string, option: string): string;
38
+ /** The last IPv4 address `nslookup` prints for the name (the first is the DNS server). */
39
+ export declare function parseNslookup(output: string): string | undefined;
40
+ export declare function runProbe(deps: ProbeDeps, options: {
41
+ hostAddress?: string;
42
+ bindAddress?: string;
43
+ prePull?: readonly string[];
44
+ }): Promise<ProbeResult>;