@telorun/runner-core 0.5.2 → 0.7.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/dist/abortable-delay.d.ts +10 -0
- package/dist/abortable-delay.d.ts.map +1 -0
- package/dist/abortable-delay.js +26 -0
- package/dist/abortable-delay.js.map +1 -0
- package/dist/backend.d.ts +10 -1
- package/dist/backend.d.ts.map +1 -1
- package/dist/config.d.ts +43 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +77 -0
- package/dist/config.js.map +1 -1
- package/dist/contract.d.ts +34 -2
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js.map +1 -1
- package/dist/debug/relay.d.ts.map +1 -1
- package/dist/debug/relay.js +3 -17
- package/dist/debug/relay.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/reachability.d.ts +37 -0
- package/dist/reachability.d.ts.map +1 -0
- package/dist/reachability.js +71 -0
- package/dist/reachability.js.map +1 -0
- package/dist/routes/sessions.d.ts +5 -0
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +83 -31
- package/dist/routes/sessions.js.map +1 -1
- package/dist/server.d.ts +7 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +11 -2
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/abortable-delay.ts +24 -0
- package/src/app-catalog.test.ts +54 -0
- package/src/backend.ts +10 -0
- package/src/config.ts +116 -1
- package/src/contract.ts +33 -3
- package/src/debug/relay.ts +4 -17
- package/src/index.ts +4 -0
- package/src/reachability.test.ts +83 -0
- package/src/reachability.ts +106 -0
- package/src/routes/sessions.ts +93 -30
- package/src/server.ts +24 -5
package/src/index.ts
CHANGED
|
@@ -30,4 +30,8 @@ export { probeRoute, type ProbeRouteDeps } from "./routes/probe.js";
|
|
|
30
30
|
export { sessionsRoute, type SessionsRouteDeps } from "./routes/sessions.js";
|
|
31
31
|
export { ioRoute, type IoRouteDeps } from "./routes/io.js";
|
|
32
32
|
export { relayDebugStream, type DebugRelayOptions } from "./debug/relay.js";
|
|
33
|
+
export {
|
|
34
|
+
watchReachability,
|
|
35
|
+
type WatchReachabilityOptions,
|
|
36
|
+
} from "./reachability.js";
|
|
33
37
|
export type { DebugFrame, DebugEvent, DebugLog } from "@telorun/debug-wire";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import type { ReachabilityState } from "./contract.js";
|
|
6
|
+
import { watchReachability } from "./reachability.js";
|
|
7
|
+
|
|
8
|
+
const servers: net.Server[] = [];
|
|
9
|
+
|
|
10
|
+
function listen(port = 0): Promise<{ port: number; server: net.Server }> {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const server = net.createServer();
|
|
13
|
+
servers.push(server);
|
|
14
|
+
server.listen(port, "127.0.0.1", () => {
|
|
15
|
+
resolve({ port: (server.address() as net.AddressInfo).port, server });
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sleep(ms: number): Promise<void> {
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
for (const s of servers) s.close();
|
|
26
|
+
servers.length = 0;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Short windows so the suite is fast; real defaults are seconds.
|
|
30
|
+
const fast = { timeoutMs: 120, intervalMs: 20, recheckIntervalMs: 20, connectTimeoutMs: 50 };
|
|
31
|
+
|
|
32
|
+
describe("watchReachability", () => {
|
|
33
|
+
it("reports checking then reachable for a live port", async () => {
|
|
34
|
+
const { port } = await listen();
|
|
35
|
+
const states: ReachabilityState[] = [];
|
|
36
|
+
await watchReachability({
|
|
37
|
+
host: "127.0.0.1",
|
|
38
|
+
ports: [port],
|
|
39
|
+
onState: (_p, s) => states.push(s),
|
|
40
|
+
signal: new AbortController().signal,
|
|
41
|
+
...fast,
|
|
42
|
+
});
|
|
43
|
+
expect(states[0]).toBe("checking");
|
|
44
|
+
expect(states.at(-1)).toBe("reachable");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("reports unreachable after the timeout when nothing listens", async () => {
|
|
48
|
+
const { port, server } = await listen();
|
|
49
|
+
server.close(); // free the port → connections are refused
|
|
50
|
+
const states: ReachabilityState[] = [];
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const run = watchReachability({
|
|
53
|
+
host: "127.0.0.1",
|
|
54
|
+
ports: [port],
|
|
55
|
+
onState: (_p, s) => states.push(s),
|
|
56
|
+
signal: controller.signal,
|
|
57
|
+
...fast,
|
|
58
|
+
});
|
|
59
|
+
await sleep(300);
|
|
60
|
+
controller.abort();
|
|
61
|
+
await run;
|
|
62
|
+
expect(states[0]).toBe("checking");
|
|
63
|
+
expect(states).toContain("unreachable");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("flips back to reachable when the port recovers", async () => {
|
|
67
|
+
const { port, server } = await listen();
|
|
68
|
+
server.close();
|
|
69
|
+
const states: ReachabilityState[] = [];
|
|
70
|
+
const run = watchReachability({
|
|
71
|
+
host: "127.0.0.1",
|
|
72
|
+
ports: [port],
|
|
73
|
+
onState: (_p, s) => states.push(s),
|
|
74
|
+
signal: new AbortController().signal,
|
|
75
|
+
...fast,
|
|
76
|
+
});
|
|
77
|
+
await sleep(200); // past the timeout → unreachable
|
|
78
|
+
await listen(port); // bind the same port → recovers
|
|
79
|
+
await run; // resolves once reachable
|
|
80
|
+
expect(states).toContain("unreachable");
|
|
81
|
+
expect(states.at(-1)).toBe("reachable");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
|
|
3
|
+
import { abortableDelay } from "./abortable-delay.js";
|
|
4
|
+
import type { ReachabilityState } from "./contract.js";
|
|
5
|
+
|
|
6
|
+
export interface WatchReachabilityOptions {
|
|
7
|
+
/** Address the runner dials — pod IP (k8s) or published host / container name (docker). */
|
|
8
|
+
host: string;
|
|
9
|
+
/** TCP ports the workload declared; each is watched and reported by port. */
|
|
10
|
+
ports: number[];
|
|
11
|
+
/** Receives every state transition for a port: `checking` on start, then
|
|
12
|
+
* `reachable` once it accepts a connection, or `unreachable` after the timeout. */
|
|
13
|
+
onState: (port: number, state: ReachabilityState) => void;
|
|
14
|
+
/** Aborts the watch — wire to the session's teardown. */
|
|
15
|
+
signal: AbortSignal;
|
|
16
|
+
/** How long a port may stay unreachable before it's reported `unreachable`.
|
|
17
|
+
* Default 30s. */
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
/** Poll interval while waiting for a port to come up. Default 1s. */
|
|
20
|
+
intervalMs?: number;
|
|
21
|
+
/** Poll interval after a port was reported `unreachable`, to flip it back to
|
|
22
|
+
* `reachable` if it recovers. Default 5s. */
|
|
23
|
+
recheckIntervalMs?: number;
|
|
24
|
+
/** Per-attempt TCP connect timeout. Default 1s. */
|
|
25
|
+
connectTimeoutMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
29
|
+
const DEFAULT_INTERVAL_MS = 1_000;
|
|
30
|
+
const DEFAULT_RECHECK_INTERVAL_MS = 5_000;
|
|
31
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 1_000;
|
|
32
|
+
|
|
33
|
+
interface WatchConfig {
|
|
34
|
+
host: string;
|
|
35
|
+
onState: (port: number, state: ReachabilityState) => void;
|
|
36
|
+
signal: AbortSignal;
|
|
37
|
+
timeoutMs: number;
|
|
38
|
+
intervalMs: number;
|
|
39
|
+
recheckIntervalMs: number;
|
|
40
|
+
connectTimeoutMs: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One TCP connect attempt. Resolves true on a completed connection, false on
|
|
44
|
+
* refusal / timeout / error. Never throws; `unref`'d so a pending attempt can't
|
|
45
|
+
* keep the event loop alive, and always torn down. */
|
|
46
|
+
function tryConnect(host: string, port: number, timeoutMs: number): Promise<boolean> {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const socket = net.connect({ host, port });
|
|
49
|
+
socket.unref();
|
|
50
|
+
let settled = false;
|
|
51
|
+
const done = (ok: boolean): void => {
|
|
52
|
+
if (settled) return;
|
|
53
|
+
settled = true;
|
|
54
|
+
socket.destroy();
|
|
55
|
+
resolve(ok);
|
|
56
|
+
};
|
|
57
|
+
socket.setTimeout(timeoutMs);
|
|
58
|
+
socket.once("connect", () => done(true));
|
|
59
|
+
socket.once("timeout", () => done(false));
|
|
60
|
+
socket.once("error", () => done(false));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function watchPort(port: number, cfg: WatchConfig): Promise<void> {
|
|
65
|
+
cfg.onState(port, "checking");
|
|
66
|
+
const start = Date.now();
|
|
67
|
+
let reportedUnreachable = false;
|
|
68
|
+
while (!cfg.signal.aborted) {
|
|
69
|
+
if (await tryConnect(cfg.host, port, cfg.connectTimeoutMs)) {
|
|
70
|
+
cfg.onState(port, "reachable");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!reportedUnreachable && Date.now() - start >= cfg.timeoutMs) {
|
|
74
|
+
reportedUnreachable = true;
|
|
75
|
+
cfg.onState(port, "unreachable");
|
|
76
|
+
}
|
|
77
|
+
await abortableDelay(reportedUnreachable ? cfg.recheckIntervalMs : cfg.intervalMs, cfg.signal);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Watches each declared TCP port for reachability from the runner network and
|
|
83
|
+
* reports per-port transitions via `onState`: `checking` immediately, then
|
|
84
|
+
* `reachable` the moment a connection succeeds, or `unreachable` after
|
|
85
|
+
* `timeoutMs` of refusal. After `unreachable` it keeps probing (slower) and
|
|
86
|
+
* flips back to `reachable` on recovery — so a slow-but-correct start
|
|
87
|
+
* self-corrects.
|
|
88
|
+
*
|
|
89
|
+
* This catches the loopback-bind / wrong-port / crash-loop failure that
|
|
90
|
+
* otherwise surfaces only as an opaque downstream 502; emitting state (not a log
|
|
91
|
+
* line) lets the editor render it on the endpoint badge. Backend-neutral: the
|
|
92
|
+
* runner supplies how its workload is dialed and where state goes.
|
|
93
|
+
*/
|
|
94
|
+
export async function watchReachability(options: WatchReachabilityOptions): Promise<void> {
|
|
95
|
+
if (options.ports.length === 0) return;
|
|
96
|
+
const cfg: WatchConfig = {
|
|
97
|
+
host: options.host,
|
|
98
|
+
onState: options.onState,
|
|
99
|
+
signal: options.signal,
|
|
100
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
101
|
+
intervalMs: options.intervalMs ?? DEFAULT_INTERVAL_MS,
|
|
102
|
+
recheckIntervalMs: options.recheckIntervalMs ?? DEFAULT_RECHECK_INTERVAL_MS,
|
|
103
|
+
connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
|
104
|
+
};
|
|
105
|
+
await Promise.all(options.ports.map((port) => watchPort(port, cfg)));
|
|
106
|
+
}
|
package/src/routes/sessions.ts
CHANGED
|
@@ -5,10 +5,12 @@ import type { RunnerBackend } from "../backend.js";
|
|
|
5
5
|
import {
|
|
6
6
|
ACCEPTED_TERMS_HEADER,
|
|
7
7
|
SessionStartError,
|
|
8
|
+
type RunBundle,
|
|
8
9
|
type RunnerTerms,
|
|
9
10
|
type SessionConfig,
|
|
10
11
|
type StartSessionRequest,
|
|
11
12
|
} from "../contract.js";
|
|
13
|
+
import type { ResolvedRunnerApp } from "../config.js";
|
|
12
14
|
import { BundlePathError, normalizeBundlePath } from "../session/bundle-path.js";
|
|
13
15
|
import { generateSessionId } from "../session/session-id.js";
|
|
14
16
|
import { SessionLimitError, type SessionRegistry } from "../session/registry.js";
|
|
@@ -24,6 +26,10 @@ export interface SessionsRouteDeps {
|
|
|
24
26
|
/** When set, a session may only start if the client acknowledges this exact
|
|
25
27
|
* terms version via the `x-telo-accepted-terms` header. */
|
|
26
28
|
terms?: RunnerTerms;
|
|
29
|
+
/** Operator-predefined applications launchable by name
|
|
30
|
+
* (`StartSessionRequest.app`), with their operator env already resolved.
|
|
31
|
+
* The catalog is the whole gate — an unknown name is rejected. */
|
|
32
|
+
apps?: Record<string, ResolvedRunnerApp>;
|
|
27
33
|
/** Backend-supplied config gate. Returns an error message to reject the
|
|
28
34
|
* request with `400 invalid_config`, or `undefined` to accept. The runner is
|
|
29
35
|
* the source of truth, so this re-checks what `/v1/capabilities` advertises
|
|
@@ -31,10 +37,13 @@ export interface SessionsRouteDeps {
|
|
|
31
37
|
validateConfig?: (config: SessionConfig) => string | undefined;
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
// `bundle`/`config` are schema-optional because an `app` session needs neither;
|
|
41
|
+
// the route enforces their presence for regular bundle sessions.
|
|
34
42
|
const startBodySchema = {
|
|
35
43
|
type: "object",
|
|
36
|
-
required: ["
|
|
44
|
+
required: ["env"],
|
|
37
45
|
properties: {
|
|
46
|
+
app: { type: "string", minLength: 1 },
|
|
38
47
|
bundle: {
|
|
39
48
|
type: "object",
|
|
40
49
|
required: ["entryRelativePath", "files"],
|
|
@@ -157,29 +166,68 @@ async function startSession(
|
|
|
157
166
|
): Promise<void> {
|
|
158
167
|
const sessionId = generateSessionId();
|
|
159
168
|
|
|
169
|
+
// App sessions launch an operator-predefined image by name: the catalog
|
|
170
|
+
// resolves the image and operator env server-side, so the client can neither
|
|
171
|
+
// pick the image nor reach the secrets — the catalog IS the gate, and an
|
|
172
|
+
// unknown name is rejected here.
|
|
173
|
+
const appEntry = body.app === undefined ? undefined : deps.apps?.[body.app];
|
|
174
|
+
if (body.app !== undefined && !appEntry) {
|
|
175
|
+
const offered = Object.keys(deps.apps ?? {});
|
|
176
|
+
reply.code(400).send({
|
|
177
|
+
error: "unknown_app",
|
|
178
|
+
message:
|
|
179
|
+
`app '${body.app}' is not offered by this runner` +
|
|
180
|
+
(offered.length > 0
|
|
181
|
+
? ` — offered apps: ${offered.join(", ")}`
|
|
182
|
+
: " — it offers no predefined applications") +
|
|
183
|
+
" (see /v1/capabilities).",
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (!appEntry && (!body.bundle || !body.config)) {
|
|
188
|
+
reply.code(400).send({
|
|
189
|
+
error: "invalid_request",
|
|
190
|
+
message: "'bundle' and 'config' are required unless launching a predefined app via 'app'.",
|
|
191
|
+
});
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let bundle: RunBundle;
|
|
160
196
|
let entryRelative: string;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
entryRelative =
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
197
|
+
let config: SessionConfig;
|
|
198
|
+
if (appEntry) {
|
|
199
|
+
// Self-contained image — no bundle to deliver; the entry path is an unused
|
|
200
|
+
// placeholder so the backend spec stays total.
|
|
201
|
+
bundle = { entryRelativePath: "telo.yaml", files: [] };
|
|
202
|
+
entryRelative = bundle.entryRelativePath;
|
|
203
|
+
config = { image: appEntry.image, pullPolicy: appEntry.pullPolicy };
|
|
204
|
+
} else {
|
|
205
|
+
bundle = body.bundle!;
|
|
206
|
+
config = body.config!;
|
|
207
|
+
try {
|
|
208
|
+
// Traversal guard for the entry path and every bundle file — a `../foo`
|
|
209
|
+
// would let the workload read or execute paths outside its session dir.
|
|
210
|
+
// Validated here (backend-neutral) so a bad path is a 400, not a backend
|
|
211
|
+
// 500, regardless of how the backend ultimately delivers the bundle.
|
|
212
|
+
entryRelative = normalizeBundlePath(bundle.entryRelativePath);
|
|
213
|
+
for (const file of bundle.files) normalizeBundlePath(file.relativePath);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if (err instanceof BundlePathError) {
|
|
216
|
+
reply.code(400).send({ error: "invalid_bundle", message: err.message });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
throw err;
|
|
172
220
|
}
|
|
173
|
-
throw err;
|
|
174
|
-
}
|
|
175
221
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
222
|
+
// Backend config gate (e.g. an image allowlist). The advertised capabilities
|
|
223
|
+
// constrain the editor; this enforces the same against any client. App
|
|
224
|
+
// sessions skip it — their image comes from the catalog, not the client.
|
|
225
|
+
if (deps.validateConfig) {
|
|
226
|
+
const message = deps.validateConfig(config);
|
|
227
|
+
if (message) {
|
|
228
|
+
reply.code(400).send({ error: "invalid_config", message });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
183
231
|
}
|
|
184
232
|
}
|
|
185
233
|
|
|
@@ -194,16 +242,28 @@ async function startSession(
|
|
|
194
242
|
throw err;
|
|
195
243
|
}
|
|
196
244
|
|
|
245
|
+
// For an app session, drop client-supplied values for any env key the
|
|
246
|
+
// catalog defines (a client must never override operator-held values, which
|
|
247
|
+
// include secrets), then inject the operator's values.
|
|
248
|
+
const clientEnv = appEntry
|
|
249
|
+
? {
|
|
250
|
+
...Object.fromEntries(
|
|
251
|
+
Object.entries(body.env).filter(([key]) => !(key in appEntry.env)),
|
|
252
|
+
),
|
|
253
|
+
...appEntry.env,
|
|
254
|
+
}
|
|
255
|
+
: body.env;
|
|
256
|
+
|
|
197
257
|
// Surface a TELO_REGISTRY_URL to the workload so the telo CLI inside picks
|
|
198
|
-
// it up. Precedence:
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
const configRegistryUrl =
|
|
258
|
+
// it up. Precedence: explicit env value > config.registryUrl (per-request
|
|
259
|
+
// override) > runner's own default. Trim client-supplied URLs so stray
|
|
260
|
+
// whitespace from an editor input doesn't flow into the workload.
|
|
261
|
+
const configRegistryUrl = config.registryUrl?.trim() || undefined;
|
|
202
262
|
const registryUrl = configRegistryUrl ?? deps.defaultRegistryUrl;
|
|
203
263
|
const sessionEnv =
|
|
204
|
-
registryUrl && !("TELO_REGISTRY_URL" in
|
|
205
|
-
? { ...
|
|
206
|
-
:
|
|
264
|
+
registryUrl && !("TELO_REGISTRY_URL" in clientEnv)
|
|
265
|
+
? { ...clientEnv, TELO_REGISTRY_URL: registryUrl }
|
|
266
|
+
: clientEnv;
|
|
207
267
|
|
|
208
268
|
// Respond as soon as the session is registered — BEFORE the backend starts.
|
|
209
269
|
// `backend.start()` now spans the on-cluster image build and pod bring-up,
|
|
@@ -221,11 +281,12 @@ async function startSession(
|
|
|
221
281
|
deps.backend
|
|
222
282
|
.start({
|
|
223
283
|
sessionId,
|
|
224
|
-
bundle
|
|
284
|
+
bundle,
|
|
225
285
|
entryRelativePath: entryRelative,
|
|
226
286
|
env: sessionEnv,
|
|
227
287
|
ports: body.ports ?? [],
|
|
228
|
-
config
|
|
288
|
+
config,
|
|
289
|
+
selfContained: appEntry !== undefined,
|
|
229
290
|
inspect: body.inspect ?? false,
|
|
230
291
|
onStatus: (status) => deps.registry.emit(sessionId, { type: "status", status }),
|
|
231
292
|
onProgress: (phase, message, done) =>
|
|
@@ -238,6 +299,8 @@ async function startSession(
|
|
|
238
299
|
onDebug: (frame) => {
|
|
239
300
|
if (isEventFrame(frame)) deps.registry.emit(sessionId, { type: "debug", frame });
|
|
240
301
|
},
|
|
302
|
+
onReachability: (port, state) =>
|
|
303
|
+
deps.registry.emit(sessionId, { type: "reachability", port, state }),
|
|
241
304
|
isUserStopped: () => entry.userStopped,
|
|
242
305
|
})
|
|
243
306
|
.then(async (session) => {
|
package/src/server.ts
CHANGED
|
@@ -3,8 +3,8 @@ import cors from "@fastify/cors";
|
|
|
3
3
|
import websocket from "@fastify/websocket";
|
|
4
4
|
|
|
5
5
|
import type { RunnerBackend } from "./backend.js";
|
|
6
|
-
import type { RunnerCoreConfig } from "./config.js";
|
|
7
|
-
import type { RunnerCapabilities, SessionConfig } from "./contract.js";
|
|
6
|
+
import type { ResolvedRunnerApp, RunnerCoreConfig } from "./config.js";
|
|
7
|
+
import type { RunnerAppDescriptor, RunnerCapabilities, SessionConfig } from "./contract.js";
|
|
8
8
|
import { capabilitiesRoute } from "./routes/capabilities.js";
|
|
9
9
|
import { healthRoute } from "./routes/health.js";
|
|
10
10
|
import { ioRoute } from "./routes/io.js";
|
|
@@ -25,8 +25,13 @@ export interface ServerDeps {
|
|
|
25
25
|
/** Runner's default registry URL, passed to workloads as TELO_REGISTRY_URL. */
|
|
26
26
|
defaultRegistryUrl?: string;
|
|
27
27
|
/** Backend config gate, enforced on `POST /v1/sessions` before the workload
|
|
28
|
-
* starts (e.g. an `image` allowlist). Rejects with `400 invalid_config`.
|
|
28
|
+
* starts (e.g. an `image` allowlist). Rejects with `400 invalid_config`.
|
|
29
|
+
* Not consulted for app sessions — their image comes from `apps`. */
|
|
29
30
|
validateConfig?: (config: SessionConfig) => string | undefined;
|
|
31
|
+
/** Operator-predefined applications launchable by name (usually
|
|
32
|
+
* `loadResolvedApps(process.env)`). Advertised on /v1/capabilities as
|
|
33
|
+
* `apps` descriptors; the session route resolves and gates against it. */
|
|
34
|
+
apps?: Record<string, ResolvedRunnerApp>;
|
|
30
35
|
registry?: SessionRegistry;
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -58,13 +63,26 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
|
|
|
58
63
|
replayBufferBytes: deps.config.replayBufferBytes,
|
|
59
64
|
});
|
|
60
65
|
|
|
66
|
+
// The app catalog is injected into the served capabilities document here, so
|
|
67
|
+
// what /v1/capabilities advertises and what the session route accepts can
|
|
68
|
+
// never drift — both come from `deps.apps`.
|
|
69
|
+
const appDescriptors: RunnerAppDescriptor[] = Object.values(deps.apps ?? {}).map(
|
|
70
|
+
({ name, title, description }) => ({ name, title, description }),
|
|
71
|
+
);
|
|
72
|
+
const withApps = (caps: RunnerCapabilities): RunnerCapabilities =>
|
|
73
|
+
appDescriptors.length > 0 ? { ...caps, apps: appDescriptors } : caps;
|
|
74
|
+
const capabilitiesGetter =
|
|
75
|
+
typeof deps.capabilities === "function"
|
|
76
|
+
? () => withApps((deps.capabilities as () => RunnerCapabilities)())
|
|
77
|
+
: withApps(deps.capabilities);
|
|
78
|
+
|
|
61
79
|
// Terms are stable across the process — resolve the capabilities once for them
|
|
62
80
|
// even when `capabilities` is a getter (the route still re-resolves per request).
|
|
63
81
|
const capabilitiesValue =
|
|
64
|
-
typeof
|
|
82
|
+
typeof capabilitiesGetter === "function" ? capabilitiesGetter() : capabilitiesGetter;
|
|
65
83
|
|
|
66
84
|
await app.register(healthRoute(deps.version));
|
|
67
|
-
await app.register(capabilitiesRoute(
|
|
85
|
+
await app.register(capabilitiesRoute(capabilitiesGetter));
|
|
68
86
|
await app.register(probeRoute({ backend: deps.backend }));
|
|
69
87
|
await app.register(
|
|
70
88
|
sessionsRoute({
|
|
@@ -76,6 +94,7 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
|
|
|
76
94
|
// The capabilities document is the single source of the runner's terms;
|
|
77
95
|
// the session route enforces what /v1/capabilities advertises.
|
|
78
96
|
terms: capabilitiesValue.terms,
|
|
97
|
+
apps: deps.apps,
|
|
79
98
|
}),
|
|
80
99
|
);
|
|
81
100
|
await app.register(ioRoute({ registry, corsOrigins: deps.config.corsOrigins }));
|