@open-everywhere/adapters 0.4.0 → 0.6.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 (54) hide show
  1. package/agent/meetings/native-http.ts +72 -0
  2. package/agent/meetings/native-meet.ts +173 -0
  3. package/agent/meetings/native-sources.ts +14 -0
  4. package/agent/meetings/native-teams.ts +119 -0
  5. package/agent/meetings/native-zoom.ts +98 -0
  6. package/agent/model/gateway.ts +44 -4
  7. package/agent/model/meeting-gateway.ts +88 -0
  8. package/agent/model/transcription-routes.ts +347 -0
  9. package/agent/oe-oauth/config.ts +1 -0
  10. package/agent/register-defaults.ts +11 -0
  11. package/agent/sandbox/long-lived-box.ts +464 -0
  12. package/dist/agent/meetings/native-http.d.ts +22 -0
  13. package/dist/agent/meetings/native-http.d.ts.map +1 -0
  14. package/dist/agent/meetings/native-http.js +61 -0
  15. package/dist/agent/meetings/native-http.js.map +1 -0
  16. package/dist/agent/meetings/native-meet.d.ts +6 -0
  17. package/dist/agent/meetings/native-meet.d.ts.map +1 -0
  18. package/dist/agent/meetings/native-meet.js +176 -0
  19. package/dist/agent/meetings/native-meet.js.map +1 -0
  20. package/dist/agent/meetings/native-sources.d.ts +2 -0
  21. package/dist/agent/meetings/native-sources.d.ts.map +1 -0
  22. package/dist/agent/meetings/native-sources.js +13 -0
  23. package/dist/agent/meetings/native-sources.js.map +1 -0
  24. package/dist/agent/meetings/native-teams.d.ts +6 -0
  25. package/dist/agent/meetings/native-teams.d.ts.map +1 -0
  26. package/dist/agent/meetings/native-teams.js +118 -0
  27. package/dist/agent/meetings/native-teams.js.map +1 -0
  28. package/dist/agent/meetings/native-zoom.d.ts +7 -0
  29. package/dist/agent/meetings/native-zoom.d.ts.map +1 -0
  30. package/dist/agent/meetings/native-zoom.js +98 -0
  31. package/dist/agent/meetings/native-zoom.js.map +1 -0
  32. package/dist/agent/model/gateway.d.ts +10 -0
  33. package/dist/agent/model/gateway.d.ts.map +1 -1
  34. package/dist/agent/model/gateway.js +43 -5
  35. package/dist/agent/model/gateway.js.map +1 -1
  36. package/dist/agent/model/meeting-gateway.d.ts +24 -0
  37. package/dist/agent/model/meeting-gateway.d.ts.map +1 -0
  38. package/dist/agent/model/meeting-gateway.js +75 -0
  39. package/dist/agent/model/meeting-gateway.js.map +1 -0
  40. package/dist/agent/model/transcription-routes.d.ts +89 -0
  41. package/dist/agent/model/transcription-routes.d.ts.map +1 -0
  42. package/dist/agent/model/transcription-routes.js +279 -0
  43. package/dist/agent/model/transcription-routes.js.map +1 -0
  44. package/dist/agent/oe-oauth/config.d.ts.map +1 -1
  45. package/dist/agent/oe-oauth/config.js +1 -0
  46. package/dist/agent/oe-oauth/config.js.map +1 -1
  47. package/dist/agent/register-defaults.d.ts.map +1 -1
  48. package/dist/agent/register-defaults.js +9 -0
  49. package/dist/agent/register-defaults.js.map +1 -1
  50. package/dist/agent/sandbox/long-lived-box.d.ts +70 -0
  51. package/dist/agent/sandbox/long-lived-box.d.ts.map +1 -0
  52. package/dist/agent/sandbox/long-lived-box.js +414 -0
  53. package/dist/agent/sandbox/long-lived-box.js.map +1 -0
  54. package/package.json +38 -2
