@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,265 @@
1
+ import type { FastifyInstance, FastifyPluginAsync, FastifyRequest } from "fastify";
2
+ import type { WebSocket } from "@fastify/websocket";
3
+
4
+ import { isTerminal } from "../contract.js";
5
+ import type { BufferedBytes } from "../session/byte-ring-buffer.js";
6
+ import type { SessionRegistry } from "../session/registry.js";
7
+
8
+ export interface IoRouteDeps {
9
+ registry: SessionRegistry;
10
+ corsOrigins: string[] | "*";
11
+ }
12
+
13
+ const RESIZE_THROTTLE_MS = 50;
14
+ const SEQ_PREFIX_BYTES = 4;
15
+ /** Upper bound on cols/rows accepted from the client. xterm + a fit addon
16
+ * produce values in the low thousands at extreme zoom; anything past this
17
+ * is either nonsense or a malicious client trying to feed
18
+ * `Number.MAX_SAFE_INTEGER` straight to the workload. */
19
+ const MAX_RESIZE_DIMENSION = 10_000;
20
+ const TERMINAL_DRAIN_INTERVAL_MS = 50;
21
+ const TERMINAL_DRAIN_MAX_MS = 2_000;
22
+
23
+ interface ControlFrame {
24
+ type: string;
25
+ cols?: number;
26
+ rows?: number;
27
+ }
28
+
29
+ export function ioRoute(deps: IoRouteDeps): FastifyPluginAsync {
30
+ return async (app: FastifyInstance) => {
31
+ app.get<{ Params: { id: string }; Querystring: { lastSeq?: string } }>(
32
+ "/v1/sessions/:id/io",
33
+ { websocket: true },
34
+ (socket, req) => handleIo(socket, req, deps),
35
+ );
36
+ };
37
+ }
38
+
39
+ function handleIo(
40
+ socket: WebSocket,
41
+ req: FastifyRequest<{ Params: { id: string }; Querystring: { lastSeq?: string } }>,
42
+ deps: IoRouteDeps,
43
+ ): void {
44
+ // Origin allowlist runs INSIDE the handler (post-handshake) rather than
45
+ // in a preValidation hook. Reason: a 403 HTTP response on a failed
46
+ // upgrade is invisible to browser WebSocket clients — they only ever
47
+ // see close code 1006 (abnormal closure), which is indistinguishable
48
+ // from a transient network failure and triggers exponential-backoff
49
+ // reconnect loops forever. Closing with an application code (4403) the
50
+ // browser CAN read lets the client fail fast.
51
+ const origin = headerString(req.headers.origin);
52
+ if (!isOriginAllowed(deps.corsOrigins, origin)) {
53
+ closeWith(socket, 4403, "forbidden_origin");
54
+ return;
55
+ }
56
+
57
+ const sessionId = req.params.id;
58
+ const entry = deps.registry.get(sessionId);
59
+ if (!entry) {
60
+ closeWith(socket, 4404, "session not found");
61
+ return;
62
+ }
63
+
64
+ const lastSeq = parseLastSeq(req.query.lastSeq);
65
+
66
+ // Subscribe BEFORE snapshotting the replay buffer. This is load-bearing:
67
+ // a `pushBytes` that fires between snapshot and subscribe would otherwise
68
+ // be lost, with no way for either side to detect the gap. Bytes that
69
+ // arrive during the deferred window are queued; once replay is sent, the
70
+ // queue is drained (filtering anything seq-overlapping with replay) and
71
+ // the handler switches to direct-send mode.
72
+ let mode: "deferred" | "direct" = "deferred";
73
+ const liveQueue: BufferedBytes[] = [];
74
+ const unsubscribe = deps.registry.subscribeBytes(sessionId, (buffered) => {
75
+ if (socket.readyState !== socket.OPEN) return;
76
+ if (mode === "deferred") {
77
+ liveQueue.push(buffered);
78
+ } else {
79
+ sendBytesFrame(socket, buffered);
80
+ }
81
+ });
82
+
83
+ const { entries, hasGap } = entry.byteBuffer.replay(lastSeq);
84
+
85
+ // If the session is already terminal AND the byte buffer holds nothing
86
+ // newer than what the client already saw, there's no live channel to
87
+ // attach to — close 4410 so the client falls back to status-only display.
88
+ if (isTerminal(entry.status) && entries.length === 0 && liveQueue.length === 0) {
89
+ unsubscribe();
90
+ closeWith(socket, 4410, "session terminal, nothing to replay");
91
+ return;
92
+ }
93
+
94
+ // Confirm the resume point so the client can validate its own bookkeeping.
95
+ sendJson(socket, { type: "seq", seq: lastSeq });
96
+ if (hasGap) {
97
+ sendJson(socket, { type: "gap", reason: "buffer_evicted" });
98
+ }
99
+ for (const buffered of entries) {
100
+ sendBytesFrame(socket, buffered);
101
+ }
102
+
103
+ // Drain any live pushes that landed during the deferred window. Replay
104
+ // entries are seq-monotonic, so anything queued with seq <= the last
105
+ // replayed seq is a duplicate and must be dropped.
106
+ const lastReplayedSeq =
107
+ entries.length > 0 ? entries[entries.length - 1]!.seq : lastSeq;
108
+ for (const buffered of liveQueue) {
109
+ if (buffered.seq > lastReplayedSeq) {
110
+ sendBytesFrame(socket, buffered);
111
+ }
112
+ }
113
+ liveQueue.length = 0;
114
+ // Synchronous flip — JS event loop guarantees no pushBytes can fire
115
+ // between the drain loop above and this assignment, so the handler
116
+ // doesn't need to lock.
117
+ mode = "direct";
118
+
119
+ let resizeTimer: NodeJS.Timeout | null = null;
120
+ let pendingResize: { cols: number; rows: number } | null = null;
121
+ const flushResize = (): void => {
122
+ resizeTimer = null;
123
+ const next = pendingResize;
124
+ pendingResize = null;
125
+ if (!next) return;
126
+ // Session may have exited between schedule and flush; backends treat
127
+ // resize on a gone workload as a no-op.
128
+ entry.session?.resize(next.cols, next.rows);
129
+ };
130
+
131
+ socket.on("message", (raw, isBinary) => {
132
+ if (isBinary) {
133
+ const session = entry.session;
134
+ if (!session) return;
135
+ const buf = raw instanceof Buffer ? raw : Buffer.from(raw as ArrayBuffer);
136
+ session.writeStdin(buf);
137
+ return;
138
+ }
139
+ const text = raw.toString();
140
+ let parsed: ControlFrame;
141
+ try {
142
+ parsed = JSON.parse(text) as ControlFrame;
143
+ } catch {
144
+ return;
145
+ }
146
+ if (parsed.type === "resize") {
147
+ const cols = clampDimension(parsed.cols);
148
+ const rows = clampDimension(parsed.rows);
149
+ if (cols === null || rows === null) return;
150
+ pendingResize = { cols, rows };
151
+ if (resizeTimer === null) {
152
+ resizeTimer = setTimeout(flushResize, RESIZE_THROTTLE_MS);
153
+ }
154
+ }
155
+ });
156
+
157
+ // Close the socket when the session reaches terminal status — replay
158
+ // already covered the buffered bytes, no live bytes will arrive after
159
+ // this, and clients can rely on socket close as their "stream finished"
160
+ // signal alongside the SSE status frame. We poll `bufferedAmount` so
161
+ // that an immediate close doesn't drop frames still queued on the
162
+ // server-side socket.
163
+ let drainTimer: NodeJS.Timeout | null = null;
164
+ const drainAndClose = (deadline: number): void => {
165
+ drainTimer = null;
166
+ if (socket.readyState !== socket.OPEN) return;
167
+ if (socket.bufferedAmount === 0 || Date.now() >= deadline) {
168
+ try {
169
+ socket.close(1000, "session terminal");
170
+ } catch {
171
+ /* already closed */
172
+ }
173
+ return;
174
+ }
175
+ drainTimer = setTimeout(() => drainAndClose(deadline), TERMINAL_DRAIN_INTERVAL_MS);
176
+ };
177
+
178
+ const unsubscribeStatus = deps.registry.subscribe(sessionId, (buffered) => {
179
+ if (buffered.event.type !== "status") return;
180
+ if (!isTerminal(entry.status)) return;
181
+ if (drainTimer !== null) return;
182
+ drainAndClose(Date.now() + TERMINAL_DRAIN_MAX_MS);
183
+ });
184
+
185
+ // Late-connect case: the session was already terminal at connect time
186
+ // and had replayable bytes (so we didn't take the 4410 fast path). The
187
+ // status subscription above will never fire — so the socket would
188
+ // otherwise stay open forever after replay completes. Schedule directly.
189
+ if (isTerminal(entry.status) && drainTimer === null) {
190
+ drainAndClose(Date.now() + TERMINAL_DRAIN_MAX_MS);
191
+ }
192
+
193
+ const cleanup = (): void => {
194
+ unsubscribe();
195
+ unsubscribeStatus();
196
+ if (resizeTimer) clearTimeout(resizeTimer);
197
+ if (drainTimer) clearTimeout(drainTimer);
198
+ };
199
+ socket.on("close", cleanup);
200
+ socket.on("error", cleanup);
201
+ }
202
+
203
+ /** Wire format for a binary frame: `[seq:4 BE][payload:N]`. The 4-byte
204
+ * prefix lets the client de-sync detect — it knows the authoritative seq
205
+ * for every byte received, instead of inferring from frame count. Replay
206
+ * duplicates and reconnect resumes both lean on this. */
207
+ function sendBytesFrame(socket: WebSocket, buffered: BufferedBytes): void {
208
+ if (socket.readyState !== socket.OPEN) return;
209
+ const prefix = Buffer.alloc(SEQ_PREFIX_BYTES);
210
+ prefix.writeUInt32BE(buffered.seq, 0);
211
+ try {
212
+ socket.send(Buffer.concat([prefix, buffered.bytes]));
213
+ } catch {
214
+ /* socket closed under our feet */
215
+ }
216
+ }
217
+
218
+ function closeWith(socket: WebSocket, code: number, reason: string): void {
219
+ try {
220
+ socket.close(code, reason);
221
+ } catch {
222
+ /* socket already closed */
223
+ }
224
+ }
225
+
226
+ function sendJson(socket: WebSocket, payload: unknown): void {
227
+ if (socket.readyState !== socket.OPEN) return;
228
+ try {
229
+ socket.send(JSON.stringify(payload));
230
+ } catch {
231
+ /* socket closed under our feet */
232
+ }
233
+ }
234
+
235
+ function parseLastSeq(raw: string | undefined): number {
236
+ if (!raw) return 0;
237
+ const n = Number.parseInt(raw, 10);
238
+ return Number.isFinite(n) && n >= 0 ? n : 0;
239
+ }
240
+
241
+ function headerString(value: string | string[] | undefined): string | undefined {
242
+ if (Array.isArray(value)) return value[0];
243
+ return value;
244
+ }
245
+
246
+ function clampDimension(raw: unknown): number | null {
247
+ const n = Number(raw);
248
+ if (!Number.isFinite(n)) return null;
249
+ const floored = Math.floor(n);
250
+ if (floored < 1) return null;
251
+ return Math.min(floored, MAX_RESIZE_DIMENSION);
252
+ }
253
+
254
+ function isOriginAllowed(
255
+ corsOrigins: string[] | "*",
256
+ origin: string | undefined,
257
+ ): boolean {
258
+ if (corsOrigins === "*") return true;
259
+ // Browsers ALWAYS send Origin on WebSocket upgrades — a missing Origin
260
+ // means the request is from a non-browser (curl, internal script, an
261
+ // attacker who stripped the header). When an explicit allowlist is
262
+ // configured, an absent Origin is a rejection.
263
+ if (!origin) return false;
264
+ return corsOrigins.includes(origin);
265
+ }
@@ -0,0 +1,35 @@
1
+ import type { FastifyInstance, FastifyPluginAsync } from "fastify";
2
+
3
+ import type { RunnerBackend } from "../backend.js";
4
+ import type { ProbeConfig } from "../contract.js";
5
+
6
+ export interface ProbeRouteDeps {
7
+ backend: RunnerBackend;
8
+ }
9
+
10
+ const bodySchema = {
11
+ type: "object",
12
+ required: ["config"],
13
+ additionalProperties: false,
14
+ properties: {
15
+ config: {
16
+ type: "object",
17
+ required: ["image", "pullPolicy"],
18
+ additionalProperties: false,
19
+ properties: {
20
+ image: { type: "string", minLength: 1 },
21
+ pullPolicy: { type: "string", enum: ["missing", "always", "never"] },
22
+ },
23
+ },
24
+ },
25
+ } as const;
26
+
27
+ export function probeRoute(deps: ProbeRouteDeps): FastifyPluginAsync {
28
+ return async (app: FastifyInstance) => {
29
+ app.post<{ Body: { config: ProbeConfig } }>(
30
+ "/v1/probe",
31
+ { schema: { body: bodySchema } },
32
+ async (req) => deps.backend.probe(req.body.config),
33
+ );
34
+ };
35
+ }
@@ -0,0 +1,270 @@
1
+ import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from "fastify";
2
+
3
+ import { isEventFrame } from "@telorun/debug-wire";
4
+ import type { RunnerBackend } from "../backend.js";
5
+ import {
6
+ ACCEPTED_TERMS_HEADER,
7
+ SessionStartError,
8
+ type RunnerTerms,
9
+ type SessionConfig,
10
+ type StartSessionRequest,
11
+ } from "../contract.js";
12
+ import { BundlePathError, normalizeBundlePath } from "../session/bundle-path.js";
13
+ import { generateSessionId } from "../session/session-id.js";
14
+ import { SessionLimitError, type SessionRegistry } from "../session/registry.js";
15
+ import { streamSessionEvents } from "../sse/channel.js";
16
+
17
+ export interface SessionsRouteDeps {
18
+ backend: RunnerBackend;
19
+ registry: SessionRegistry;
20
+ corsOrigins: string[] | "*";
21
+ /** The runner's own default registry URL, surfaced to the workload as
22
+ * TELO_REGISTRY_URL when the request doesn't override it. */
23
+ defaultRegistryUrl?: string;
24
+ /** When set, a session may only start if the client acknowledges this exact
25
+ * terms version via the `x-telo-accepted-terms` header. */
26
+ terms?: RunnerTerms;
27
+ /** Backend-supplied config gate. Returns an error message to reject the
28
+ * request with `400 invalid_config`, or `undefined` to accept. The runner is
29
+ * the source of truth, so this re-checks what `/v1/capabilities` advertises
30
+ * (e.g. an `image` allowlist) against a client that skipped the editor. */
31
+ validateConfig?: (config: SessionConfig) => string | undefined;
32
+ }
33
+
34
+ const startBodySchema = {
35
+ type: "object",
36
+ required: ["bundle", "env", "config"],
37
+ properties: {
38
+ bundle: {
39
+ type: "object",
40
+ required: ["entryRelativePath", "files"],
41
+ properties: {
42
+ entryRelativePath: { type: "string", minLength: 1 },
43
+ files: {
44
+ type: "array",
45
+ items: {
46
+ type: "object",
47
+ required: ["relativePath", "contents"],
48
+ properties: {
49
+ relativePath: { type: "string", minLength: 1 },
50
+ contents: { type: "string" },
51
+ },
52
+ },
53
+ },
54
+ },
55
+ },
56
+ env: {
57
+ type: "object",
58
+ additionalProperties: { type: "string" },
59
+ },
60
+ ports: {
61
+ type: "array",
62
+ items: {
63
+ type: "object",
64
+ required: ["port", "protocol"],
65
+ properties: {
66
+ port: { type: "integer", minimum: 1, maximum: 65535 },
67
+ protocol: { type: "string", enum: ["tcp", "udp"] },
68
+ },
69
+ },
70
+ },
71
+ config: {
72
+ type: "object",
73
+ required: ["image", "pullPolicy"],
74
+ properties: {
75
+ image: { type: "string", minLength: 1 },
76
+ pullPolicy: { type: "string", enum: ["missing", "always", "never"] },
77
+ registryUrl: { type: "string", minLength: 1 },
78
+ },
79
+ },
80
+ inspect: { type: "boolean" },
81
+ },
82
+ } as const;
83
+
84
+ export function sessionsRoute(deps: SessionsRouteDeps): FastifyPluginAsync {
85
+ return async (app: FastifyInstance) => {
86
+ app.post<{ Body: StartSessionRequest }>(
87
+ "/v1/sessions",
88
+ { schema: { body: startBodySchema } },
89
+ async (req, reply) => {
90
+ // Terms enforcement — the server is the source of truth, so a client that
91
+ // skips the editor gate still can't start a session without acknowledging
92
+ // the current terms version.
93
+ if (deps.terms) {
94
+ const raw = req.headers[ACCEPTED_TERMS_HEADER];
95
+ const accepted = Array.isArray(raw) ? raw[0] : raw;
96
+ if (accepted !== deps.terms.version) {
97
+ reply.code(428).send({ error: "terms_required", terms: deps.terms });
98
+ return;
99
+ }
100
+ }
101
+ return startSession(app, deps, req.body, reply);
102
+ },
103
+ );
104
+
105
+ app.get<{ Params: { id: string } }>("/v1/sessions/:id", async (req, reply) => {
106
+ const entry = deps.registry.get(req.params.id);
107
+ if (!entry) {
108
+ reply.code(404).send({ error: "not_found", message: `session '${req.params.id}' not in registry` });
109
+ return;
110
+ }
111
+ reply.send({
112
+ sessionId: entry.sessionId,
113
+ status: entry.status,
114
+ createdAt: entry.createdAt.toISOString(),
115
+ exitedAt: entry.exitedAt?.toISOString(),
116
+ });
117
+ });
118
+
119
+ app.delete<{ Params: { id: string } }>("/v1/sessions/:id", async (req, reply) => {
120
+ const entry = deps.registry.get(req.params.id);
121
+ if (!entry) {
122
+ reply.code(204).send();
123
+ return;
124
+ }
125
+ entry.userStopped = true;
126
+ if (entry.session) {
127
+ try {
128
+ await entry.session.stop();
129
+ } catch (err) {
130
+ app.log.error({ err, sessionId: entry.sessionId }, "failed to stop session");
131
+ reply.code(500).send({ error: "stop_failed", message: (err as Error).message });
132
+ return;
133
+ }
134
+ }
135
+ reply.code(204).send();
136
+ });
137
+
138
+ app.get<{ Params: { id: string }; Querystring: { lastEventId?: string } }>(
139
+ "/v1/sessions/:id/events",
140
+ async (req, reply) =>
141
+ streamSessionEvents({
142
+ registry: deps.registry,
143
+ req,
144
+ reply,
145
+ sessionId: req.params.id,
146
+ corsOrigins: deps.corsOrigins,
147
+ }),
148
+ );
149
+ };
150
+ }
151
+
152
+ async function startSession(
153
+ app: FastifyInstance,
154
+ deps: SessionsRouteDeps,
155
+ body: StartSessionRequest,
156
+ reply: FastifyReply,
157
+ ): Promise<void> {
158
+ const sessionId = generateSessionId();
159
+
160
+ let entryRelative: string;
161
+ try {
162
+ // Traversal guard for the entry path and every bundle file — a `../foo`
163
+ // would let the workload read or execute paths outside its session dir.
164
+ // Validated here (backend-neutral) so a bad path is a 400, not a backend
165
+ // 500, regardless of how the backend ultimately delivers the bundle.
166
+ entryRelative = normalizeBundlePath(body.bundle.entryRelativePath);
167
+ for (const file of body.bundle.files) normalizeBundlePath(file.relativePath);
168
+ } catch (err) {
169
+ if (err instanceof BundlePathError) {
170
+ reply.code(400).send({ error: "invalid_bundle", message: err.message });
171
+ return;
172
+ }
173
+ throw err;
174
+ }
175
+
176
+ // Backend config gate (e.g. an image allowlist). The advertised capabilities
177
+ // constrain the editor; this enforces the same against any client.
178
+ if (deps.validateConfig) {
179
+ const message = deps.validateConfig(body.config);
180
+ if (message) {
181
+ reply.code(400).send({ error: "invalid_config", message });
182
+ return;
183
+ }
184
+ }
185
+
186
+ let entry: ReturnType<SessionRegistry["register"]>;
187
+ try {
188
+ entry = deps.registry.register({ sessionId });
189
+ } catch (err) {
190
+ if (err instanceof SessionLimitError) {
191
+ reply.code(409).send({ error: "too_many_sessions", message: err.message });
192
+ return;
193
+ }
194
+ throw err;
195
+ }
196
+
197
+ // Surface a TELO_REGISTRY_URL to the workload so the telo CLI inside picks
198
+ // it up. Precedence: body.env explicit value > body.config.registryUrl
199
+ // (per-request override) > runner's own default. Trim client-supplied URLs
200
+ // so stray whitespace from an editor input doesn't flow into the workload.
201
+ const configRegistryUrl = body.config.registryUrl?.trim() || undefined;
202
+ const registryUrl = configRegistryUrl ?? deps.defaultRegistryUrl;
203
+ const sessionEnv =
204
+ registryUrl && !("TELO_REGISTRY_URL" in body.env)
205
+ ? { ...body.env, TELO_REGISTRY_URL: registryUrl }
206
+ : body.env;
207
+
208
+ // Respond as soon as the session is registered — BEFORE the backend starts.
209
+ // `backend.start()` now spans the on-cluster image build and pod bring-up,
210
+ // which can take seconds-to-minutes; awaiting it here would hide the event
211
+ // stream until the workload is already up, so the client never sees build /
212
+ // provision / boot progress live. Returning the streamUrl first lets the
213
+ // client connect immediately; start runs in the background and its progress,
214
+ // output, and terminal status flow over the stream.
215
+ reply.code(201).send({
216
+ sessionId,
217
+ streamUrl: `/v1/sessions/${sessionId}/events`,
218
+ createdAt: entry.createdAt.toISOString(),
219
+ });
220
+
221
+ deps.backend
222
+ .start({
223
+ sessionId,
224
+ bundle: body.bundle,
225
+ entryRelativePath: entryRelative,
226
+ env: sessionEnv,
227
+ ports: body.ports ?? [],
228
+ config: body.config,
229
+ inspect: body.inspect ?? false,
230
+ onStatus: (status) => deps.registry.emit(sessionId, { type: "status", status }),
231
+ onProgress: (phase, message, done) =>
232
+ deps.registry.emit(sessionId, { type: "progress", phase, message, done }),
233
+ onOutput: (chunk) => deps.registry.pushBytes(sessionId, chunk),
234
+ // Relay only kernel *event* frames to the client. stdout/stderr already
235
+ // arrive over the byte channel (onOutput), so forwarding log frames would
236
+ // double the traffic and let log spam evict lifecycle events from the
237
+ // byte-capped replay buffer. The editor discards relayed logs anyway.
238
+ onDebug: (frame) => {
239
+ if (isEventFrame(frame)) deps.registry.emit(sessionId, { type: "debug", frame });
240
+ },
241
+ isUserStopped: () => entry.userStopped,
242
+ })
243
+ .then(async (session) => {
244
+ entry.session = session;
245
+ // Pre-start DELETE race: a DELETE received during backend.start (e.g.
246
+ // while an image build was running) can't stop a workload that didn't
247
+ // exist yet — it set userStopped and returned 204. Now that the workload
248
+ // is live, honor the earlier DELETE.
249
+ if (entry.userStopped) {
250
+ try {
251
+ await session.stop();
252
+ } catch (err) {
253
+ app.log.warn({ err, sessionId }, "failed to stop after race with pre-start DELETE");
254
+ }
255
+ }
256
+ })
257
+ .catch((err) => {
258
+ // The 201 is already sent, so a start failure surfaces as a terminal
259
+ // `failed` status on the stream (the registry schedules eviction on a
260
+ // terminal status; the SSE channel delivers it, then closes).
261
+ const message =
262
+ err instanceof SessionStartError
263
+ ? `${err.stage}: ${err.message}`
264
+ : err instanceof Error
265
+ ? err.message
266
+ : String(err);
267
+ app.log.error({ err, sessionId }, "session start failed");
268
+ deps.registry.emit(sessionId, { type: "status", status: { kind: "failed", message } });
269
+ });
270
+ }
package/src/server.ts ADDED
@@ -0,0 +1,108 @@
1
+ import Fastify, { type FastifyBaseLogger, type FastifyInstance } from "fastify";
2
+ import cors from "@fastify/cors";
3
+ import websocket from "@fastify/websocket";
4
+
5
+ import type { RunnerBackend } from "./backend.js";
6
+ import type { RunnerCoreConfig } from "./config.js";
7
+ import type { RunnerCapabilities, SessionConfig } from "./contract.js";
8
+ import { capabilitiesRoute } from "./routes/capabilities.js";
9
+ import { healthRoute } from "./routes/health.js";
10
+ import { ioRoute } from "./routes/io.js";
11
+ import { probeRoute } from "./routes/probe.js";
12
+ import { sessionsRoute } from "./routes/sessions.js";
13
+ import { SessionRegistry } from "./session/registry.js";
14
+
15
+ export interface ServerDeps {
16
+ backend: RunnerBackend;
17
+ config: RunnerCoreConfig;
18
+ /** The concrete runner's package version, surfaced on /v1/health. */
19
+ version: string;
20
+ /** The runner's self-description + editable config schema, served on
21
+ * /v1/capabilities so the editor renders a generic runner config form. A
22
+ * getter is re-resolved per request — use it when the config surface changes
23
+ * at runtime (e.g. a base-image catalog refreshed from a registry). */
24
+ capabilities: RunnerCapabilities | (() => RunnerCapabilities);
25
+ /** Runner's default registry URL, passed to workloads as TELO_REGISTRY_URL. */
26
+ defaultRegistryUrl?: string;
27
+ /** Backend config gate, enforced on `POST /v1/sessions` before the workload
28
+ * starts (e.g. an `image` allowlist). Rejects with `400 invalid_config`. */
29
+ validateConfig?: (config: SessionConfig) => string | undefined;
30
+ registry?: SessionRegistry;
31
+ }
32
+
33
+ export interface ServerHandle {
34
+ app: FastifyInstance;
35
+ registry: SessionRegistry;
36
+ }
37
+
38
+ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
39
+ const app = Fastify({
40
+ logger: { level: deps.config.logLevel },
41
+ });
42
+
43
+ // CORS: SSE and fetch from the editor's browser origin are cross-origin by
44
+ // default. A runner with no auth is driven by whoever can reach the port, so
45
+ // default to `*` and let operators narrow via RUNNER_CORS_ORIGINS.
46
+ await app.register(cors, {
47
+ origin: deps.config.corsOrigins,
48
+ methods: ["GET", "POST", "DELETE", "OPTIONS"],
49
+ });
50
+
51
+ await app.register(websocket);
52
+
53
+ const registry =
54
+ deps.registry ??
55
+ new SessionRegistry({
56
+ maxSessions: deps.config.maxSessions,
57
+ exitTtlMs: deps.config.exitTtlMs,
58
+ replayBufferBytes: deps.config.replayBufferBytes,
59
+ });
60
+
61
+ // Terms are stable across the process — resolve the capabilities once for them
62
+ // even when `capabilities` is a getter (the route still re-resolves per request).
63
+ const capabilitiesValue =
64
+ typeof deps.capabilities === "function" ? deps.capabilities() : deps.capabilities;
65
+
66
+ await app.register(healthRoute(deps.version));
67
+ await app.register(capabilitiesRoute(deps.capabilities));
68
+ await app.register(probeRoute({ backend: deps.backend }));
69
+ await app.register(
70
+ sessionsRoute({
71
+ backend: deps.backend,
72
+ registry,
73
+ corsOrigins: deps.config.corsOrigins,
74
+ defaultRegistryUrl: deps.defaultRegistryUrl,
75
+ validateConfig: deps.validateConfig,
76
+ // The capabilities document is the single source of the runner's terms;
77
+ // the session route enforces what /v1/capabilities advertises.
78
+ terms: capabilitiesValue.terms,
79
+ }),
80
+ );
81
+ await app.register(ioRoute({ registry, corsOrigins: deps.config.corsOrigins }));
82
+
83
+ return { app, registry };
84
+ }
85
+
86
+ /**
87
+ * Stop every live session. Used by graceful shutdown — marks each entry
88
+ * userStopped and stops its backend workload so nothing leaks past process
89
+ * exit. Backend-neutral: it only touches the abstract `BackendSession`.
90
+ */
91
+ export async function stopAllSessions(
92
+ registry: SessionRegistry,
93
+ log: Pick<FastifyBaseLogger, "info" | "warn">,
94
+ ): Promise<void> {
95
+ const live = registry.list().filter((e) => e.session !== null && e.exitedAt === null);
96
+ if (live.length === 0) return;
97
+ log.info({ count: live.length }, "stopping live sessions before shutdown");
98
+ await Promise.all(
99
+ live.map(async (entry) => {
100
+ entry.userStopped = true;
101
+ try {
102
+ await entry.session?.stop();
103
+ } catch (err) {
104
+ log.warn({ err, sessionId: entry.sessionId }, "failed to stop session during shutdown");
105
+ }
106
+ }),
107
+ );
108
+ }