@telorun/runner-core 0.5.2

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 (109) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +38 -0
  3. package/dist/backend.d.ts +71 -0
  4. package/dist/backend.d.ts.map +1 -0
  5. package/dist/backend.js +2 -0
  6. package/dist/backend.js.map +1 -0
  7. package/dist/base-image-catalog.d.ts +110 -0
  8. package/dist/base-image-catalog.d.ts.map +1 -0
  9. package/dist/base-image-catalog.js +245 -0
  10. package/dist/base-image-catalog.js.map +1 -0
  11. package/dist/capabilities-schema.d.ts +33 -0
  12. package/dist/capabilities-schema.d.ts.map +1 -0
  13. package/dist/capabilities-schema.js +44 -0
  14. package/dist/capabilities-schema.js.map +1 -0
  15. package/dist/config.d.ts +37 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +93 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/contract.d.ts +170 -0
  20. package/dist/contract.d.ts.map +1 -0
  21. package/dist/contract.js +24 -0
  22. package/dist/contract.js.map +1 -0
  23. package/dist/debug/relay.d.ts +25 -0
  24. package/dist/debug/relay.d.ts.map +1 -0
  25. package/dist/debug/relay.js +89 -0
  26. package/dist/debug/relay.js.map +1 -0
  27. package/dist/dependency-key.d.ts +38 -0
  28. package/dist/dependency-key.d.ts.map +1 -0
  29. package/dist/dependency-key.js +68 -0
  30. package/dist/dependency-key.js.map +1 -0
  31. package/dist/index.d.ts +20 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +19 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/routes/capabilities.d.ts +12 -0
  36. package/dist/routes/capabilities.d.ts.map +1 -0
  37. package/dist/routes/capabilities.js +15 -0
  38. package/dist/routes/capabilities.js.map +1 -0
  39. package/dist/routes/health.d.ts +5 -0
  40. package/dist/routes/health.d.ts.map +1 -0
  41. package/dist/routes/health.js +8 -0
  42. package/dist/routes/health.js.map +1 -0
  43. package/dist/routes/io.d.ts +8 -0
  44. package/dist/routes/io.d.ts.map +1 -0
  45. package/dist/routes/io.js +239 -0
  46. package/dist/routes/io.js.map +1 -0
  47. package/dist/routes/probe.d.ts +7 -0
  48. package/dist/routes/probe.d.ts.map +1 -0
  49. package/dist/routes/probe.js +22 -0
  50. package/dist/routes/probe.js.map +1 -0
  51. package/dist/routes/sessions.d.ts +22 -0
  52. package/dist/routes/sessions.d.ts.map +1 -0
  53. package/dist/routes/sessions.js +223 -0
  54. package/dist/routes/sessions.js.map +1 -0
  55. package/dist/server.d.ts +34 -0
  56. package/dist/server.d.ts.map +1 -0
  57. package/dist/server.js +67 -0
  58. package/dist/server.js.map +1 -0
  59. package/dist/session/bundle-path.d.ts +12 -0
  60. package/dist/session/bundle-path.d.ts.map +1 -0
  61. package/dist/session/bundle-path.js +27 -0
  62. package/dist/session/bundle-path.js.map +1 -0
  63. package/dist/session/byte-ring-buffer.d.ts +29 -0
  64. package/dist/session/byte-ring-buffer.d.ts.map +1 -0
  65. package/dist/session/byte-ring-buffer.js +54 -0
  66. package/dist/session/byte-ring-buffer.js.map +1 -0
  67. package/dist/session/registry.d.ts +62 -0
  68. package/dist/session/registry.d.ts.map +1 -0
  69. package/dist/session/registry.js +156 -0
  70. package/dist/session/registry.js.map +1 -0
  71. package/dist/session/ring-buffer.d.ts +37 -0
  72. package/dist/session/ring-buffer.d.ts.map +1 -0
  73. package/dist/session/ring-buffer.js +64 -0
  74. package/dist/session/ring-buffer.js.map +1 -0
  75. package/dist/session/session-id.d.ts +3 -0
  76. package/dist/session/session-id.d.ts.map +1 -0
  77. package/dist/session/session-id.js +19 -0
  78. package/dist/session/session-id.js.map +1 -0
  79. package/dist/sse/channel.d.ts +11 -0
  80. package/dist/sse/channel.d.ts.map +1 -0
  81. package/dist/sse/channel.js +129 -0
  82. package/dist/sse/channel.js.map +1 -0
  83. package/package.json +48 -0
  84. package/src/backend.ts +88 -0
  85. package/src/base-image-catalog.test.ts +209 -0
  86. package/src/base-image-catalog.ts +320 -0
  87. package/src/capabilities-schema.test.ts +54 -0
  88. package/src/capabilities-schema.ts +71 -0
  89. package/src/config.ts +122 -0
  90. package/src/contract.ts +170 -0
  91. package/src/debug/relay.ts +104 -0
  92. package/src/dependency-key.test.ts +64 -0
  93. package/src/dependency-key.ts +105 -0
  94. package/src/index.ts +33 -0
  95. package/src/routes/capabilities.ts +20 -0
  96. package/src/routes/health.ts +9 -0
  97. package/src/routes/io.ts +265 -0
  98. package/src/routes/probe.ts +35 -0
  99. package/src/routes/sessions.ts +270 -0
  100. package/src/server.ts +108 -0
  101. package/src/session/bundle-path.ts +27 -0
  102. package/src/session/byte-ring-buffer.ts +62 -0
  103. package/src/session/registry.test.ts +34 -0
  104. package/src/session/registry.ts +185 -0
  105. package/src/session/ring-buffer.test.ts +54 -0
  106. package/src/session/ring-buffer.ts +75 -0
  107. package/src/session/session-id.test.ts +17 -0
  108. package/src/session/session-id.ts +20 -0
  109. package/src/sse/channel.ts +154 -0
