@opengeni/api-router 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +16 -0
- package/dist/app.js +35 -0
- package/dist/app.js.map +1 -0
- package/dist/chunk-XSYUDIX3.js +6331 -0
- package/dist/chunk-XSYUDIX3.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +567 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
- package/src/app.ts +351 -0
- package/src/auth/managed-auth.ts +237 -0
- package/src/http/auth.ts +92 -0
- package/src/http/common.ts +16 -0
- package/src/http/sse.ts +89 -0
- package/src/index.ts +362 -0
- package/src/mcp/documents.ts +57 -0
- package/src/mcp/server.ts +961 -0
- package/src/mcp/session-view.ts +281 -0
- package/src/routes/api-keys.ts +65 -0
- package/src/routes/billing.ts +495 -0
- package/src/routes/capabilities.ts +80 -0
- package/src/routes/codex.ts +393 -0
- package/src/routes/documents.ts +185 -0
- package/src/routes/enrollments.ts +357 -0
- package/src/routes/environments.ts +175 -0
- package/src/routes/files.ts +148 -0
- package/src/routes/github.ts +341 -0
- package/src/routes/install.ts +218 -0
- package/src/routes/machines.ts +107 -0
- package/src/routes/packs.ts +241 -0
- package/src/routes/scheduled-tasks.ts +126 -0
- package/src/routes/sessions.ts +1083 -0
- package/src/routes/social.ts +119 -0
- package/src/routes/workspaces.ts +206 -0
- package/src/sandbox/access.ts +89 -0
- package/src/sandbox/auth-callout.ts +178 -0
- package/src/sandbox/channel-a.ts +265 -0
- package/src/sandbox/enrollment.ts +498 -0
- package/src/sandbox/machines.ts +255 -0
- package/src/sandbox/metrics-ingestion.ts +289 -0
- package/src/sandbox/viewer.ts +993 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CreateSocialConnectionRequest,
|
|
3
|
+
CreateSocialPostRequest,
|
|
4
|
+
} from "@opengeni/contracts";
|
|
5
|
+
import {
|
|
6
|
+
createSocialConnection,
|
|
7
|
+
createSocialPost,
|
|
8
|
+
listSocialConnections,
|
|
9
|
+
listSocialPosts,
|
|
10
|
+
} from "@opengeni/db";
|
|
11
|
+
import type { Hono } from "hono";
|
|
12
|
+
import { HTTPException } from "hono/http-exception";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { requireAccessGrant } from "@opengeni/core";
|
|
15
|
+
import type { ApiRouteDeps } from "@opengeni/core";
|
|
16
|
+
import { boundedLimit } from "../http/common";
|
|
17
|
+
|
|
18
|
+
export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
19
|
+
const { db } = deps;
|
|
20
|
+
|
|
21
|
+
app.get("/v1/workspaces/:workspaceId/social/connections", async (c) => {
|
|
22
|
+
const workspaceId = c.req.param("workspaceId");
|
|
23
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
24
|
+
return c.json(await listSocialConnections(db, workspaceId, boundedLimit(c.req.query("limit"))));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
app.post("/v1/workspaces/:workspaceId/social/connections", async (c) => {
|
|
28
|
+
const workspaceId = c.req.param("workspaceId");
|
|
29
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
30
|
+
const payload = CreateSocialConnectionRequest.parse(await c.req.json());
|
|
31
|
+
try {
|
|
32
|
+
return c.json(await createSocialConnection(db, {
|
|
33
|
+
accountId: grant.accountId,
|
|
34
|
+
workspaceId,
|
|
35
|
+
provider: payload.provider,
|
|
36
|
+
accountHandle: payload.accountHandle,
|
|
37
|
+
accountName: payload.accountName ?? null,
|
|
38
|
+
externalAccountId: payload.externalAccountId ?? null,
|
|
39
|
+
status: payload.status,
|
|
40
|
+
scopes: payload.scopes,
|
|
41
|
+
credentialRef: payload.credentialRef ?? null,
|
|
42
|
+
tokenMetadata: payload.tokenMetadata,
|
|
43
|
+
metadata: payload.metadata,
|
|
44
|
+
}), 201);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw socialHttpException(error);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
app.get("/v1/workspaces/:workspaceId/social/posts", async (c) => {
|
|
51
|
+
const workspaceId = c.req.param("workspaceId");
|
|
52
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
53
|
+
const since = parseSince(c.req.query("since"));
|
|
54
|
+
const connectionIds = parseConnectionIds(c.req.query("connectionIds") ?? c.req.query("connectionId"));
|
|
55
|
+
return c.json(await listSocialPosts(db, {
|
|
56
|
+
workspaceId,
|
|
57
|
+
...(connectionIds?.length ? { connectionIds } : {}),
|
|
58
|
+
...(since ? { since } : {}),
|
|
59
|
+
limit: boundedLimit(c.req.query("limit")),
|
|
60
|
+
}));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
app.post("/v1/workspaces/:workspaceId/social/posts", async (c) => {
|
|
64
|
+
const workspaceId = c.req.param("workspaceId");
|
|
65
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
66
|
+
const payload = CreateSocialPostRequest.parse(await c.req.json());
|
|
67
|
+
try {
|
|
68
|
+
return c.json(await createSocialPost(db, {
|
|
69
|
+
accountId: grant.accountId,
|
|
70
|
+
workspaceId,
|
|
71
|
+
connectionId: payload.connectionId,
|
|
72
|
+
externalPostId: payload.externalPostId ?? null,
|
|
73
|
+
url: payload.url ?? null,
|
|
74
|
+
authorHandle: payload.authorHandle ?? null,
|
|
75
|
+
text: payload.text,
|
|
76
|
+
publishedAt: new Date(payload.publishedAt),
|
|
77
|
+
metrics: payload.metrics,
|
|
78
|
+
raw: payload.raw,
|
|
79
|
+
}), 201);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw socialHttpException(error);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseSince(raw: string | undefined): Date | undefined {
|
|
87
|
+
if (!raw) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const since = new Date(raw);
|
|
91
|
+
if (Number.isNaN(since.getTime())) {
|
|
92
|
+
throw new HTTPException(422, { message: "since must be an ISO date-time" });
|
|
93
|
+
}
|
|
94
|
+
return since;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseConnectionIds(raw: string | undefined): string[] | undefined {
|
|
98
|
+
if (!raw) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
const values = raw.split(",").map((value) => value.trim()).filter(Boolean);
|
|
102
|
+
const parsed = z.array(z.string().uuid()).safeParse(values);
|
|
103
|
+
if (!parsed.success) {
|
|
104
|
+
throw new HTTPException(422, { message: "connectionIds must be a comma-separated list of UUIDs" });
|
|
105
|
+
}
|
|
106
|
+
const ids = parsed.data;
|
|
107
|
+
return [...new Set(ids)];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function socialHttpException(error: unknown): HTTPException {
|
|
111
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
112
|
+
if (message.includes("not found")) {
|
|
113
|
+
return new HTTPException(404, { message });
|
|
114
|
+
}
|
|
115
|
+
if (message.includes("duplicate key")) {
|
|
116
|
+
return new HTTPException(409, { message: "social connection or post already exists" });
|
|
117
|
+
}
|
|
118
|
+
return new HTTPException(500, { message });
|
|
119
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AddWorkspaceMemberRequest,
|
|
3
|
+
CreateWorkspaceRequest,
|
|
4
|
+
ListWorkspaceMembersResponse,
|
|
5
|
+
UpdateWorkspaceMemberRequest,
|
|
6
|
+
UpdateWorkspaceRequest,
|
|
7
|
+
Workspace,
|
|
8
|
+
WorkspaceMember,
|
|
9
|
+
type AccessContext,
|
|
10
|
+
type Permission,
|
|
11
|
+
} from "@opengeni/contracts";
|
|
12
|
+
import {
|
|
13
|
+
allWorkspacePermissions,
|
|
14
|
+
countActiveSessionsForWorkspace,
|
|
15
|
+
countWorkspacesForAccount,
|
|
16
|
+
createWorkspace,
|
|
17
|
+
deleteWorkspace,
|
|
18
|
+
getManagedUserByEmail,
|
|
19
|
+
grantWorkspaceAccess,
|
|
20
|
+
listScheduledTasks,
|
|
21
|
+
listWorkspaceMembers,
|
|
22
|
+
listWorkspacesForSubject,
|
|
23
|
+
removeWorkspaceMember,
|
|
24
|
+
requireWorkspace,
|
|
25
|
+
updateWorkspace,
|
|
26
|
+
} from "@opengeni/db";
|
|
27
|
+
import type { Hono } from "hono";
|
|
28
|
+
import { HTTPException } from "hono/http-exception";
|
|
29
|
+
import { hasPermission, requireAccessContext, requireAccessGrant } from "@opengeni/core";
|
|
30
|
+
import { requireLimit } from "@opengeni/core";
|
|
31
|
+
import type { ApiRouteDeps } from "@opengeni/core";
|
|
32
|
+
import { assertWorkspaceDeletable, assertWorkspaceMemberRemovable, resolveMemberSubjectId } from "@opengeni/core";
|
|
33
|
+
|
|
34
|
+
export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
35
|
+
app.get("/v1/access/me", async (c) => {
|
|
36
|
+
return c.json(await requireAccessContext(c, deps));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
app.get("/v1/workspaces", async (c) => {
|
|
40
|
+
const context = await requireAccessContext(c, deps);
|
|
41
|
+
const readableWorkspaceIds = [...new Set(context.workspaceGrants
|
|
42
|
+
.filter((grant) => hasPermission(grant.permissions, "workspace:read"))
|
|
43
|
+
.map((grant) => grant.workspaceId))];
|
|
44
|
+
if (readableWorkspaceIds.length > 0) {
|
|
45
|
+
const workspaces = await Promise.all(readableWorkspaceIds.map((workspaceId) => requireWorkspace(deps.db, workspaceId)));
|
|
46
|
+
return c.json(workspaces.map((workspace) => Workspace.parse(workspace)));
|
|
47
|
+
}
|
|
48
|
+
return c.json((await listWorkspacesForSubject(deps.db, context.subjectId)).map((workspace) => Workspace.parse(workspace)));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
app.post("/v1/workspaces", async (c) => {
|
|
52
|
+
const context = await requireAccessContext(c, deps);
|
|
53
|
+
const payload = CreateWorkspaceRequest.parse(await c.req.json());
|
|
54
|
+
const accountId = payload.accountId ?? context.defaultAccountId;
|
|
55
|
+
if (!accountId) {
|
|
56
|
+
throw new HTTPException(409, { message: "account selection is required" });
|
|
57
|
+
}
|
|
58
|
+
requireAccountPermission(context, accountId, "workspace:create");
|
|
59
|
+
await requireLimit(deps, { accountId, action: "workspace:create", quantity: 1 });
|
|
60
|
+
const workspace = await createWorkspace(deps.db, {
|
|
61
|
+
accountId,
|
|
62
|
+
name: payload.name.trim(),
|
|
63
|
+
slug: payload.slug?.trim() || null,
|
|
64
|
+
externalSource: payload.externalSource ?? null,
|
|
65
|
+
externalId: payload.externalId ?? null,
|
|
66
|
+
...(payload.agentInstructions !== undefined ? { agentInstructions: normalizeAgentInstructions(payload.agentInstructions) } : {}),
|
|
67
|
+
});
|
|
68
|
+
await grantWorkspaceAccess(deps.db, {
|
|
69
|
+
accountId,
|
|
70
|
+
workspaceId: workspace.id,
|
|
71
|
+
subjectId: context.subjectId,
|
|
72
|
+
role: "owner",
|
|
73
|
+
permissions: allWorkspacePermissions,
|
|
74
|
+
...(context.subjectLabel ? { subjectLabel: context.subjectLabel } : {}),
|
|
75
|
+
});
|
|
76
|
+
return c.json(Workspace.parse(workspace), 201);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
app.get("/v1/workspaces/:workspaceId", async (c) => {
|
|
80
|
+
const workspaceId = c.req.param("workspaceId");
|
|
81
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
82
|
+
return c.json(Workspace.parse(await requireWorkspace(deps.db, workspaceId)));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
app.patch("/v1/workspaces/:workspaceId", async (c) => {
|
|
86
|
+
const workspaceId = c.req.param("workspaceId");
|
|
87
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
88
|
+
const payload = UpdateWorkspaceRequest.parse(await c.req.json());
|
|
89
|
+
const workspace = await updateWorkspace(deps.db, workspaceId, {
|
|
90
|
+
...(payload.name !== undefined ? { name: payload.name.trim() } : {}),
|
|
91
|
+
...(payload.slug !== undefined ? { slug: payload.slug?.trim() || null } : {}),
|
|
92
|
+
...(payload.agentInstructions !== undefined ? { agentInstructions: normalizeAgentInstructions(payload.agentInstructions) } : {}),
|
|
93
|
+
});
|
|
94
|
+
return c.json(Workspace.parse(workspace));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
app.delete("/v1/workspaces/:workspaceId", async (c) => {
|
|
98
|
+
const workspaceId = c.req.param("workspaceId");
|
|
99
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
100
|
+
// Refuse before any external/DB mutation: never delete the account's only
|
|
101
|
+
// workspace, and never delete while a session could still be running in
|
|
102
|
+
// Temporal (there is no clean per-session terminate to call, so deleting
|
|
103
|
+
// the row would orphan the workflow — the operator must stop them first).
|
|
104
|
+
const [workspaceCountForAccount, activeSessionCount] = await Promise.all([
|
|
105
|
+
countWorkspacesForAccount(deps.db, grant.accountId),
|
|
106
|
+
countActiveSessionsForWorkspace(deps.db, workspaceId),
|
|
107
|
+
]);
|
|
108
|
+
assertWorkspaceDeletable({ workspaceCountForAccount, activeSessionCount });
|
|
109
|
+
// Clean external Temporal state the FK cascade can't reach: every scheduled
|
|
110
|
+
// task's schedule (best-effort, mirroring the scheduled-task delete path).
|
|
111
|
+
const tasks = await listScheduledTasks(deps.db, workspaceId, 1000);
|
|
112
|
+
await Promise.all(tasks.map((task) =>
|
|
113
|
+
deps.workflowClient.deleteScheduledTaskSchedule({ temporalScheduleId: task.temporalScheduleId }).catch(() => undefined),
|
|
114
|
+
));
|
|
115
|
+
await deleteWorkspace(deps.db, workspaceId);
|
|
116
|
+
return c.body(null, 204);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// --- Members ("People with access") ---------------------------------------
|
|
120
|
+
|
|
121
|
+
app.get("/v1/workspaces/:workspaceId/members", async (c) => {
|
|
122
|
+
const workspaceId = c.req.param("workspaceId");
|
|
123
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
124
|
+
const members = await listWorkspaceMembers(deps.db, workspaceId);
|
|
125
|
+
return c.json(ListWorkspaceMembersResponse.parse({ members }));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
app.post("/v1/workspaces/:workspaceId/members", async (c) => {
|
|
129
|
+
const workspaceId = c.req.param("workspaceId");
|
|
130
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
|
|
131
|
+
const payload = AddWorkspaceMemberRequest.parse(await c.req.json());
|
|
132
|
+
const email = payload.email.trim();
|
|
133
|
+
// Email invites for not-yet-registered users are deferred: an unknown email
|
|
134
|
+
// resolves to null, which resolveMemberSubjectId turns into a 404.
|
|
135
|
+
const subjectId = resolveMemberSubjectId(await getManagedUserByEmail(deps.db, email));
|
|
136
|
+
await grantWorkspaceAccess(deps.db, {
|
|
137
|
+
accountId: grant.accountId,
|
|
138
|
+
workspaceId,
|
|
139
|
+
subjectId,
|
|
140
|
+
subjectLabel: email,
|
|
141
|
+
...(payload.role !== undefined ? { role: payload.role } : {}),
|
|
142
|
+
permissions: payload.permissions,
|
|
143
|
+
});
|
|
144
|
+
const members = await listWorkspaceMembers(deps.db, workspaceId);
|
|
145
|
+
const member = members.find((candidate) => candidate.subjectId === subjectId);
|
|
146
|
+
if (!member) {
|
|
147
|
+
throw new HTTPException(500, { message: "failed to add member" });
|
|
148
|
+
}
|
|
149
|
+
return c.json(WorkspaceMember.parse(member), 201);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
app.patch("/v1/workspaces/:workspaceId/members/:subjectId", async (c) => {
|
|
153
|
+
const workspaceId = c.req.param("workspaceId");
|
|
154
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
|
|
155
|
+
const subjectId = decodeURIComponent(c.req.param("subjectId"));
|
|
156
|
+
const payload = UpdateWorkspaceMemberRequest.parse(await c.req.json());
|
|
157
|
+
const existing = await listWorkspaceMembers(deps.db, workspaceId);
|
|
158
|
+
const current = existing.find((member) => member.subjectId === subjectId);
|
|
159
|
+
if (!current) {
|
|
160
|
+
throw new HTTPException(404, { message: "member not found" });
|
|
161
|
+
}
|
|
162
|
+
await grantWorkspaceAccess(deps.db, {
|
|
163
|
+
accountId: grant.accountId,
|
|
164
|
+
workspaceId,
|
|
165
|
+
subjectId,
|
|
166
|
+
...(current.subjectLabel ? { subjectLabel: current.subjectLabel } : {}),
|
|
167
|
+
role: payload.role ?? current.role,
|
|
168
|
+
permissions: payload.permissions,
|
|
169
|
+
});
|
|
170
|
+
const members = await listWorkspaceMembers(deps.db, workspaceId);
|
|
171
|
+
const member = members.find((candidate) => candidate.subjectId === subjectId);
|
|
172
|
+
if (!member) {
|
|
173
|
+
throw new HTTPException(500, { message: "failed to update member" });
|
|
174
|
+
}
|
|
175
|
+
return c.json(WorkspaceMember.parse(member));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
app.delete("/v1/workspaces/:workspaceId/members/:subjectId", async (c) => {
|
|
179
|
+
const workspaceId = c.req.param("workspaceId");
|
|
180
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
|
|
181
|
+
const subjectId = decodeURIComponent(c.req.param("subjectId"));
|
|
182
|
+
const members = await listWorkspaceMembers(deps.db, workspaceId);
|
|
183
|
+
// Never remove yourself, and never remove the last administering member.
|
|
184
|
+
assertWorkspaceMemberRemovable({ members, subjectId, callerSubjectId: grant.subjectId });
|
|
185
|
+
await removeWorkspaceMember(deps.db, workspaceId, subjectId);
|
|
186
|
+
return c.body(null, 204);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A persona override that is null or trims to empty collapses to null (use the
|
|
191
|
+
// deployment default). Otherwise the template is stored verbatim so the runtime
|
|
192
|
+
// can substitute the non-bypassable CORE at its {{core}} marker.
|
|
193
|
+
function normalizeAgentInstructions(value: string | null): string | null {
|
|
194
|
+
if (value === null) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
const trimmed = value.trim();
|
|
198
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function requireAccountPermission(context: AccessContext, accountId: string, permission: Permission): void {
|
|
202
|
+
const grant = context.accountGrants.find((candidate) => candidate.accountId === accountId);
|
|
203
|
+
if (!grant || (!grant.permissions.includes(permission) && !grant.permissions.includes("account:admin"))) {
|
|
204
|
+
throw new HTTPException(403, { message: `missing permission: ${permission}` });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// apps/api/src/sandbox/access.ts — the API-tier sandbox access seam.
|
|
2
|
+
//
|
|
3
|
+
// This is the foundation of the API-DIRECT control plane
|
|
4
|
+
// (docs/design/sandbox-surfacing): the apps/api process constructs its OWN
|
|
5
|
+
// sandbox client and resumes boxes by id IN-PROCESS, so non-turn ops (viewer
|
|
6
|
+
// attach, FS/git reads, tunnel URL mint) never touch Temporal or a worker.
|
|
7
|
+
//
|
|
8
|
+
// IMPORT DISCIPLINE (enforced by apps/api/test/sandbox-access-import-guard.test.ts):
|
|
9
|
+
// apps/api accesses sandbox construction/resume symbols ONLY via the
|
|
10
|
+
// agent-loop-free leaf `@opengeni/runtime/sandbox` — NEVER the bare
|
|
11
|
+
// `@opengeni/runtime` barrel (which pulls the @openai/agents agent loop into
|
|
12
|
+
// the API process). This file is the single chokepoint for that import.
|
|
13
|
+
import { createSandboxClient } from "@opengeni/runtime/sandbox";
|
|
14
|
+
import type { Settings } from "@opengeni/config";
|
|
15
|
+
|
|
16
|
+
// The structural shapes the API needs from a provider sandbox client now live in
|
|
17
|
+
// @opengeni/core (`sandbox-types.ts`) — `dependencies.ts` (also in core)
|
|
18
|
+
// references them as the `sandboxClient` / `resumeBoxById` provider seams, so
|
|
19
|
+
// core is their single owner. We re-export them from here so existing apps/api
|
|
20
|
+
// importers (and the `@opengeni/runtime/sandbox` value implementation below)
|
|
21
|
+
// keep the same import site. (Mirrors @openai/agents/sandbox's SandboxClient
|
|
22
|
+
// without importing the agent-loop barrel.)
|
|
23
|
+
export type {
|
|
24
|
+
ApiSandboxSession,
|
|
25
|
+
ApiSandboxClient,
|
|
26
|
+
ResumeBoxByIdInput,
|
|
27
|
+
ResumedSandboxSession,
|
|
28
|
+
} from "@opengeni/core";
|
|
29
|
+
import type {
|
|
30
|
+
ApiSandboxClient,
|
|
31
|
+
ApiSandboxSession,
|
|
32
|
+
ResumeBoxByIdInput,
|
|
33
|
+
ResumedSandboxSession,
|
|
34
|
+
} from "@opengeni/core";
|
|
35
|
+
|
|
36
|
+
export class SandboxResumeError extends Error {
|
|
37
|
+
constructor(message: string, readonly cause?: unknown) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "SandboxResumeError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Construct the API process's own sandbox client from settings, agent-loop-free.
|
|
45
|
+
* Returns undefined when `sandboxBackend=none` (no box to touch). The Modal
|
|
46
|
+
* token + app name are read from settings (already parsed by getSettings and
|
|
47
|
+
* present in the API runtime env), so the client can resume Modal boxes by id.
|
|
48
|
+
*/
|
|
49
|
+
export function createApiSandboxClient(settings: Settings): ApiSandboxClient | undefined {
|
|
50
|
+
const client = createSandboxClient(settings) as ApiSandboxClient | undefined;
|
|
51
|
+
return client;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the `resumeBoxById` helper bound to the API's sandbox client. Given a
|
|
56
|
+
* backend + a serialized resume_state envelope, it resumes the box and returns
|
|
57
|
+
* a live session for one in-process op. The caller drives exec/readFile and then
|
|
58
|
+
* drops the handle (resume → use → drop); it does NOT own the box.
|
|
59
|
+
*/
|
|
60
|
+
export function makeResumeBoxById(client: ApiSandboxClient | undefined): (input: ResumeBoxByIdInput) => Promise<ResumedSandboxSession> {
|
|
61
|
+
return async ({ backend, resumeState }: ResumeBoxByIdInput): Promise<ResumedSandboxSession> => {
|
|
62
|
+
if (!client) {
|
|
63
|
+
throw new SandboxResumeError(
|
|
64
|
+
"The API sandbox client is not configured (sandboxBackend=none); cannot resume a box by id.",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (client.backendId !== backend) {
|
|
68
|
+
throw new SandboxResumeError(
|
|
69
|
+
`Resume backend "${backend}" does not match the API sandbox client backend "${client.backendId}"; a cross-backend resume_state envelope cannot be deserialized.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (!client.deserializeSessionState || !client.resume) {
|
|
73
|
+
throw new SandboxResumeError(
|
|
74
|
+
`The configured sandbox backend "${client.backendId}" does not support resume-by-id (no deserializeSessionState/resume).`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
let session: ApiSandboxSession;
|
|
78
|
+
try {
|
|
79
|
+
const state = await client.deserializeSessionState(resumeState);
|
|
80
|
+
session = await client.resume(state);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw new SandboxResumeError(
|
|
83
|
+
`Failed to resume sandbox box by id on backend "${backend}": ${error instanceof Error ? error.message : String(error)}`,
|
|
84
|
+
error,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return session;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// apps/api/src/sandbox/auth-callout.ts — the NATS AUTH-CALLOUT responder (the
|
|
2
|
+
// bring-your-own-compute M-AUTH tenancy boundary; dossier §10.1 NATS Accounts per
|
|
3
|
+
// workspace + §17 the isolation smoke + §19 the NATS-Accounts-misconfig leak risk).
|
|
4
|
+
//
|
|
5
|
+
// THE BOUNDARY THIS CLOSES: an external agent connects to NATS presenting its
|
|
6
|
+
// `oge_` enrollment bearer as the connect auth-token. nats-server (configured with
|
|
7
|
+
// `auth_callout`) issues an authorization request on $SYS.REQ.USER.AUTH. THIS
|
|
8
|
+
// responder:
|
|
9
|
+
// 1. decodes the authorization request (the `user_nkey` the response must scope
|
|
10
|
+
// to, the `server_id` for the response `aud`, and the presented `auth_token`);
|
|
11
|
+
// 2. VALIDATES the bearer with verifyEnrollmentBearer (HMAC, via
|
|
12
|
+
// resolveEnrollmentSigningSecret) — an invalid/expired/forged bearer is denied;
|
|
13
|
+
// 3. confirms the enrollment is still ACTIVE in the DB (a revoked machine is
|
|
14
|
+
// denied even with a still-unexpired bearer);
|
|
15
|
+
// 4. signs a NATS user JWT granting pub/sub ONLY `agent.<ws>.>` + `_INBOX.>`
|
|
16
|
+
// (deny-all-else by an allow-list) and returns it inside a signed
|
|
17
|
+
// authorization-response JWT.
|
|
18
|
+
//
|
|
19
|
+
// That per-subject scope is the per-workspace ISOLATION: workspace A's agent is
|
|
20
|
+
// cryptographically incapable of pub/sub on `agent.B.>`. nats-server enforces the
|
|
21
|
+
// signed permission set; the boundary does not rely on subject naming alone (the M4
|
|
22
|
+
// transport test proved the CODE constructs scoped subjects; THIS proves the SERVER
|
|
23
|
+
// refuses cross-workspace access).
|
|
24
|
+
//
|
|
25
|
+
// SECURITY (§18): the bearer value + the account signing seed are NEVER logged. A
|
|
26
|
+
// validation failure → a DENIAL response (the server refuses the connection); a
|
|
27
|
+
// responder error → the request is left UNANSWERED (fail-closed; the server denies
|
|
28
|
+
// on its callout timeout). The bearer's `exp` caps the minted credential's life so a
|
|
29
|
+
// revoked/expired enrollment cannot outlive its bearer.
|
|
30
|
+
|
|
31
|
+
import { resolveEnrollmentSigningSecret, type NatsCalloutConfig, type Settings } from "@opengeni/config";
|
|
32
|
+
import { verifyEnrollmentBearer } from "@opengeni/contracts";
|
|
33
|
+
import { getEnrollment, type Database } from "@opengeni/db";
|
|
34
|
+
import {
|
|
35
|
+
createResponderConnection,
|
|
36
|
+
decodeAuthRequest,
|
|
37
|
+
mintAuthResponse,
|
|
38
|
+
mintUserJwt,
|
|
39
|
+
workspaceAgentPermissions,
|
|
40
|
+
type ResponderConnection,
|
|
41
|
+
} from "@opengeni/events";
|
|
42
|
+
import type { Observability } from "@opengeni/observability";
|
|
43
|
+
|
|
44
|
+
/** The NATS subject nats-server publishes authorization requests on (ADR-26). */
|
|
45
|
+
export const AUTH_CALLOUT_SUBJECT = "$SYS.REQ.USER.AUTH";
|
|
46
|
+
|
|
47
|
+
export interface AuthCalloutDeps {
|
|
48
|
+
db: Database;
|
|
49
|
+
settings: Settings;
|
|
50
|
+
callout: NatsCalloutConfig;
|
|
51
|
+
observability?: Observability;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The pure validate→scoped-JWT decision, isolated from the NATS transport so it is
|
|
56
|
+
* unit-testable. Given the raw authorization-request JWT bytes, returns the signed
|
|
57
|
+
* authorization-response JWT bytes to reply with — a GRANT (embedding a scoped user
|
|
58
|
+
* JWT) on success, a DENIAL (carrying `nats.error`, no user JWT) otherwise. NEVER
|
|
59
|
+
* throws on a bad/invalid request: every failure becomes a signed denial (the
|
|
60
|
+
* server then refuses the connection cleanly).
|
|
61
|
+
*/
|
|
62
|
+
export async function handleAuthorizationRequest(
|
|
63
|
+
deps: AuthCalloutDeps,
|
|
64
|
+
requestBytes: Uint8Array,
|
|
65
|
+
): Promise<Uint8Array> {
|
|
66
|
+
const requestJwt = Buffer.from(requestBytes).toString("utf8");
|
|
67
|
+
const decoded = decodeAuthRequest(requestJwt);
|
|
68
|
+
if (!decoded) {
|
|
69
|
+
// A malformed request we cannot even read the user_nkey/server_id from — there
|
|
70
|
+
// is nothing to scope a response to. Leave it for the server's timeout by
|
|
71
|
+
// throwing (the transport leaves it unanswered, fail-closed).
|
|
72
|
+
deps.observability?.warn?.("auth-callout: undecodable authorization request", {});
|
|
73
|
+
throw new Error("undecodable authorization request");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const deny = (reason: string): Uint8Array => {
|
|
77
|
+
// A SIGNED denial: the server reads `nats.error` and refuses the connection.
|
|
78
|
+
const response = mintAuthResponse({
|
|
79
|
+
userPublicKey: decoded.userNkey,
|
|
80
|
+
serverId: decoded.serverId,
|
|
81
|
+
accountSeed: deps.callout.accountSeed,
|
|
82
|
+
error: reason,
|
|
83
|
+
});
|
|
84
|
+
return Buffer.from(response, "utf8");
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const bearer = decoded.authToken;
|
|
88
|
+
if (!bearer) {
|
|
89
|
+
return deny("missing enrollment bearer");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const secret = resolveEnrollmentSigningSecret(deps.settings);
|
|
93
|
+
if (!secret) {
|
|
94
|
+
// The credential plane is off for this deployment — deny rather than mint an
|
|
95
|
+
// unscoped credential. (The responder should not even be running in this case,
|
|
96
|
+
// but fail-closed regardless.)
|
|
97
|
+
return deny("enrollment credential plane disabled");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const claims = await verifyEnrollmentBearer(secret, bearer);
|
|
101
|
+
if (!claims) {
|
|
102
|
+
// Invalid signature / malformed / expired bearer. NEVER log the bearer value.
|
|
103
|
+
deps.observability?.warn?.("auth-callout: rejected an invalid enrollment bearer", {});
|
|
104
|
+
return deny("invalid or expired enrollment bearer");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Confirm the enrollment is still ACTIVE — a revoked machine is denied even with a
|
|
108
|
+
// still-unexpired bearer (the revoke path flips status; this re-checks at connect).
|
|
109
|
+
const enrollment = await getEnrollment(deps.db, claims.workspaceId, claims.enrollmentId);
|
|
110
|
+
if (!enrollment || enrollment.status !== "active") {
|
|
111
|
+
deps.observability?.warn?.("auth-callout: denied a revoked or unknown enrollment", {
|
|
112
|
+
workspaceId: claims.workspaceId,
|
|
113
|
+
agentId: claims.agentId,
|
|
114
|
+
});
|
|
115
|
+
return deny("enrollment is not active");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Belt-and-braces: the bearer's agentId/enrollmentId must match the row we found.
|
|
119
|
+
// (verifyEnrollmentBearer already binds them; this guards a future schema where
|
|
120
|
+
// agentId != enrollmentId.)
|
|
121
|
+
if (enrollment.id !== claims.enrollmentId) {
|
|
122
|
+
return deny("enrollment identity mismatch");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// GRANT: a user JWT scoped to ONLY this workspace's agent subtree + the reply
|
|
126
|
+
// inbox. This allow-list IS the per-workspace isolation boundary.
|
|
127
|
+
const permissions = workspaceAgentPermissions(claims.workspaceId);
|
|
128
|
+
const userJwt = mintUserJwt({
|
|
129
|
+
userPublicKey: decoded.userNkey,
|
|
130
|
+
accountSeed: deps.callout.accountSeed,
|
|
131
|
+
name: claims.agentId,
|
|
132
|
+
permissions,
|
|
133
|
+
// Server-config-mode placement: the embedded user JWT's `aud` is the account
|
|
134
|
+
// the user binds to (the configured `auth_callout.account`). All agents +
|
|
135
|
+
// the privileged control plane share this account so subjects route; the
|
|
136
|
+
// per-workspace isolation is carried by the subject permissions above.
|
|
137
|
+
audienceAccount: deps.callout.accountName,
|
|
138
|
+
// Tie the credential's life to the bearer's remaining life: a revoked/expired
|
|
139
|
+
// enrollment cannot outlive its bearer at the NATS layer either.
|
|
140
|
+
expiresAtSeconds: claims.exp,
|
|
141
|
+
});
|
|
142
|
+
const response = mintAuthResponse({
|
|
143
|
+
userPublicKey: decoded.userNkey,
|
|
144
|
+
serverId: decoded.serverId,
|
|
145
|
+
accountSeed: deps.callout.accountSeed,
|
|
146
|
+
userJwt,
|
|
147
|
+
});
|
|
148
|
+
deps.observability?.info?.("auth-callout: granted a workspace-scoped NATS credential", {
|
|
149
|
+
workspaceId: claims.workspaceId,
|
|
150
|
+
agentId: claims.agentId,
|
|
151
|
+
});
|
|
152
|
+
return Buffer.from(response, "utf8");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Start the auth-callout responder: open a SEPARATE NATS connection authenticated
|
|
157
|
+
* as the callout `auth_users` user, subscribe $SYS.REQ.USER.AUTH, and answer every
|
|
158
|
+
* authorization request via {@link handleAuthorizationRequest}. Returns a handle
|
|
159
|
+
* whose `close()` drains the connection. Gated by the caller (sandboxSelfhostedEnabled
|
|
160
|
+
* + a resolvable callout config); a deployment without the callout plane never starts
|
|
161
|
+
* it.
|
|
162
|
+
*/
|
|
163
|
+
export async function startAuthCalloutResponder(
|
|
164
|
+
deps: AuthCalloutDeps,
|
|
165
|
+
natsUrl: string,
|
|
166
|
+
): Promise<ResponderConnection> {
|
|
167
|
+
const connection = await createResponderConnection(
|
|
168
|
+
natsUrl,
|
|
169
|
+
{ kind: "user-password", user: deps.callout.user, pass: deps.callout.password },
|
|
170
|
+
AUTH_CALLOUT_SUBJECT,
|
|
171
|
+
(bytes) => handleAuthorizationRequest(deps, bytes),
|
|
172
|
+
{ name: "opengeni-auth-callout" },
|
|
173
|
+
);
|
|
174
|
+
deps.observability?.info?.("OpenGeni NATS auth-callout responder started", {
|
|
175
|
+
subject: AUTH_CALLOUT_SUBJECT,
|
|
176
|
+
});
|
|
177
|
+
return connection;
|
|
178
|
+
}
|