@@ -0,0 +1,464 @@
1
+ // The long-lived box on each sandbox backend — the vendor half of core's sandbox/long-lived-box.ts seam.
2
+ // One implementation per backend `resolveSandboxBackend()` can pick, so a box runs where the conversation
3
+ // sandbox runs. A box carries the `oeBox` label and never `sessionId`, so the turn-end stopper
4
+ // (backend-stop.ts) cannot match it.
5
+ //
6
+ // A PERSISTENT box (a `persist` spec — the person's browser, OPE-2441) keeps its files between opens: a
7
+ // NAMED, persistent Vercel Sandbox whose filesystem snapshot survives each stop, or a docker container on a
8
+ // named volume. Before it starts one, the backend stops any box still running on the same key, because two
9
+ // programs on one browser profile corrupt it.
10
+
11
+ import { Sandbox } from "@vercel/sandbox";
12
+ import {
13
+ BOX_LABEL,
14
+ BOX_REF_LABEL,
15
+ boxLabels,
16
+ clampBoxMinutes,
17
+ registerLongLivedBoxBackend,
18
+ resolveSandboxBackend,
19
+ type BoxListing,
20
+ type BoxPurpose,
21
+ type BoxSpec,
22
+ type LongLivedBoxBackend,
23
+ type OpenedBox,
24
+ } from "@open-everywhere/core/agent/adapter-api.js";
25
+ import { dockerStatusToSandboxStatus } from "./backend-stop.js";
26
+ import { execFile } from "node:child_process";
27
+
28
+ /** Vercel Sandbox's plan ceiling per session on Pro and Enterprise (vercel.com/docs/sandbox/pricing). */
29
+ export const VERCEL_BOX_CEILING_MINUTES = 24 * 60;
30
+ /** Docker has no platform ceiling. OE still caps a box, at the same 24 h, so one rule bounds both stacks. */
31
+ const DOCKER_BOX_CEILING_MINUTES = 24 * 60;
32
+
33
+ function isPurpose(v: string | undefined): v is BoxPurpose {
34
+ return v === "meeting" || v === "browser";
35
+ }
36
+
37
+ // ── Vercel ────────────────────────────────────────────────────────────────────────────────────
38
+
39
+ /** The raw `@vercel/sandbox` surface the Vercel box needs. Injected in tests. */
40
+ export interface VercelBoxSdk {
41
+ create(params: {
42
+ image: string;
43
+ vcpus: number;
44
+ timeoutMs: number;
45
+ tags: Record<string, string>;
46
+ env: Record<string, string>;
47
+ networkPolicy: "allow-all" | "deny-all" | { allow: string[] };
48
+ }): Promise<{ name: string; createdAt: number }>;
49
+ /** `env` (a persistent box) goes to this one command only, never into the sandbox's stored config. */
50
+ runDetached(name: string, cmd: string, args: string[], timeoutMs: number, env?: Record<string, string>): Promise<void>;
51
+ get(name: string): Promise<BoxListing | undefined>;
52
+ /** Every sandbox carrying the `oeBox` tag with this purpose. */
53
+ listByPurpose(purpose: BoxPurpose): Promise<BoxListing[]>;
54
+ stop(name: string): Promise<void>;
55
+ /**
56
+ * A persistent, NAMED sandbox: create it, or update the one that exists (and stop it first when it is
57
+ * running, so the program that starts next is the only one on its files). The next command resumes it
58
+ * from its snapshot. `createdAt` is the start of THIS session, for billing.
59
+ */
60
+ openPersistent?(params: {
61
+ name: string;
62
+ image: string;
63
+ vcpus: number;
64
+ timeoutMs: number;
65
+ tags: Record<string, string>;
66
+ networkPolicy: "allow-all" | "deny-all" | { allow: string[] };
67
+ snapshotExpirationMs: number;
68
+ ports: number[];
69
+ }): Promise<{ name: string; createdAt: number }>;
70
+ /** The public HTTPS URL of an exposed port. */
71
+ domain?(name: string, port: number): Promise<string>;
72
+ /** Delete a persistent sandbox and its snapshots. Idempotent. */
73
+ remove?(name: string): Promise<void>;
74
+ }
75
+
76
+ const defaultVercelBoxSdk: VercelBoxSdk = {
77
+ async create(p) {
78
+ const sandbox = await Sandbox.create({
79
+ image: p.image,
80
+ resources: { vcpus: p.vcpus },
81
+ timeout: p.timeoutMs,
82
+ tags: p.tags,
83
+ env: p.env,
84
+ networkPolicy: p.networkPolicy,
85
+ // A box is single-use. Nothing resumes it, so it keeps no snapshot.
86
+ persistent: false,
87
+ });
88
+ return { name: sandbox.name, createdAt: sandbox.createdAt.getTime() };
89
+ },
90
+ async runDetached(name, cmd, args, timeoutMs, env) {
91
+ const sandbox = await Sandbox.get({ name, resume: false });
92
+ await sandbox.runCommand({ cmd, args, detached: true, timeoutMs, ...(env !== undefined ? { env } : {}) });
93
+ },
94
+ async get(name) {
95
+ try {
96
+ const s = await Sandbox.get({ name, resume: false });
97
+ const purpose = s.tags?.[BOX_LABEL];
98
+ return {
99
+ name: s.name,
100
+ status: s.status,
101
+ createdAt: s.createdAt.getTime(),
102
+ ...(isPurpose(purpose) ? { purpose } : {}),
103
+ ...(s.tags?.[BOX_REF_LABEL] !== undefined ? { ref: s.tags[BOX_REF_LABEL] } : {}),
104
+ };
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ },
109
+ async listByPurpose(purpose) {
110
+ const result = await Sandbox.list({ tags: { oeBox: purpose } });
111
+ const out: BoxListing[] = [];
112
+ for await (const s of result) out.push({ name: s.name, status: s.status, createdAt: s.createdAt, purpose });
113
+ return out;
114
+ },
115
+ async stop(name) {
116
+ try {
117
+ await (await Sandbox.get({ name, resume: false })).stop();
118
+ } catch {
119
+ // Idempotent: a box that is already gone is stopped.
120
+ }
121
+ },
122
+ async openPersistent(p) {
123
+ let existing: Sandbox | undefined;
124
+ try {
125
+ existing = await Sandbox.get({ name: p.name, resume: false });
126
+ } catch {
127
+ existing = undefined;
128
+ }
129
+ if (existing === undefined) {
130
+ await Sandbox.create({
131
+ name: p.name,
132
+ image: p.image,
133
+ resources: { vcpus: p.vcpus },
134
+ timeout: p.timeoutMs,
135
+ tags: p.tags,
136
+ networkPolicy: p.networkPolicy,
137
+ ports: p.ports,
138
+ // The filesystem (the browser profile) survives each stop as a snapshot, and expires unused.
139
+ persistent: true,
140
+ snapshotExpiration: p.snapshotExpirationMs,
141
+ });
142
+ return { name: p.name, createdAt: Date.now() };
143
+ }
144
+ // A program still running on these files would share the profile with the one about to start. A stop
145
+ // snapshots the filesystem; the next command resumes from it with no process left over.
146
+ if (existing.status === "running" || existing.status === "pending") await existing.stop();
147
+ await existing.update({ timeout: p.timeoutMs, networkPolicy: p.networkPolicy, ports: p.ports, tags: p.tags, snapshotExpiration: p.snapshotExpirationMs });
148
+ return { name: p.name, createdAt: Date.now() };
149
+ },
150
+ async domain(name, port) {
151
+ return (await Sandbox.get({ name, resume: false })).domain(port);
152
+ },
153
+ async remove(name) {
154
+ try {
155
+ await (await Sandbox.get({ name, resume: false })).delete({ deleteOrphanSnapshots: true });
156
+ } catch {
157
+ // Idempotent: a sandbox that is already gone is deleted.
158
+ }
159
+ },
160
+ };
161
+
162
+ /** The Vercel firewall form of a box's egress. */
163
+ function vercelNetworkPolicy(egress: BoxSpec["egress"]): "allow-all" | "deny-all" | { allow: string[] } {
164
+ return egress === "open" ? "allow-all" : egress.allow.length === 0 ? "deny-all" : { allow: [...egress.allow] };
165
+ }
166
+
167
+ export function createVercelBoxBackend(sdk: VercelBoxSdk = defaultVercelBoxSdk): LongLivedBoxBackend {
168
+ return {
169
+ backend: "vercel",
170
+ metered: true,
171
+ maxMinutesCeiling: VERCEL_BOX_CEILING_MINUTES,
172
+ // The Vercel Sandbox firewall holds an `{ allow }` list at the network, outside the box.
173
+ enforcesHostAllowList: true,
174
+ async open(spec: BoxSpec): Promise<OpenedBox> {
175
+ const [cmd, ...args] = spec.command;
176
+ if (cmd === undefined) throw new Error("a box needs a command to run");
177
+ const maxMinutes = clampBoxMinutes(spec.maxMinutes, VERCEL_BOX_CEILING_MINUTES);
178
+ const timeoutMs = maxMinutes * 60_000;
179
+ if (spec.persist !== undefined) {
180
+ if (sdk.openPersistent === undefined || sdk.domain === undefined) throw new Error("this Vercel box SDK cannot keep a box's files between opens");
181
+ const opened = await sdk.openPersistent({
182
+ name: spec.persist.key,
183
+ image: spec.image,
184
+ vcpus: Math.max(1, Math.floor(spec.vcpus)),
185
+ timeoutMs,
186
+ tags: boxLabels(spec),
187
+ networkPolicy: vercelNetworkPolicy(spec.egress),
188
+ snapshotExpirationMs: Math.max(1, Math.floor(spec.persist.keepDays)) * 24 * 60 * 60_000,
189
+ ports: spec.port !== undefined ? [spec.port] : [],
190
+ });
191
+ try {
192
+ // The per-open secrets go to this command only. A persistent sandbox stores its create-time env in its
193
+ // config, so a key placed there would outlive the box it was minted for.
194
+ await sdk.runDetached(opened.name, cmd, args, timeoutMs, { ...spec.env });
195
+ } catch (err) {
196
+ await sdk.stop(opened.name);
197
+ throw err;
198
+ }
199
+ const url = spec.port !== undefined ? await sdk.domain(opened.name, spec.port) : undefined;
200
+ return { name: opened.name, backend: "vercel", createdAt: opened.createdAt, maxMinutes, ...(url !== undefined ? { url } : {}) };
201
+ }
202
+ const created = await sdk.create({
203
+ image: spec.image,
204
+ // Vercel sizes memory from vCPUs (2 GiB each), so `memoryMb` has no field to land in.
205
+ vcpus: Math.max(1, Math.floor(spec.vcpus)),
206
+ // The session timeout IS the cap: Vercel stops the VM at it whatever the bot does.
207
+ timeoutMs,
208
+ tags: boxLabels(spec),
209
+ env: { ...spec.env },
210
+ networkPolicy: vercelNetworkPolicy(spec.egress),
211
+ });
212
+ try {
213
+ await sdk.runDetached(created.name, cmd, args, timeoutMs);
214
+ } catch (err) {
215
+ // A box whose program never started must not idle out its whole cap.
216
+ await sdk.stop(created.name);
217
+ throw err;
218
+ }
219
+ return { name: created.name, backend: "vercel", createdAt: created.createdAt, maxMinutes };
220
+ },
221
+ status: async (name) => await sdk.get(name),
222
+ list: async (purpose) => await sdk.listByPurpose(purpose),
223
+ stop: async (name) => await sdk.stop(name),
224
+ async forget(key) {
225
+ if (sdk.remove === undefined) throw new Error("this Vercel box SDK cannot delete a persistent box");
226
+ await sdk.remove(key);
227
+ },
228
+ };
229
+ }
230
+
231
+ // ── docker ────────────────────────────────────────────────────────────────────────────────────
232
+
233
+ /** The pid ceiling of a docker box: Chromium's threads count against it, so it is generous. */
234
+ const DOCKER_BOX_PIDS_LIMIT = 2048;
235
+
236
+ /** The docker label keys. Prefixed like eve's own `eve.sandbox.tag.*`, but never its `sessionId` key. */
237
+ export const DOCKER_BOX_LABEL = `oe.box.${BOX_LABEL}`;
238
+ const DOCKER_BOX_REF_LABEL = `oe.box.${BOX_REF_LABEL}`;
239
+ /** The label naming a persistent box's volume, so a second open finds the box still on it. */
240
+ export const DOCKER_BOX_PERSIST_LABEL = "oe.box.oePersist";
241
+
242
+ /** A docker volume name from a persistence key: lowercase, `[a-z0-9_.-]`, never empty. */
243
+ function dockerVolumeName(key: string): string {
244
+ const clean = key.toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 60);
245
+ return clean.length > 0 ? clean : "oe-box-data";
246
+ }
247
+
248
+ /**
249
+ * The docker CLI for boxes. `env` carries the VALUES of the box's `--env NAME` flags: docker reads each named
250
+ * variable from its own environment, so a value (the meeting's callback key, a meeting passcode) never sits
251
+ * on the argv, where `ps` shows it and Node's `execFile` error message would repeat it.
252
+ */
253
+ export type BoxDockerRunner = (args: readonly string[], env?: Readonly<Record<string, string>>) => Promise<string>;
254
+
255
+ const defaultDockerRunner: BoxDockerRunner = async (args, env) =>
256
+ await new Promise<string>((resolve, reject) => {
257
+ const bin = process.env.EVE_DOCKER_PATH?.trim() || "docker";
258
+ execFile(bin, [...args], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024, ...(env !== undefined ? { env: { ...process.env, ...env } } : {}) }, (err, stdout, stderr) => {
259
+ // A SANITISED error: the verb and docker's own first stderr line, never the argv. The message reaches
260
+ // an approval card's outcome line and the `approval.failed` audit row.
261
+ if (err) reject(new Error(`docker ${args[0] ?? ""} failed: ${String(stderr).split("\n")[0]?.trim() || "no error output"}`));
262
+ else resolve(stdout);
263
+ });
264
+ });
265
+
266
+ /** A docker container name from the box ref: lowercase, `[a-z0-9_.-]`, unique per open. */
267
+ function dockerBoxName(spec: Pick<BoxSpec, "purpose" | "ref">, nonce: string): string {
268
+ const ref = spec.ref.toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 40);
269
+ return `oe-box-${spec.purpose}-${ref}-${nonce}`;
270
+ }
271
+
272
+ /** The `docker run` argument list for a box. Pure, so the sizing and the labels are unit-tested. */
273
+ export function dockerRunArgs(spec: BoxSpec, name: string, maxMinutes: number): string[] {
274
+ if (spec.egress !== "open" && spec.egress.allow.length > 0) {
275
+ // docker's own network has no host allow-list, and a box outside the egress proxy cannot be
276
+ // filtered by it (OPE-2407). Refuse rather than run a box open that asked to be closed.
277
+ throw new Error("the docker backend cannot enforce a host allow-list on a box — refusing to open it with open network");
278
+ }
279
+ const labels = boxLabels(spec);
280
+ const args = [
281
+ "run",
282
+ "-d",
283
+ // The box removes itself when its program exits, so nothing accumulates on the host.
284
+ "--rm",
285
+ "--name",
286
+ name,
287
+ "--label",
288
+ `${DOCKER_BOX_LABEL}=${labels[BOX_LABEL]}`,
289
+ "--label",
290
+ `${DOCKER_BOX_REF_LABEL}=${labels[BOX_REF_LABEL]}`,
291
+ "--cpus",
292
+ String(Math.max(0.5, spec.vcpus)),
293
+ "--memory",
294
+ `${Math.max(256, Math.floor(spec.memoryMb))}m`,
295
+ "--shm-size",
296
+ `${Math.max(64, Math.floor(spec.shmMb ?? 64))}m`,
297
+ // Hardening every box gets: no Linux capabilities, no privilege gain through setuid binaries, and a pid
298
+ // ceiling (Chromium runs a few hundred threads; a fork bomb stops here). The box program runs as the
299
+ // image's non-root user when the spec names one.
300
+ "--cap-drop",
301
+ "ALL",
302
+ "--security-opt",
303
+ "no-new-privileges",
304
+ "--pids-limit",
305
+ String(DOCKER_BOX_PIDS_LIMIT),
306
+ ...(spec.user !== undefined ? ["--user", spec.user] : []),
307
+ ];
308
+ if (spec.persist !== undefined) {
309
+ args.push("--label", `${DOCKER_BOX_PERSIST_LABEL}=${dockerVolumeName(spec.persist.key)}`, "--volume", `${dockerVolumeName(spec.persist.key)}:${spec.persist.mount}`);
310
+ }
311
+ if (spec.egress !== "open") {
312
+ // A closed box has no network, so nothing could reach its port either.
313
+ if (spec.port !== undefined) throw new Error("a docker box with no network cannot serve a port — refusing to open it");
314
+ args.push("--network", "none");
315
+ } else if (spec.network !== undefined) {
316
+ args.push("--network", spec.network);
317
+ }
318
+ // Off a named network the port is published on the loopback interface only, never on the host's others.
319
+ if (spec.port !== undefined && spec.network === undefined) args.push("--publish", `127.0.0.1::${spec.port}`);
320
+ // Names only: the values travel in the CLI's own environment (BoxDockerRunner), never on the argv.
321
+ for (const k of Object.keys(spec.env)) args.push("--env", k);
322
+ // `timeout` is the cap on this backend: coreutils kills the program at it, and `--rm` removes the box.
323
+ args.push("--entrypoint", "timeout", spec.image, `${maxMinutes * 60}s`, ...spec.command);
324
+ return args;
325
+ }
326
+
327
+ export function createDockerBoxBackend(run: BoxDockerRunner = defaultDockerRunner, nonce: () => string = () => Date.now().toString(36)): LongLivedBoxBackend {
328
+ async function inspect(names: readonly string[]): Promise<BoxListing[]> {
329
+ if (names.length === 0) return [];
330
+ let out: string;
331
+ try {
332
+ out = await run([
333
+ "inspect",
334
+ "--format",
335
+ `{{.Name}}\t{{.State.Status}}\t{{.State.StartedAt}}\t{{index .Config.Labels "${DOCKER_BOX_LABEL}"}}\t{{index .Config.Labels "${DOCKER_BOX_REF_LABEL}"}}`,
336
+ ...names,
337
+ ]);
338
+ } catch {
339
+ return [];
340
+ }
341
+ const listings: BoxListing[] = [];
342
+ for (const line of out.split("\n")) {
343
+ const [rawName, state, startedAt, purpose, ref] = line.trim().split("\t");
344
+ if (rawName === undefined || state === undefined) continue;
345
+ const parsed = startedAt === undefined ? Number.NaN : Date.parse(startedAt);
346
+ listings.push({
347
+ name: rawName.replace(/^\//, ""),
348
+ status: dockerStatusToSandboxStatus(state),
349
+ createdAt: Number.isFinite(parsed) ? parsed : Date.now(),
350
+ ...(isPurpose(purpose) ? { purpose } : {}),
351
+ ...(ref !== undefined && ref.length > 0 ? { ref } : {}),
352
+ });
353
+ }
354
+ return listings;
355
+ }
356
+ /** Stop every box still running on a persistent key's volume. */
357
+ async function stopOnVolume(volume: string): Promise<void> {
358
+ const ids = (await run(["ps", "-q", "--filter", `label=${DOCKER_BOX_PERSIST_LABEL}=${volume}`]))
359
+ .split("\n")
360
+ .map((l) => l.trim())
361
+ .filter((l) => l.length > 0);
362
+ for (const id of ids) {
363
+ try {
364
+ await run(["stop", "-t", "10", id]);
365
+ } catch {
366
+ // Already gone.
367
+ }
368
+ }
369
+ }
370
+ /** Where the app reaches a box's port: by name on a shared network, else the loopback port docker picked. */
371
+ async function boxUrl(spec: BoxSpec, name: string): Promise<string | undefined> {
372
+ if (spec.port === undefined) return undefined;
373
+ if (spec.network !== undefined) return `http://${name}:${spec.port}`;
374
+ const mapped = (await run(["port", name, `${spec.port}/tcp`])).split("\n").map((l) => l.trim()).find((l) => /^127\.0\.0\.1:\d+$/.test(l));
375
+ return mapped === undefined ? undefined : `http://${mapped}`;
376
+ }
377
+ return {
378
+ backend: "docker",
379
+ metered: false,
380
+ maxMinutesCeiling: DOCKER_BOX_CEILING_MINUTES,
381
+ // docker has no host firewall for a box: `dockerRunArgs` refuses a non-empty `{ allow }` list.
382
+ enforcesHostAllowList: false,
383
+ async open(spec) {
384
+ if (spec.command.length === 0) throw new Error("a box needs a command to run");
385
+ const maxMinutes = clampBoxMinutes(spec.maxMinutes, DOCKER_BOX_CEILING_MINUTES);
386
+ const name = dockerBoxName(spec, nonce());
387
+ const args = dockerRunArgs(spec, name, maxMinutes);
388
+ if (spec.persist !== undefined) await stopOnVolume(dockerVolumeName(spec.persist.key));
389
+ await run(args, spec.env);
390
+ const [listing] = await inspect([name]);
391
+ let url: string | undefined;
392
+ try {
393
+ url = await boxUrl(spec, name);
394
+ } catch (err) {
395
+ await run(["stop", "-t", "10", name]).catch(() => "");
396
+ throw err;
397
+ }
398
+ return { name, backend: "docker", createdAt: listing?.createdAt ?? Date.now(), maxMinutes, ...(url !== undefined ? { url } : {}) };
399
+ },
400
+ async status(name) {
401
+ const [listing] = await inspect([name]);
402
+ return listing;
403
+ },
404
+ async list(purpose) {
405
+ const names = (await run(["ps", "-a", "--no-trunc", "--filter", `label=${DOCKER_BOX_LABEL}=${purpose}`, "--format", "{{.Names}}"]))
406
+ .split("\n")
407
+ .map((l) => l.trim())
408
+ .filter((l) => l.length > 0);
409
+ return await inspect(names);
410
+ },
411
+ async stop(name) {
412
+ try {
413
+ await run(["stop", "-t", "10", name]);
414
+ } catch {
415
+ // Idempotent: `--rm` already removed a box whose program exited.
416
+ }
417
+ },
418
+ async forget(key) {
419
+ const volume = dockerVolumeName(key);
420
+ await stopOnVolume(volume);
421
+ // `-f` makes a missing volume a success, so a forget is idempotent.
422
+ await run(["volume", "rm", "-f", volume]);
423
+ },
424
+ };
425
+ }
426
+
427
+ // ── just-bash ─────────────────────────────────────────────────────────────────────────────────
428
+
429
+ /** just-bash is an in-process interpreter: it has no VM, no container and no browser. It refuses. */
430
+ export const justBashBoxBackend: LongLivedBoxBackend = {
431
+ backend: "justbash",
432
+ metered: false,
433
+ maxMinutesCeiling: 0,
434
+ unavailable:
435
+ "this deployment's sandbox backend is just-bash, which runs inside the app process and cannot run a long-lived box (no VM, no container, no browser). Declare `sandbox.selfHostBackend: \"docker\"` or run on Vercel Sandbox",
436
+ async open() {
437
+ throw new Error(justBashBoxBackend.unavailable);
438
+ },
439
+ async status() {
440
+ return undefined;
441
+ },
442
+ async list() {
443
+ return [];
444
+ },
445
+ async stop() {
446
+ // Nothing to stop: this backend never opens a box.
447
+ },
448
+ };
449
+
450
+ // ── selection ─────────────────────────────────────────────────────────────────────────────────
451
+
452
+ /** The box backend for this deployment: the SAME rule `resolveSandboxStopper()` reads, so they never disagree. */
453
+ export function selectLongLivedBoxBackend(): LongLivedBoxBackend {
454
+ const backend = resolveSandboxBackend();
455
+ if (backend === "vercel") return createVercelBoxBackend();
456
+ return backend === "justbash" ? justBashBoxBackend : createDockerBoxBackend();
457
+ }
458
+
459
+ /** Boot registration, from `registerDefaultAdapters`. */
460
+ export function registerLongLivedBoxes(): string {
461
+ const backend = selectLongLivedBoxBackend();
462
+ registerLongLivedBoxBackend(backend);
463
+ return backend.backend;
464
+ }
@@ -0,0 +1,22 @@
1
+ /** The fetch a reader uses. Injected in tests with recorded fixtures; the live one is the global fetch. */
2
+ export type NativeFetch = (url: string, init?: RequestInit) => Promise<Response>;
3
+ /** The live fetch every reader uses by default: the global fetch with a per-call timeout. */
4
+ export declare const timedFetch: NativeFetch;
5
+ /** Read a response body as text, refusing one larger than the cap. */
6
+ export declare function readCappedText(res: Response): Promise<string>;
7
+ /** Read a JSON body, or `{}` when it does not parse (an error body need not be JSON). */
8
+ export declare function readJsonLoose(res: Response): Promise<Record<string, unknown>>;
9
+ /** A thrown error a reader turns into "the platform did not answer, check again later". Never echoes a body. */
10
+ export declare function unexpectedStatus(what: string, status: number): Error;
11
+ /**
12
+ * A per-process access-token cache, keyed by the caller (credential + subject). A token is reused until one
13
+ * minute before it expires. The key never holds the secret itself: callers pass a non-secret key.
14
+ */
15
+ export declare class TokenCache {
16
+ #private;
17
+ get(key: string, now: number): string | undefined;
18
+ set(key: string, token: string, expiresInSeconds: unknown, now: number): void;
19
+ }
20
+ /** True when `raw` is an https URL on `host` or one of its subdomains. A reader sends its token only there. */
21
+ export declare function onHost(raw: string, host: string): boolean;
22
+ //# sourceMappingURL=native-http.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"native-http.d.ts","sourceRoot":"","sources":["../../../agent/meetings/native-http.ts"],"names":[],"mappings":"AAIA,2GAA2G;AAC3G,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAOjF,6FAA6F;AAC7F,eAAO,MAAM,UAAU,EAAE,WAAyG,CAAC;AAKnI,sEAAsE;AACtE,wBAAsB,cAAc,CAAC,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,MAAM,CAAC,CAMnE;AAED,yFAAyF;AACzF,wBAAsB,aAAa,CAAC,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAOnF;AAED,gHAAgH;AAChH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,KAAK,CAEpE;AAOD;;;GAGG;AACH,qBAAa,UAAU;;IAErB,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGhD;IACD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAG5E;CACF;AAED,+GAA+G;AAC/G,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAOzD"}
@@ -0,0 +1,61 @@
1
+ // Shared plumbing for the native-transcript readers (OPE-2471): an injectable fetch, a capped body read, and
2
+ // a small token cache. Every reader is host code that calls one vendor API with the organisation's own
3
+ // credential; nothing here enters a sandbox, and no response text is ever echoed into a thread.
4
+ /** How long ONE vendor call may take. A call that runs out throws, and core counts the read as "the platform
5
+ * did not answer": OE checks again on the next sweep. It bounds each call only: a read makes up to a few calls
6
+ * in sequence, and the meeting sweep bounds a whole tick with its own time budget (meetings/native-recap.ts). */
7
+ const NATIVE_CALL_TIMEOUT_MS = 30_000;
8
+ /** The live fetch every reader uses by default: the global fetch with a per-call timeout. */
9
+ export const timedFetch = (url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(NATIVE_CALL_TIMEOUT_MS) });
10
+ /** The largest body a reader accepts: a three-hour transcript is well under 2 MB. */
11
+ const MAX_NATIVE_BODY_BYTES = 8 * 1024 * 1024;
12
+ /** Read a response body as text, refusing one larger than the cap. */
13
+ export async function readCappedText(res) {
14
+ const declared = Number(res.headers.get("content-length") ?? "0");
15
+ if (Number.isFinite(declared) && declared > MAX_NATIVE_BODY_BYTES)
16
+ throw new Error(`response body is ${declared} bytes, over the ${MAX_NATIVE_BODY_BYTES}-byte cap`);
17
+ const text = await res.text();
18
+ if (text.length > MAX_NATIVE_BODY_BYTES)
19
+ throw new Error(`response body is over the ${MAX_NATIVE_BODY_BYTES}-byte cap`);
20
+ return text;
21
+ }
22
+ /** Read a JSON body, or `{}` when it does not parse (an error body need not be JSON). */
23
+ export async function readJsonLoose(res) {
24
+ try {
25
+ const v = JSON.parse(await readCappedText(res));
26
+ return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
27
+ }
28
+ catch {
29
+ return {};
30
+ }
31
+ }
32
+ /** A thrown error a reader turns into "the platform did not answer, check again later". Never echoes a body. */
33
+ export function unexpectedStatus(what, status) {
34
+ return new Error(`${what} answered HTTP ${status}`);
35
+ }
36
+ /**
37
+ * A per-process access-token cache, keyed by the caller (credential + subject). A token is reused until one
38
+ * minute before it expires. The key never holds the secret itself: callers pass a non-secret key.
39
+ */
40
+ export class TokenCache {
41
+ #tokens = new Map();
42
+ get(key, now) {
43
+ const t = this.#tokens.get(key);
44
+ return t !== undefined && t.expiresAt - 60_000 > now ? t.token : undefined;
45
+ }
46
+ set(key, token, expiresInSeconds, now) {
47
+ const s = typeof expiresInSeconds === "number" && Number.isFinite(expiresInSeconds) && expiresInSeconds > 0 ? expiresInSeconds : 300;
48
+ this.#tokens.set(key, { token, expiresAt: now + s * 1000 });
49
+ }
50
+ }
51
+ /** True when `raw` is an https URL on `host` or one of its subdomains. A reader sends its token only there. */
52
+ export function onHost(raw, host) {
53
+ try {
54
+ const u = new URL(raw);
55
+ return u.protocol === "https:" && u.port === "" && (u.hostname === host || u.hostname.endsWith(`.${host}`));
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ //# sourceMappingURL=native-http.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"native-http.js","sourceRoot":"","sources":["../../../agent/meetings/native-http.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ import type { NativeTranscriptSource } from "@open-everywhere/core/agent/adapter-api.js";
2
+ import { type NativeFetch } from "./native-http.js";
3
+ export declare const MEET_SCOPE = "https://www.googleapis.com/auth/meetings.space.readonly";
4
+ export declare const MEET_ACCESS_HINT = "In the Google Workspace Admin console, grant the service account's client id domain-wide delegation of https://www.googleapis.com/auth/meetings.space.readonly, and enable the Google Meet REST API in its Cloud project.";
5
+ export declare function meetTranscriptSource(fetchImpl?: NativeFetch, now?: () => number): NativeTranscriptSource;
6
+ //# sourceMappingURL=native-meet.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"native-meet.d.ts","sourceRoot":"","sources":["../../../agent/meetings/native-meet.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAA0B,sBAAsB,EAAgE,MAAM,4CAA4C,CAAC;AAC/K,OAAO,EAAuD,KAAK,WAAW,EAAc,MAAM,kBAAkB,CAAC;AAIrH,eAAO,MAAM,UAAU,4DAA4D,CAAC;AAGpF,eAAO,MAAM,gBAAgB,8NACgM,CAAC;AA0B9N,wBAAgB,oBAAoB,CAAC,SAAS,GAAE,WAAwB,EAAE,GAAG,GAAE,MAAM,MAAiB,GAAG,sBAAsB,CAyH9H"}