@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.
- package/dist/chunk-2PO56VAL.js +3478 -0
- package/dist/chunk-2PO56VAL.js.map +1 -0
- package/dist/index.d.ts +912 -0
- package/dist/index.js +3663 -0
- package/dist/index.js.map +1 -0
- package/dist/sandbox/index.d.ts +1738 -0
- package/dist/sandbox/index.js +187 -0
- package/dist/sandbox/index.js.map +1 -0
- package/package.json +49 -0
- package/src/bundled_hashicorp_terraform_skills/LICENSE +373 -0
- package/src/bundled_hashicorp_terraform_skills/README.md +18 -0
- package/src/bundled_hashicorp_terraform_skills/UPSTREAM_GIT_SHA +1 -0
- package/src/bundled_hashicorp_terraform_skills/azure-verified-modules/SKILL.md +613 -0
- package/src/bundled_hashicorp_terraform_skills/checkov/SKILL.md +43 -0
- package/src/bundled_hashicorp_terraform_skills/refactor-module/SKILL.md +538 -0
- package/src/bundled_hashicorp_terraform_skills/social-media-marketing/SKILL.md +35 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-search-import/SKILL.md +372 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-search-import/references/MANUAL-IMPORT.md +113 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-search-import/scripts/list_resources.sh +38 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/SKILL.md +480 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/api-monitoring.md +543 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/component-blocks.md +476 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/deployment-blocks.md +391 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/examples.md +1529 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/linked-stacks.md +187 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/troubleshooting.md +671 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-style-guide/SKILL.md +353 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/SKILL.md +451 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/references/CI_CD.md +80 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/references/EXAMPLES.md +314 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/references/MOCK_PROVIDERS.md +171 -0
- package/src/codex-tool-search.ts +267 -0
- package/src/context-compaction.ts +538 -0
- package/src/history-sanitizer.ts +719 -0
- package/src/index.ts +3299 -0
- package/src/sandbox/capabilities.ts +69 -0
- package/src/sandbox/channel-a.ts +1031 -0
- package/src/sandbox/display-stack.ts +231 -0
- package/src/sandbox/errors.ts +34 -0
- package/src/sandbox/index.ts +832 -0
- package/src/sandbox/providers/blaxel.ts +35 -0
- package/src/sandbox/providers/cloudflare.ts +24 -0
- package/src/sandbox/providers/daytona.ts +34 -0
- package/src/sandbox/providers/docker.ts +17 -0
- package/src/sandbox/providers/e2b.ts +36 -0
- package/src/sandbox/providers/index.ts +107 -0
- package/src/sandbox/providers/local.ts +13 -0
- package/src/sandbox/providers/modal.ts +55 -0
- package/src/sandbox/providers/none.ts +13 -0
- package/src/sandbox/providers/runloop.ts +32 -0
- package/src/sandbox/providers/selfhosted.ts +96 -0
- package/src/sandbox/providers/types.ts +38 -0
- package/src/sandbox/providers/vercel.ts +29 -0
- package/src/sandbox/recording.ts +286 -0
- package/src/sandbox/routing/backend-resolver.ts +189 -0
- package/src/sandbox/routing/routing-session.ts +455 -0
- package/src/sandbox/select.ts +371 -0
- package/src/sandbox/selfhosted/capabilities.ts +255 -0
- package/src/sandbox/selfhosted/control-rpc.ts +351 -0
- package/src/sandbox/selfhosted/session.ts +930 -0
- package/src/sandbox/selfhosted/testing.ts +230 -0
- package/src/sandbox/stream-port.ts +185 -0
- package/src/sandbox/stream-token.ts +90 -0
- package/src/sandbox/terminal-server.ts +203 -0
- 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";
|