@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.
- package/README.md +14 -0
- package/dist/index.d.ts +735 -0
- package/dist/index.js +2627 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
- package/src/access/index.ts +186 -0
- package/src/billing/limits.ts +207 -0
- package/src/dependencies.ts +70 -0
- package/src/domain/capabilities.ts +959 -0
- package/src/domain/environments.ts +115 -0
- package/src/domain/packs.ts +241 -0
- package/src/domain/resources.ts +221 -0
- package/src/domain/scheduled-tasks.ts +321 -0
- package/src/domain/sessions.ts +812 -0
- package/src/domain/workspace-members.ts +80 -0
- package/src/index.ts +59 -0
- package/src/managed-auth-type.ts +20 -0
- package/src/sandbox/fleet.ts +460 -0
- package/src/sandbox/routing.ts +127 -0
- package/src/sandbox-types.ts +61 -0
|
@@ -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
|
+
}
|