@@ -0,0 +1,320 @@
1
+ /**
2
+ * A base-image catalog backed by the Docker Hub repository-tags API. A runner
3
+ * advertises the resolved tag list as an `enum` on its session `image` field so
4
+ * the editor renders a base-image picker, and validates a chosen image against
5
+ * the same list server-side — the runner is the source of truth, so a non-editor
6
+ * client can't widen the allowed set.
7
+ *
8
+ * The list is fetched at startup and on a refresh interval, cached in memory,
9
+ * and degrades gracefully: when Docker Hub is unreachable the catalog still
10
+ * serves the configured default (always present), so the runner boots and a
11
+ * session can still start on the default image.
12
+ *
13
+ * Free of runner specifics — it takes only a repository, a default ref, and a
14
+ * tag filter — but the tag source is specifically Docker Hub's registry API
15
+ * (`hub.docker.com/v2`); a base on another registry (GHCR, a private registry)
16
+ * can't be enumerated or digest-resolved here. The Docker registry the kubelet
17
+ * pulls from is a separate concern; this resolves the *menu* of base images, not
18
+ * pull auth.
19
+ */
20
+
21
+ /** A composable predicate set narrowing a repository's tag list to the offered
22
+ * menu. Named flags cover the common cases; `include`/`exclude` are the regex
23
+ * escape hatch for everything else. */
24
+ export interface TagFilter {
25
+ /** Keep only `MAJOR.MINOR.PATCH[-variant]` tags — drops moving tags (`latest`,
26
+ * `0`, `0.30`) and their variants (`latest-slim`, `0-slim`). */
27
+ pinnedOnly?: boolean;
28
+ /** Drop commit-hash tags (`sha-<hex>` or a bare hex string). */
29
+ excludeSha?: boolean;
30
+ /** Drop semver prereleases (`0.30.1-rc.1`, `…-alpha`). Variant suffixes like
31
+ * `-slim` / `-rust-1.95.0` are NOT prereleases and are kept. */
32
+ excludePrerelease?: boolean;
33
+ /** A tag must match at least one of these to be kept. */
34
+ include?: RegExp[];
35
+ /** A tag matching any of these is dropped. */
36
+ exclude?: RegExp[];
37
+ }
38
+
39
+ export interface BaseImageCatalogOptions {
40
+ /** `namespace/repository`, e.g. `telorun/node`. */
41
+ repository: string;
42
+ /** Always-present default ref — the pre-selected option and the
43
+ * Docker-Hub-unreachable fallback, e.g. `telorun/node:latest-slim`. */
44
+ defaultRef: string;
45
+ filter?: TagFilter;
46
+ /** Cap the advertised list to this many tags (newest first). Default 20. */
47
+ limit?: number;
48
+ /** Background refresh cadence in ms. Default 1h. */
49
+ refreshIntervalMs?: number;
50
+ /** Upper bound on tags pulled from Docker Hub before filtering. Default 200. */
51
+ maxTagsScanned?: number;
52
+ /** Per-request timeout for a Docker Hub call, in ms. Default 10s. */
53
+ requestTimeoutMs?: number;
54
+ /** Injectable fetch for tests; defaults to the global `fetch`. */
55
+ fetchImpl?: typeof fetch;
56
+ }
57
+
58
+ const SHA_TAG = /^(?:sha-)?[0-9a-f]{7,}$/i;
59
+ const PINNED_TAG = /^\d+\.\d+\.\d+(?:[-.].*)?$/;
60
+ const PRERELEASE_CORE = /^\d+\.\d+\.\d+-(.+)$/;
61
+ /** First `-` identifier on a pinned tag that marks it a prerelease rather than a
62
+ * build variant (`-slim`, `-rust-*`). */
63
+ const PRERELEASE_TOKENS = new Set([
64
+ "rc",
65
+ "alpha",
66
+ "beta",
67
+ "pre",
68
+ "preview",
69
+ "next",
70
+ "canary",
71
+ "dev",
72
+ "nightly",
73
+ "snapshot",
74
+ ]);
75
+
76
+ function isShaTag(tag: string): boolean {
77
+ return SHA_TAG.test(tag);
78
+ }
79
+
80
+ function isPrerelease(tag: string): boolean {
81
+ const m = PRERELEASE_CORE.exec(tag);
82
+ if (!m) return false;
83
+ const firstId = m[1].split(/[-.]/, 1)[0].toLowerCase();
84
+ return PRERELEASE_TOKENS.has(firstId);
85
+ }
86
+
87
+ /** Apply a {@link TagFilter} to a tag list, preserving input order. Pure — the
88
+ * unit-testable core of the catalog. */
89
+ export function filterTags(tags: string[], filter: TagFilter = {}): string[] {
90
+ return tags.filter((tag) => {
91
+ if (filter.excludeSha && isShaTag(tag)) return false;
92
+ if (filter.pinnedOnly && !PINNED_TAG.test(tag)) return false;
93
+ if (filter.excludePrerelease && isPrerelease(tag)) return false;
94
+ if (filter.include && filter.include.length > 0 && !filter.include.some((re) => re.test(tag))) {
95
+ return false;
96
+ }
97
+ if (filter.exclude && filter.exclude.some((re) => re.test(tag))) return false;
98
+ return true;
99
+ });
100
+ }
101
+
102
+ function splitRepository(repository: string): [namespace: string, repo: string] {
103
+ const slash = repository.indexOf("/");
104
+ // A bare name (no slash) is a Docker official image under `library/`.
105
+ if (slash < 0) return ["library", repository];
106
+ return [repository.slice(0, slash), repository.slice(slash + 1)];
107
+ }
108
+
109
+ function unique(refs: string[]): string[] {
110
+ const seen = new Set<string>();
111
+ const out: string[] = [];
112
+ for (const ref of refs) {
113
+ if (!seen.has(ref)) {
114
+ seen.add(ref);
115
+ out.push(ref);
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * Fetch a repository's tags from Docker Hub, newest first, bounded by
123
+ * `maxTagsScanned`. Paginates the `/v2/repositories/{ns}/{repo}/tags` endpoint
124
+ * and sorts by `last_updated` descending so the cap keeps the most recent tags
125
+ * regardless of the API's page ordering. Throws on a network/HTTP error — the
126
+ * caller decides whether a failure degrades to default-only.
127
+ */
128
+ async function fetchRepoTags(
129
+ repository: string,
130
+ maxTagsScanned: number,
131
+ requestTimeoutMs: number,
132
+ fetchImpl: typeof fetch,
133
+ ): Promise<string[]> {
134
+ const [namespace, repo] = splitRepository(repository);
135
+ const collected: Array<{ name: string; lastUpdated: string }> = [];
136
+ let url: string | null =
137
+ `https://hub.docker.com/v2/repositories/${encodeURIComponent(namespace)}/` +
138
+ `${encodeURIComponent(repo)}/tags?page_size=100`;
139
+
140
+ while (url && collected.length < maxTagsScanned) {
141
+ const res = await fetchImpl(url, {
142
+ headers: { Accept: "application/json" },
143
+ signal: AbortSignal.timeout(requestTimeoutMs),
144
+ });
145
+ if (!res.ok) {
146
+ throw new Error(
147
+ `Docker Hub tags request for '${repository}' failed: ${res.status} ${res.statusText}`,
148
+ );
149
+ }
150
+ const body = (await res.json()) as {
151
+ next?: string | null;
152
+ results?: Array<{ name?: string; last_updated?: string }>;
153
+ };
154
+ for (const r of body.results ?? []) {
155
+ if (typeof r.name === "string") {
156
+ collected.push({ name: r.name, lastUpdated: r.last_updated ?? "" });
157
+ }
158
+ }
159
+ url = body.next ?? null;
160
+ }
161
+
162
+ collected.sort((a, b) =>
163
+ a.lastUpdated < b.lastUpdated ? 1 : a.lastUpdated > b.lastUpdated ? -1 : 0,
164
+ );
165
+ return collected.map((c) => c.name);
166
+ }
167
+
168
+ /**
169
+ * Parse a Docker image reference into the Docker Hub coordinates needed to look
170
+ * up its digest, or `null` when the ref points at a non-Docker-Hub registry.
171
+ * Handles the `library/` shorthand, optional `docker.io` host, and an already
172
+ * `@sha256:`-pinned digest (carried through verbatim).
173
+ */
174
+ export function parseDockerHubRef(
175
+ ref: string,
176
+ ): { namespace: string; repo: string; tag: string; digest?: string } | null {
177
+ let rest = ref;
178
+ let digest: string | undefined;
179
+ const at = ref.indexOf("@");
180
+ if (at >= 0) {
181
+ digest = ref.slice(at + 1);
182
+ rest = ref.slice(0, at);
183
+ }
184
+
185
+ // A first path segment with a `.`/`:` (or `localhost`) is a registry host.
186
+ const firstSlash = rest.indexOf("/");
187
+ const firstPart = firstSlash >= 0 ? rest.slice(0, firstSlash) : "";
188
+ if (firstPart.includes(".") || firstPart.includes(":") || firstPart === "localhost") {
189
+ const hubHosts = new Set(["docker.io", "index.docker.io", "registry-1.docker.io"]);
190
+ if (!hubHosts.has(firstPart)) return null;
191
+ rest = rest.slice(firstSlash + 1);
192
+ }
193
+
194
+ // `rest` is now `name[:tag]`; the tag is the segment after a colon that isn't
195
+ // inside the path (i.e. after the last `/`).
196
+ const lastColon = rest.lastIndexOf(":");
197
+ const lastSlash = rest.lastIndexOf("/");
198
+ let name = rest;
199
+ let tag = "latest";
200
+ if (lastColon > lastSlash) {
201
+ name = rest.slice(0, lastColon);
202
+ tag = rest.slice(lastColon + 1);
203
+ }
204
+
205
+ const slash = name.indexOf("/");
206
+ return {
207
+ namespace: slash >= 0 ? name.slice(0, slash) : "library",
208
+ repo: slash >= 0 ? name.slice(slash + 1) : name,
209
+ tag,
210
+ digest,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Resolve a Docker Hub image ref's CURRENT manifest digest. A moving tag
216
+ * (`latest-slim`) maps to a new digest once a new version is published, so a
217
+ * build that folds this into its cache key detects that the base moved and
218
+ * rebuilds. An already `@sha256:`-pinned ref returns its digest without a
219
+ * network call. Returns `undefined` for a non-Docker-Hub ref (not an error —
220
+ * resolution simply doesn't apply) or when a Hub request fails. A genuine
221
+ * request failure is reported to `onError` rather than swallowed, so an operator
222
+ * whose Hub calls persistently fail (auth / network / rate-limit) gets a signal
223
+ * instead of an unexplained "always reuses the cached image".
224
+ */
225
+ export async function resolveTagDigest(
226
+ ref: string,
227
+ opts: {
228
+ fetchImpl?: typeof fetch;
229
+ requestTimeoutMs?: number;
230
+ onError?: (err: unknown) => void;
231
+ } = {},
232
+ ): Promise<string | undefined> {
233
+ const parsed = parseDockerHubRef(ref);
234
+ if (!parsed) return undefined;
235
+ if (parsed.digest) return parsed.digest;
236
+
237
+ const fetchImpl = opts.fetchImpl ?? fetch;
238
+ const url =
239
+ `https://hub.docker.com/v2/repositories/${encodeURIComponent(parsed.namespace)}/` +
240
+ `${encodeURIComponent(parsed.repo)}/tags/${encodeURIComponent(parsed.tag)}`;
241
+ try {
242
+ const res = await fetchImpl(url, {
243
+ headers: { Accept: "application/json" },
244
+ signal: AbortSignal.timeout(opts.requestTimeoutMs ?? 10_000),
245
+ });
246
+ if (!res.ok) {
247
+ opts.onError?.(new Error(`Docker Hub tag request for '${ref}' failed: ${res.status} ${res.statusText}`));
248
+ return undefined;
249
+ }
250
+ const body = (await res.json()) as { digest?: string; images?: Array<{ digest?: string }> };
251
+ return body.digest ?? body.images?.find((i) => i.digest)?.digest ?? undefined;
252
+ } catch (err) {
253
+ opts.onError?.(err);
254
+ return undefined;
255
+ }
256
+ }
257
+
258
+ export class BaseImageCatalog {
259
+ private readonly repository: string;
260
+ private readonly defaultRef: string;
261
+ private readonly filter: TagFilter;
262
+ private readonly limit: number;
263
+ private readonly refreshIntervalMs: number;
264
+ private readonly maxTagsScanned: number;
265
+ private readonly requestTimeoutMs: number;
266
+ private readonly fetchImpl: typeof fetch;
267
+ /** Filtered tags (bare, newest first); empty until the first refresh. */
268
+ private tags: string[] = [];
269
+ private timer?: ReturnType<typeof setInterval>;
270
+
271
+ constructor(opts: BaseImageCatalogOptions) {
272
+ this.repository = opts.repository;
273
+ this.defaultRef = opts.defaultRef;
274
+ this.filter = opts.filter ?? {};
275
+ this.limit = opts.limit ?? 20;
276
+ this.refreshIntervalMs = opts.refreshIntervalMs ?? 60 * 60 * 1000;
277
+ this.maxTagsScanned = opts.maxTagsScanned ?? 200;
278
+ this.requestTimeoutMs = opts.requestTimeoutMs ?? 10_000;
279
+ this.fetchImpl = opts.fetchImpl ?? fetch;
280
+ }
281
+
282
+ /** The advertised image refs: the default first, then the filtered tags. */
283
+ current(): string[] {
284
+ const refs = this.tags.map((tag) => `${this.repository}:${tag}`);
285
+ return unique([this.defaultRef, ...refs]);
286
+ }
287
+
288
+ /** Whether `ref` is offered by this catalog (server-side allowlist check). */
289
+ isAllowed(ref: string): boolean {
290
+ return this.current().includes(ref);
291
+ }
292
+
293
+ /** Refetch and re-filter the tag list. Throws on a fetch/HTTP failure. */
294
+ async refresh(): Promise<void> {
295
+ const tags = await fetchRepoTags(
296
+ this.repository,
297
+ this.maxTagsScanned,
298
+ this.requestTimeoutMs,
299
+ this.fetchImpl,
300
+ );
301
+ this.tags = filterTags(tags, this.filter).slice(0, this.limit);
302
+ }
303
+
304
+ /** Begin periodic background refresh. Refresh failures are surfaced to
305
+ * `onError` rather than swallowed; the last good list keeps serving. */
306
+ start(onError?: (err: unknown) => void): void {
307
+ if (this.timer) return;
308
+ this.timer = setInterval(() => {
309
+ this.refresh().catch((err) => onError?.(err));
310
+ }, this.refreshIntervalMs);
311
+ this.timer.unref?.();
312
+ }
313
+
314
+ stop(): void {
315
+ if (this.timer) {
316
+ clearInterval(this.timer);
317
+ this.timer = undefined;
318
+ }
319
+ }
320
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { sessionConfigSchema } from "./capabilities-schema.js";
4
+
5
+ describe("sessionConfigSchema", () => {
6
+ it("advertises editable image/pullPolicy with defaults", () => {
7
+ const schema = sessionConfigSchema({ imageDefault: "telorun/node:0-slim" });
8
+ const props = schema.properties as Record<string, Record<string, unknown>>;
9
+ expect(props.image.default).toBe("telorun/node:0-slim");
10
+ expect(props.image.readOnly).toBeUndefined();
11
+ expect(props.pullPolicy.default).toBe("missing");
12
+ expect(props.registryUrl).toBeUndefined();
13
+ expect(schema.required).toEqual(["image", "pullPolicy"]);
14
+ });
15
+
16
+ it("locks image (only) when enforced; pullPolicy stays editable", () => {
17
+ const schema = sessionConfigSchema({ imageDefault: "telorun/node:latest-slim", enforced: true });
18
+ const props = schema.properties as Record<string, Record<string, unknown>>;
19
+ expect(props.image.readOnly).toBe(true);
20
+ expect(props.image.default).toBe("telorun/node:latest-slim");
21
+ expect(props.pullPolicy.readOnly).toBeUndefined();
22
+ });
23
+
24
+ it("includes registryUrl only when requested", () => {
25
+ const schema = sessionConfigSchema({ imageDefault: "x", registryUrl: true });
26
+ const props = schema.properties as Record<string, unknown>;
27
+ expect(props.registryUrl).toBeDefined();
28
+ });
29
+
30
+ it("renders image as an editable enum picker — overriding enforced — when imageEnum is set", () => {
31
+ const schema = sessionConfigSchema({
32
+ imageDefault: "telorun/node:latest-slim",
33
+ enforced: true,
34
+ imageEnum: ["telorun/node:latest-slim", "telorun/node:0.30.1-slim"],
35
+ });
36
+ const props = schema.properties as Record<string, Record<string, unknown>>;
37
+ expect(props.image.enum).toEqual(["telorun/node:latest-slim", "telorun/node:0.30.1-slim"]);
38
+ expect(props.image.readOnly).toBeUndefined();
39
+ expect(props.image.default).toBe("telorun/node:latest-slim");
40
+ // pullPolicy is a client-editable freshness control.
41
+ expect(props.pullPolicy.readOnly).toBeUndefined();
42
+ });
43
+
44
+ it("ignores an empty imageEnum and falls back to the enforced field", () => {
45
+ const schema = sessionConfigSchema({
46
+ imageDefault: "telorun/node:latest-slim",
47
+ enforced: true,
48
+ imageEnum: [],
49
+ });
50
+ const props = schema.properties as Record<string, Record<string, unknown>>;
51
+ expect(props.image.enum).toBeUndefined();
52
+ expect(props.image.readOnly).toBe(true);
53
+ });
54
+ });
@@ -0,0 +1,71 @@
1
+ import type { JsonSchema, PullPolicy } from "./contract.js";
2
+
3
+ export interface SessionConfigSchemaOptions {
4
+ /** Default container image; becomes the field's advertised `default`. */
5
+ imageDefault: string;
6
+ /** When set (and non-empty), constrains `image` to this allowlist — advertised
7
+ * as a JSON Schema `enum` so the editor renders a base-image picker. The
8
+ * picker is editable within the list, so an `imageEnum` overrides `enforced`
9
+ * for the `image` field (a one-entry enum is effectively locked anyway). */
10
+ imageEnum?: string[];
11
+ /** Default pull policy (defaults to `missing`). */
12
+ pullPolicyDefault?: PullPolicy;
13
+ /** Override the `pullPolicy` field description. `pullPolicy` semantics differ
14
+ * per backend (docker: container pull policy; k8s: base-image build
15
+ * freshness), so each runner can supply wording that matches its behaviour. */
16
+ pullPolicyDescription?: string;
17
+ /** When true, `image` is server-enforced — advertised `readOnly` (locked to
18
+ * `imageDefault`) so the editor renders it disabled but still sends it. No
19
+ * effect once `imageEnum` is set (the picker is the constraint). `pullPolicy`
20
+ * is always client-editable. */
21
+ enforced?: boolean;
22
+ /** Include the optional `registryUrl` field (a runner that forwards a module
23
+ * registry URL to its workloads). */
24
+ registryUrl?: boolean;
25
+ }
26
+
27
+ /**
28
+ * Builds the JSON Schema a runner advertises on `/v1/capabilities` for the
29
+ * editable `SessionConfig` surface. `baseUrl` is deliberately absent — the
30
+ * client owns it. Server-enforced runners pass `enforced: true` to lock `image`
31
+ * as `readOnly` (the value still travels on the wire); `pullPolicy` is always
32
+ * client-editable.
33
+ */
34
+ export function sessionConfigSchema(opts: SessionConfigSchemaOptions): JsonSchema {
35
+ const readOnly = opts.enforced === true;
36
+ const hasEnum = Array.isArray(opts.imageEnum) && opts.imageEnum.length > 0;
37
+ const properties: Record<string, JsonSchema> = {
38
+ image: {
39
+ type: "string",
40
+ minLength: 1,
41
+ default: opts.imageDefault,
42
+ title: "Image",
43
+ description: "Container image the runner spawns for each run.",
44
+ // An allowlist renders as an editable picker; otherwise fall back to the
45
+ // enforced (readOnly) single value.
46
+ ...(hasEnum ? { enum: opts.imageEnum } : readOnly ? { readOnly: true } : {}),
47
+ },
48
+ pullPolicy: {
49
+ type: "string",
50
+ enum: ["missing", "always", "never"],
51
+ default: opts.pullPolicyDefault ?? "missing",
52
+ title: "Pull policy",
53
+ description:
54
+ opts.pullPolicyDescription ??
55
+ "`missing` pulls on first use; `always` forces a pull every run; `never` fails if the image isn't present.",
56
+ },
57
+ };
58
+ if (opts.registryUrl) {
59
+ properties.registryUrl = {
60
+ type: "string",
61
+ title: "Registry URL",
62
+ description:
63
+ "Optional base URL for the telo module registry, forwarded to the runner as TELO_REGISTRY_URL. Leave blank for the default registry.",
64
+ };
65
+ }
66
+ return {
67
+ type: "object",
68
+ required: ["image", "pullPolicy"],
69
+ properties,
70
+ };
71
+ }
package/src/config.ts ADDED
@@ -0,0 +1,122 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ import type { RunnerTerms } from "./contract.js";
5
+
6
+ /**
7
+ * Backend-neutral runner configuration. Concrete runners (docker, k8s) extend
8
+ * `RunnerCoreConfig` with their own fields and reuse these parse helpers so
9
+ * config validation and error reporting stay identical across backends.
10
+ */
11
+ export interface RunnerCoreConfig {
12
+ port: number;
13
+ logLevel: string;
14
+ maxSessions: number;
15
+ exitTtlMs: number;
16
+ replayBufferBytes: number;
17
+ corsOrigins: string[] | "*";
18
+ }
19
+
20
+ export class RunnerConfigError extends Error {}
21
+
22
+ export function parsePositiveInt(
23
+ raw: string | undefined,
24
+ fallback: number,
25
+ field: string,
26
+ ): number {
27
+ if (raw === undefined || raw.trim() === "") return fallback;
28
+ const n = Number(raw);
29
+ if (!Number.isInteger(n) || n <= 0) {
30
+ throw new RunnerConfigError(`${field} must be a positive integer, got '${raw}'.`);
31
+ }
32
+ return n;
33
+ }
34
+
35
+ export function parseBool(raw: string | undefined, fallback: boolean, field: string): boolean {
36
+ const v = raw?.trim().toLowerCase();
37
+ if (v === undefined || v === "") return fallback;
38
+ if (v === "true" || v === "1" || v === "yes") return true;
39
+ if (v === "false" || v === "0" || v === "no") return false;
40
+ throw new RunnerConfigError(`${field} must be a boolean (true/false), got '${raw}'.`);
41
+ }
42
+
43
+ export function parsePort(raw: string | undefined, fallback: number): number {
44
+ const portStr = raw ?? String(fallback);
45
+ const port = Number(portStr);
46
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
47
+ throw new RunnerConfigError(`PORT must be an integer in 1..65535, got '${portStr}'.`);
48
+ }
49
+ return port;
50
+ }
51
+
52
+ /**
53
+ * Build the runner's usage terms from the environment. Terms are opt-in: returns
54
+ * `undefined` (no gate) unless a non-empty body is provided.
55
+ *
56
+ * The body comes from `RUNNER_TERMS_FILE` (a path read at startup — the natural
57
+ * fit for a k8s ConfigMap mount or a bind-mounted file) or, for short notes,
58
+ * inline `RUNNER_TERMS_BODY`. `RUNNER_TERMS_VERSION` is optional and defaults to
59
+ * a short hash of the body, so editing the agreement automatically re-prompts
60
+ * every client; set it explicitly only to control "material change vs. typo".
61
+ * A configured-but-unreadable `RUNNER_TERMS_FILE` is a hard error rather than a
62
+ * silent "no terms", so a misconfiguration can't quietly drop the gate.
63
+ */
64
+ export function loadTermsFromEnv(env: NodeJS.ProcessEnv): RunnerTerms | undefined {
65
+ const body = resolveTermsBody(env);
66
+ if (!body || body.trim() === "") return undefined;
67
+ return {
68
+ version: env.RUNNER_TERMS_VERSION?.trim() || hashTermsVersion(body),
69
+ title: env.RUNNER_TERMS_TITLE?.trim() || "Usage agreement",
70
+ body,
71
+ };
72
+ }
73
+
74
+ function resolveTermsBody(env: NodeJS.ProcessEnv): string | undefined {
75
+ const file = env.RUNNER_TERMS_FILE?.trim();
76
+ if (file) {
77
+ try {
78
+ return readFileSync(file, "utf8");
79
+ } catch (err) {
80
+ throw new RunnerConfigError(
81
+ `RUNNER_TERMS_FILE could not be read at '${file}': ${(err as Error).message}`,
82
+ );
83
+ }
84
+ }
85
+ return env.RUNNER_TERMS_BODY;
86
+ }
87
+
88
+ /** Short content fingerprint used as the terms version when none is set. Any
89
+ * edit to the body changes it, which re-prompts every client. */
90
+ function hashTermsVersion(body: string): string {
91
+ return createHash("sha256").update(body).digest("hex").slice(0, 12);
92
+ }
93
+
94
+ export function parseCorsOrigins(raw: string | undefined): string[] | "*" {
95
+ if (raw === undefined || raw.trim() === "" || raw.trim() === "*") return "*";
96
+ return raw
97
+ .split(",")
98
+ .map((s) => s.trim())
99
+ .filter((s) => s.length > 0);
100
+ }
101
+
102
+ export function loadCoreConfig(
103
+ env: NodeJS.ProcessEnv,
104
+ defaults: { port?: number } = {},
105
+ ): RunnerCoreConfig {
106
+ return {
107
+ port: parsePort(env.PORT, defaults.port ?? 8061),
108
+ logLevel: env.LOG_LEVEL?.trim() || "info",
109
+ maxSessions: parsePositiveInt(env.RUNNER_MAX_SESSIONS, 32, "RUNNER_MAX_SESSIONS"),
110
+ // Exited sessions linger so the editor can re-attach and replay their console
111
+ // + inspection history after a page reload. The registry evicts the oldest
112
+ // *terminal* session early when at capacity, so a long TTL never blocks a new
113
+ // run while live sessions are protected.
114
+ exitTtlMs: parsePositiveInt(env.RUNNER_EXIT_TTL_MS, 4 * 60 * 60 * 1000, "RUNNER_EXIT_TTL_MS"),
115
+ replayBufferBytes: parsePositiveInt(
116
+ env.RUNNER_REPLAY_BUFFER_BYTES,
117
+ 5_000_000,
118
+ "RUNNER_REPLAY_BUFFER_BYTES",
119
+ ),
120
+ corsOrigins: parseCorsOrigins(env.RUNNER_CORS_ORIGINS),
121
+ };
122
+ }