@opengeni/api-router 4.1.0 → 4.1.2-canary.36199476632001
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 +7 -1
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
- package/dist/auth/managed-auth.d.ts +37 -1
- package/dist/auth/signup-funnel-metrics.d.ts +44 -0
- package/dist/{chunk-24C5LPNE.js → chunk-QDIBJSM4.js} +3277 -1509
- package/dist/{chunk-24C5LPNE.js.map → chunk-QDIBJSM4.js.map} +1 -1
- package/dist/http/cors.d.ts +12 -0
- package/dist/http/local-browser-boundary.d.ts +72 -0
- package/dist/http/metrics-listener.d.ts +22 -0
- package/dist/http/registered-route-label.d.ts +16 -0
- package/dist/http/request-source.d.ts +41 -3
- package/dist/http/user-content.d.ts +36 -0
- package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
- package/dist/index.js +90 -31
- package/dist/index.js.map +1 -1
- package/dist/integrations/github-lens-connect.d.ts +1 -1
- package/dist/integrations/oauth-client.d.ts +30 -0
- package/dist/integrations/slack-bot.d.ts +48 -2
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/mcp/documents.d.ts +2 -0
- package/dist/mcp/first-party-tool-permissions.d.ts +12 -0
- package/dist/mcp/server.d.ts +35 -0
- package/dist/model-catalog.d.ts +9 -3
- package/dist/observability.d.ts +3 -1
- package/dist/routes/billing.d.ts +27 -0
- package/dist/routes/browser-sessions.d.ts +3 -0
- package/dist/routes/client-errors.d.ts +42 -0
- package/dist/routes/computer-sessions.d.ts +2 -0
- package/dist/routes/github-browser-pages.d.ts +12 -0
- package/dist/routes/insights.d.ts +26 -0
- package/dist/sandbox/auth-callout.d.ts +8 -0
- package/dist/sandbox-file-artifacts.d.ts +17 -1
- package/package.json +19 -19
- package/src/app.ts +124 -12
- package/src/auth/managed-auth-rate-limits.ts +252 -0
- package/src/auth/managed-auth.ts +137 -17
- package/src/auth/signup-funnel-metrics.ts +164 -0
- package/src/http/auth.ts +6 -0
- package/src/http/cors.ts +37 -5
- package/src/http/local-browser-boundary.ts +343 -0
- package/src/http/metrics-listener.ts +57 -0
- package/src/http/registered-route-label.ts +34 -0
- package/src/http/request-source.ts +154 -13
- package/src/http/sse.ts +36 -16
- package/src/http/user-content.ts +158 -0
- package/src/http/websocket-upgrade-dispatch.ts +29 -0
- package/src/index.ts +22 -8
- package/src/integrations/atlassian.ts +7 -3
- package/src/integrations/fiken.ts +9 -3
- package/src/integrations/github-app-connect.ts +12 -2
- package/src/integrations/google-drive.ts +7 -3
- package/src/integrations/oauth-client.ts +99 -12
- package/src/integrations/personal-github.ts +4 -2
- package/src/integrations/provider-oauth.ts +10 -3
- package/src/integrations/slack-bot.ts +43 -1
- package/src/integrations/social-oauth.ts +7 -2
- package/src/mcp/documents.ts +9 -2
- package/src/mcp/files.ts +5 -1
- package/src/mcp/first-party-tool-permissions.ts +6 -0
- package/src/mcp/knowledge-entries.ts +15 -2
- package/src/mcp/server.ts +191 -17
- package/src/mcp-oauth.ts +4 -6
- package/src/model-catalog.ts +30 -3
- package/src/observability.ts +15 -1
- package/src/routes/billing.ts +70 -9
- package/src/routes/browser-sessions.ts +169 -0
- package/src/routes/client-errors.ts +182 -0
- package/src/routes/company-brain.ts +4 -0
- package/src/routes/computer-sessions.ts +37 -9
- package/src/routes/connections.ts +7 -1
- package/src/routes/documents.ts +6 -1
- package/src/routes/editable-artifacts.ts +2 -1
- package/src/routes/enrollments.ts +2 -13
- package/src/routes/files.ts +17 -3
- package/src/routes/github-browser-pages.ts +162 -0
- package/src/routes/github.ts +122 -49
- package/src/routes/insights.ts +70 -6
- package/src/routes/knowledge.ts +24 -1
- package/src/routes/managed-auth-session-sets.ts +26 -6
- package/src/routes/managed-onboarding.ts +23 -19
- package/src/routes/organization-memberships.ts +3 -0
- package/src/routes/scheduled-tasks.ts +9 -2
- package/src/routes/sessions.ts +89 -0
- package/src/routes/workspace-artifacts.ts +9 -0
- package/src/routes/workspace-state.ts +4 -0
- package/src/routes/workspaces.ts +38 -15
- package/src/sandbox/auth-callout.ts +62 -11
- package/src/sandbox/channel-a.ts +8 -0
- package/src/sandbox/viewer.ts +16 -3
- package/src/sandbox-file-artifacts.ts +37 -26
- package/src/site-uploads.ts +8 -1
- package/src/workspace-tool-gateway.ts +1 -0
package/src/routes/sessions.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { getRetainedProviderCommand } from "@opengeni/db/retained-provider-commands";
|
|
2
2
|
import { searchSessionMessagesForSubject, SessionMessageSearchCursorError } from "@opengeni/db";
|
|
3
|
+
import { listSessionEventSlices } from "@opengeni/db/session-event-slices";
|
|
4
|
+
import * as sessionPreviewSchema from "@opengeni/db/schema";
|
|
5
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
3
6
|
import { SessionMessageSearchRequest } from "@opengeni/contracts";
|
|
4
7
|
import { scheduledSessionIds } from "@opengeni/db";
|
|
5
8
|
import { withSiteSessionOrigin } from "@opengeni/core";
|
|
@@ -2924,6 +2927,90 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2924
2927
|
return c.json(page.events);
|
|
2925
2928
|
});
|
|
2926
2929
|
|
|
2930
|
+
// Selected search hit only: never use the audit event projection here, even
|
|
2931
|
+
// in summary mode (it can contain modelContext or truncate visible text).
|
|
2932
|
+
app.get(
|
|
2933
|
+
"/v1/workspaces/:workspaceId/sessions/:sessionId/events/:eventId/message-preview",
|
|
2934
|
+
async (c) => {
|
|
2935
|
+
const workspaceId = c.req.param("workspaceId");
|
|
2936
|
+
await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
2937
|
+
const sessionId = c.req.param("sessionId");
|
|
2938
|
+
await assertSessionExists(db, workspaceId, sessionId);
|
|
2939
|
+
const eventId = c.req.param("eventId");
|
|
2940
|
+
const rawSequence = c.req.query("sequence");
|
|
2941
|
+
const sequence = Number(rawSequence);
|
|
2942
|
+
if (
|
|
2943
|
+
!z.string().uuid().safeParse(eventId).success ||
|
|
2944
|
+
rawSequence === undefined ||
|
|
2945
|
+
!/^\d+$/.test(rawSequence) ||
|
|
2946
|
+
!Number.isSafeInteger(sequence) ||
|
|
2947
|
+
sequence < 1 ||
|
|
2948
|
+
sequence > 2_147_483_647
|
|
2949
|
+
)
|
|
2950
|
+
throw new HTTPException(400, { message: "Invalid message preview reference" });
|
|
2951
|
+
|
|
2952
|
+
const notFound = () => new HTTPException(404, { message: "message not found" });
|
|
2953
|
+
const maxUnits = 12_000;
|
|
2954
|
+
const eventTable = sessionPreviewSchema.sessionEvents;
|
|
2955
|
+
// The slice helper supports structured result views too. Check only the
|
|
2956
|
+
// JSON scalar *kind* before calling it; never fetch a structured text value.
|
|
2957
|
+
const candidates = await withWorkspaceRls(db, workspaceId, (tx) =>
|
|
2958
|
+
tx
|
|
2959
|
+
.select({ kind: sql<string | null>`jsonb_typeof(${eventTable.payload}->'text')` })
|
|
2960
|
+
.from(eventTable)
|
|
2961
|
+
.where(
|
|
2962
|
+
and(
|
|
2963
|
+
eq(eventTable.workspaceId, workspaceId),
|
|
2964
|
+
eq(eventTable.sessionId, sessionId),
|
|
2965
|
+
eq(eventTable.id, eventId),
|
|
2966
|
+
eq(eventTable.sequence, sequence),
|
|
2967
|
+
sql`${eventTable.type} in ('user.message', 'agent.message.completed')`,
|
|
2968
|
+
),
|
|
2969
|
+
)
|
|
2970
|
+
.limit(1),
|
|
2971
|
+
);
|
|
2972
|
+
if (candidates[0]?.kind !== "string") throw notFound();
|
|
2973
|
+
const read = (offset: number) =>
|
|
2974
|
+
listSessionEventSlices(db, workspaceId, sessionId, {
|
|
2975
|
+
sourceSequence: sequence,
|
|
2976
|
+
sourceOffset: offset,
|
|
2977
|
+
after: sequence - 1,
|
|
2978
|
+
before: sequence + 1,
|
|
2979
|
+
includeTypes: ["user.message", "agent.message.completed"],
|
|
2980
|
+
view: "conversation",
|
|
2981
|
+
});
|
|
2982
|
+
const first = await read(0);
|
|
2983
|
+
const event = first.events[0];
|
|
2984
|
+
const slice = first.slices?.[sequence];
|
|
2985
|
+
// Includes the slice reader's duplicate, late and unclaimed-prompt gates.
|
|
2986
|
+
if (!event || event.id !== eventId || event.sequence !== sequence || !slice || slice.omitted)
|
|
2987
|
+
throw notFound();
|
|
2988
|
+
if (slice.total > maxUnits) return c.json({ status: "unavailable" as const });
|
|
2989
|
+
|
|
2990
|
+
let text = slice.text;
|
|
2991
|
+
let offset = slice.unit === "utf16" ? slice.text.length : Array.from(slice.text).length;
|
|
2992
|
+
if (text.length > maxUnits) return c.json({ status: "unavailable" as const });
|
|
2993
|
+
while (offset < slice.total) {
|
|
2994
|
+
if (offset === 0) throw notFound();
|
|
2995
|
+
const next = await read(offset);
|
|
2996
|
+
const part = next.slices?.[sequence];
|
|
2997
|
+
if (
|
|
2998
|
+
next.events[0]?.id !== eventId ||
|
|
2999
|
+
!part ||
|
|
3000
|
+
part.omitted ||
|
|
3001
|
+
part.unit !== slice.unit ||
|
|
3002
|
+
part.total !== slice.total ||
|
|
3003
|
+
part.offset !== offset
|
|
3004
|
+
)
|
|
3005
|
+
throw notFound();
|
|
3006
|
+
text += part.text;
|
|
3007
|
+
offset += slice.unit === "utf16" ? part.text.length : Array.from(part.text).length;
|
|
3008
|
+
if (text.length > maxUnits) return c.json({ status: "unavailable" as const });
|
|
3009
|
+
}
|
|
3010
|
+
return c.json({ status: "available" as const, text });
|
|
3011
|
+
},
|
|
3012
|
+
);
|
|
3013
|
+
|
|
2927
3014
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events/stream", async (c) => {
|
|
2928
3015
|
const workspaceId = c.req.param("workspaceId");
|
|
2929
3016
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
@@ -4817,6 +4904,8 @@ export function sessionAuthorizationOperationForHttp(
|
|
|
4817
4904
|
return verb === "POST" ? "session.context.write" : null;
|
|
4818
4905
|
}
|
|
4819
4906
|
if (suffix === "/events/stream" && verb === "GET") return "session.stream.read";
|
|
4907
|
+
if (/^\/events\/[^/]+\/message-preview$/.test(suffix) && verb === "GET")
|
|
4908
|
+
return "session.events.read";
|
|
4820
4909
|
if (suffix === "/events") {
|
|
4821
4910
|
if (verb === "GET") return "session.events.read";
|
|
4822
4911
|
if (verb === "POST") return "session.append";
|
|
@@ -37,6 +37,7 @@ import type { Context, Hono } from "hono";
|
|
|
37
37
|
import { HTTPException } from "hono/http-exception";
|
|
38
38
|
import { z } from "zod";
|
|
39
39
|
import { readWorkspaceArtifactContent } from "../workspace-artifact-content";
|
|
40
|
+
import { userContentDispositionHeaders } from "../http/user-content";
|
|
40
41
|
import {
|
|
41
42
|
projectWorkspaceArtifactDetailProvenance,
|
|
42
43
|
projectWorkspaceArtifactMutationProvenance,
|
|
@@ -322,7 +323,15 @@ export function registerWorkspaceArtifactRoutes(app: Hono, deps: ApiRouteDeps):
|
|
|
322
323
|
headers: {
|
|
323
324
|
"Content-Type": "text/html; charset=utf-8",
|
|
324
325
|
"Cache-Control": "private, no-store",
|
|
326
|
+
// A Site runs its own scripts, but only inside an opaque origin: no
|
|
327
|
+
// same-origin access to the console, and no cross-site embedding.
|
|
325
328
|
"Content-Security-Policy": "sandbox allow-scripts",
|
|
329
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
330
|
+
"X-Content-Type-Options": "nosniff",
|
|
331
|
+
// Clients read this HTML with fetch and render it in their own frame
|
|
332
|
+
// (the console uses srcdoc). Opening the raw URL downloads it rather
|
|
333
|
+
// than running a publisher-authored page at an app-origin URL.
|
|
334
|
+
...userContentDispositionHeaders("text/html", "site.html"),
|
|
326
335
|
},
|
|
327
336
|
},
|
|
328
337
|
);
|
|
@@ -24,6 +24,7 @@ import { HTTPException } from "hono/http-exception";
|
|
|
24
24
|
|
|
25
25
|
import { serializeWorkspaceStateExport } from "../workspace-state-export";
|
|
26
26
|
import { projectWorkspaceState } from "../workspace-state-projection";
|
|
27
|
+
import { USER_CONTENT_SECURITY_HEADERS } from "../http/user-content";
|
|
27
28
|
|
|
28
29
|
async function readWorkspaceState(
|
|
29
30
|
deps: ApiRouteDeps,
|
|
@@ -135,6 +136,9 @@ export function registerWorkspaceStateRoutes(app: Hono, deps: ApiRouteDeps): voi
|
|
|
135
136
|
"content-disposition",
|
|
136
137
|
`attachment; filename="workspace-state-${workspaceId}-sanitized.json"`,
|
|
137
138
|
);
|
|
139
|
+
for (const [name, value] of Object.entries(USER_CONTENT_SECURITY_HEADERS)) {
|
|
140
|
+
context.header(name, value);
|
|
141
|
+
}
|
|
138
142
|
return context.body(serializeWorkspaceStateExport(state));
|
|
139
143
|
});
|
|
140
144
|
}
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
ensureWorkspaceByExternalIdentity,
|
|
43
43
|
findWorkspaceByExternalIdentity,
|
|
44
44
|
getManagedUserProfilesByIds,
|
|
45
|
+
getWorkspace,
|
|
45
46
|
getWorkspaceModelPolicy,
|
|
46
47
|
grantWorkspaceAccess,
|
|
47
48
|
listWorkspaceMembers,
|
|
@@ -95,6 +96,9 @@ import {
|
|
|
95
96
|
requireWorkspaceSettingsGrant,
|
|
96
97
|
requireFreshAccessGrant,
|
|
97
98
|
resolveWorkspaceCatalogSettings,
|
|
99
|
+
creditsDefaultSessionModel,
|
|
100
|
+
resolveDefaultSessionModelForSelections,
|
|
101
|
+
resolveWorkspaceModelSelection,
|
|
98
102
|
} from "@opengeni/core";
|
|
99
103
|
import { requireLimit } from "@opengeni/core";
|
|
100
104
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
@@ -107,7 +111,7 @@ import {
|
|
|
107
111
|
import { boundedLimit } from "../http/common";
|
|
108
112
|
import { ApiHttpError } from "../http/api-error";
|
|
109
113
|
import { browserSseDeliveryOptions, sseWorkspaceControlStream } from "../http/sse";
|
|
110
|
-
import {
|
|
114
|
+
import { projectWorkspaceModelCatalog } from "../model-catalog";
|
|
111
115
|
import { deleteWorkspaceForRequest } from "../workspace-deletion";
|
|
112
116
|
import {
|
|
113
117
|
AI_GATEWAY_REALTIME_MODELS,
|
|
@@ -441,6 +445,7 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
441
445
|
organizationOpenRouterConnectionActive,
|
|
442
446
|
organizationGatewayCustomModels,
|
|
443
447
|
organizationOpenRouterCustomModels,
|
|
448
|
+
workspace,
|
|
444
449
|
] = await Promise.all([
|
|
445
450
|
getWorkspaceConnectionModelRestrictions(deps.db, workspaceId, grant.subjectId),
|
|
446
451
|
deps.resolveCatalogSettings(),
|
|
@@ -477,24 +482,42 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
477
482
|
workspaceId,
|
|
478
483
|
providerKind: "openrouter",
|
|
479
484
|
}),
|
|
485
|
+
getWorkspace(deps.db, workspaceId),
|
|
480
486
|
]);
|
|
487
|
+
const selections = resolveWorkspaceModelSelection({
|
|
488
|
+
connectionModelRestrictions,
|
|
489
|
+
settings: resolvedCatalog.settings,
|
|
490
|
+
policy,
|
|
491
|
+
codexSubscriptionActive,
|
|
492
|
+
xaiSubscriptionActive,
|
|
493
|
+
workspaceGatewayConnectionActive,
|
|
494
|
+
workspaceGatewayCustomModels,
|
|
495
|
+
workspaceOpenRouterConnectionActive: openRouterConnectionActive,
|
|
496
|
+
workspaceOpenRouterCustomModels,
|
|
497
|
+
organizationGatewayConnectionActive,
|
|
498
|
+
organizationOpenRouterConnectionActive,
|
|
499
|
+
organizationGatewayCustomModels,
|
|
500
|
+
organizationOpenRouterCustomModels,
|
|
501
|
+
});
|
|
502
|
+
// The same precedence the server applies when a new chat, API create, or
|
|
503
|
+
// scheduled occurrence names no model; published so pickers show it.
|
|
504
|
+
const workspaceSettings = workspace?.settings ?? {};
|
|
505
|
+
const defaultSelection = await resolveDefaultSessionModelForSelections(deps.db, {
|
|
506
|
+
settings: resolvedCatalog.settings,
|
|
507
|
+
accountId: grant.accountId,
|
|
508
|
+
workspaceSettings,
|
|
509
|
+
selections,
|
|
510
|
+
});
|
|
481
511
|
c.header("cache-control", "private, no-store");
|
|
482
512
|
return c.json(
|
|
483
513
|
WorkspaceModelCatalogResponse.parse(
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
workspaceGatewayCustomModels,
|
|
492
|
-
workspaceOpenRouterConnectionActive: openRouterConnectionActive,
|
|
493
|
-
workspaceOpenRouterCustomModels,
|
|
494
|
-
organizationGatewayConnectionActive,
|
|
495
|
-
organizationOpenRouterConnectionActive,
|
|
496
|
-
organizationGatewayCustomModels,
|
|
497
|
-
organizationOpenRouterCustomModels,
|
|
514
|
+
projectWorkspaceModelCatalog(selections, {
|
|
515
|
+
defaultSelection,
|
|
516
|
+
creditsSelection: creditsDefaultSessionModel({
|
|
517
|
+
settings: resolvedCatalog.settings,
|
|
518
|
+
selections,
|
|
519
|
+
workspaceSettings,
|
|
520
|
+
}),
|
|
498
521
|
}),
|
|
499
522
|
),
|
|
500
523
|
);
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
// on its callout timeout). The bearer's `exp` caps the minted credential's life so a
|
|
29
29
|
// revoked/expired enrollment cannot outlive its bearer.
|
|
30
30
|
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
|
+
import { createLogThrottle, type LogThrottle } from "@opengeni/observability";
|
|
31
33
|
import {
|
|
32
34
|
resolveEnrollmentSigningSecret,
|
|
33
35
|
type NatsCalloutConfig,
|
|
@@ -45,7 +47,7 @@ import {
|
|
|
45
47
|
type ResponderConnection,
|
|
46
48
|
} from "@opengeni/events";
|
|
47
49
|
import type { Observability } from "@opengeni/observability";
|
|
48
|
-
import {
|
|
50
|
+
import { observabilityEventBusOptions } from "../observability";
|
|
49
51
|
import { AGENT_CONNECTION_LEASE_MS } from "./connection-authority";
|
|
50
52
|
|
|
51
53
|
/** The NATS subject nats-server publishes authorization requests on (ADR-26). */
|
|
@@ -57,6 +59,44 @@ export interface AuthCalloutDeps {
|
|
|
57
59
|
settings: Settings;
|
|
58
60
|
callout: NatsCalloutConfig;
|
|
59
61
|
observability?: Observability;
|
|
62
|
+
/** Bounds repeated denial warnings; defaults to one process-wide throttle. */
|
|
63
|
+
denialWarningThrottle?: LogThrottle;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A stale or revoked machine re-dials on its reconnect backoff forever, so an
|
|
67
|
+
* unthrottled denial warning repeats every few seconds per machine. Log each
|
|
68
|
+
* distinct machine's first denial, then at most once per interval with the
|
|
69
|
+
* count of denials it hid. The denial decision itself is never throttled. */
|
|
70
|
+
export const AUTH_CALLOUT_DENIAL_WARNING_INTERVAL_MS = 60_000;
|
|
71
|
+
const processDenialWarningThrottle = createLogThrottle({
|
|
72
|
+
intervalMs: AUTH_CALLOUT_DENIAL_WARNING_INTERVAL_MS,
|
|
73
|
+
maxKeys: 1_024,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** Closed denial vocabulary. The public log projection drops identifiers, so
|
|
77
|
+
* `reason` is what tells an operator which throttle key a count belongs to. */
|
|
78
|
+
type AuthCalloutDenialReason = "invalid_bearer" | "inactive_enrollment" | "duplicate_runner";
|
|
79
|
+
|
|
80
|
+
function warnDenial(
|
|
81
|
+
deps: AuthCalloutDeps,
|
|
82
|
+
key: string,
|
|
83
|
+
reason: AuthCalloutDenialReason,
|
|
84
|
+
message: string,
|
|
85
|
+
attributes: Record<string, string | number> = {},
|
|
86
|
+
): void {
|
|
87
|
+
const admission = (deps.denialWarningThrottle ?? processDenialWarningThrottle).admit(key);
|
|
88
|
+
if (!admission) return;
|
|
89
|
+
deps.observability?.warn?.(message, {
|
|
90
|
+
...attributes,
|
|
91
|
+
reason,
|
|
92
|
+
...(admission.suppressedCount > 0 ? { suppressedCount: admission.suppressedCount } : {}),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A process-local dedupe key for a rejected bearer. The digest is never
|
|
97
|
+
* logged, and the bearer value never leaves this function. */
|
|
98
|
+
function rejectedBearerKey(bearer: string): string {
|
|
99
|
+
return `invalid-bearer:${createHash("sha256").update(bearer).digest("hex").slice(0, 32)}`;
|
|
60
100
|
}
|
|
61
101
|
|
|
62
102
|
/**
|
|
@@ -108,7 +148,12 @@ export async function handleAuthorizationRequest(
|
|
|
108
148
|
const claims = await verifyEnrollmentBearer(secret, bearer);
|
|
109
149
|
if (!claims) {
|
|
110
150
|
// Invalid signature / malformed / expired bearer. NEVER log the bearer value.
|
|
111
|
-
|
|
151
|
+
warnDenial(
|
|
152
|
+
deps,
|
|
153
|
+
rejectedBearerKey(bearer),
|
|
154
|
+
"invalid_bearer",
|
|
155
|
+
"auth-callout: rejected an invalid enrollment bearer",
|
|
156
|
+
);
|
|
112
157
|
return deny("invalid or expired enrollment bearer");
|
|
113
158
|
}
|
|
114
159
|
|
|
@@ -116,10 +161,13 @@ export async function handleAuthorizationRequest(
|
|
|
116
161
|
// still-unexpired bearer (the revoke path flips status; this re-checks at connect).
|
|
117
162
|
const enrollment = await getEnrollment(deps.db, claims.workspaceId, claims.enrollmentId);
|
|
118
163
|
if (!enrollment || enrollment.status !== "active") {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
164
|
+
warnDenial(
|
|
165
|
+
deps,
|
|
166
|
+
`inactive:${claims.workspaceId}:${claims.agentId}`,
|
|
167
|
+
"inactive_enrollment",
|
|
168
|
+
"auth-callout: denied a revoked or unknown enrollment",
|
|
169
|
+
{ workspaceId: claims.workspaceId, agentId: claims.agentId },
|
|
170
|
+
);
|
|
123
171
|
return deny("enrollment is not active");
|
|
124
172
|
}
|
|
125
173
|
|
|
@@ -151,10 +199,13 @@ export async function handleAuthorizationRequest(
|
|
|
151
199
|
leaseMs: AGENT_CONNECTION_LEASE_MS,
|
|
152
200
|
});
|
|
153
201
|
if (!claim.claimed) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
202
|
+
warnDenial(
|
|
203
|
+
deps,
|
|
204
|
+
`duplicate:${claims.workspaceId}:${claims.agentId}`,
|
|
205
|
+
"duplicate_runner",
|
|
206
|
+
"auth-callout: denied a duplicate live runner",
|
|
207
|
+
{ workspaceId: claims.workspaceId, agentId: claims.agentId },
|
|
208
|
+
);
|
|
158
209
|
return deny("another runner instance currently owns this enrollment");
|
|
159
210
|
}
|
|
160
211
|
|
|
@@ -214,7 +265,7 @@ export async function startAuthCalloutResponder(
|
|
|
214
265
|
(bytes) => handleAuthorizationRequest(deps, bytes),
|
|
215
266
|
{
|
|
216
267
|
name: "opengeni-auth-callout",
|
|
217
|
-
...(deps.observability ?
|
|
268
|
+
...(deps.observability ? observabilityEventBusOptions(deps.observability) : {}),
|
|
218
269
|
},
|
|
219
270
|
);
|
|
220
271
|
deps.observability?.info?.("OpenGeni NATS auth-callout responder started", {
|
package/src/sandbox/channel-a.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
hasGitCredentialRepositorySelection,
|
|
23
23
|
hasGitHubRepositorySelection,
|
|
24
24
|
sandboxLifecycleTransitionWaitMs,
|
|
25
|
+
sandboxWarmRateMicrosPerSecond,
|
|
25
26
|
stableSandboxEnvironmentForRun,
|
|
26
27
|
type Settings,
|
|
27
28
|
} from "@opengeni/config";
|
|
@@ -42,6 +43,7 @@ import {
|
|
|
42
43
|
SandboxImageConflictError,
|
|
43
44
|
SandboxProviderReadLockUnavailableError,
|
|
44
45
|
SandboxRigConflictError,
|
|
46
|
+
SandboxPaidComputeAdmissionError,
|
|
45
47
|
withSandboxProviderReadLock,
|
|
46
48
|
type Database,
|
|
47
49
|
type LeaseSnapshot,
|
|
@@ -823,6 +825,10 @@ async function withChannelAOperation<T>(
|
|
|
823
825
|
holderId,
|
|
824
826
|
subjectId: session.id,
|
|
825
827
|
backend: session.sandboxBackend,
|
|
828
|
+
warmBilling: {
|
|
829
|
+
mode: settings.sandboxWarmBillingMode,
|
|
830
|
+
rateMicrosPerSecond: sandboxWarmRateMicrosPerSecond(settings, session.sandboxBackend),
|
|
831
|
+
},
|
|
826
832
|
os: session.sandboxOs,
|
|
827
833
|
image: sandboxRuntime.image,
|
|
828
834
|
rigVersionId: session.rigVersionId,
|
|
@@ -1210,6 +1216,8 @@ async function withChannelAOperation<T>(
|
|
|
1210
1216
|
* already-HTTPException unchanged. */
|
|
1211
1217
|
export function mapChannelAError(error: unknown, waitSignal?: AbortSignal): unknown {
|
|
1212
1218
|
if (error instanceof HTTPException) return error;
|
|
1219
|
+
if (error instanceof SandboxPaidComputeAdmissionError)
|
|
1220
|
+
return new HTTPException(402, { message: error.message, cause: error });
|
|
1213
1221
|
if (isChannelARequestCancellation(error, waitSignal))
|
|
1214
1222
|
return new HTTPException(499 as never, {
|
|
1215
1223
|
message: "request cancelled",
|
package/src/sandbox/viewer.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
hasGitHubRepositorySelection,
|
|
27
27
|
resolveStreamTokenSecret,
|
|
28
28
|
sandboxLifecycleTransitionWaitMs,
|
|
29
|
+
sandboxWarmRateMicrosPerSecond,
|
|
29
30
|
stableSandboxEnvironmentForRun,
|
|
30
31
|
} from "@opengeni/config";
|
|
31
32
|
import type { Settings } from "@opengeni/config";
|
|
@@ -37,7 +38,7 @@ import {
|
|
|
37
38
|
getSandbox,
|
|
38
39
|
getScheduledScopedRigVersionMetadata,
|
|
39
40
|
getSandboxSessionEnvelope,
|
|
40
|
-
|
|
41
|
+
heartbeatLeaseHolderStatus,
|
|
41
42
|
loadWorkspaceEnvironmentForRun,
|
|
42
43
|
markSandboxProviderReady,
|
|
43
44
|
markWarmLeaseInstanceLost,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
releaseLeaseHolder,
|
|
48
49
|
SandboxLeaseSupersededError,
|
|
49
50
|
SandboxViewerAdmissionBlockedError,
|
|
51
|
+
SandboxPaidComputeAdmissionError,
|
|
50
52
|
type Database,
|
|
51
53
|
type LeaseSnapshot,
|
|
52
54
|
type SandboxRecord,
|
|
@@ -300,6 +302,10 @@ export async function attachViewer(
|
|
|
300
302
|
...(attachSubjectId ? { viewerSubjectId: attachSubjectId } : {}),
|
|
301
303
|
...(attachAuthorityEpoch !== null ? { viewerAuthorityEpoch: attachAuthorityEpoch } : {}),
|
|
302
304
|
backend: session.sandboxBackend,
|
|
305
|
+
warmBilling: {
|
|
306
|
+
mode: settings.sandboxWarmBillingMode,
|
|
307
|
+
rateMicrosPerSecond: sandboxWarmRateMicrosPerSecond(settings, session.sandboxBackend),
|
|
308
|
+
},
|
|
303
309
|
os: session.sandboxOs,
|
|
304
310
|
image: sandboxRuntime.image,
|
|
305
311
|
rigVersionId: session.rigVersionId,
|
|
@@ -309,6 +315,9 @@ export async function attachViewer(
|
|
|
309
315
|
...(input.waitSignal ? { waitSignal: input.waitSignal } : {}),
|
|
310
316
|
});
|
|
311
317
|
} catch (error) {
|
|
318
|
+
if (error instanceof SandboxPaidComputeAdmissionError) {
|
|
319
|
+
throw new HTTPException(402, { message: error.message, cause: error });
|
|
320
|
+
}
|
|
312
321
|
if (error instanceof SandboxViewerAdmissionBlockedError) {
|
|
313
322
|
throw new HTTPException(error.reason === "balance" ? 402 : 429, {
|
|
314
323
|
message:
|
|
@@ -592,7 +601,7 @@ export async function heartbeatViewer(
|
|
|
592
601
|
expectedEpoch: number;
|
|
593
602
|
},
|
|
594
603
|
): Promise<boolean> {
|
|
595
|
-
const
|
|
604
|
+
const status = await heartbeatLeaseHolderStatus(services.db, {
|
|
596
605
|
accountId: input.accountId,
|
|
597
606
|
workspaceId: input.workspaceId,
|
|
598
607
|
sandboxGroupId: input.sandboxGroupId,
|
|
@@ -600,8 +609,12 @@ export async function heartbeatViewer(
|
|
|
600
609
|
holderId: input.viewerId,
|
|
601
610
|
leaseTtlMs: services.settings.sandboxLeaseTtlMs,
|
|
602
611
|
expectedEpoch: input.expectedEpoch,
|
|
612
|
+
billingMode: services.settings.sandboxWarmBillingMode,
|
|
603
613
|
});
|
|
604
|
-
|
|
614
|
+
// A funding fence stops renewing paid provider time, but the holder is
|
|
615
|
+
// deliberately kept alive while the viewer winds down or adds funds. Other
|
|
616
|
+
// fences (epoch/rotation/drain) still require the client to re-attach.
|
|
617
|
+
if (!status.leaseExtended) return status.holderAlive && status.fence === "funding";
|
|
605
618
|
const lease = await readLease(services.db, input.workspaceId, input.sandboxGroupId);
|
|
606
619
|
if (lease?.liveness === "warm" && lease.leaseEpoch === input.expectedEpoch && lease.instanceId) {
|
|
607
620
|
await renewSandboxProviderExpiration({
|
|
@@ -27,6 +27,12 @@ import {
|
|
|
27
27
|
} from "@opengeni/db";
|
|
28
28
|
import { retryWhileMissing, type ObjectHead, type ObjectStorage } from "@opengeni/storage";
|
|
29
29
|
import { HTTPException } from "hono/http-exception";
|
|
30
|
+
import {
|
|
31
|
+
isConnectedMachineAbsolutePath,
|
|
32
|
+
relativeConnectedMachinePath,
|
|
33
|
+
resolveConnectedMachinePath,
|
|
34
|
+
type SandboxChannelAService,
|
|
35
|
+
} from "@opengeni/runtime/sandbox";
|
|
30
36
|
|
|
31
37
|
import { withChannelARead } from "./sandbox/channel-a";
|
|
32
38
|
import { sanitizeFilename } from "./routes/files";
|
|
@@ -98,13 +104,12 @@ async function publishSandboxFileArtifactInScope(
|
|
|
98
104
|
});
|
|
99
105
|
}
|
|
100
106
|
|
|
101
|
-
const path = sandboxArtifactRelativePath(input.path);
|
|
102
107
|
const maxArtifactBytes = Math.min(SANDBOX_FILE_ARTIFACT_MAX_BYTES, storage.maxSinglePutSizeBytes);
|
|
103
108
|
if (maxArtifactBytes < 1) {
|
|
104
109
|
throw new HTTPException(503, { message: "object storage cannot accept artifact files" });
|
|
105
110
|
}
|
|
106
111
|
const readLimit = Math.min(25 * 1024 * 1024, maxArtifactBytes + FILE_READ_SENTINEL_BYTES);
|
|
107
|
-
const read = await withChannelARead(
|
|
112
|
+
const { path, sandboxPath, read } = await withChannelARead(
|
|
108
113
|
{
|
|
109
114
|
db: deps.db,
|
|
110
115
|
settings: deps.settings,
|
|
@@ -119,7 +124,7 @@ async function publishSandboxFileArtifactInScope(
|
|
|
119
124
|
...(input.signal ? { waitSignal: input.signal } : {}),
|
|
120
125
|
operation: "artifact.publish",
|
|
121
126
|
},
|
|
122
|
-
({ service }) => service.
|
|
127
|
+
({ service }) => readSandboxArtifactFile(service, input.path, readLimit),
|
|
123
128
|
);
|
|
124
129
|
if (read.truncated || read.sizeBytes > maxArtifactBytes) {
|
|
125
130
|
throw new HTTPException(413, {
|
|
@@ -234,48 +239,54 @@ async function publishSandboxFileArtifactInScope(
|
|
|
234
239
|
});
|
|
235
240
|
return SandboxFileArtifactReceipt.parse({
|
|
236
241
|
type: "sandbox_file",
|
|
237
|
-
sandboxPath
|
|
242
|
+
sandboxPath,
|
|
238
243
|
filename,
|
|
239
244
|
artifact,
|
|
240
245
|
});
|
|
241
246
|
}
|
|
242
247
|
|
|
243
|
-
|
|
248
|
+
/** Use the root captured by the same fenced Channel-A service that reads bytes,
|
|
249
|
+
* not the session's placement-home label or a caller-supplied root. */
|
|
250
|
+
export async function readSandboxArtifactFile(
|
|
251
|
+
service: Pick<SandboxChannelAService, "capabilities" | "fsRead">,
|
|
252
|
+
value: string,
|
|
253
|
+
maxBytes: number,
|
|
254
|
+
) {
|
|
255
|
+
const root = service.capabilities().FileSystem.root;
|
|
256
|
+
const path = sandboxArtifactRelativePath(value, root);
|
|
257
|
+
const sandboxPath = resolveConnectedMachinePath(root, path);
|
|
258
|
+
const read = await service.fsRead({ path, encoding: "base64", maxBytes });
|
|
259
|
+
return { path, sandboxPath, read };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function sandboxArtifactRelativePath(value: string, workspaceRoot = "/workspace"): string {
|
|
244
263
|
let path = value.trim();
|
|
245
264
|
if (path.startsWith("sandbox:")) {
|
|
246
265
|
path = path.slice("sandbox:".length);
|
|
247
266
|
}
|
|
248
|
-
if (path
|
|
267
|
+
if (!path || /[\\/]$/.test(path)) {
|
|
249
268
|
throw new HTTPException(400, { message: "sandbox artifact path must name a file" });
|
|
250
269
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
270
|
+
let absolute: string;
|
|
271
|
+
let relative: string | null;
|
|
272
|
+
try {
|
|
273
|
+
if (!isConnectedMachineAbsolutePath(workspaceRoot)) throw new Error("Missing workspace root");
|
|
274
|
+
absolute = resolveConnectedMachinePath(workspaceRoot, path);
|
|
275
|
+
relative = relativeConnectedMachinePath(workspaceRoot, absolute);
|
|
276
|
+
} catch {
|
|
254
277
|
throw new HTTPException(400, {
|
|
255
|
-
message: "sandbox artifact path
|
|
278
|
+
message: "sandbox artifact path is invalid for this workspace",
|
|
256
279
|
});
|
|
257
280
|
}
|
|
258
|
-
if (!
|
|
259
|
-
throw new HTTPException(400, { message: "sandbox artifact path must name a file" });
|
|
260
|
-
}
|
|
261
|
-
if (path.includes("\0")) {
|
|
262
|
-
throw new HTTPException(400, { message: "sandbox artifact path is invalid" });
|
|
263
|
-
}
|
|
264
|
-
const normalized = posix.normalize(path);
|
|
265
|
-
if (
|
|
266
|
-
normalized === "." ||
|
|
267
|
-
normalized === ".." ||
|
|
268
|
-
normalized.startsWith("../") ||
|
|
269
|
-
normalized.endsWith("/")
|
|
270
|
-
) {
|
|
281
|
+
if (!relative) {
|
|
271
282
|
throw new HTTPException(400, {
|
|
272
|
-
message: "sandbox artifact path must
|
|
283
|
+
message: "sandbox artifact path must name a file inside the active workspace root",
|
|
273
284
|
});
|
|
274
285
|
}
|
|
275
|
-
if (
|
|
286
|
+
if (absolute.length > SANDBOX_ARTIFACT_ABSOLUTE_PATH_MAX_CHARS) {
|
|
276
287
|
throw new HTTPException(400, { message: "sandbox artifact path is too long" });
|
|
277
288
|
}
|
|
278
|
-
return
|
|
289
|
+
return relative;
|
|
279
290
|
}
|
|
280
291
|
|
|
281
292
|
export function sandboxArtifactSafeFilename(filename: string): string {
|
package/src/site-uploads.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
prepareWorkspaceArtifactContent,
|
|
12
12
|
type PreparedWorkspaceArtifactContent,
|
|
13
13
|
} from "./workspace-artifact-content";
|
|
14
|
+
import { userContentSignedGetUrlOptions } from "./http/user-content";
|
|
14
15
|
type Storage = NonNullable<ObjectStorageDependency>;
|
|
15
16
|
export const MAX_SITE_SOURCE_BYTES = 64 * 1024 * 1024;
|
|
16
17
|
|
|
@@ -205,7 +206,13 @@ export async function workspaceArtifactDownloads(
|
|
|
205
206
|
audience: "sandbox" | "public",
|
|
206
207
|
) {
|
|
207
208
|
const [html, source] = await Promise.all([
|
|
208
|
-
|
|
209
|
+
// Site HTML runs publisher scripts; opened directly from storage it would
|
|
210
|
+
// render outside the console's opaque-origin frame, so it downloads.
|
|
211
|
+
storage.createGetUrl({
|
|
212
|
+
key: ref.contentKey,
|
|
213
|
+
audience,
|
|
214
|
+
...userContentSignedGetUrlOptions("text/html", "site.html"),
|
|
215
|
+
}),
|
|
209
216
|
ref.sourceKey ? storage.createGetUrl({ key: ref.sourceKey, audience }) : null,
|
|
210
217
|
]);
|
|
211
218
|
return {
|