@opengeni/runtime 0.2.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.
Files changed (65) hide show
  1. package/dist/chunk-2PO56VAL.js +3478 -0
  2. package/dist/chunk-2PO56VAL.js.map +1 -0
  3. package/dist/index.d.ts +912 -0
  4. package/dist/index.js +3663 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/sandbox/index.d.ts +1738 -0
  7. package/dist/sandbox/index.js +187 -0
  8. package/dist/sandbox/index.js.map +1 -0
  9. package/package.json +49 -0
  10. package/src/bundled_hashicorp_terraform_skills/LICENSE +373 -0
  11. package/src/bundled_hashicorp_terraform_skills/README.md +18 -0
  12. package/src/bundled_hashicorp_terraform_skills/UPSTREAM_GIT_SHA +1 -0
  13. package/src/bundled_hashicorp_terraform_skills/azure-verified-modules/SKILL.md +613 -0
  14. package/src/bundled_hashicorp_terraform_skills/checkov/SKILL.md +43 -0
  15. package/src/bundled_hashicorp_terraform_skills/refactor-module/SKILL.md +538 -0
  16. package/src/bundled_hashicorp_terraform_skills/social-media-marketing/SKILL.md +35 -0
  17. package/src/bundled_hashicorp_terraform_skills/terraform-search-import/SKILL.md +372 -0
  18. package/src/bundled_hashicorp_terraform_skills/terraform-search-import/references/MANUAL-IMPORT.md +113 -0
  19. package/src/bundled_hashicorp_terraform_skills/terraform-search-import/scripts/list_resources.sh +38 -0
  20. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/SKILL.md +480 -0
  21. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/api-monitoring.md +543 -0
  22. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/component-blocks.md +476 -0
  23. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/deployment-blocks.md +391 -0
  24. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/examples.md +1529 -0
  25. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/linked-stacks.md +187 -0
  26. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/troubleshooting.md +671 -0
  27. package/src/bundled_hashicorp_terraform_skills/terraform-style-guide/SKILL.md +353 -0
  28. package/src/bundled_hashicorp_terraform_skills/terraform-test/SKILL.md +451 -0
  29. package/src/bundled_hashicorp_terraform_skills/terraform-test/references/CI_CD.md +80 -0
  30. package/src/bundled_hashicorp_terraform_skills/terraform-test/references/EXAMPLES.md +314 -0
  31. package/src/bundled_hashicorp_terraform_skills/terraform-test/references/MOCK_PROVIDERS.md +171 -0
  32. package/src/codex-tool-search.ts +267 -0
  33. package/src/context-compaction.ts +538 -0
  34. package/src/history-sanitizer.ts +719 -0
  35. package/src/index.ts +3299 -0
  36. package/src/sandbox/capabilities.ts +69 -0
  37. package/src/sandbox/channel-a.ts +1031 -0
  38. package/src/sandbox/display-stack.ts +231 -0
  39. package/src/sandbox/errors.ts +34 -0
  40. package/src/sandbox/index.ts +832 -0
  41. package/src/sandbox/providers/blaxel.ts +35 -0
  42. package/src/sandbox/providers/cloudflare.ts +24 -0
  43. package/src/sandbox/providers/daytona.ts +34 -0
  44. package/src/sandbox/providers/docker.ts +17 -0
  45. package/src/sandbox/providers/e2b.ts +36 -0
  46. package/src/sandbox/providers/index.ts +107 -0
  47. package/src/sandbox/providers/local.ts +13 -0
  48. package/src/sandbox/providers/modal.ts +55 -0
  49. package/src/sandbox/providers/none.ts +13 -0
  50. package/src/sandbox/providers/runloop.ts +32 -0
  51. package/src/sandbox/providers/selfhosted.ts +96 -0
  52. package/src/sandbox/providers/types.ts +38 -0
  53. package/src/sandbox/providers/vercel.ts +29 -0
  54. package/src/sandbox/recording.ts +286 -0
  55. package/src/sandbox/routing/backend-resolver.ts +189 -0
  56. package/src/sandbox/routing/routing-session.ts +455 -0
  57. package/src/sandbox/select.ts +371 -0
  58. package/src/sandbox/selfhosted/capabilities.ts +255 -0
  59. package/src/sandbox/selfhosted/control-rpc.ts +351 -0
  60. package/src/sandbox/selfhosted/session.ts +930 -0
  61. package/src/sandbox/selfhosted/testing.ts +230 -0
  62. package/src/sandbox/stream-port.ts +185 -0
  63. package/src/sandbox/stream-token.ts +90 -0
  64. package/src/sandbox/terminal-server.ts +203 -0
  65. package/src/sandbox-computer.ts +835 -0
