@opengeni/core 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.
@@ -0,0 +1,80 @@
1
+ // Pure, HTTP-shaped guard helpers for the workspace member + workspace delete
2
+ // routes. Kept out of the route bodies so the don't-orphan rules (never remove
3
+ // the last admin, never delete an account's last workspace or one with a live
4
+ // session) are unit-testable without a database.
5
+ import type { Permission, WorkspaceMember } from "@opengeni/contracts";
6
+ import { HTTPException } from "hono/http-exception";
7
+
8
+ /** The membership permission that grants member-management (admin is the wildcard). */
9
+ const MEMBER_ADMIN_PERMISSIONS: Permission[] = ["workspace:admin", "members:manage"];
10
+
11
+ /** A member can manage other members (directly or via the admin wildcard). */
12
+ export function memberCanAdminister(member: Pick<WorkspaceMember, "permissions">): boolean {
13
+ return member.permissions.some((permission) => MEMBER_ADMIN_PERMISSIONS.includes(permission));
14
+ }
15
+
16
+ /** Only `user:` subjects are people; `api_key:` subjects belong to API keys. */
17
+ export function isUserMember(member: Pick<WorkspaceMember, "subjectId">): boolean {
18
+ return member.subjectId.startsWith("user:");
19
+ }
20
+
21
+ /**
22
+ * Turn an email lookup result into the membership subject id. A null id means
23
+ * no registered user matched the email — email invites for not-yet-registered
24
+ * users are deferred, so that is a 404 (not a 400) at the API surface.
25
+ */
26
+ export function resolveMemberSubjectId(userId: string | null): string {
27
+ if (!userId) {
28
+ throw new HTTPException(404, { message: "user is not registered" });
29
+ }
30
+ return `user:${userId}`;
31
+ }
32
+
33
+ /**
34
+ * Guard the member-remove path. Refuses (409) to remove the caller's own
35
+ * membership and refuses to remove the last member that still holds an admin
36
+ * permission, so a workspace can never be orphaned with no one able to manage
37
+ * it. `members` is the full roster (every subject, including api_key ones —
38
+ * an api_key with workspace:admin still counts as an administering subject).
39
+ */
40
+ export function assertWorkspaceMemberRemovable(input: {
41
+ members: WorkspaceMember[];
42
+ subjectId: string;
43
+ callerSubjectId: string;
44
+ }): void {
45
+ const { members, subjectId, callerSubjectId } = input;
46
+ if (subjectId === callerSubjectId) {
47
+ throw new HTTPException(409, { message: "you cannot remove your own membership" });
48
+ }
49
+ const target = members.find((member) => member.subjectId === subjectId);
50
+ if (!target) {
51
+ throw new HTTPException(404, { message: "member not found" });
52
+ }
53
+ if (memberCanAdminister(target)) {
54
+ const remainingAdmins = members.filter((member) => member.subjectId !== subjectId && memberCanAdminister(member));
55
+ if (remainingAdmins.length === 0) {
56
+ throw new HTTPException(409, { message: "cannot remove the last member who can manage this workspace" });
57
+ }
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Guard the workspace-delete path before any external/DB mutation. Refuses
63
+ * (409) to delete the account's last workspace, and refuses while any session
64
+ * could still be running in Temporal (there is no clean per-session terminate
65
+ * to call first, so we will not orphan a workflow — the operator must stop the
66
+ * sessions first).
67
+ */
68
+ export function assertWorkspaceDeletable(input: {
69
+ workspaceCountForAccount: number;
70
+ activeSessionCount: number;
71
+ }): void {
72
+ if (input.workspaceCountForAccount <= 1) {
73
+ throw new HTTPException(409, { message: "cannot delete the account's only workspace" });
74
+ }
75
+ if (input.activeSessionCount > 0) {
76
+ throw new HTTPException(409, {
77
+ message: "stop the workspace's running sessions before deleting it",
78
+ });
79
+ }
80
+ }
package/src/index.ts ADDED
@@ -0,0 +1,59 @@
1
+ // @opengeni/core — the framework-agnostic OpenGeni core.
2
+ //
3
+ // WHAT THIS PACKAGE IS: the OpenGeni domain, access, and billing layers carved
4
+ // out of `apps/api` into an importable library, so a host (e.g. cloudgeni) can
5
+ // call the OpenGeni core DIRECTLY, off-HTTP — e.g. `createSessionForRequest(
6
+ // deps, grant, workspaceId, input)` — without standing up the Hono router.
7
+ // `apps/api` (@opengeni/api-router) and `apps/worker` (@opengeni/worker-bundle)
8
+ // remain the STANDALONE RUNNERS that consume this library; nothing about the
9
+ // standalone served API or the worker boot changed.
10
+ //
11
+ // BEHAVIOR-PRESERVING MOVE PASS (Chunk 3): this extraction is a pure file-move +
12
+ // import-rewrite with ZERO behavior change. The domain keeps throwing Hono
13
+ // `HTTPException` exactly as before — so `hono` is a real runtime dependency of
14
+ // @opengeni/core for now. The typed-errors carve-out (transport-neutral error
15
+ // hierarchy + HTTP adapter in the router) is DEFERRED to a later pass; there is
16
+ // no `errors.ts` here yet.
17
+ //
18
+ // DEPENDENCY DISCIPLINE: the moved closure references the engine-internal
19
+ // sandbox client (`@opengeni/runtime/sandbox`, via the fleet/routing service it
20
+ // needs for `swapActiveSandbox`) and the type slots
21
+ // `@opengeni/storage`/`documents`/`observability` (in `dependencies.ts`). The
22
+ // storage/documents/observability references are TYPE-ONLY (erased at build),
23
+ // so they are devDependencies. `@opengeni/runtime` and `@opengeni/codex` are
24
+ // real runtime deps (fleet routing + the codex model-id prefix constant). The
25
+ // Better Auth `Auth` type (`managed-auth-type.ts`) is a type-only devDependency.
26
+
27
+ // The central dependency type surface (AppDependencies, ApiRouteDeps,
28
+ // SessionWorkflowClient, DocumentIndexClient, ObjectStorageDependency).
29
+ export * from "./dependencies";
30
+
31
+ // Boundary type slots referenced by dependencies.ts. The IMPLEMENTATIONS that
32
+ // construct these (the real sandbox client / Better Auth instance) stay in
33
+ // apps/api because they pull engine-internal / driver packages; only the
34
+ // structural TYPES live here.
35
+ export * from "./sandbox-types";
36
+ export * from "./managed-auth-type";
37
+
38
+ // Sandbox fleet/routing service — the closure of `domain/sessions.ts`
39
+ // (`swapActiveSandbox` + `FleetContext`). apps/api re-imports these for its MCP
40
+ // fleet tools, the machines REST route, and the rest of the sandbox layer.
41
+ export * from "./sandbox/fleet";
42
+ export * from "./sandbox/routing";
43
+
44
+ // Access layer (transport-neutral grant resolution + permission checks).
45
+ export * from "./access";
46
+
47
+ // Billing / usage-limit admission (checkLimit / requireLimit / recordWorkspaceUsage).
48
+ export * from "./billing/limits";
49
+
50
+ // Domain layer — the off-HTTP V2 surface (createSessionForRequest,
51
+ // postUserMessageTurn, createAndStartSession, capability/pack/environment/
52
+ // scheduled-task/workspace-member logic, …).
53
+ export * from "./domain/capabilities";
54
+ export * from "./domain/environments";
55
+ export * from "./domain/packs";
56
+ export * from "./domain/resources";
57
+ export * from "./domain/scheduled-tasks";
58
+ export * from "./domain/sessions";
59
+ export * from "./domain/workspace-members";
@@ -0,0 +1,20 @@
1
+ // @opengeni/core ManagedAuth TYPE alias.
2
+ //
3
+ // WHY THIS MODULE LIVES IN CORE: `dependencies.ts` carries a `managedAuth?:
4
+ // ManagedAuth | null` passthrough slot and the access layer calls
5
+ // `managedAuth.api.getSession({ headers })`. Both are framework-agnostic, so
6
+ // they belong in @opengeni/core. The CONSTRUCTION of a real Better Auth
7
+ // instance — `createManagedAuth`, which opens its own `pg.Pool` and wires
8
+ // Resend — stays in `apps/api/src/auth/managed-auth.ts` (it pulls the `pg`
9
+ // driver, which must NEVER enter @opengeni/core).
10
+ //
11
+ // This is a TYPE-ONLY import of Better Auth's `Auth` generic: it is fully
12
+ // erased at build time (tsup `dts`/transpile drop it), so it adds NO runtime
13
+ // dependency and NO driver import to the published @opengeni/core tarball —
14
+ // `better-auth` is a devDependency for typecheck only. Keeping the alias as the
15
+ // exact `Auth<any>` shape (not a hand-narrowed structural type) means
16
+ // `apps/api/src/app.ts` — which uses `managedAuth.handler(...)` AND the
17
+ // passthrough `deps.managedAuth` — stays byte-identically typed.
18
+ import type { Auth } from "better-auth";
19
+
20
+ export type ManagedAuth = Auth<any>;
@@ -0,0 +1,460 @@
1
+ // apps/api/src/sandbox/fleet.ts — the FLEET service backing the fleet MCP tools
2
+ // (M7): list / attach / swap / run_on / provision over the heterogeneous fleet
3
+ // (the session's Modal group box + the workspace's enrolled selfhosted machines).
4
+ //
5
+ // Each operation is workspace-scoped (the caller's grant) and, for the
6
+ // session-pointer mutations (attach/swap), session-scoped (the worker-signed
7
+ // sessionId claim). The swap is the epoch-fenced CAS `setActiveSandbox`: it bumps
8
+ // active_epoch + repoints active_sandbox_id, which the routing proxy reads on the
9
+ // NEXT tool call. Liveness for a selfhosted target is a real ControlRpc ping over
10
+ // the events bus (the subject IS the registry); a Modal box is "live" while its
11
+ // session group exists. `run_on` builds a one-off backend session and runs a
12
+ // single op WITHOUT touching the active pointer.
13
+
14
+ import type { Settings } from "@opengeni/config";
15
+ import {
16
+ getEnrollment,
17
+ getSandbox,
18
+ listEnrollments,
19
+ listSandboxes,
20
+ readActiveSandbox,
21
+ requireSession,
22
+ setActiveSandbox,
23
+ type Database,
24
+ type EnrollmentRecord,
25
+ type SandboxRecord,
26
+ } from "@opengeni/db";
27
+ import type { EventBus } from "@opengeni/events";
28
+ import {
29
+ NatsControlRpc,
30
+ selfhostedLiveness,
31
+ SelfhostedSession,
32
+ type ControlRpc,
33
+ type NatsRequestConnection,
34
+ } from "@opengeni/runtime/sandbox";
35
+ import { HTTPException } from "hono/http-exception";
36
+ import { relayConfigFromSettings } from "./routing";
37
+
38
+ export type FleetServices = {
39
+ db: Database;
40
+ settings: Settings;
41
+ bus?: EventBus;
42
+ };
43
+
44
+ export type FleetContext = {
45
+ accountId: string;
46
+ workspaceId: string;
47
+ /** The calling session (the pointer the attach/swap mutates + whose group box
48
+ * is the default fleet member). */
49
+ sessionId: string;
50
+ /** The session's own group sandbox backend (modal/selfhosted/…). */
51
+ sessionBackend: string;
52
+ /** The session's own group sandbox id (the lease group). */
53
+ sessionGroupId: string;
54
+ };
55
+
56
+ /**
57
+ * Build a session-scoped {@link FleetContext}: load the session (workspace-
58
+ * scoped), reject a session with no box (backend:none — the fleet is only
59
+ * meaningful for a sandboxed session), and project its group backend/id. Shared
60
+ * by the worker-signed MCP fleet tools and the user-authenticated swap REST
61
+ * route so both resolve the SAME context (no drift). The `accountId`/`workspaceId`/
62
+ * `sessionId` come from the trusted grant/route; the backend + group id come from
63
+ * the session row.
64
+ */
65
+ export async function buildFleetContextForSession(
66
+ deps: { db: Database },
67
+ ctx: { accountId: string; workspaceId: string; sessionId: string },
68
+ ): Promise<FleetContext> {
69
+ const session = await requireSession(deps.db, ctx.workspaceId, ctx.sessionId);
70
+ if (session.sandboxBackend === "none") {
71
+ throw new HTTPException(422, {
72
+ message: "this session has no sandbox (backend: none); the fleet is unavailable",
73
+ });
74
+ }
75
+ return {
76
+ accountId: ctx.accountId,
77
+ workspaceId: ctx.workspaceId,
78
+ sessionId: ctx.sessionId,
79
+ sessionBackend: session.sandboxBackend,
80
+ sessionGroupId: session.sandboxGroupId,
81
+ };
82
+ }
83
+
84
+ /** The dominant liveness of a fleet member, surfaced to the dock + the agent. */
85
+ export type FleetLiveness = "online" | "reconnecting" | "offline";
86
+
87
+ /**
88
+ * A fleet member as the agent + the dock see it (the M8b/M9 UI seam — the
89
+ * `sandboxes_list` response entry the dock renders). STABLE shape: the dock keys
90
+ * on `id`, renders `name`/`kind`/`liveness`, and marks `active`. The session's own
91
+ * Modal group box is a synthetic entry with `id: groupId`, `kind: "modal"`, and a
92
+ * null `enrollmentId`; an enrolled machine carries its sandbox + enrollment ids.
93
+ */
94
+ export type FleetSandboxEntry = {
95
+ /** The sandbox id used as the attach/swap/run_on `target`. For the session's
96
+ * own group box this is the group id (a null active pointer == this box). */
97
+ id: string;
98
+ kind: "modal" | "selfhosted";
99
+ name: string;
100
+ liveness: FleetLiveness;
101
+ /** True for the session's currently-active sandbox (the routing target). */
102
+ active: boolean;
103
+ /** True for the session's own group box (the default/home sandbox). */
104
+ isSessionGroup: boolean;
105
+ enrollmentId: string | null;
106
+ /** Whether this target can be attached/swapped to right now (live + addressable). */
107
+ attachable: boolean;
108
+ /** Selfhosted only: whether whole-machine + screen-control consent is acked. */
109
+ consented?: boolean;
110
+ /** Selfhosted only: whether a display (real/Xvfb) is present. */
111
+ hasDisplay?: boolean;
112
+ lastSeenAt?: string | null;
113
+ };
114
+
115
+ export type FleetListResult = {
116
+ /** The session's currently-active sandbox id, or null == the group box. */
117
+ activeSandboxId: string | null;
118
+ activeEpoch: number;
119
+ sandboxes: FleetSandboxEntry[];
120
+ };
121
+
122
+ /** A swap/attach outcome the tool returns. */
123
+ export type FleetSwapResult = {
124
+ swapped: boolean;
125
+ activeSandboxId: string | null;
126
+ activeEpoch: number;
127
+ reason?: string;
128
+ };
129
+
130
+ const PROBE_TIMEOUT_MS = 5_000;
131
+
132
+ function controlRpc(bus: EventBus | undefined): ControlRpc {
133
+ return new NatsControlRpc(async (): Promise<NatsRequestConnection | null> => {
134
+ if (!bus) {
135
+ return null;
136
+ }
137
+ return bus.getRequestConnection();
138
+ });
139
+ }
140
+
141
+ /** Probe an enrolled machine's liveness: a real ControlRpc ping (the subject IS
142
+ * the registry), mapped through `selfhostedLiveness` (the enrollment row's
143
+ * status/consent/display + lastSeenAt disambiguate a probe-miss into
144
+ * reconnecting vs offline). A revoked/never-seen enrollment is offline without a
145
+ * probe. */
146
+ async function probeEnrollment(
147
+ services: FleetServices,
148
+ workspaceId: string,
149
+ enrollment: EnrollmentRecord,
150
+ ): Promise<{ liveness: FleetLiveness; consented: boolean; hasDisplay: boolean }> {
151
+ const { settings, bus } = services;
152
+ let probeResponded = false;
153
+ if (enrollment.status === "active") {
154
+ const session = new SelfhostedSession({
155
+ workspaceId,
156
+ agentId: enrollment.id,
157
+ controlRpc: controlRpc(bus),
158
+ relay: relayConfigFromSettings(settings),
159
+ timeoutMs: PROBE_TIMEOUT_MS,
160
+ });
161
+ try {
162
+ probeResponded = await session.ping();
163
+ } catch {
164
+ probeResponded = false;
165
+ }
166
+ }
167
+ const state = selfhostedLiveness({
168
+ enrollment: {
169
+ status: enrollment.status,
170
+ exposure: enrollment.exposure,
171
+ allowScreenControl: enrollment.allowScreenControl,
172
+ hasDisplay: enrollment.hasDisplay,
173
+ lastSeenAt: enrollment.lastSeenAt,
174
+ },
175
+ probeResponded,
176
+ });
177
+ return { liveness: state.state, consented: state.consented, hasDisplay: state.hasDisplay };
178
+ }
179
+
180
+ /**
181
+ * List the fleet: the session's own Modal group box (a synthetic entry) + the
182
+ * workspace's first-class selfhosted sandboxes (each probed for liveness), each
183
+ * with an `active` marker derived from the session's active pointer.
184
+ */
185
+ export async function listFleet(services: FleetServices, ctx: FleetContext): Promise<FleetListResult> {
186
+ const { db } = services;
187
+ const pointer = (await readActiveSandbox(db, ctx.workspaceId, ctx.sessionId)) ?? {
188
+ activeSandboxId: null,
189
+ activeEpoch: 0,
190
+ };
191
+
192
+ const entries: FleetSandboxEntry[] = [];
193
+
194
+ // The session's own group box (the default/home sandbox; null active pointer ==
195
+ // this box). It is live by virtue of being the session's resumable group.
196
+ const groupActive = pointer.activeSandboxId === null;
197
+ entries.push({
198
+ id: ctx.sessionGroupId,
199
+ kind: ctx.sessionBackend === "selfhosted" ? "selfhosted" : "modal",
200
+ name: "session sandbox",
201
+ liveness: "online",
202
+ active: groupActive,
203
+ isSessionGroup: true,
204
+ enrollmentId: null,
205
+ attachable: true,
206
+ });
207
+
208
+ // The workspace's first-class selfhosted sandboxes (enrolled machines). Probe
209
+ // each for liveness; a missing enrollment is offline.
210
+ const sandboxes = await listSandboxes(db, ctx.workspaceId);
211
+ for (const sandbox of sandboxes) {
212
+ if (sandbox.kind !== "selfhosted" || !sandbox.enrollmentId) {
213
+ continue;
214
+ }
215
+ const enrollment = await getEnrollment(db, ctx.workspaceId, sandbox.enrollmentId);
216
+ const probe = enrollment
217
+ ? await probeEnrollment(services, ctx.workspaceId, enrollment)
218
+ : { liveness: "offline" as FleetLiveness, consented: false, hasDisplay: false };
219
+ entries.push({
220
+ id: sandbox.id,
221
+ kind: "selfhosted",
222
+ name: sandbox.name,
223
+ liveness: probe.liveness,
224
+ active: pointer.activeSandboxId === sandbox.id,
225
+ isSessionGroup: false,
226
+ enrollmentId: sandbox.enrollmentId,
227
+ attachable: probe.liveness === "online",
228
+ consented: probe.consented,
229
+ hasDisplay: probe.hasDisplay,
230
+ lastSeenAt: enrollment?.lastSeenAt ?? null,
231
+ });
232
+ }
233
+
234
+ return { activeSandboxId: pointer.activeSandboxId, activeEpoch: pointer.activeEpoch, sandboxes: entries };
235
+ }
236
+
237
+ /** Resolve a swap target id → the value `setActiveSandbox` writes. The session's
238
+ * own group id maps to NULL (the default pointer); a first-class sandbox id is
239
+ * validated (workspace ownership + liveness) and written verbatim. */
240
+ async function resolveTarget(
241
+ services: FleetServices,
242
+ ctx: FleetContext,
243
+ target: string,
244
+ ): Promise<{ ok: true; targetSandboxId: string | null } | { ok: false; reason: string }> {
245
+ // The session's own group box → the default pointer (null).
246
+ if (target === ctx.sessionGroupId || target === "session" || target === "default") {
247
+ return { ok: true, targetSandboxId: null };
248
+ }
249
+ const sandbox = await getSandbox(services.db, ctx.workspaceId, target);
250
+ if (!sandbox) {
251
+ return { ok: false, reason: `sandbox ${target} not found in this workspace` };
252
+ }
253
+ if (sandbox.kind === "selfhosted") {
254
+ if (!sandbox.enrollmentId) {
255
+ return { ok: false, reason: `selfhosted sandbox ${target} has no enrollment` };
256
+ }
257
+ const enrollment = await getEnrollment(services.db, ctx.workspaceId, sandbox.enrollmentId);
258
+ if (!enrollment) {
259
+ return { ok: false, reason: `enrollment for sandbox ${target} not found` };
260
+ }
261
+ const probe = await probeEnrollment(services, ctx.workspaceId, enrollment);
262
+ if (probe.liveness !== "online") {
263
+ return { ok: false, reason: `sandbox ${target} is ${probe.liveness}; cannot attach to a non-online machine` };
264
+ }
265
+ }
266
+ return { ok: true, targetSandboxId: sandbox.id };
267
+ }
268
+
269
+ /**
270
+ * THE SWAP (and attach — identical mechanic). Validate the target's ownership +
271
+ * liveness, then repoint the session via the epoch-fenced CAS `setActiveSandbox`:
272
+ * read the current epoch, then CAS on it. A concurrent double-swap lets exactly
273
+ * one win; the loser re-reads + may retry. The bumped epoch fences any in-flight
274
+ * op cached against the old pointer, which then retries against the new active
275
+ * sandbox (the routing proxy's fenced-retry role).
276
+ */
277
+ export async function swapActiveSandbox(
278
+ services: FleetServices,
279
+ ctx: FleetContext,
280
+ target: string,
281
+ // The session's working directory to seed alongside the pointer (create-time
282
+ // machine targeting). OMITTED ⇒ the column is left unchanged (a live swap/attach
283
+ // never touches it); threaded straight into the epoch-fenced setActiveSandbox CAS.
284
+ workingDir?: string | null,
285
+ ): Promise<FleetSwapResult> {
286
+ const resolved = await resolveTarget(services, ctx, target);
287
+ if (!resolved.ok) {
288
+ const pointer = (await readActiveSandbox(services.db, ctx.workspaceId, ctx.sessionId)) ?? {
289
+ activeSandboxId: null,
290
+ activeEpoch: 0,
291
+ };
292
+ return { swapped: false, activeSandboxId: pointer.activeSandboxId, activeEpoch: pointer.activeEpoch, reason: resolved.reason };
293
+ }
294
+
295
+ // Read the current epoch, then CAS on it (the fence). One retry on a lost race
296
+ // (a concurrent swap bumped the epoch between read and write).
297
+ for (let attempt = 0; attempt < 2; attempt += 1) {
298
+ const pointer = (await readActiveSandbox(services.db, ctx.workspaceId, ctx.sessionId)) ?? {
299
+ activeSandboxId: null,
300
+ activeEpoch: 0,
301
+ };
302
+ // No-op swap (already pointed there) is a success without an epoch bump churn.
303
+ if (pointer.activeSandboxId === resolved.targetSandboxId) {
304
+ return { swapped: true, activeSandboxId: pointer.activeSandboxId, activeEpoch: pointer.activeEpoch };
305
+ }
306
+ const result = await setActiveSandbox(services.db, {
307
+ accountId: ctx.accountId,
308
+ workspaceId: ctx.workspaceId,
309
+ sessionId: ctx.sessionId,
310
+ targetSandboxId: resolved.targetSandboxId,
311
+ expectedEpoch: pointer.activeEpoch,
312
+ ...(workingDir !== undefined ? { workingDir } : {}),
313
+ });
314
+ if (result.swapped && result.pointer) {
315
+ return { swapped: true, activeSandboxId: result.pointer.activeSandboxId, activeEpoch: result.pointer.activeEpoch };
316
+ }
317
+ // CAS lost (a concurrent swap won) — re-read + retry once.
318
+ }
319
+ const pointer = (await readActiveSandbox(services.db, ctx.workspaceId, ctx.sessionId)) ?? {
320
+ activeSandboxId: null,
321
+ activeEpoch: 0,
322
+ };
323
+ return {
324
+ swapped: false,
325
+ activeSandboxId: pointer.activeSandboxId,
326
+ activeEpoch: pointer.activeEpoch,
327
+ reason: "a concurrent swap won the epoch fence; re-read and retry",
328
+ };
329
+ }
330
+
331
+ export type RunOnOp =
332
+ | { kind: "exec"; cmd: string; workdir?: string }
333
+ | { kind: "read"; path: string }
334
+ | { kind: "write"; path: string; content: string };
335
+
336
+ export type RunOnResult = {
337
+ target: string;
338
+ kind: string;
339
+ ok: boolean;
340
+ stdout?: string;
341
+ stderr?: string;
342
+ exitCode?: number | null;
343
+ content?: string;
344
+ bytesWritten?: number;
345
+ reason?: string;
346
+ };
347
+
348
+ /**
349
+ * Run a ONE-OFF op against a SPECIFIC target WITHOUT changing the active pointer
350
+ * (the dossier `run_on`). Only selfhosted targets are routable as a one-off here
351
+ * (a Modal target is the session's group box, reached via the normal Channel-A /
352
+ * turn path — `run_on` is for reaching a NON-active enrolled machine without
353
+ * swapping). The op is fenced under the target's enrollment, addressed to its
354
+ * agent subject; an offline machine surfaces a clear reason, never a wrong-box
355
+ * landing.
356
+ */
357
+ export async function runOnSandbox(
358
+ services: FleetServices,
359
+ ctx: FleetContext,
360
+ target: string,
361
+ op: RunOnOp,
362
+ ): Promise<RunOnResult> {
363
+ const sandbox = await getSandbox(services.db, ctx.workspaceId, target);
364
+ if (!sandbox) {
365
+ return { target, kind: op.kind, ok: false, reason: `sandbox ${target} not found in this workspace` };
366
+ }
367
+ if (sandbox.kind !== "selfhosted" || !sandbox.enrollmentId) {
368
+ return {
369
+ target,
370
+ kind: op.kind,
371
+ ok: false,
372
+ reason: `run_on routes one-off ops to enrolled selfhosted machines; ${sandbox.kind} targets are reached via the active sandbox (swap to it first)`,
373
+ };
374
+ }
375
+ const enrollment = await getEnrollment(services.db, ctx.workspaceId, sandbox.enrollmentId);
376
+ if (!enrollment || enrollment.status !== "active") {
377
+ return { target, kind: op.kind, ok: false, reason: `sandbox ${target} is not enrolled/active` };
378
+ }
379
+
380
+ const session = new SelfhostedSession({
381
+ workspaceId: ctx.workspaceId,
382
+ agentId: sandbox.enrollmentId,
383
+ controlRpc: controlRpc(services.bus),
384
+ relay: relayConfigFromSettings(services.settings),
385
+ });
386
+
387
+ try {
388
+ if (op.kind === "exec") {
389
+ const res = await session.exec({ cmd: op.cmd, ...(op.workdir ? { workdir: op.workdir } : {}) });
390
+ return { target, kind: "exec", ok: true, stdout: res.stdout, stderr: res.stderr, exitCode: res.exitCode };
391
+ }
392
+ if (op.kind === "read") {
393
+ const bytes = await session.readFile({ path: op.path });
394
+ return { target, kind: "read", ok: true, content: new TextDecoder().decode(bytes) };
395
+ }
396
+ // write
397
+ const bytesWritten = await session.writeFile({ path: op.path, content: op.content });
398
+ return { target, kind: "write", ok: true, bytesWritten };
399
+ } catch (error) {
400
+ const reason = error instanceof Error ? error.message : String(error);
401
+ return { target, kind: op.kind, ok: false, reason };
402
+ }
403
+ }
404
+
405
+ export type ProvisionResult =
406
+ | {
407
+ kind: "selfhosted";
408
+ instructions: string;
409
+ installCommandUnix: string;
410
+ installCommandWindows: string;
411
+ verificationUri: string;
412
+ note: string;
413
+ }
414
+ | { kind: "modal"; sandbox: SandboxRecord; note: string };
415
+
416
+ /**
417
+ * Provision a new fleet member.
418
+ * - selfhosted → return the device-flow enrollment instructions (the agent
419
+ * surfaces them to a HUMAN, who installs the agent + enrolls — the agent
420
+ * cannot click the loud whole-machine consent itself).
421
+ * - modal → create a first-class named modal `sandboxes` record (a swap target).
422
+ * NOTE: the Modal BOX is materialized lazily when first swapped-to (Modal
423
+ * lifecycle is owned by the lease — unchanged per dossier §21).
424
+ */
425
+ export async function provisionSandbox(
426
+ services: FleetServices,
427
+ ctx: FleetContext,
428
+ input: { kind: "selfhosted" | "modal"; name?: string },
429
+ ): Promise<ProvisionResult> {
430
+ if (input.kind === "selfhosted") {
431
+ const base = (services.settings.publicBaseUrl ?? "https://get.opengeni.ai").replace(/\/+$/, "");
432
+ return {
433
+ kind: "selfhosted",
434
+ instructions:
435
+ "Share these instructions with a human operator. They install the OpenGeni agent on the machine, run `opengeni-agent enroll`, complete the device-flow at the verification URL (the loud whole-machine + screen-control consent), and the machine then appears here as an attachable selfhosted sandbox.",
436
+ // Install from THIS control plane's origin (not a hardcoded public CDN): the
437
+ // served install script is rewritten to pull the per-SHA agent baked into
438
+ // this exact deployment (see apps/api/src/routes/install.ts), so a deployed
439
+ // env is self-contained and a private/air-gapped one works with no public DNS.
440
+ installCommandUnix: `curl -fsSL ${base}/install.sh | sh`,
441
+ installCommandWindows: `irm ${base}/install.ps1 | iex`,
442
+ verificationUri: `${base}/device`,
443
+ note: "Whole-machine access requires explicit human consent in the device-flow web page; the agent cannot self-consent.",
444
+ };
445
+ }
446
+ // modal: create a first-class named modal sandbox record (a swap target). The
447
+ // box is materialized lazily on first swap (Modal lifecycle unchanged).
448
+ const { createSandbox } = await import("@opengeni/db");
449
+ const sandbox = await createSandbox(services.db, {
450
+ accountId: ctx.accountId,
451
+ workspaceId: ctx.workspaceId,
452
+ kind: "modal",
453
+ name: input.name?.trim() || "modal-box",
454
+ });
455
+ return {
456
+ kind: "modal",
457
+ sandbox,
458
+ note: "A named Modal sandbox record was created. Its box is materialized when first swapped-to; the session's own group box remains the default until then.",
459
+ };
460
+ }