@@ -0,0 +1,230 @@
1
+ // `MockAgentResponder` — an in-process `ControlRpc` test double standing in for
2
+ // a real enrolled agent over NATS (the live NATS transport is M4). It answers
3
+ // the op table (ping / exec / fs.read / fs.write / fs.list / fs.stat / git /
4
+ // metrics / desktopEnsure) against an in-memory virtual filesystem + a pluggable
5
+ // exec handler, so the `SelfhostedSession` surface and the mocked-NATS
6
+ // integration tests run with zero broker.
7
+ //
8
+ // It is shipped from the runtime package (a testing util, not test-only-private)
9
+ // because the API/worker integration suites (M4+) reuse it to drive
10
+ // `withChannelA`/viewer/swap end-to-end without a real machine (dossier §16).
11
+
12
+ import {
13
+ AgentError,
14
+ ControlRequest,
15
+ ControlResponse,
16
+ ErrorCode,
17
+ FsEntryKind,
18
+ type ExecRequest,
19
+ type ExecResponse,
20
+ type FsListResponse,
21
+ type FsReadResponse,
22
+ type FsStatResponse,
23
+ type FsWriteResponse,
24
+ } from "@opengeni/agent-proto";
25
+ import type { ControlRpc } from "./control-rpc";
26
+
27
+ const encoder = new TextEncoder();
28
+ const decoder = new TextDecoder();
29
+
30
+ /** A pluggable exec handler — given an ExecRequest, return an ExecResponse (or
31
+ * throw to surface a synthesized error). Defaults to a trivial echo. */
32
+ export type MockExecHandler = (req: ExecRequest) => ExecResponse | Promise<ExecResponse>;
33
+
34
+ export interface MockAgentResponderOptions {
35
+ /** Whether a responder exists at all. When false EVERY request yields an
36
+ * AGENT_OFFLINE error (the "machine is offline" condition) — used to drive the
37
+ * agent_offline capability + the isProviderSandboxNotFoundError test. */
38
+ online?: boolean;
39
+ /** Whether the agent has acknowledged whole-machine / screen-control consent.
40
+ * When false, an op gated on consent yields CONSENT_REQUIRED. Defaults true. */
41
+ consented?: boolean;
42
+ /** Force the agent into a draining posture (every op → DRAINING). */
43
+ draining?: boolean;
44
+ /** Seed files (path → string|Uint8Array) into the virtual filesystem. */
45
+ files?: Record<string, string | Uint8Array>;
46
+ /** A custom exec handler; defaults to an echo of argv. */
47
+ exec?: MockExecHandler;
48
+ /** The hostname the mock reports (so PTY/exec `$HOSTNAME`-style asserts work). */
49
+ hostname?: string;
50
+ }
51
+
52
+ /**
53
+ * An in-process `ControlRpc` answering the agent op table against an in-memory
54
+ * virtual filesystem. Drive a `SelfhostedSession` with this to test exec /
55
+ * readFile / writeFile / list / stat round-trips without any NATS.
56
+ */
57
+ export class MockAgentResponder implements ControlRpc {
58
+ private online: boolean;
59
+ private readonly consented: boolean;
60
+ private readonly draining: boolean;
61
+ private readonly files = new Map<string, Uint8Array>();
62
+ private readonly execHandler: MockExecHandler;
63
+ readonly hostname: string;
64
+
65
+ /** Every request seen, for assertion (subject + decoded ControlRequest). */
66
+ readonly requests: Array<{ subject: string; req: ControlRequest }> = [];
67
+
68
+ constructor(opts: MockAgentResponderOptions = {}) {
69
+ this.online = opts.online ?? true;
70
+ this.consented = opts.consented ?? true;
71
+ this.draining = opts.draining ?? false;
72
+ this.hostname = opts.hostname ?? "mock-machine";
73
+ this.execHandler = opts.exec ?? ((req) => defaultEcho(req, this.hostname));
74
+ for (const [path, content] of Object.entries(opts.files ?? {})) {
75
+ this.files.set(normalize(path), typeof content === "string" ? encoder.encode(content) : content);
76
+ }
77
+ }
78
+
79
+ /** Flip the responder offline mid-test (a deliberate stop / blip). */
80
+ setOnline(online: boolean): void {
81
+ this.online = online;
82
+ }
83
+
84
+ /** Read a file the session wrote (test assertion helper). */
85
+ fileText(path: string): string | undefined {
86
+ const bytes = this.files.get(normalize(path));
87
+ return bytes ? decoder.decode(bytes) : undefined;
88
+ }
89
+
90
+ async request(subject: string, req: ControlRequest, _opts: { timeoutMs: number }): Promise<ControlResponse> {
91
+ this.requests.push({ subject, req });
92
+ if (!this.online) {
93
+ return errorResponse(req.requestId, ErrorCode.ERROR_CODE_AGENT_OFFLINE, "the enrolled agent is offline", false);
94
+ }
95
+ if (this.draining) {
96
+ return errorResponse(req.requestId, ErrorCode.ERROR_CODE_DRAINING, "the agent is draining", true);
97
+ }
98
+ const op = req.op;
99
+ if (!op) {
100
+ return errorResponse(req.requestId, ErrorCode.ERROR_CODE_PROTOCOL, "empty op", false);
101
+ }
102
+ switch (op.$case) {
103
+ case "ping":
104
+ return ok(req.requestId, { $case: "ping", ping: { nonce: op.ping.nonce, agentMonotonicMs: "0" } });
105
+ case "exec": {
106
+ const res = await this.execHandler(op.exec);
107
+ return ok(req.requestId, { $case: "exec", exec: res });
108
+ }
109
+ case "fsRead": {
110
+ const bytes = this.files.get(normalize(op.fsRead.path));
111
+ if (!bytes) {
112
+ return errorResponse(req.requestId, ErrorCode.ERROR_CODE_NOT_FOUND, `no such file: ${op.fsRead.path}`, false);
113
+ }
114
+ const res: FsReadResponse = { content: bytes, totalSize: String(bytes.length) };
115
+ return ok(req.requestId, { $case: "fsRead", fsRead: res });
116
+ }
117
+ case "fsWrite": {
118
+ const path = normalize(op.fsWrite.path);
119
+ const next = op.fsWrite.append
120
+ ? concat(this.files.get(path) ?? new Uint8Array(0), op.fsWrite.content)
121
+ : op.fsWrite.content;
122
+ this.files.set(path, next);
123
+ const res: FsWriteResponse = { bytesWritten: String(op.fsWrite.content.length) };
124
+ return ok(req.requestId, { $case: "fsWrite", fsWrite: res });
125
+ }
126
+ case "fsList": {
127
+ const prefix = normalize(op.fsList.path).replace(/\/?$/, "/");
128
+ const res: FsListResponse = {
129
+ entries: [...this.files.keys()]
130
+ .filter((p) => p.startsWith(prefix))
131
+ .map((p) => {
132
+ const bytes = this.files.get(p)!;
133
+ const rel = p.slice(prefix.length);
134
+ return {
135
+ name: rel.split("/").pop() ?? rel,
136
+ path: rel,
137
+ kind: FsEntryKind.FS_ENTRY_KIND_FILE,
138
+ size: String(bytes.length),
139
+ modifiedMs: "0",
140
+ mode: 0o644,
141
+ };
142
+ }),
143
+ };
144
+ return ok(req.requestId, { $case: "fsList", fsList: res });
145
+ }
146
+ case "fsStat": {
147
+ const bytes = this.files.get(normalize(op.fsStat.path));
148
+ const res: FsStatResponse = bytes
149
+ ? {
150
+ exists: true,
151
+ entry: {
152
+ name: normalize(op.fsStat.path).split("/").pop() ?? "",
153
+ path: op.fsStat.path,
154
+ kind: FsEntryKind.FS_ENTRY_KIND_FILE,
155
+ size: String(bytes.length),
156
+ modifiedMs: "0",
157
+ mode: 0o644,
158
+ },
159
+ }
160
+ : { exists: false, entry: undefined };
161
+ return ok(req.requestId, { $case: "fsStat", fsStat: res });
162
+ }
163
+ case "desktopEnsure": {
164
+ // The desktop STREAM (view) is DISPLAY-gated, not consent-gated: the real
165
+ // agent registers the channel + captures frames regardless of screen-control
166
+ // consent (`register_desktop` in hub.rs sets `allow_input` from consent and
167
+ // the pump captures anyway) — only INPUT injection is gated. This stub has a
168
+ // (mock) display and does not model input injection, so desktopEnsure always
169
+ // succeeds; `consented` only affects the computer-use INPUT plane.
170
+ return ok(req.requestId, {
171
+ $case: "desktopEnsure",
172
+ desktopEnsure: {
173
+ channel: { channelId: "mock-desktop", workspaceId: "", agentId: "", kind: 1, port: 6080 },
174
+ display: { id: ":99", width: 1024, height: 768, virtual: true },
175
+ },
176
+ });
177
+ }
178
+ case "ptyOpen": {
179
+ // The PTY plane is display-INDEPENDENT and has NO consent gate (unlike
180
+ // desktopEnsure) — a terminal works on a headless machine. Returns a PTY
181
+ // StreamChannel on the 7681 port.
182
+ return ok(req.requestId, {
183
+ $case: "ptyOpen",
184
+ ptyOpen: {
185
+ ptyId: "mock-pty",
186
+ channel: { channelId: "mock-pty", workspaceId: "", agentId: "", kind: 1, port: 7681 },
187
+ },
188
+ });
189
+ }
190
+ default:
191
+ return errorResponse(req.requestId, ErrorCode.ERROR_CODE_UNSUPPORTED, `mock does not implement ${op.$case}`, false);
192
+ }
193
+ }
194
+ }
195
+
196
+ function defaultEcho(req: ExecRequest, hostname: string): ExecResponse {
197
+ // A trivial deterministic exec: echo the joined argv; if argv mentions
198
+ // HOSTNAME, emit the mock hostname so terminal-style asserts work.
199
+ const joined = req.command.join(" ");
200
+ const stdout = /hostname|HOSTNAME/.test(joined) ? hostname : joined;
201
+ return {
202
+ exitCode: 0,
203
+ stdout: encoder.encode(`${stdout}\n`),
204
+ stderr: new Uint8Array(0),
205
+ timedOut: false,
206
+ durationMs: "1",
207
+ };
208
+ }
209
+
210
+ function ok(requestId: string, result: NonNullable<ControlResponse["result"]>): ControlResponse {
211
+ return { requestId, error: undefined, result };
212
+ }
213
+
214
+ function errorResponse(requestId: string, code: ErrorCode, message: string, retryable: boolean): ControlResponse {
215
+ const error: AgentError = { code, message, retryable, detail: {} };
216
+ return { requestId, error, result: undefined };
217
+ }
218
+
219
+ function normalize(path: string): string {
220
+ // Collapse to a leading-slash absolute form for stable keys.
221
+ const trimmed = path.replace(/\/+$/, "");
222
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
223
+ }
224
+
225
+ function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
226
+ const out = new Uint8Array(a.length + b.length);
227
+ out.set(a, 0);
228
+ out.set(b, a.length);
229
+ return out;
230
+ }
@@ -0,0 +1,185 @@
1
+ // @opengeni/runtime/sandbox — the pixel DATA PLANE: exposeStreamPort (P4.2).
2
+ //
3
+ // This is the heart of Channel B's data plane. exposeStreamPort resolves the
4
+ // provider's scoped tunnel for the ONE exposed stream port (6080), assembles the
5
+ // direct-to-provider WS URL (client → provider-tunnel direct; the pixel socket
6
+ // never traverses OpenGeni), and mints the scoped OpenGeni stream token. It is a
7
+ // plain function over a live, externally-owned `{session}` handle — NO Temporal,
8
+ // NO worker RPC, NO actor. The API-direct handshake handler (apps/api) calls it
9
+ // in-process on a freshly-resumed-by-id box and returns the result as the HTTP
10
+ // response; the worker's per-turn resume path calls the same function when a turn
11
+ // is the first to bring the box up. Both pull it from this single agent-loop-free
12
+ // leaf (@opengeni/runtime/sandbox).
13
+ //
14
+ // THE TOKEN IS NOT A URL QUERY PARAM. The provider's own scoped tunnel URL
15
+ // (Modal raw-TLS host:port, Daytona signed preview, Blaxel preview-token query)
16
+ // carries the reach-the-port boundary; the OpenGeni stream token is RECORDED
17
+ // against the viewer holder and is the in-box websockify edge boundary (P3/P5).
18
+ // Per the master-spine ruling, exposeStreamPort returns the token alongside the
19
+ // URL so the caller records it; it does NOT append it to `url`.
20
+
21
+ import { DESKTOP_STREAM_PORT } from "@opengeni/contracts";
22
+ import { mintStreamToken, STREAM_TOKEN_DEFAULT_TTL_SECONDS } from "./stream-token";
23
+
24
+ /** The provider-resolved endpoint for an exposed port. Mirrors the SDK's
25
+ * `ExposedPortEndpoint` (host/port/tls/query/...) WITHOUT importing the
26
+ * agent-loop barrel — the leaf stays agent-loop-free. */
27
+ export type ExposedPortEndpoint = {
28
+ host: string;
29
+ port: number;
30
+ tls?: boolean;
31
+ query?: string;
32
+ protocol?: string;
33
+ url?: string;
34
+ /** The URL path the socket connects on. Modal/Daytona/Blaxel serve the edge at
35
+ * the root (`/`, the default); the selfhosted relay serves it at `/stream`
36
+ * (M8b). When set, buildStreamUrl uses it instead of the root. */
37
+ path?: string;
38
+ [key: string]: unknown;
39
+ };
40
+
41
+ /** The structural slice of a provider session we need to resolve a tunnel. */
42
+ type PortResolvableSession = {
43
+ resolveExposedPort?: (port: number) => Promise<ExposedPortEndpoint>;
44
+ };
45
+
46
+ /** Thrown when the provider cannot expose the stream port (no resolveExposedPort,
47
+ * or the provider tunnel lookup failed). The caller degrades the desktop cell to
48
+ * `transport:null` (a value, never a crash) — a headless-only provider or a
49
+ * transient tunnel failure must not fail the whole handshake. */
50
+ export class StreamPortUnavailableError extends Error {
51
+ constructor(message: string, readonly cause?: unknown) {
52
+ super(message);
53
+ this.name = "StreamPortUnavailableError";
54
+ }
55
+ }
56
+
57
+ export type ExposeStreamPortInput = {
58
+ workspaceId: string;
59
+ sessionId: string;
60
+ /** The sandbox_lease_holders viewer row id the token is scoped to. */
61
+ viewerId: string;
62
+ /** The live lease epoch — the fence the token is pinned to. */
63
+ leaseEpoch: number;
64
+ /** The HMAC secret for the scoped stream token (resolveStreamTokenSecret). */
65
+ streamTokenSecret: string;
66
+ /** The exposed stream port; defaults to 6080. */
67
+ port?: number;
68
+ /** Token TTL in seconds; defaults to STREAM_TOKEN_DEFAULT_TTL_SECONDS. */
69
+ ttlSeconds?: number;
70
+ /** The framebuffer geometry to echo back to the client. */
71
+ resolution?: [number, number];
72
+ /** Override the issue clock (tests). Seconds since the epoch. */
73
+ nowSeconds?: number;
74
+ };
75
+
76
+ export type ExposeStreamPortResult = {
77
+ /** The direct-to-provider WS URL the viewer connects to (provider-scoped; the
78
+ * OpenGeni token is NOT appended). */
79
+ url: string;
80
+ /** The scoped OpenGeni stream token — recorded against the holder, NEVER a URL
81
+ * query param. */
82
+ token: string;
83
+ /** ISO absolute expiry of the token (the rotation hot-swap window backstop). */
84
+ expiresAt: string;
85
+ /** The pixel transport the client speaks. */
86
+ transport: "vnc-ws";
87
+ /** The reference noVNC client the SDK helper mounts. */
88
+ client: "novnc";
89
+ resolution: [number, number];
90
+ leaseEpoch: number;
91
+ };
92
+
93
+ const DEFAULT_RESOLUTION: [number, number] = [1280, 800];
94
+
95
+ /**
96
+ * Assemble the direct-to-provider WS URL from a resolved endpoint. The SDK's
97
+ * `urlForExposedPort(endpoint,'ws')` is the canonical tls-aware, IPv6-bracketing,
98
+ * provider-query-preserving assembler — we reimplement its exact logic here so
99
+ * the leaf stays agent-loop-free (the helper lives behind the bare
100
+ * `@openai/agents-core` root, which the import-discipline test forbids). The
101
+ * provider's own `endpoint.query` (Blaxel `bl_preview_token`, Daytona signed
102
+ * token) is preserved; the OpenGeni token is NOT appended (it is recorded against
103
+ * the holder + validated at the in-box websockify edge).
104
+ */
105
+ export function buildStreamUrl(endpoint: ExposedPortEndpoint): string {
106
+ if (typeof endpoint.host !== "string" || endpoint.host.length === 0 || typeof endpoint.port !== "number") {
107
+ throw new StreamPortUnavailableError(
108
+ `provider returned a malformed exposed-port endpoint (host=${String(endpoint.host)}, port=${String(endpoint.port)})`,
109
+ );
110
+ }
111
+ const tls = endpoint.tls ?? false;
112
+ const scheme = tls ? "wss" : "ws";
113
+ const defaultPort = tls ? 443 : 80;
114
+ // Bracket a bare IPv6 host (urlForExposedPort parity).
115
+ const host = endpoint.host.includes(":") && !endpoint.host.startsWith("[") ? `[${endpoint.host}]` : endpoint.host;
116
+ // The path: default the root `/` (Modal/Daytona/Blaxel edge), or the
117
+ // provider-supplied path (the selfhosted relay's `/stream` route, M8b). Always
118
+ // leading-slash-normalized.
119
+ const rawPath = typeof endpoint.path === "string" && endpoint.path.length > 0 ? endpoint.path : "/";
120
+ const path = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
121
+ const origin = endpoint.port === defaultPort ? `${scheme}://${host}` : `${scheme}://${host}:${endpoint.port}`;
122
+ const authority = `${origin}${path}`;
123
+ const query = endpoint.query ?? "";
124
+ return query ? `${authority}?${query}` : authority;
125
+ }
126
+
127
+ /**
128
+ * Resolve the provider's scoped tunnel for the stream port and mint the scoped
129
+ * OpenGeni stream token. Returns a coherent `{url, token, expiresAt, transport,
130
+ * client, resolution}` cell the caller records on the lease (data_plane_url) and
131
+ * returns in the DesktopStream handshake.
132
+ *
133
+ * Throws `StreamPortUnavailableError` when the provider session cannot resolve
134
+ * the port (no `resolveExposedPort`, or the tunnel lookup failed) — the caller
135
+ * maps this to a `transport:null` degradation (a value, never a crash).
136
+ */
137
+ export async function exposeStreamPort(
138
+ session: unknown,
139
+ input: ExposeStreamPortInput,
140
+ ): Promise<ExposeStreamPortResult> {
141
+ const s = session as PortResolvableSession;
142
+ const port = input.port ?? DESKTOP_STREAM_PORT;
143
+ if (typeof s?.resolveExposedPort !== "function") {
144
+ throw new StreamPortUnavailableError(
145
+ "provider session cannot resolve exposed ports (no resolveExposedPort) — desktop stream unavailable",
146
+ );
147
+ }
148
+
149
+ let endpoint: ExposedPortEndpoint;
150
+ try {
151
+ // (I7/OD-7) per-provider URL re-resolution folds in here: a provider with a
152
+ // preview/signed token (Daytona/Blaxel) re-resolves its own short-TTL token
153
+ // on every call, so a rotation re-mints both planes' freshness in one place.
154
+ endpoint = await s.resolveExposedPort(port);
155
+ } catch (error) {
156
+ throw new StreamPortUnavailableError(
157
+ `provider failed to resolve the stream port ${port}: ${error instanceof Error ? error.message : String(error)}`,
158
+ error,
159
+ );
160
+ }
161
+
162
+ const url = buildStreamUrl(endpoint);
163
+ const ttlSeconds = input.ttlSeconds ?? STREAM_TOKEN_DEFAULT_TTL_SECONDS;
164
+ const nowSeconds = input.nowSeconds ?? Math.floor(Date.now() / 1000);
165
+ const token = await mintStreamToken(input.streamTokenSecret, {
166
+ workspaceId: input.workspaceId,
167
+ sessionId: input.sessionId,
168
+ viewerId: input.viewerId,
169
+ leaseEpoch: input.leaseEpoch,
170
+ mode: "view",
171
+ port,
172
+ ttlSeconds,
173
+ nowSeconds,
174
+ });
175
+
176
+ return {
177
+ url,
178
+ token,
179
+ expiresAt: new Date((nowSeconds + ttlSeconds) * 1000).toISOString(),
180
+ transport: "vnc-ws",
181
+ client: "novnc",
182
+ resolution: input.resolution ?? DEFAULT_RESOLUTION,
183
+ leaseEpoch: input.leaseEpoch,
184
+ };
185
+ }
@@ -0,0 +1,90 @@
1
+ // @opengeni/runtime/sandbox — scoped stream-token mint/verify (P3.1).
2
+ //
3
+ // The agent-loop-free home for the scoped data-plane stream token used by the
4
+ // desktop pixel plane (Channel B, master-spine §C.3 / crosscut PART 1.3). It is
5
+ // a THIN wrapper over the contracts HMAC envelope (signStreamToken /
6
+ // verifyStreamToken) — NOT a second crypto: it REUSES the exact base64Url +
7
+ // hmacSha256 construction that backs signDelegatedAccessToken, with the distinct
8
+ // `ogs_` prefix and the hard-narrow StreamTokenPayload claim set.
9
+ //
10
+ // It lives under @opengeni/runtime/sandbox so the API-direct control plane
11
+ // (apps/api) can mint + verify stream tokens from the same single agent-loop-free
12
+ // leaf it already pulls createSandboxClient / resume-by-id from.
13
+
14
+ import {
15
+ DESKTOP_STREAM_PORT,
16
+ StreamTokenPayload,
17
+ signStreamToken,
18
+ verifyStreamToken as verifyStreamTokenEnvelope,
19
+ type StreamTokenPayload as StreamTokenPayloadType,
20
+ } from "@opengeni/contracts";
21
+
22
+ // The default stream-token TTL (seconds). The token is short-lived by design:
23
+ // URL rotation is event-driven under the epoch fence (re-resolve recorded on the
24
+ // lease), not on a keepalive clock — so the token never needs a long life.
25
+ export const STREAM_TOKEN_DEFAULT_TTL_SECONDS = 120;
26
+
27
+ export type MintStreamTokenInput = {
28
+ workspaceId: string;
29
+ sessionId: string;
30
+ /** The sandbox_lease_holders viewer row id. */
31
+ viewerId: string;
32
+ /** The epoch the token is fenced to. For a Modal box this is the live LEASE
33
+ * epoch (re-minted on box rollover). For a SELFHOSTED relay stream (M8b) this is
34
+ * the session's swap `active_epoch`: the relay tracks the highest epoch any
35
+ * viewer presented per channel and REJECTS a token with a lower epoch, so a
36
+ * viewer whose token predates a swap-away cannot reach the machine the session
37
+ * swapped off of. One field, two fences — the relay/in-box edge reads it as the
38
+ * stale-viewer floor either way. */
39
+ leaseEpoch: number;
40
+ /** v1 is always "view"; "control" is the never-granted raw-input plane. */
41
+ mode?: "view" | "control";
42
+ /** The exposed stream port (noVNC); defaults to 6080. */
43
+ port?: number;
44
+ /** TTL in seconds; defaults to STREAM_TOKEN_DEFAULT_TTL_SECONDS. */
45
+ ttlSeconds?: number;
46
+ /** Override the issue clock (tests). Seconds since the epoch. */
47
+ nowSeconds?: number;
48
+ };
49
+
50
+ /**
51
+ * Mint a scoped stream token for one viewer holder. Builds the hard-narrow
52
+ * StreamTokenPayload (the claim set the in-box edge / control plane validates)
53
+ * and signs it with the resolved stream-token secret via the contracts HMAC
54
+ * envelope (`ogs_` prefix). The token is RECORDED against the holder row by the
55
+ * caller and is NEVER appended to the data-plane URL as a query param.
56
+ */
57
+ export async function mintStreamToken(secret: string, input: MintStreamTokenInput): Promise<string> {
58
+ const nowSeconds = input.nowSeconds ?? Math.floor(Date.now() / 1000);
59
+ const ttlSeconds = input.ttlSeconds ?? STREAM_TOKEN_DEFAULT_TTL_SECONDS;
60
+ const payload: StreamTokenPayloadType = StreamTokenPayload.parse({
61
+ workspaceId: input.workspaceId,
62
+ sessionId: input.sessionId,
63
+ viewerId: input.viewerId,
64
+ leaseEpoch: input.leaseEpoch,
65
+ mode: input.mode ?? "view",
66
+ port: input.port ?? DESKTOP_STREAM_PORT,
67
+ exp: nowSeconds + ttlSeconds,
68
+ });
69
+ return signStreamToken(secret, payload);
70
+ }
71
+
72
+ /**
73
+ * Verify a scoped stream token. Returns the parsed claims on success, or null on
74
+ * a bad prefix / malformed envelope / bad HMAC signature / schema-invalid claims
75
+ * / expiry. Re-exports the contracts verify; the leaf is the agent-loop-free
76
+ * import surface the API uses.
77
+ *
78
+ * The epoch fence (claim.leaseEpoch vs the LIVE lease epoch) and the
79
+ * workspace+session scope are enforced at USE by the caller against the live
80
+ * lease + route params — verify proves authenticity + freshness only.
81
+ */
82
+ export async function verifyStreamToken(
83
+ secret: string,
84
+ token: string,
85
+ nowSeconds = Math.floor(Date.now() / 1000),
86
+ ): Promise<StreamTokenPayloadType | null> {
87
+ return verifyStreamTokenEnvelope(secret, token, nowSeconds);
88
+ }
89
+
90
+ export { StreamTokenPayload, type StreamTokenPayload as StreamTokenPayloadType } from "@opengeni/contracts";