@opengeni/sdk 3.3.2 → 3.4.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 +1007 -72
- package/dist/adapters/express.d.ts +31 -0
- package/dist/adapters/express.js +87 -0
- package/dist/adapters/express.js.map +1 -0
- package/dist/adapters/hono.d.ts +16 -0
- package/dist/adapters/hono.js +8 -0
- package/dist/adapters/hono.js.map +1 -0
- package/dist/adapters/next.d.ts +29 -0
- package/dist/adapters/next.js +22 -0
- package/dist/adapters/next.js.map +1 -0
- package/dist/allowance-refusal.d.ts +2 -0
- package/dist/allowance-refusal.js +50 -0
- package/dist/allowance-refusal.js.map +1 -0
- package/dist/app-links.d.ts +42 -0
- package/dist/app-links.js +66 -0
- package/dist/app-links.js.map +1 -0
- package/dist/artifact-catalog.d.ts +40 -0
- package/dist/artifact-client.d.ts +20 -8
- package/dist/artifacts.d.ts +1 -1
- package/dist/artifacts.js +8 -6
- package/dist/automations.js +8 -2
- package/dist/automations.js.map +1 -1
- package/dist/azure-live-transport.d.ts +32 -0
- package/dist/browser.d.ts +7 -2
- package/dist/browser.js +27 -5
- package/dist/chat/fold.d.ts +75 -0
- package/dist/chat/handler.d.ts +41 -0
- package/dist/chat/http.d.ts +67 -0
- package/dist/chat/ids.d.ts +34 -0
- package/dist/chat/index.d.ts +9 -0
- package/dist/chat/index.js +1369 -0
- package/dist/chat/index.js.map +1 -0
- package/dist/chat/openai.d.ts +30 -0
- package/dist/chat/opengeni.d.ts +129 -0
- package/dist/chat/types.d.ts +207 -0
- package/dist/chat/vercel.d.ts +49 -0
- package/dist/chats.d.ts +8 -0
- package/dist/chunk-4AHR4KGA.js +301 -0
- package/dist/chunk-4AHR4KGA.js.map +1 -0
- package/dist/chunk-6GYGMLX4.js +332 -0
- package/dist/chunk-6GYGMLX4.js.map +1 -0
- package/dist/chunk-7EWT3PV6.js +368 -0
- package/dist/chunk-7EWT3PV6.js.map +1 -0
- package/dist/chunk-FTWVVPR6.js +1665 -0
- package/dist/chunk-FTWVVPR6.js.map +1 -0
- package/dist/{chunk-VYCTL62C.js → chunk-GZQSWAKD.js} +1 -1
- package/dist/chunk-GZQSWAKD.js.map +1 -0
- package/dist/chunk-KGEABAID.js +71 -0
- package/dist/chunk-KGEABAID.js.map +1 -0
- package/dist/{chunk-YKZME56C.js → chunk-KWADQ227.js} +3 -3
- package/dist/{chunk-YKZME56C.js.map → chunk-KWADQ227.js.map} +1 -1
- package/dist/{chunk-JUS2UYRP.js → chunk-NCU5OAN7.js} +27 -2
- package/dist/chunk-NCU5OAN7.js.map +1 -0
- package/dist/{chunk-YXQX7RQT.js → chunk-OI5YLASH.js} +73 -2
- package/dist/chunk-OI5YLASH.js.map +1 -0
- package/dist/chunk-OV3POE62.js +359 -0
- package/dist/chunk-OV3POE62.js.map +1 -0
- package/dist/chunk-P7UWKORG.js +561 -0
- package/dist/chunk-P7UWKORG.js.map +1 -0
- package/dist/chunk-QGEIL4TR.js +195 -0
- package/dist/chunk-QGEIL4TR.js.map +1 -0
- package/dist/{chunk-TRNI7KEO.js → chunk-S2NLS3ES.js} +3669 -1281
- package/dist/chunk-S2NLS3ES.js.map +1 -0
- package/dist/chunk-UM4ZXXJR.js +9 -0
- package/dist/chunk-UM4ZXXJR.js.map +1 -0
- package/dist/{chunk-QT3KFVDP.js → chunk-UME3X7CP.js} +28 -32
- package/dist/chunk-UME3X7CP.js.map +1 -0
- package/dist/chunk-VT4YM4XQ.js +210 -0
- package/dist/chunk-VT4YM4XQ.js.map +1 -0
- package/dist/chunk-WSYYXONG.js +1602 -0
- package/dist/chunk-WSYYXONG.js.map +1 -0
- package/dist/{chunk-C2H7HBNP.js → chunk-WVCJRSGJ.js} +119 -14
- package/dist/chunk-WVCJRSGJ.js.map +1 -0
- package/dist/chunk-X5ITQVXE.js +15 -0
- package/dist/chunk-X5ITQVXE.js.map +1 -0
- package/dist/chunk-XA7HVIHI.js +126 -0
- package/dist/chunk-XA7HVIHI.js.map +1 -0
- package/dist/{chunk-KKRO2VCP.js → chunk-YJCHB7R3.js} +60 -12
- package/dist/chunk-YJCHB7R3.js.map +1 -0
- package/dist/chunk-YKIHNLWD.js +153 -0
- package/dist/chunk-YKIHNLWD.js.map +1 -0
- package/dist/chunk-ZDUBPV33.js +277 -0
- package/dist/chunk-ZDUBPV33.js.map +1 -0
- package/dist/client.d.ts +1068 -118
- package/dist/codex-realtime-controller.d.ts +13 -0
- package/dist/codex-realtime-controller.js +4 -2
- package/dist/codex-realtime-v3-wire.d.ts +3 -2
- package/dist/codex-realtime.d.ts +5 -1
- package/dist/company-brain.d.ts +1 -11
- package/dist/core.d.ts +7 -2
- package/dist/core.js +25 -6
- package/dist/deprecation.d.ts +47 -0
- package/dist/document-authority-client.d.ts +24 -2
- package/dist/document-authority.js +7 -5
- package/dist/editable-artifacts/index.d.ts +1 -1
- package/dist/editable-artifacts/types.d.ts +12 -2
- package/dist/editable-artifacts.js +19 -3
- package/dist/editable-artifacts.js.map +1 -1
- package/dist/embedding-client.d.ts +129 -0
- package/dist/errors.d.ts +76 -1
- package/dist/external-vault-receiver.d.ts +203 -0
- package/dist/external-vault-receiver.js +564 -0
- package/dist/external-vault-receiver.js.map +1 -0
- package/dist/feedback.d.ts +23 -0
- package/dist/gateway-realtime-transport.js +1 -1
- package/dist/github-repositories.d.ts +10 -0
- package/dist/github-repositories.js +36 -0
- package/dist/github-repositories.js.map +1 -0
- package/dist/index.d.ts +62 -11
- package/dist/index.js +557 -338
- package/dist/index.js.map +1 -1
- package/dist/insights-usage.d.ts +33 -0
- package/dist/insights-usage.js +7 -0
- package/dist/insights-usage.js.map +1 -0
- package/dist/interaction.d.ts +165 -71
- package/dist/interaction.js +2 -2
- package/dist/knowledge.d.ts +7 -0
- package/dist/knowledge.js +20 -0
- package/dist/knowledge.js.map +1 -0
- package/dist/known-tools.d.ts +4 -0
- package/dist/message-links.d.ts +68 -0
- package/dist/model-context.d.ts +68 -0
- package/dist/model-display.d.ts +2 -0
- package/dist/model-display.js +17 -0
- package/dist/model-display.js.map +1 -0
- package/dist/model-picker-order.d.ts +1 -0
- package/dist/model-picker-order.js +10 -0
- package/dist/model-picker-order.js.map +1 -0
- package/dist/organization-integration-policy.d.ts +26 -0
- package/dist/organization-integration-policy.js +26 -0
- package/dist/organization-integration-policy.js.map +1 -0
- package/dist/pr-review-client.d.ts +1 -1
- package/dist/pr-review.d.ts +1 -1
- package/dist/pr-review.js.map +1 -1
- package/dist/preference-registry.d.ts +1 -1
- package/dist/prepared-mcp-setup.d.ts +5 -0
- package/dist/proxy.d.ts +13 -11
- package/dist/realtime.d.ts +1 -1
- package/dist/realtime.js +253 -7
- package/dist/realtime.js.map +1 -1
- package/dist/session-history-import.d.ts +22 -0
- package/dist/session-history-import.js +36 -0
- package/dist/session-history-import.js.map +1 -0
- package/dist/session-list-entries.d.ts +1 -0
- package/dist/session-list-entries.js +7 -0
- package/dist/session-list-entries.js.map +1 -0
- package/dist/session-message-search.d.ts +53 -0
- package/dist/session-proxy-client.d.ts +28 -0
- package/dist/session-proxy.d.ts +334 -0
- package/dist/session-proxy.js +24 -0
- package/dist/session-proxy.js.map +1 -0
- package/dist/session-titles.d.ts +2 -28
- package/dist/session-usage-limits.d.ts +10 -0
- package/dist/session-usage-limits.js +43 -0
- package/dist/session-usage-limits.js.map +1 -0
- package/dist/shared-catalog-loader.d.ts +34 -0
- package/dist/site-ai.d.ts +35 -0
- package/dist/site-bridge-host.d.ts +112 -0
- package/dist/site-bridge-host.js +286 -0
- package/dist/site-bridge-host.js.map +1 -0
- package/dist/site-browser-entry.d.ts +1 -0
- package/dist/site-browser-runtime.gen.d.ts +1 -0
- package/dist/site-data.d.ts +178 -0
- package/dist/site-document.d.ts +5 -0
- package/dist/site-document.js +20 -0
- package/dist/site-document.js.map +1 -0
- package/dist/site-document.test.d.ts +1 -0
- package/dist/site-http.d.ts +17 -0
- package/dist/site-sql.d.ts +31 -0
- package/dist/site-tool-bridge.d.ts +57 -0
- package/dist/site.d.ts +186 -0
- package/dist/site.js +63 -0
- package/dist/site.js.map +1 -0
- package/dist/skills.d.ts +75 -0
- package/dist/sse.d.ts +1 -1
- package/dist/stream.d.ts +2 -0
- package/dist/tenant-workspaces.d.ts +47 -0
- package/dist/tenant-workspaces.js +8 -0
- package/dist/tenant-workspaces.js.map +1 -0
- package/dist/tool-auth.d.ts +87 -0
- package/dist/tool-auth.js +23 -0
- package/dist/tool-auth.js.map +1 -0
- package/dist/tool-display-metadata.d.ts +3 -0
- package/dist/tools-37DCTCNZ.js +12 -0
- package/dist/tools-37DCTCNZ.js.map +1 -0
- package/dist/tools.d.ts +81 -0
- package/dist/transcription.d.ts +1 -1
- package/dist/types.d.ts +2807 -583
- package/dist/usage-allowances.d.ts +36 -0
- package/dist/usage-allowances.js +80 -0
- package/dist/usage-allowances.js.map +1 -0
- package/dist/vault-types.d.ts +324 -0
- package/dist/vaults-api.d.ts +65 -0
- package/dist/workspace-artifacts.d.ts +47 -5
- package/dist/workspace-instruction-policies.d.ts +2 -2
- package/dist/workspace-integrations.d.ts +74 -0
- package/dist/workspace-integrations.js +67 -0
- package/dist/workspace-integrations.js.map +1 -0
- package/dist/workspace-state.d.ts +8 -71
- package/package.json +103 -5
- package/src/adapters/express.ts +136 -0
- package/src/adapters/hono.ts +18 -0
- package/src/adapters/next.ts +42 -0
- package/src/allowance-refusal.ts +5 -0
- package/src/app-links.ts +117 -0
- package/src/artifact-catalog.ts +51 -0
- package/src/artifact-client.ts +40 -49
- package/src/artifacts.ts +1 -0
- package/src/automations-client.ts +8 -2
- package/src/azure-live-transport.ts +286 -0
- package/src/browser.ts +37 -0
- package/src/chat/fold.ts +431 -0
- package/src/chat/handler.ts +240 -0
- package/src/chat/http.ts +257 -0
- package/src/chat/ids.ts +85 -0
- package/src/chat/index.ts +88 -0
- package/src/chat/openai.ts +439 -0
- package/src/chat/opengeni.ts +737 -0
- package/src/chat/types.ts +219 -0
- package/src/chat/vercel.ts +204 -0
- package/src/chats.ts +16 -0
- package/src/client.ts +5697 -1833
- package/src/codex-realtime-controller.ts +163 -8
- package/src/codex-realtime-v3-wire.ts +4 -3
- package/src/codex-realtime-v3.ts +12 -3
- package/src/codex-realtime.ts +11 -3
- package/src/company-brain.ts +0 -40
- package/src/core.ts +20 -0
- package/src/deprecation.ts +149 -0
- package/src/document-authority-client.ts +104 -1
- package/src/editable-artifacts/controller.ts +36 -2
- package/src/editable-artifacts/index.ts +1 -0
- package/src/editable-artifacts/types.ts +29 -16
- package/src/embedding-client.ts +519 -0
- package/src/errors.ts +303 -4
- package/src/external-vault-receiver.ts +914 -0
- package/src/feedback.ts +20 -0
- package/src/gateway-realtime-transport.ts +3 -3
- package/src/github-repositories.ts +60 -0
- package/src/index.ts +603 -65
- package/src/insights-usage.ts +64 -0
- package/src/interaction.ts +315 -93
- package/src/knowledge.ts +72 -0
- package/src/known-tools.ts +279 -0
- package/src/message-links.ts +178 -0
- package/src/model-context.ts +113 -0
- package/src/model-display.ts +11 -0
- package/src/model-picker-order.ts +6 -0
- package/src/organization-integration-policy.ts +54 -0
- package/src/pr-review-client.ts +1 -1
- package/src/pr-review.ts +1 -1
- package/src/preference-registry.ts +2 -0
- package/src/prepared-mcp-setup.ts +123 -0
- package/src/proxy.ts +23 -12
- package/src/realtime.ts +15 -6
- package/src/session-history-import.ts +84 -0
- package/src/session-list-entries.ts +1 -0
- package/src/session-message-search.ts +47 -0
- package/src/session-proxy-client.ts +68 -0
- package/src/session-proxy.ts +2098 -0
- package/src/session-titles.ts +8 -82
- package/src/session-usage-limits.ts +71 -0
- package/src/shared-catalog-loader.ts +169 -0
- package/src/site-ai.ts +284 -0
- package/src/site-bridge-host.ts +422 -0
- package/src/site-browser-entry.ts +6 -0
- package/src/site-browser-runtime.gen.ts +2 -0
- package/src/site-data.ts +372 -0
- package/src/site-document.test.ts +64 -0
- package/src/site-document.ts +18 -0
- package/src/site-http.ts +171 -0
- package/src/site-sql.ts +153 -0
- package/src/site-tool-bridge.ts +401 -0
- package/src/site.ts +878 -0
- package/src/skills.ts +69 -0
- package/src/sse.ts +9 -3
- package/src/stream.ts +33 -10
- package/src/tenant-workspaces.ts +148 -0
- package/src/tool-auth.ts +301 -0
- package/src/tool-display-metadata.ts +27 -0
- package/src/tools.ts +452 -0
- package/src/transcription.ts +3 -0
- package/src/types.ts +3170 -582
- package/src/usage-allowances.ts +189 -0
- package/src/vault-types.ts +379 -0
- package/src/vaults-api.ts +339 -0
- package/src/workspace-artifacts.ts +55 -6
- package/src/workspace-instruction-policies.ts +2 -1
- package/src/workspace-integrations.ts +402 -0
- package/src/workspace-state.ts +8 -82
- package/dist/chunk-C2H7HBNP.js.map +0 -1
- package/dist/chunk-C4DJPZRU.js +0 -119
- package/dist/chunk-C4DJPZRU.js.map +0 -1
- package/dist/chunk-JUS2UYRP.js.map +0 -1
- package/dist/chunk-KKRO2VCP.js.map +0 -1
- package/dist/chunk-QT3KFVDP.js.map +0 -1
- package/dist/chunk-TRNI7KEO.js.map +0 -1
- package/dist/chunk-VYCTL62C.js.map +0 -1
- package/dist/chunk-YXQX7RQT.js.map +0 -1
|
@@ -0,0 +1,2098 @@
|
|
|
1
|
+
import type { OpenGeniEmbeddingClient } from "./embedding-client";
|
|
2
|
+
import { OpenGeniApiError, OpenGeniSetupError } from "./errors";
|
|
3
|
+
import { chatDefaults, type Chats } from "./chats";
|
|
4
|
+
import { SESSION_SCOPE_HEADER } from "./message-links";
|
|
5
|
+
import type { WorkspaceIdOptions, WorkspaceIdTarget } from "./tenant-workspaces";
|
|
6
|
+
import { proxySessionEventStream } from "./proxy";
|
|
7
|
+
import { OPENGENI_API_CONTRACT_HEADER, OPENGENI_API_CONTRACT_REVISION } from "./types";
|
|
8
|
+
import type {
|
|
9
|
+
CreateSessionRequest,
|
|
10
|
+
FileResourceRef,
|
|
11
|
+
LatencyMode,
|
|
12
|
+
ReasoningEffort,
|
|
13
|
+
RetainedArtifactContent,
|
|
14
|
+
SessionMcpCredentialUpdateInput,
|
|
15
|
+
} from "./types";
|
|
16
|
+
import { mintToolToken, resolveToolTokenSecret } from "./tool-auth";
|
|
17
|
+
import {
|
|
18
|
+
downloadSessionProxySiteHtml,
|
|
19
|
+
getSessionProxyArtifactAssociation,
|
|
20
|
+
getSessionProxyWorkspaceGrant,
|
|
21
|
+
SESSION_PROXY_SITE_HTML_MAX_BYTES,
|
|
22
|
+
SessionProxySiteHtmlTooLargeError,
|
|
23
|
+
} from "./session-proxy-client";
|
|
24
|
+
export {
|
|
25
|
+
downloadSessionProxySiteHtml,
|
|
26
|
+
getSessionProxyArtifactAssociation,
|
|
27
|
+
getSessionProxyWorkspaceGrant,
|
|
28
|
+
SESSION_PROXY_SITE_HTML_MAX_BYTES,
|
|
29
|
+
SessionProxySiteHtmlTooLargeError,
|
|
30
|
+
} from "./session-proxy-client";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Packaged same-origin backend for the React conversation surfaces.
|
|
34
|
+
*
|
|
35
|
+
* Mount the handler at a catch-all route (for example `/api/opengeni/*`) and
|
|
36
|
+
* point an unmodified browser `OpenGeniClient({ baseUrl: "/api/opengeni" })`
|
|
37
|
+
* at it. Every request is authenticated by the host's `resolve` hook, pinned
|
|
38
|
+
* to the resolved workspace, executed as the resolved external user through
|
|
39
|
+
* `asUser` (never the organization key's own service authority), and limited
|
|
40
|
+
* to the exact native routes `SessionConversation` and its hooks use. It is
|
|
41
|
+
* an explicit dispatcher, not an arbitrary upstream proxy.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
type ProxyClient = OpenGeniEmbeddingClient;
|
|
45
|
+
|
|
46
|
+
/** The `@opengeni/sdk/chat` `Opengeni` facade, structurally. */
|
|
47
|
+
type ProxyFacade = {
|
|
48
|
+
readonly client: ProxyClient;
|
|
49
|
+
readonly source: string;
|
|
50
|
+
workspaceId(target: { tenant: string }): Promise<string>;
|
|
51
|
+
workspaceIdFor?(target: WorkspaceIdTarget, options: WorkspaceIdOptions): Promise<string>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Who the authenticated request acts as, in your own ids. Never derive any of
|
|
56
|
+
* it from the request body or path. `{ user, tenant }`: one workspace per
|
|
57
|
+
* tenant. `{ user }`: one workspace per user. `{ user, workspaceId }`: your
|
|
58
|
+
* own workspace. Tenant and per-user mapping require the `Opengeni` facade
|
|
59
|
+
* from `@opengeni/sdk/chat`; workspaces are created on first use and Opengeni
|
|
60
|
+
* adds the user on their first request.
|
|
61
|
+
*/
|
|
62
|
+
export type SessionProxyResolution = (
|
|
63
|
+
| { workspaceId: string; tenant?: undefined }
|
|
64
|
+
| { tenant: string; workspaceId?: undefined }
|
|
65
|
+
| { tenant?: undefined; workspaceId?: undefined }
|
|
66
|
+
) & {
|
|
67
|
+
/** Host-authenticated external user id; every call runs through `asUser(user)`. */
|
|
68
|
+
user: string;
|
|
69
|
+
/** External identity source. Defaults to the facade's `source`, else `"default"`. */
|
|
70
|
+
source?: string | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* The user is an anonymous visitor your product has not signed in, for
|
|
73
|
+
* example a website visitor keyed by a cookie. Visitors cannot upload
|
|
74
|
+
* composer file attachments unless the handler sets `visitorUploads: true`;
|
|
75
|
+
* everything else is unchanged.
|
|
76
|
+
*/
|
|
77
|
+
visitor?: boolean | undefined;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Host auth hook: return the resolution, or a `Response` (for example 401) to reject. */
|
|
81
|
+
export type SessionProxyResolve = (
|
|
82
|
+
request: Request,
|
|
83
|
+
) => Promise<SessionProxyResolution | Response> | SessionProxyResolution | Response;
|
|
84
|
+
|
|
85
|
+
export type SessionProxyContext = {
|
|
86
|
+
request: Request;
|
|
87
|
+
workspaceId: string;
|
|
88
|
+
user: string;
|
|
89
|
+
source: string;
|
|
90
|
+
/** The `asUser` client the proxy uses for this request. */
|
|
91
|
+
client: ProxyClient;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The only fields a browser may send when creating a session through the proxy.
|
|
96
|
+
* After the `createSession` hook returns, the proxy adds the attached files to
|
|
97
|
+
* the created request (deduplicated by file id) and, when `modelSelection` is
|
|
98
|
+
* not `false`, applies the browser's explicit model choices over the hook's,
|
|
99
|
+
* exactly as follow-up messages carry them. Return a `Response` from the hook
|
|
100
|
+
* to refuse an input.
|
|
101
|
+
*/
|
|
102
|
+
export type SessionProxyCreateInput = {
|
|
103
|
+
/** Empty for a voice-first session; no initial user message is fabricated. */
|
|
104
|
+
initialMessage: string;
|
|
105
|
+
/** Available only while the proxy permits realtime voice. */
|
|
106
|
+
startMode?: "realtime" | undefined;
|
|
107
|
+
/** Browser retry key; replay is scoped to the acting user by the API. */
|
|
108
|
+
idempotencyKey?: string | undefined;
|
|
109
|
+
/** Files attached to the first message (uploaded through this proxy). */
|
|
110
|
+
resources?: FileResourceRef[] | undefined;
|
|
111
|
+
/** The user's model choice; only when `modelSelection` is not `false`. */
|
|
112
|
+
model?: string | undefined;
|
|
113
|
+
/** The user's reasoning choice; only when `modelSelection` is not `false`. */
|
|
114
|
+
reasoningEffort?: ReasoningEffort | undefined;
|
|
115
|
+
/** The user's latency choice; only when `modelSelection` is not `false`. */
|
|
116
|
+
latencyMode?: LatencyMode | undefined;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Which browser action is about to forward a user message or response. */
|
|
120
|
+
export type SessionProxyMessageInput = {
|
|
121
|
+
/** Absent for `create`. */
|
|
122
|
+
sessionId?: string | undefined;
|
|
123
|
+
/** An empty voice-first session shell, not an initial model-producing turn. */
|
|
124
|
+
startMode?: "realtime" | undefined;
|
|
125
|
+
/**
|
|
126
|
+
* `realtime` is live voice: once when a call starts (refuse it, or rotate
|
|
127
|
+
* MCP credentials), and before each batch of finalized transcripts and spoken
|
|
128
|
+
* requests is saved (refuse it, or add `modelContext`).
|
|
129
|
+
*/
|
|
130
|
+
delivery: "create" | "send" | "steer" | "submit" | "realtime";
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Server-side additions the host attaches to one forwarded user message or response. */
|
|
134
|
+
export type SessionProxyMessageExtras = {
|
|
135
|
+
/**
|
|
136
|
+
* Model-visible context for this message (current page, time zone, today's
|
|
137
|
+
* date). Placed before any context the browser sent. Not secret. Ignored for
|
|
138
|
+
* approval decisions and human-input responses, which are not new messages,
|
|
139
|
+
* and when a live voice call starts. Keep it stable for the same message:
|
|
140
|
+
* a retried voice entry must carry the same context to be accepted.
|
|
141
|
+
*/
|
|
142
|
+
modelContext?: string | undefined;
|
|
143
|
+
/**
|
|
144
|
+
* Header-only credential rotation for MCP servers already attached to the
|
|
145
|
+
* session (for example a fresh short-lived per-user bearer), applied
|
|
146
|
+
* atomically as the message or response is accepted. Ignored for `create`,
|
|
147
|
+
* where the `createSession` hook sets the initial headers.
|
|
148
|
+
*/
|
|
149
|
+
mcpCredentialUpdates?: SessionMcpCredentialUpdateInput[] | undefined;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Your product's own MCP tool server, attached to every session this proxy
|
|
154
|
+
* creates with a per-user bearer token. Verify it on every MCP request with
|
|
155
|
+
* `verifyToolRequest` from `@opengeni/sdk/tool-auth`. Tools always act as the
|
|
156
|
+
* chat's creator: in a shared chat, other members' messages do not change it.
|
|
157
|
+
*/
|
|
158
|
+
export type SessionProxyToolServer = {
|
|
159
|
+
/**
|
|
160
|
+
* Public HTTPS URL of your MCP endpoint, e.g. `https://app.example.com/api/mcp`.
|
|
161
|
+
* Defaults to `OPENGENI_TOOL_SERVER_URL`, which `verifyToolRequest` also reads.
|
|
162
|
+
*/
|
|
163
|
+
url?: string | undefined;
|
|
164
|
+
/** Session MCP server id (model-facing tool prefix). Defaults to `"app"`. */
|
|
165
|
+
id?: string | undefined;
|
|
166
|
+
/** Display name. */
|
|
167
|
+
name?: string | undefined;
|
|
168
|
+
/**
|
|
169
|
+
* Tools the user must approve before each call: list your write tools here
|
|
170
|
+
* (unprefixed MCP tool names), or `true` for every tool. Others run directly.
|
|
171
|
+
*/
|
|
172
|
+
approvals?: { ask: string[] | true } | undefined;
|
|
173
|
+
/** Token signing secret. Defaults to `OPENGENI_API_KEY`; `verifyToolRequest` must use the same. */
|
|
174
|
+
secret?: string | undefined;
|
|
175
|
+
/** Token lifetime in seconds. Defaults to 24 hours; refreshed on every message, approval, and answer. */
|
|
176
|
+
ttlSeconds?: number | undefined;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export type SessionProxyHandlerOptions = {
|
|
180
|
+
/**
|
|
181
|
+
* Private (default) uses personal Knowledge and session-only reach; shared uses
|
|
182
|
+
* workspace Knowledge and reach. Isolated also gives each user their own tenant workspace.
|
|
183
|
+
*/
|
|
184
|
+
chats?: Chats | undefined;
|
|
185
|
+
/** Mandatory host auth hook, called on every request. */
|
|
186
|
+
resolve: SessionProxyResolve;
|
|
187
|
+
/**
|
|
188
|
+
* Host CSRF/session policy for non-GET requests. Without it the proxy only
|
|
189
|
+
* rejects `Sec-Fetch-Site: cross-site` mutations and requires JSON bodies;
|
|
190
|
+
* cookie-authenticated hosts should supply their existing CSRF check.
|
|
191
|
+
*/
|
|
192
|
+
authorizeMutation?: ((request: Request) => boolean | Promise<boolean>) | undefined;
|
|
193
|
+
/**
|
|
194
|
+
* Optional product-level session check (for example "this ticket's session
|
|
195
|
+
* belongs to this user"). Opengeni still enforces membership and private
|
|
196
|
+
* visibility on every call.
|
|
197
|
+
*/
|
|
198
|
+
authorizeSession?:
|
|
199
|
+
| ((sessionId: string, context: SessionProxyContext) => boolean | Promise<boolean>)
|
|
200
|
+
| undefined;
|
|
201
|
+
/**
|
|
202
|
+
* Server-controlled session creation. The browser supplies only
|
|
203
|
+
* {@link SessionProxyCreateInput}; this hook returns the complete create
|
|
204
|
+
* request (agent, tools, MCP servers, Skills, instructions, model policy). Omit it
|
|
205
|
+
* to disable browser-initiated creation entirely.
|
|
206
|
+
*/
|
|
207
|
+
createSession?:
|
|
208
|
+
| ((
|
|
209
|
+
input: SessionProxyCreateInput,
|
|
210
|
+
context: SessionProxyContext,
|
|
211
|
+
) => CreateSessionRequest | Response | Promise<CreateSessionRequest | Response>)
|
|
212
|
+
| undefined;
|
|
213
|
+
/**
|
|
214
|
+
* Called before every forwarded user message (send, steer, composer submit,
|
|
215
|
+
* and browser-started create), approval decision, human-input response, and
|
|
216
|
+
* live voice start and transcript save (`delivery: "realtime"`).
|
|
217
|
+
* Return server-owned `modelContext` (messages and voice transcripts only)
|
|
218
|
+
* and MCP credential rotations, or a `Response` to reject the action.
|
|
219
|
+
*/
|
|
220
|
+
beforeForwardMessage?:
|
|
221
|
+
| ((
|
|
222
|
+
input: SessionProxyMessageInput,
|
|
223
|
+
context: SessionProxyContext,
|
|
224
|
+
) =>
|
|
225
|
+
| SessionProxyMessageExtras
|
|
226
|
+
| Response
|
|
227
|
+
| undefined
|
|
228
|
+
| Promise<SessionProxyMessageExtras | Response | undefined>)
|
|
229
|
+
| undefined;
|
|
230
|
+
/**
|
|
231
|
+
* Attach your product's MCP tool server to every session the `createSession`
|
|
232
|
+
* hook creates, authenticated as the resolved user. The proxy mints the
|
|
233
|
+
* token, adds the `mcpServers` entry (plus an eager ref when the hook lists
|
|
234
|
+
* explicit `tools`), and rotates the token on every send, steer, submit,
|
|
235
|
+
* approval decision, and human-input answer.
|
|
236
|
+
*/
|
|
237
|
+
toolServer?: SessionProxyToolServer | undefined;
|
|
238
|
+
/** Mount prefix, e.g. `/api/opengeni`. Defaults to everything before the first `/v1/`. */
|
|
239
|
+
basePath?: string | undefined;
|
|
240
|
+
/** Maximum JSON request body. Defaults to 1 MiB. */
|
|
241
|
+
maxBodyBytes?: number | undefined;
|
|
242
|
+
/**
|
|
243
|
+
* Expose composer file attachments (upload begin/complete, download URL) and
|
|
244
|
+
* the media agents produce in a session: generated images, browser and
|
|
245
|
+
* computer screenshots, published sandbox files, and generated video
|
|
246
|
+
* playback. Workspace-level artifact reads must name their session in the
|
|
247
|
+
* `x-opengeni-session-id` header (`SessionConversation` does this); the proxy
|
|
248
|
+
* runs `authorizeSession` and only forwards an artifact Opengeni proves that
|
|
249
|
+
* session produced. Defaults to true.
|
|
250
|
+
*/
|
|
251
|
+
files?: boolean | undefined;
|
|
252
|
+
/**
|
|
253
|
+
* Let anonymous visitors (`resolve` returned `visitor: true`) upload composer
|
|
254
|
+
* file attachments. Defaults to false: for a visitor the client config reports
|
|
255
|
+
* file uploads off, so stock UIs hide the attach button, and the upload routes
|
|
256
|
+
* are refused. Agent-produced media stays readable under `files`. Signed-in
|
|
257
|
+
* users follow `files` alone.
|
|
258
|
+
*/
|
|
259
|
+
visitorUploads?: boolean | undefined;
|
|
260
|
+
/**
|
|
261
|
+
* Forward composer voice input (`POST .../transcriptions`, one recording per
|
|
262
|
+
* request) as the resolved user; Opengeni still requires that user's
|
|
263
|
+
* `sessions:create` permission and the workspace's voice-input setting.
|
|
264
|
+
* `false` reports voice input unavailable in the client config so stock UIs
|
|
265
|
+
* hide the microphone. Defaults to true.
|
|
266
|
+
*/
|
|
267
|
+
voiceInput?: boolean | undefined;
|
|
268
|
+
/**
|
|
269
|
+
* Forward live speech-to-speech voice for existing chats as the resolved
|
|
270
|
+
* user: the voice model catalog and the call lifecycle routes under
|
|
271
|
+
* `.../sessions/{id}/realtime`. Opengeni still requires that user's
|
|
272
|
+
* `sessions:control` permission, binds the call to that user and browser,
|
|
273
|
+
* runs spoken requests as ordinary steers of the chat, and meters
|
|
274
|
+
* deployment-funded voice against the organization's credits. Defaults to
|
|
275
|
+
* true. The stock conversation's voice button is opt-in: explicit `true`
|
|
276
|
+
* reports `realtimeVoice: true` in the client config so `SessionConversation`
|
|
277
|
+
* and `OpenGeniChat` show it (when a voice model is available), as does their
|
|
278
|
+
* own `realtimeVoice` prop. `false` reports `realtimeVoice: false` so stock
|
|
279
|
+
* UIs hide it, and refuses the routes.
|
|
280
|
+
*/
|
|
281
|
+
realtimeVoice?: boolean | undefined;
|
|
282
|
+
/**
|
|
283
|
+
* Let the browser read a file from the session's sandbox (`POST .../fs/read`)
|
|
284
|
+
* so `sandbox:` links in agent replies can be downloaded. Only `path`,
|
|
285
|
+
* `encoding`, and `maxBytes` are forwarded; Opengeni still requires the
|
|
286
|
+
* user's `files:read` permission on that session. Explicit opt-in, default
|
|
287
|
+
* false. Reads are confined to its working directory, including on Connected
|
|
288
|
+
* Machines; symlinked paths are refused.
|
|
289
|
+
*/
|
|
290
|
+
sandboxFiles?: boolean | undefined;
|
|
291
|
+
/**
|
|
292
|
+
* Serve the embedded artifact viewer and inline Site previews: reads of the
|
|
293
|
+
* editable artifacts and Sites a session produced, and live-ticket minting
|
|
294
|
+
* for the editor. Explicit opt-in, default false.
|
|
295
|
+
*
|
|
296
|
+
* Every request must name its session in the `x-opengeni-session-id` header
|
|
297
|
+
* (`SessionArtifactViewer` and `SessionConversation` do this). The proxy
|
|
298
|
+
* runs `authorizeSession`, then only forwards an artifact Opengeni associates with
|
|
299
|
+
* that session, so the browser cannot open other workspace artifacts through
|
|
300
|
+
* it. Editing still requires the user's own `artifacts:publish` grant. Site
|
|
301
|
+
* tool calls are not proxied.
|
|
302
|
+
*
|
|
303
|
+
* The editor's live socket is ticket-authenticated and connects to Opengeni
|
|
304
|
+
* directly; the ticket binds the source session and the API revalidates its
|
|
305
|
+
* authority while connected. `editableLiveUrl` overrides the derived URL.
|
|
306
|
+
* Site HTML streams with backpressure and cancellation, with a 25 MiB
|
|
307
|
+
* actual-byte ceiling (`SESSION_PROXY_SITE_HTML_MAX_BYTES`). Oversize streams
|
|
308
|
+
* fail with `SessionProxySiteHtmlTooLargeError` / `site_html_too_large`;
|
|
309
|
+
* headers cannot be replaced once streaming has begun.
|
|
310
|
+
*/
|
|
311
|
+
artifacts?: boolean | { editableLiveUrl?: string | undefined } | undefined;
|
|
312
|
+
/**
|
|
313
|
+
* Read the workspace's Sites without naming a session: list, detail,
|
|
314
|
+
* content and HTML of published artifacts the signed-in user may read, for
|
|
315
|
+
* pages that belong to the workspace rather than to one chat (for example a
|
|
316
|
+
* page several sessions keep up to date, shown with `SiteDetail`). Read-only:
|
|
317
|
+
* publishing, rollback and status changes stay closed. Requests that send
|
|
318
|
+
* `x-opengeni-session-id` keep the session-scoped `artifacts` checks.
|
|
319
|
+
* Explicit opt-in, default false.
|
|
320
|
+
*/
|
|
321
|
+
sites?: boolean | undefined;
|
|
322
|
+
/**
|
|
323
|
+
* Chat list for `SessionList` / `OpenGeniChat` (`listSessionPage` only).
|
|
324
|
+
* `"mine"` (default) lists sessions the resolved user created; `"visible"`
|
|
325
|
+
* lists every session Opengeni lets that user read in the workspace (shared
|
|
326
|
+
* chats included); `false` disables listing.
|
|
327
|
+
*/
|
|
328
|
+
sessionList?: "mine" | "visible" | false | undefined;
|
|
329
|
+
/** Let the user archive or restore their own chats. Defaults to true. */
|
|
330
|
+
archive?: boolean | undefined;
|
|
331
|
+
/**
|
|
332
|
+
* Let the browser choose model, reasoning effort, and latency per message,
|
|
333
|
+
* draft, or new chat (still limited by the workspace model catalog). When
|
|
334
|
+
* false those choices are removed from messages and draft saves, and refused
|
|
335
|
+
* on create. Saves use the actor's
|
|
336
|
+
* server-owned draft policy (initially the session defaults). Submit must repeat
|
|
337
|
+
* the saved policy unchanged as an integrity fence, not a new selection: the
|
|
338
|
+
* API atomically checks the saved revision/content or replays the original
|
|
339
|
+
* receipt. Hide the composer's model picker to match. Defaults to true.
|
|
340
|
+
* Pass `true` explicitly to also show end users the stock model picker
|
|
341
|
+
* (`SessionConversation`/`OpenGeniChat` hide it unless asked). The picker
|
|
342
|
+
* lists the workspace's model catalog, so the workspace's allowed-model
|
|
343
|
+
* settings decide which models end users see.
|
|
344
|
+
*/
|
|
345
|
+
modelSelection?: boolean | undefined;
|
|
346
|
+
/**
|
|
347
|
+
* SSE heartbeat interval. Defaults to 5 seconds, under Bun.serve's default
|
|
348
|
+
* 10-second `idleTimeout`, which otherwise closes a quiet event stream.
|
|
349
|
+
*/
|
|
350
|
+
heartbeatMs?: number | undefined;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Default SSE heartbeat. Bun.serve closes a connection that sends nothing for
|
|
355
|
+
* 10 seconds by default, so a quiet stream (a long tool call, an idle chat)
|
|
356
|
+
* would drop and reconnect every 10 seconds through a Bun or Hono-on-Bun host.
|
|
357
|
+
*/
|
|
358
|
+
const SESSION_PROXY_DEFAULT_HEARTBEAT_MS = 5_000;
|
|
359
|
+
|
|
360
|
+
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
361
|
+
const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
362
|
+
const QUEUE_OPERATIONS: ReadonlySet<string> = new Set(["move", "edit", "steer", "delete"]);
|
|
363
|
+
const CREATE_FIELDS: ReadonlySet<string> = new Set([
|
|
364
|
+
"initialMessage",
|
|
365
|
+
"idempotencyKey",
|
|
366
|
+
"resources",
|
|
367
|
+
"startMode",
|
|
368
|
+
]);
|
|
369
|
+
const MODEL_FIELDS = ["model", "reasoningEffort", "latencyMode"] as const;
|
|
370
|
+
/** The API's single-recording ceiling (25 MiB) plus multipart framing. */
|
|
371
|
+
const MAX_TRANSCRIPTION_BODY_BYTES = 25 * 1024 * 1024 + 64 * 1024;
|
|
372
|
+
|
|
373
|
+
class ProxyRejection extends Error {
|
|
374
|
+
constructor(
|
|
375
|
+
readonly status: number,
|
|
376
|
+
readonly code: string,
|
|
377
|
+
message: string,
|
|
378
|
+
) {
|
|
379
|
+
super(message);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function reject(status: number, code: string, message: string): never {
|
|
384
|
+
throw new ProxyRejection(status, code, message);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
|
388
|
+
return new Response(JSON.stringify(body), {
|
|
389
|
+
status,
|
|
390
|
+
headers: {
|
|
391
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
392
|
+
"Cache-Control": "no-store",
|
|
393
|
+
...headers,
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function errorJson(status: number, code: string, message: string): Response {
|
|
399
|
+
return json({ error: { code, message } }, status);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function isFacade(target: ProxyClient | ProxyFacade): target is ProxyFacade {
|
|
403
|
+
return !("asUser" in target) && "client" in target;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Create the packaged session proxy. Returns a web-standard
|
|
408
|
+
* `(Request) => Promise<Response>` for Next.js route handlers, Hono, Bun.serve,
|
|
409
|
+
* Cloudflare Workers, and similar hosts.
|
|
410
|
+
*/
|
|
411
|
+
export function createSessionProxyHandler(
|
|
412
|
+
target: ProxyClient | ProxyFacade,
|
|
413
|
+
options: SessionProxyHandlerOptions,
|
|
414
|
+
): (request: Request) => Promise<Response> {
|
|
415
|
+
const service = isFacade(target) ? target.client : target;
|
|
416
|
+
const defaultSource = isFacade(target) ? target.source : "default";
|
|
417
|
+
// One rejected-key notice per handler (each handler holds one server key).
|
|
418
|
+
const rejectedKeyNotice = { sent: false };
|
|
419
|
+
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
420
|
+
const filesEnabled = options.files ?? true;
|
|
421
|
+
const voiceInputEnabled = options.voiceInput ?? true;
|
|
422
|
+
const realtimeVoiceEnabled = options.realtimeVoice ?? true;
|
|
423
|
+
const sandboxFilesEnabled = options.sandboxFiles === true;
|
|
424
|
+
const artifactsEnabled = options.artifacts !== undefined && options.artifacts !== false;
|
|
425
|
+
const sitesEnabled = options.sites === true;
|
|
426
|
+
const editableLiveUrl = artifactsEnabled
|
|
427
|
+
? liveSocketUrl(
|
|
428
|
+
(typeof options.artifacts === "object" ? options.artifacts.editableLiveUrl : undefined) ??
|
|
429
|
+
service.apiUrl(EDITABLE_ARTIFACT_LIVE_PATH),
|
|
430
|
+
)
|
|
431
|
+
: null;
|
|
432
|
+
const modelSelection = options.modelSelection ?? true;
|
|
433
|
+
const heartbeatMs = options.heartbeatMs ?? SESSION_PROXY_DEFAULT_HEARTBEAT_MS;
|
|
434
|
+
const sessionList = options.sessionList ?? "mine";
|
|
435
|
+
const archiveEnabled = options.archive ?? true;
|
|
436
|
+
const chats = options.chats ?? "private";
|
|
437
|
+
const defaults = chatDefaults(chats);
|
|
438
|
+
const toolServer = options.toolServer ? normalizeToolServer(options.toolServer) : undefined;
|
|
439
|
+
// Whether a session carries this proxy's tool server (attachments are immutable).
|
|
440
|
+
const toolSessions = new Map<string, Promise<string | null>>();
|
|
441
|
+
// Canonical subject per external user, for the "mine" list filter.
|
|
442
|
+
const subjects = new Map<string, Promise<string>>();
|
|
443
|
+
const subjectOf = (client: ProxyClient, key: string): Promise<string> => {
|
|
444
|
+
let subject = subjects.get(key);
|
|
445
|
+
if (!subject) {
|
|
446
|
+
subject = client.getAccessContext().then((access) => access.subjectId);
|
|
447
|
+
subject.catch(() => subjects.delete(key));
|
|
448
|
+
if (subjects.size >= 1_000) subjects.delete(subjects.keys().next().value!);
|
|
449
|
+
subjects.set(key, subject);
|
|
450
|
+
}
|
|
451
|
+
return subject;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
return async (request) => {
|
|
455
|
+
try {
|
|
456
|
+
const method = request.method;
|
|
457
|
+
if (!PROXY_METHODS.includes(method)) {
|
|
458
|
+
return new Response(null, {
|
|
459
|
+
status: 405,
|
|
460
|
+
headers: { Allow: PROXY_METHODS.join(", ") },
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
const resolved = await options.resolve(request);
|
|
464
|
+
if (resolved instanceof Response) return resolved;
|
|
465
|
+
if (typeof resolved?.user !== "string" || !resolved.user) {
|
|
466
|
+
// Never fall back to the organization key's service authority.
|
|
467
|
+
return errorJson(401, "user_required", "Authentication required.");
|
|
468
|
+
}
|
|
469
|
+
const url = new URL(request.url);
|
|
470
|
+
const segments = routeSegments(url.pathname, options.basePath);
|
|
471
|
+
if (!segments) return errorJson(404, "route_not_allowed", "Not found.");
|
|
472
|
+
|
|
473
|
+
if (method !== "GET") {
|
|
474
|
+
const allowed = options.authorizeMutation
|
|
475
|
+
? await options.authorizeMutation(request)
|
|
476
|
+
: request.headers.get("sec-fetch-site") !== "cross-site";
|
|
477
|
+
if (!allowed) return errorJson(403, "mutation_denied", "Request denied.");
|
|
478
|
+
}
|
|
479
|
+
const source = resolved.source ?? defaultSource;
|
|
480
|
+
let workspaceId: string;
|
|
481
|
+
// A tenant/workspaceId key that resolves to nothing must never fall
|
|
482
|
+
// through to the per-user workspace: that is a host auth bug, not a choice.
|
|
483
|
+
if (
|
|
484
|
+
("workspaceId" in resolved && !resolved.workspaceId && !resolved.tenant) ||
|
|
485
|
+
("tenant" in resolved && !resolved.tenant && !resolved.workspaceId) ||
|
|
486
|
+
resolved.workspaceId === "" ||
|
|
487
|
+
resolved.tenant === ""
|
|
488
|
+
) {
|
|
489
|
+
throw new TypeError(
|
|
490
|
+
"resolve returned an empty tenant or workspaceId. Return a non-empty id, or omit the key for one workspace per user.",
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
if (chats === "isolated") {
|
|
494
|
+
if (resolved.workspaceId || !isFacade(target) || !target.workspaceIdFor) {
|
|
495
|
+
throw new TypeError(
|
|
496
|
+
'chats: "isolated" requires the Opengeni facade and a tenant or user resolution.',
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
workspaceId = await target.workspaceIdFor(
|
|
500
|
+
{ tenant: resolved.tenant, user: resolved.user, source },
|
|
501
|
+
{ isolation: "user" },
|
|
502
|
+
);
|
|
503
|
+
} else if (resolved.workspaceId) {
|
|
504
|
+
workspaceId = resolved.workspaceId;
|
|
505
|
+
} else if (resolved.tenant && isFacade(target)) {
|
|
506
|
+
workspaceId = await target.workspaceId({ tenant: resolved.tenant });
|
|
507
|
+
} else if (
|
|
508
|
+
!("tenant" in resolved) &&
|
|
509
|
+
!("workspaceId" in resolved) &&
|
|
510
|
+
isFacade(target) &&
|
|
511
|
+
target.workspaceIdFor
|
|
512
|
+
) {
|
|
513
|
+
// A user alone: their own workspace, keyed by the identity source.
|
|
514
|
+
workspaceId = await target.workspaceIdFor(
|
|
515
|
+
{ user: resolved.user, source },
|
|
516
|
+
{ isolation: "user" },
|
|
517
|
+
);
|
|
518
|
+
} else {
|
|
519
|
+
throw new TypeError(
|
|
520
|
+
"resolve must return a workspaceId (or a tenant or a user alone when given the Opengeni facade).",
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
// Visitors upload only when the host opts them in; reads stay under `files`.
|
|
524
|
+
const uploadsEnabled =
|
|
525
|
+
filesEnabled && (resolved.visitor !== true || options.visitorUploads === true);
|
|
526
|
+
const client = service.asUser(resolved.user, { source });
|
|
527
|
+
const context: SessionProxyContext = {
|
|
528
|
+
request,
|
|
529
|
+
workspaceId,
|
|
530
|
+
user: resolved.user,
|
|
531
|
+
source,
|
|
532
|
+
client,
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const call = { signal: request.signal };
|
|
536
|
+
const toolToken = async () =>
|
|
537
|
+
await mintToolToken({
|
|
538
|
+
audience: toolServer!.url,
|
|
539
|
+
user: resolved.user,
|
|
540
|
+
tenant: resolved.tenant,
|
|
541
|
+
workspaceId,
|
|
542
|
+
source,
|
|
543
|
+
secret: toolServer!.secret,
|
|
544
|
+
ttlSeconds: toolServer!.ttlSeconds,
|
|
545
|
+
});
|
|
546
|
+
/** The creator's subject when the session carries this tool server, else null. */
|
|
547
|
+
const toolServerOwner = (sessionId: string): Promise<string | null> => {
|
|
548
|
+
const key = `${workspaceId}\u0000${sessionId}`;
|
|
549
|
+
let attached = toolSessions.get(key);
|
|
550
|
+
if (!attached) {
|
|
551
|
+
attached = client
|
|
552
|
+
.getSession(workspaceId, sessionId, call)
|
|
553
|
+
.then((session) =>
|
|
554
|
+
(session.mcpServers ?? []).some(
|
|
555
|
+
(server) => server.id === toolServer!.id && server.url === toolServer!.url,
|
|
556
|
+
)
|
|
557
|
+
? (session.createdBy?.subjectId ?? null)
|
|
558
|
+
: null,
|
|
559
|
+
);
|
|
560
|
+
attached.catch(() => toolSessions.delete(key));
|
|
561
|
+
if (toolSessions.size >= 1_000) toolSessions.delete(toolSessions.keys().next().value!);
|
|
562
|
+
toolSessions.set(key, attached);
|
|
563
|
+
}
|
|
564
|
+
return attached;
|
|
565
|
+
};
|
|
566
|
+
const messageExtras = async (
|
|
567
|
+
input: SessionProxyMessageInput,
|
|
568
|
+
): Promise<SessionProxyMessageExtras | Response | undefined> => {
|
|
569
|
+
const extras = options.beforeForwardMessage
|
|
570
|
+
? await options.beforeForwardMessage(input, context)
|
|
571
|
+
: undefined;
|
|
572
|
+
if (extras instanceof Response || !toolServer || !input.sessionId) return extras;
|
|
573
|
+
const updates = extras?.mcpCredentialUpdates ?? [];
|
|
574
|
+
// A host-supplied rotation for the same id wins; sessions created
|
|
575
|
+
// without this tool server (or for an older URL) are left alone.
|
|
576
|
+
if (updates.some((update) => update.id === toolServer.id)) return extras;
|
|
577
|
+
// Only the chat's creator refreshes: tools keep acting as that user.
|
|
578
|
+
const owner = await toolServerOwner(input.sessionId);
|
|
579
|
+
if (!owner || owner !== (await subjectOf(client, `${source}\u0000${resolved.user}`))) {
|
|
580
|
+
return extras;
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
...extras,
|
|
584
|
+
mcpCredentialUpdates: [
|
|
585
|
+
...updates,
|
|
586
|
+
{
|
|
587
|
+
id: toolServer.id,
|
|
588
|
+
headers: { Authorization: `Bearer ${await toolToken()}` },
|
|
589
|
+
},
|
|
590
|
+
],
|
|
591
|
+
};
|
|
592
|
+
};
|
|
593
|
+
/**
|
|
594
|
+
* Best-effort standalone rotation before a voice call. Opengeni refuses
|
|
595
|
+
* it while a turn is running; that turn's message already refreshed them.
|
|
596
|
+
*/
|
|
597
|
+
const refreshRealtimeCredentials = async (
|
|
598
|
+
sessionId: string,
|
|
599
|
+
updates: SessionMcpCredentialUpdateInput[],
|
|
600
|
+
): Promise<void> => {
|
|
601
|
+
if (updates.length === 0) return;
|
|
602
|
+
try {
|
|
603
|
+
const servers = (await client.getSession(workspaceId, sessionId, call)).mcpServers ?? [];
|
|
604
|
+
const rotations = updates.flatMap((update) => {
|
|
605
|
+
const server = servers.find((candidate) => candidate.id === update.id);
|
|
606
|
+
return server
|
|
607
|
+
? [
|
|
608
|
+
{
|
|
609
|
+
id: update.id,
|
|
610
|
+
expectedCredentialVersion: server.credentialVersion,
|
|
611
|
+
expectedServerUrl: server.url,
|
|
612
|
+
headers: update.headers,
|
|
613
|
+
},
|
|
614
|
+
]
|
|
615
|
+
: [];
|
|
616
|
+
});
|
|
617
|
+
if (rotations.length === 0) return;
|
|
618
|
+
await client.rotateSessionMcpCredentials(workspaceId, sessionId, {
|
|
619
|
+
operationKey: crypto.randomUUID(),
|
|
620
|
+
updates: rotations,
|
|
621
|
+
});
|
|
622
|
+
} catch (error) {
|
|
623
|
+
if (!(error instanceof OpenGeniApiError)) throw error;
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
/** Browser input sanitized, then server-owned extras merged in. */
|
|
627
|
+
const forwardMessage = async (
|
|
628
|
+
value: unknown,
|
|
629
|
+
input: SessionProxyMessageInput,
|
|
630
|
+
): Promise<Record<string, unknown> | Response> => {
|
|
631
|
+
// Submit repeats the saved policy as a mandatory integrity fence. Never
|
|
632
|
+
// replace it with a newer draft's policy: outcome-unknown retries must
|
|
633
|
+
// retain the original receipt hash. The API rejects any new selection.
|
|
634
|
+
const message = sanitizeMessage(value, modelSelection || input.delivery === "submit");
|
|
635
|
+
const extras = await messageExtras(input);
|
|
636
|
+
if (extras instanceof Response) return extras;
|
|
637
|
+
const modelContext = joinContext(
|
|
638
|
+
extras?.modelContext,
|
|
639
|
+
typeof message.modelContext === "string" ? message.modelContext : undefined,
|
|
640
|
+
);
|
|
641
|
+
return {
|
|
642
|
+
...message,
|
|
643
|
+
...(modelContext ? { modelContext } : {}),
|
|
644
|
+
...(extras?.mcpCredentialUpdates?.length
|
|
645
|
+
? { mcpCredentialUpdates: extras.mcpCredentialUpdates }
|
|
646
|
+
: {}),
|
|
647
|
+
};
|
|
648
|
+
};
|
|
649
|
+
/** Responses reuse send hooks unchanged; only credentials are added. */
|
|
650
|
+
const forwardResponse = async (
|
|
651
|
+
value: unknown,
|
|
652
|
+
input: SessionProxyMessageInput,
|
|
653
|
+
): Promise<Record<string, unknown> | Response> => {
|
|
654
|
+
const payload = browserPayload(value);
|
|
655
|
+
const extras = await messageExtras(input);
|
|
656
|
+
if (extras instanceof Response) return extras;
|
|
657
|
+
return {
|
|
658
|
+
...payload,
|
|
659
|
+
...(extras?.mcpCredentialUpdates?.length
|
|
660
|
+
? { mcpCredentialUpdates: extras.mcpCredentialUpdates }
|
|
661
|
+
: {}),
|
|
662
|
+
};
|
|
663
|
+
};
|
|
664
|
+
// Unknown additive query parameters on allowlisted reads pass through, so a
|
|
665
|
+
// newer browser SDK keeps working; the route and method allowlist is exact.
|
|
666
|
+
const query = Object.fromEntries(url.searchParams);
|
|
667
|
+
const read = async (path: string) =>
|
|
668
|
+
json(await client.requestJson("GET", path, undefined, query, call));
|
|
669
|
+
const [root, ...rest] = segments;
|
|
670
|
+
if (root === "config") {
|
|
671
|
+
if (rest.length === 1 && rest[0] === "client" && method === "GET") {
|
|
672
|
+
// The browser speaks this proxy's contract, not the upstream deployment's:
|
|
673
|
+
// report the server SDK's revision so an Opengeni deploy never makes the
|
|
674
|
+
// embedded page look stale (and never triggers a host reload).
|
|
675
|
+
const config = await client.requestJson<Record<string, unknown>>(
|
|
676
|
+
"GET",
|
|
677
|
+
"/v1/config/client",
|
|
678
|
+
undefined,
|
|
679
|
+
{ ...query, workspaceId },
|
|
680
|
+
call,
|
|
681
|
+
);
|
|
682
|
+
let artifacts: Awaited<ReturnType<typeof artifactViewerCapability>> | null = null;
|
|
683
|
+
if (editableLiveUrl) {
|
|
684
|
+
try {
|
|
685
|
+
// Successful effective-grant resolution negotiates the viewer's
|
|
686
|
+
// narrow API support. Older APIs do not have this endpoint: that
|
|
687
|
+
// 404 disables only artifacts, not ordinary conversation config.
|
|
688
|
+
artifacts = {
|
|
689
|
+
editableLiveUrl,
|
|
690
|
+
cachePartition: await cachePartition(
|
|
691
|
+
await getSessionProxyWorkspaceGrant(client, workspaceId, call),
|
|
692
|
+
workspaceId,
|
|
693
|
+
source,
|
|
694
|
+
),
|
|
695
|
+
};
|
|
696
|
+
} catch (error) {
|
|
697
|
+
if (!(error instanceof OpenGeniApiError) || error.status !== 404) throw error;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// Upstream proxy capabilities never authorize this host's routes.
|
|
701
|
+
// Task usage limits are set by this host's server, never through the
|
|
702
|
+
// browser boundary, so the producer capability is not forwarded.
|
|
703
|
+
const {
|
|
704
|
+
artifacts: _upstreamArtifacts,
|
|
705
|
+
sessionUsageLimits: _upstreamUsageLimits,
|
|
706
|
+
...conversationConfig
|
|
707
|
+
} = config;
|
|
708
|
+
const upstreamVoice = config.voiceInput;
|
|
709
|
+
if (upstreamVoice && typeof upstreamVoice === "object") {
|
|
710
|
+
// Only one-shot recordings are forwarded, never resumable chunk uploads.
|
|
711
|
+
const { resumable: _resumable, ...voice } = upstreamVoice as Record<string, unknown>;
|
|
712
|
+
conversationConfig.voiceInput = voiceInputEnabled
|
|
713
|
+
? voice
|
|
714
|
+
: { ...voice, available: false };
|
|
715
|
+
}
|
|
716
|
+
const upstreamUploads = config.fileUploads;
|
|
717
|
+
if (!uploadsEnabled) {
|
|
718
|
+
// The browser cannot upload through this proxy: stock UIs hide the attach control.
|
|
719
|
+
conversationConfig.fileUploads = {
|
|
720
|
+
...(upstreamUploads && typeof upstreamUploads === "object" ? upstreamUploads : {}),
|
|
721
|
+
enabled: false,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
return json({
|
|
725
|
+
...conversationConfig,
|
|
726
|
+
apiContractRevision: OPENGENI_API_CONTRACT_REVISION,
|
|
727
|
+
// The resolved workspace: a browser given only the proxy's baseUrl
|
|
728
|
+
// reads it here instead of knowing any Opengeni id.
|
|
729
|
+
workspaceId,
|
|
730
|
+
sandboxFiles: sandboxFilesEnabled,
|
|
731
|
+
// Stock UIs show Site previews and the artifact viewer only when
|
|
732
|
+
// this proxy serves them; `false` keeps them from failing on click.
|
|
733
|
+
artifacts: artifacts ?? false,
|
|
734
|
+
// Whether the stock "New chat" and "Archive" actions can succeed.
|
|
735
|
+
sessionCreation: options.createSession !== undefined,
|
|
736
|
+
archive: archiveEnabled,
|
|
737
|
+
// Explicit true also tells stock UIs to show the voice button, which
|
|
738
|
+
// is otherwise opt-in on the embedded conversation.
|
|
739
|
+
...(realtimeVoiceEnabled
|
|
740
|
+
? options.realtimeVoice === true
|
|
741
|
+
? { realtimeVoice: true }
|
|
742
|
+
: {}
|
|
743
|
+
: { realtimeVoice: false }),
|
|
744
|
+
// Explicit true also tells stock UIs to offer end users the model picker.
|
|
745
|
+
...(modelSelection
|
|
746
|
+
? options.modelSelection === true
|
|
747
|
+
? { modelSelection: true }
|
|
748
|
+
: {}
|
|
749
|
+
: { modelSelection: false }),
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
return errorJson(404, "route_not_allowed", "Not found.");
|
|
753
|
+
}
|
|
754
|
+
if (root !== "workspaces" || rest.length < 1) {
|
|
755
|
+
return errorJson(404, "route_not_allowed", "Not found.");
|
|
756
|
+
}
|
|
757
|
+
const [pathWorkspaceId, area, ...tail] = rest as [string, string | undefined, ...string[]];
|
|
758
|
+
if (pathWorkspaceId !== workspaceId) {
|
|
759
|
+
return errorJson(403, "workspace_not_allowed", "This workspace is not available.");
|
|
760
|
+
}
|
|
761
|
+
const base = `/v1/workspaces/${workspaceId}`;
|
|
762
|
+
|
|
763
|
+
// Only the resolved user's own usage. No full roster, allowance config,
|
|
764
|
+
// member selectors, or controls can pass through this browser boundary.
|
|
765
|
+
if (area === "usage" && tail.length === 1 && tail[0] === "me" && method === "GET") {
|
|
766
|
+
if (Object.keys(query).some((key) => key !== "period")) {
|
|
767
|
+
return errorJson(400, "invalid_usage_query", "Only period is accepted for own usage.");
|
|
768
|
+
}
|
|
769
|
+
return await read(`${base}/usage/me`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Workspace reads and the live control stream used by <OpenGeniProvider>.
|
|
773
|
+
if (area === undefined && method === "GET") return await read(base);
|
|
774
|
+
if (area === "model-catalog" && tail.length === 0 && method === "GET") {
|
|
775
|
+
return await read(`${base}/model-catalog`);
|
|
776
|
+
}
|
|
777
|
+
if (area === "realtime-model-catalog" && tail.length === 0 && method === "GET") {
|
|
778
|
+
if (!realtimeVoiceEnabled) return errorJson(404, "route_not_allowed", "Not found.");
|
|
779
|
+
return await read(`${base}/realtime-model-catalog`);
|
|
780
|
+
}
|
|
781
|
+
if (area === "live-events" && tail.length === 1 && tail[0] === "stream" && method === "GET") {
|
|
782
|
+
// Surface authorization failures as HTTP status before streaming.
|
|
783
|
+
await client.requestJson("GET", base, undefined, {}, call);
|
|
784
|
+
return workspaceLiveStream(client, workspaceId, url.searchParams, request, heartbeatMs);
|
|
785
|
+
}
|
|
786
|
+
if (area === "inference-control" && tail.length === 0 && method === "POST") {
|
|
787
|
+
// The conversation's paused-state UI only offers workspace Resume.
|
|
788
|
+
const body = await readJsonBody(request, maxBodyBytes);
|
|
789
|
+
if (body?.action !== "resume") {
|
|
790
|
+
reject(403, "control_not_allowed", "Only workspace resume is available.");
|
|
791
|
+
}
|
|
792
|
+
return json(await client.requestJson("POST", `${base}/inference-control`, body));
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (area === "files" && filesEnabled && method === "POST") {
|
|
796
|
+
if (tail[0] === "uploads" && !uploadsEnabled) {
|
|
797
|
+
return errorJson(404, "route_not_allowed", "Not found.");
|
|
798
|
+
}
|
|
799
|
+
if (tail.length === 1 && tail[0] === "uploads") {
|
|
800
|
+
const body = await readJsonBody(request, maxBodyBytes);
|
|
801
|
+
return json(
|
|
802
|
+
await client.requestJson("POST", `${base}/files/uploads`, pick(body, UPLOAD_FIELDS)),
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
if (tail.length === 3 && tail[0] === "uploads" && tail[2] === "complete") {
|
|
806
|
+
return json(
|
|
807
|
+
await client.requestJson("POST", `${base}/files/uploads/${tail[1]}/complete`),
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
if (tail.length === 2 && tail[1] === "download-url") {
|
|
811
|
+
return json(
|
|
812
|
+
await client.requestJson(
|
|
813
|
+
"POST",
|
|
814
|
+
`${base}/files/${tail[0]}/download-url`,
|
|
815
|
+
undefined,
|
|
816
|
+
query,
|
|
817
|
+
call,
|
|
818
|
+
),
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
return errorJson(404, "route_not_allowed", "Not found.");
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (area === "artifacts") {
|
|
825
|
+
// Retained media a session produced: only an artifact Opengeni proves
|
|
826
|
+
// that session produced, never an arbitrary workspace artifact.
|
|
827
|
+
const [artifactId, ...artifactOp] = tail as [string | undefined, ...string[]];
|
|
828
|
+
const route = `${method} ${artifactOp.join("/")}`;
|
|
829
|
+
if (
|
|
830
|
+
!filesEnabled ||
|
|
831
|
+
!artifactId ||
|
|
832
|
+
!SEGMENT.test(artifactId) ||
|
|
833
|
+
(route !== "GET content" && route !== "POST playback-source")
|
|
834
|
+
) {
|
|
835
|
+
return errorJson(404, "route_not_allowed", "Not found.");
|
|
836
|
+
}
|
|
837
|
+
const sessionId = request.headers.get(SESSION_SCOPE_HEADER)?.trim() ?? "";
|
|
838
|
+
if (!SEGMENT.test(sessionId)) {
|
|
839
|
+
reject(400, "session_scope_required", "Artifact reads must name their session.");
|
|
840
|
+
}
|
|
841
|
+
if (options.authorizeSession && !(await options.authorizeSession(sessionId, context))) {
|
|
842
|
+
return errorJson(404, "session_not_found", "Session not found.");
|
|
843
|
+
}
|
|
844
|
+
await getSessionProxyArtifactAssociation(
|
|
845
|
+
client,
|
|
846
|
+
workspaceId,
|
|
847
|
+
sessionId,
|
|
848
|
+
"retained",
|
|
849
|
+
artifactId,
|
|
850
|
+
call,
|
|
851
|
+
);
|
|
852
|
+
if (route === "POST playback-source") {
|
|
853
|
+
return json(
|
|
854
|
+
await client.requestJson(
|
|
855
|
+
"POST",
|
|
856
|
+
`${base}/artifacts/${artifactId}/playback-source`,
|
|
857
|
+
undefined,
|
|
858
|
+
{},
|
|
859
|
+
call,
|
|
860
|
+
),
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
return retainedContent(
|
|
864
|
+
await client.getRetainedArtifactContent(workspaceId, artifactId, {
|
|
865
|
+
...retainedRange(request),
|
|
866
|
+
signal: request.signal,
|
|
867
|
+
}),
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (area === "transcriptions" && tail.length === 0 && method === "POST") {
|
|
872
|
+
if (!voiceInputEnabled) return errorJson(404, "route_not_allowed", "Not found.");
|
|
873
|
+
return json(await client.transcribeAudio(workspaceId, await transcriptionInput(request)));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (area === "editable-artifacts" || area === "published-artifacts") {
|
|
877
|
+
if (
|
|
878
|
+
area === "published-artifacts" &&
|
|
879
|
+
sitesEnabled &&
|
|
880
|
+
!request.headers.get(SESSION_SCOPE_HEADER)
|
|
881
|
+
) {
|
|
882
|
+
// Workspace Sites under Opengeni's own read authorization for this user.
|
|
883
|
+
const [siteId, ...siteOp] = tail as [string | undefined, ...string[]];
|
|
884
|
+
const op = siteOp.join("/");
|
|
885
|
+
if (method !== "GET") return errorJson(404, "route_not_allowed", "Not found.");
|
|
886
|
+
if (siteId === undefined) {
|
|
887
|
+
return json(
|
|
888
|
+
await client.requestJson(
|
|
889
|
+
"GET",
|
|
890
|
+
`${base}/published-artifacts`,
|
|
891
|
+
undefined,
|
|
892
|
+
pick(query, SITE_LIST_FIELDS) as Record<string, string>,
|
|
893
|
+
call,
|
|
894
|
+
),
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
if (!SEGMENT.test(siteId) || (op !== "" && op !== "content" && op !== "html")) {
|
|
898
|
+
return errorJson(404, "route_not_allowed", "Not found.");
|
|
899
|
+
}
|
|
900
|
+
const versionId = query.versionId;
|
|
901
|
+
if (
|
|
902
|
+
versionId !== undefined &&
|
|
903
|
+
(typeof versionId !== "string" || !SEGMENT.test(versionId))
|
|
904
|
+
) {
|
|
905
|
+
reject(400, "version_invalid", "versionId is invalid.");
|
|
906
|
+
}
|
|
907
|
+
if (op === "html") {
|
|
908
|
+
if (typeof versionId !== "string") {
|
|
909
|
+
reject(400, "version_required", "versionId is required.");
|
|
910
|
+
}
|
|
911
|
+
const html = await downloadSessionProxySiteHtml(client, workspaceId, siteId, {
|
|
912
|
+
versionId: versionId as string,
|
|
913
|
+
signal: request.signal,
|
|
914
|
+
});
|
|
915
|
+
return new Response(boundedSiteHtml(html.body, request.signal), {
|
|
916
|
+
headers: SITE_HTML_HEADERS,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
const item = `${base}/published-artifacts/${siteId}`;
|
|
920
|
+
return json(
|
|
921
|
+
await client.requestJson(
|
|
922
|
+
"GET",
|
|
923
|
+
op === "content" ? `${item}/content` : item,
|
|
924
|
+
undefined,
|
|
925
|
+
op === "content" && typeof versionId === "string" ? { versionId } : {},
|
|
926
|
+
call,
|
|
927
|
+
),
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
if (!artifactsEnabled) return errorJson(404, "route_not_allowed", "Not found.");
|
|
931
|
+
const [artifactId, ...artifactOp] = tail as [string | undefined, ...string[]];
|
|
932
|
+
const route = `${method} ${artifactOp.join("/")}`;
|
|
933
|
+
const editable = area === "editable-artifacts";
|
|
934
|
+
const allowed = editable
|
|
935
|
+
? route === "GET " || route === "POST live-ticket"
|
|
936
|
+
: route === "GET " || route === "GET html";
|
|
937
|
+
if (!artifactId || !SEGMENT.test(artifactId) || !allowed) {
|
|
938
|
+
return errorJson(404, "route_not_allowed", "Not found.");
|
|
939
|
+
}
|
|
940
|
+
const sessionId = request.headers.get(SESSION_SCOPE_HEADER)?.trim() ?? "";
|
|
941
|
+
if (!SEGMENT.test(sessionId)) {
|
|
942
|
+
reject(400, "session_scope_required", "Artifact reads must name their session.");
|
|
943
|
+
}
|
|
944
|
+
if (options.authorizeSession && !(await options.authorizeSession(sessionId, context))) {
|
|
945
|
+
return errorJson(404, "session_not_found", "Session not found.");
|
|
946
|
+
}
|
|
947
|
+
// No positive authorization cache: revocation must take effect even
|
|
948
|
+
// between detail, HTML and ticket requests for the same artifact.
|
|
949
|
+
await getSessionProxyArtifactAssociation(
|
|
950
|
+
client,
|
|
951
|
+
workspaceId,
|
|
952
|
+
sessionId,
|
|
953
|
+
editable ? "editable" : "site",
|
|
954
|
+
artifactId,
|
|
955
|
+
call,
|
|
956
|
+
);
|
|
957
|
+
const item = `${base}/${area}/${artifactId}`;
|
|
958
|
+
if (route === "GET ") {
|
|
959
|
+
const replicaId = editable ? query.replicaId : undefined;
|
|
960
|
+
return json(
|
|
961
|
+
await client.requestJson(
|
|
962
|
+
"GET",
|
|
963
|
+
item,
|
|
964
|
+
undefined,
|
|
965
|
+
typeof replicaId === "string" ? { replicaId } : {},
|
|
966
|
+
call,
|
|
967
|
+
),
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
if (route === "POST live-ticket") {
|
|
971
|
+
const body = await readJsonBody(request, maxBodyBytes);
|
|
972
|
+
return json(
|
|
973
|
+
await client.requestJson("POST", `${item}/live-ticket`, {
|
|
974
|
+
...pick(body, TICKET_FIELDS),
|
|
975
|
+
sourceSessionId: sessionId,
|
|
976
|
+
}),
|
|
977
|
+
201,
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
const versionId = query.versionId;
|
|
981
|
+
if (typeof versionId !== "string" || !SEGMENT.test(versionId)) {
|
|
982
|
+
reject(400, "version_required", "versionId is required.");
|
|
983
|
+
}
|
|
984
|
+
const html = await downloadSessionProxySiteHtml(client, workspaceId, artifactId, {
|
|
985
|
+
versionId,
|
|
986
|
+
signal: request.signal,
|
|
987
|
+
});
|
|
988
|
+
return new Response(boundedSiteHtml(html.body, request.signal), {
|
|
989
|
+
headers: SITE_HTML_HEADERS,
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (area !== "sessions") return errorJson(404, "route_not_allowed", "Not found.");
|
|
994
|
+
|
|
995
|
+
if (tail.length === 0 && method === "GET") {
|
|
996
|
+
if (!sessionList) return errorJson(404, "route_not_allowed", "Not found.");
|
|
997
|
+
if (query.view !== "page") {
|
|
998
|
+
reject(400, "page_view_required", "List sessions with listSessionPage.");
|
|
999
|
+
}
|
|
1000
|
+
const listQuery: Record<string, string> = { ...query };
|
|
1001
|
+
if (sessionList === "mine") {
|
|
1002
|
+
// Server-enforced creator filter; the browser cannot widen it.
|
|
1003
|
+
listQuery.createdByKind = "subject";
|
|
1004
|
+
listQuery.createdBySubjectId = await subjectOf(client, `${source}\u0000${resolved.user}`);
|
|
1005
|
+
}
|
|
1006
|
+
const page = await client.requestJson<Record<string, unknown>>(
|
|
1007
|
+
"GET",
|
|
1008
|
+
`${base}/sessions`,
|
|
1009
|
+
undefined,
|
|
1010
|
+
listQuery,
|
|
1011
|
+
call,
|
|
1012
|
+
);
|
|
1013
|
+
// Pins are a separate personal projection this proxy does not manage.
|
|
1014
|
+
return json(sessionList === "mine" ? { ...page, pinned: [] } : page);
|
|
1015
|
+
}
|
|
1016
|
+
if (tail.length === 0) {
|
|
1017
|
+
if (method !== "POST" || !options.createSession) {
|
|
1018
|
+
return errorJson(404, "route_not_allowed", "Not found.");
|
|
1019
|
+
}
|
|
1020
|
+
const input = createInput(await readJsonBody(request, maxBodyBytes), modelSelection);
|
|
1021
|
+
if (input.startMode === "realtime" && !realtimeVoiceEnabled) {
|
|
1022
|
+
return errorJson(404, "route_not_allowed", "Not found.");
|
|
1023
|
+
}
|
|
1024
|
+
const hooked = await options.createSession(input, context);
|
|
1025
|
+
if (hooked instanceof Response) return hooked;
|
|
1026
|
+
const created = withBrowserCreateChoices(
|
|
1027
|
+
toolServer ? withToolServer(hooked, toolServer, await toolToken()) : hooked,
|
|
1028
|
+
input,
|
|
1029
|
+
);
|
|
1030
|
+
const extras = await messageExtras({
|
|
1031
|
+
delivery: "create",
|
|
1032
|
+
...(input.startMode ? { startMode: input.startMode } : {}),
|
|
1033
|
+
});
|
|
1034
|
+
if (extras instanceof Response) return extras;
|
|
1035
|
+
const modelContext = joinContext(extras?.modelContext, created.modelContext);
|
|
1036
|
+
return json(
|
|
1037
|
+
await client.createSession(workspaceId, {
|
|
1038
|
+
...created,
|
|
1039
|
+
visibility: created.visibility ?? defaults.visibility,
|
|
1040
|
+
agentAccess: created.agentAccess ?? defaults.agentAccess,
|
|
1041
|
+
memoryScope: created.memoryScope ?? defaults.memoryScope,
|
|
1042
|
+
...(modelContext ? { modelContext } : {}),
|
|
1043
|
+
}),
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const [sessionId, ...op] = tail as [string, ...string[]];
|
|
1048
|
+
if (options.authorizeSession && !(await options.authorizeSession(sessionId, context))) {
|
|
1049
|
+
return errorJson(404, "session_not_found", "Session not found.");
|
|
1050
|
+
}
|
|
1051
|
+
const session = `${base}/sessions/${sessionId}`;
|
|
1052
|
+
const route = `${method} ${op.join("/")}`;
|
|
1053
|
+
const body = method === "GET" ? undefined : await readJsonBody(request, maxBodyBytes, true);
|
|
1054
|
+
const sanitize = (value: unknown) => sanitizeMessage(value, modelSelection);
|
|
1055
|
+
const saveDraftPolicy = async (message: Record<string, unknown>) => {
|
|
1056
|
+
if (modelSelection) return message;
|
|
1057
|
+
// Save requires explicit policy fields. Use this actor's durable draft,
|
|
1058
|
+
// not browser choices. The API's expectedRevision fence rejects races.
|
|
1059
|
+
const draft = await client.getComposerDraft(workspaceId, sessionId, call);
|
|
1060
|
+
return {
|
|
1061
|
+
...message,
|
|
1062
|
+
model: draft.model,
|
|
1063
|
+
reasoningEffort: draft.reasoningEffort,
|
|
1064
|
+
latencyMode: draft.latencyMode,
|
|
1065
|
+
};
|
|
1066
|
+
};
|
|
1067
|
+
const forward = async (path: string, payload: Record<string, unknown> | Response) =>
|
|
1068
|
+
payload instanceof Response
|
|
1069
|
+
? payload
|
|
1070
|
+
: json(await client.requestJson("POST", path, payload));
|
|
1071
|
+
|
|
1072
|
+
if (op[0] === "realtime") {
|
|
1073
|
+
if (!realtimeVoiceEnabled) return errorJson(404, "route_not_allowed", "Not found.");
|
|
1074
|
+
const realtime = realtimeRoute(method, op.slice(1));
|
|
1075
|
+
if (!realtime) return errorJson(404, "route_not_allowed", "Not found.");
|
|
1076
|
+
if (!body) reject(400, "invalid_body", "A JSON object body is required.");
|
|
1077
|
+
const path = `${session}/${op.join("/")}`;
|
|
1078
|
+
if (realtime === "begin") {
|
|
1079
|
+
// The host may refuse a call; MCP credentials (including the tool
|
|
1080
|
+
// server's per-user token) are refreshed before voice can delegate.
|
|
1081
|
+
const extras = await messageExtras({
|
|
1082
|
+
sessionId,
|
|
1083
|
+
delivery: "realtime",
|
|
1084
|
+
});
|
|
1085
|
+
if (extras instanceof Response) return extras;
|
|
1086
|
+
await refreshRealtimeCredentials(sessionId, extras?.mcpCredentialUpdates ?? []);
|
|
1087
|
+
}
|
|
1088
|
+
let payload: Record<string, unknown> = body;
|
|
1089
|
+
if (realtime === "sync" && options.beforeForwardMessage && hasRealtimeMessages(body)) {
|
|
1090
|
+
const extras = await options.beforeForwardMessage(
|
|
1091
|
+
{ sessionId, delivery: "realtime" },
|
|
1092
|
+
context,
|
|
1093
|
+
);
|
|
1094
|
+
if (extras instanceof Response) return extras;
|
|
1095
|
+
payload = withRealtimeContext(body, extras?.modelContext);
|
|
1096
|
+
}
|
|
1097
|
+
return json(await client.requestJson(method, path, payload));
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
switch (route) {
|
|
1101
|
+
case "GET ":
|
|
1102
|
+
return await read(session);
|
|
1103
|
+
case "PUT archive": {
|
|
1104
|
+
if (!archiveEnabled) return errorJson(404, "route_not_allowed", "Not found.");
|
|
1105
|
+
const archived = body?.archived;
|
|
1106
|
+
const expectedVersion = body?.expectedVersion;
|
|
1107
|
+
if (typeof archived !== "boolean") reject(400, "invalid_body", "archived is required.");
|
|
1108
|
+
return json(
|
|
1109
|
+
await client.requestJson("PUT", `${session}/archive`, {
|
|
1110
|
+
archived,
|
|
1111
|
+
...(typeof expectedVersion === "number" ? { expectedVersion } : {}),
|
|
1112
|
+
}),
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
case "PATCH ": {
|
|
1116
|
+
const title = body?.title;
|
|
1117
|
+
if (typeof title !== "string" || Object.keys(body ?? {}).length !== 1) {
|
|
1118
|
+
reject(400, "invalid_body", "Only { title } may be updated.");
|
|
1119
|
+
}
|
|
1120
|
+
return json(await client.requestJson("PATCH", session, { title }));
|
|
1121
|
+
}
|
|
1122
|
+
case "GET events":
|
|
1123
|
+
return await listEvents(client, `${session}/events`, query, call);
|
|
1124
|
+
case "GET events/stream":
|
|
1125
|
+
// Surface authorization failures as HTTP status before streaming.
|
|
1126
|
+
await client.getSession(workspaceId, sessionId, call);
|
|
1127
|
+
return proxySessionEventStream(client, workspaceId, sessionId, {
|
|
1128
|
+
after: request,
|
|
1129
|
+
signal: request.signal,
|
|
1130
|
+
heartbeatMs,
|
|
1131
|
+
});
|
|
1132
|
+
case "POST events": {
|
|
1133
|
+
const event = clientEvent(body);
|
|
1134
|
+
const payload =
|
|
1135
|
+
event.type === "user.message"
|
|
1136
|
+
? await forwardMessage(event.payload, {
|
|
1137
|
+
sessionId,
|
|
1138
|
+
delivery: "send",
|
|
1139
|
+
})
|
|
1140
|
+
: await forwardResponse(event.payload, {
|
|
1141
|
+
sessionId,
|
|
1142
|
+
delivery: "send",
|
|
1143
|
+
});
|
|
1144
|
+
return await forward(
|
|
1145
|
+
`${session}/events`,
|
|
1146
|
+
payload instanceof Response ? payload : { ...event, payload },
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
case "POST steer":
|
|
1150
|
+
return await forward(
|
|
1151
|
+
`${session}/steer`,
|
|
1152
|
+
await forwardMessage(body, { sessionId, delivery: "steer" }),
|
|
1153
|
+
);
|
|
1154
|
+
case "GET queue":
|
|
1155
|
+
return await read(`${session}/queue`);
|
|
1156
|
+
case "GET composer-draft":
|
|
1157
|
+
return await read(`${session}/composer-draft`);
|
|
1158
|
+
case "PUT composer-draft":
|
|
1159
|
+
return json(
|
|
1160
|
+
await client.requestJson(
|
|
1161
|
+
"PUT",
|
|
1162
|
+
`${session}/composer-draft`,
|
|
1163
|
+
await saveDraftPolicy(sanitize(body)),
|
|
1164
|
+
),
|
|
1165
|
+
);
|
|
1166
|
+
case "POST composer-draft/submit": {
|
|
1167
|
+
const message = await forwardMessage(body, {
|
|
1168
|
+
sessionId,
|
|
1169
|
+
delivery: "submit",
|
|
1170
|
+
});
|
|
1171
|
+
return await forward(`${session}/composer-draft/submit`, message);
|
|
1172
|
+
}
|
|
1173
|
+
case "POST control": {
|
|
1174
|
+
if (body?.action !== "pause" && body?.action !== "resume") {
|
|
1175
|
+
reject(403, "control_not_allowed", "Only pause and resume are available.");
|
|
1176
|
+
}
|
|
1177
|
+
return json(await client.requestJson("POST", `${session}/control`, body));
|
|
1178
|
+
}
|
|
1179
|
+
case "GET human-input-requests":
|
|
1180
|
+
return await read(`${session}/human-input-requests`);
|
|
1181
|
+
case "GET goal":
|
|
1182
|
+
return await read(`${session}/goal`);
|
|
1183
|
+
case "PATCH goal": {
|
|
1184
|
+
// Pause and resume only: the objective, limits and completion stay
|
|
1185
|
+
// with the agent and the product's own server. A browser rationale is
|
|
1186
|
+
// dropped, so end-user text never reaches the goal record.
|
|
1187
|
+
const status = body?.status;
|
|
1188
|
+
if (
|
|
1189
|
+
(status !== "paused" && status !== "active") ||
|
|
1190
|
+
Object.keys(body ?? {}).some((key) => key !== "status" && key !== "rationale")
|
|
1191
|
+
) {
|
|
1192
|
+
reject(
|
|
1193
|
+
403,
|
|
1194
|
+
"goal_update_not_allowed",
|
|
1195
|
+
"Only { status: paused | active } is available.",
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
return json(await client.requestJson("PATCH", `${session}/goal`, { status }));
|
|
1199
|
+
}
|
|
1200
|
+
case "DELETE goal":
|
|
1201
|
+
await client.deleteGoal(workspaceId, sessionId);
|
|
1202
|
+
return new Response(null, { status: 204 });
|
|
1203
|
+
case "POST fs/read":
|
|
1204
|
+
case "POST fs/read-workspace": {
|
|
1205
|
+
if (!sandboxFilesEnabled) return errorJson(404, "route_not_allowed", "Not found.");
|
|
1206
|
+
// A distinct route fails closed on older APIs that would strip the
|
|
1207
|
+
// additive workspaceOnly field and otherwise permit machine-wide reads.
|
|
1208
|
+
return json(
|
|
1209
|
+
await client.requestJson("POST", `${session}/fs/read-workspace`, sandboxRead(body)),
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
if (op[0] === "artifacts" && (op.length === 2 || op[2] === "content") && method === "GET") {
|
|
1214
|
+
// Retained screenshots: the API matches the artifact to this session;
|
|
1215
|
+
// the proxy first proves the user can read the session itself.
|
|
1216
|
+
if (!filesEnabled || op.length > 3) {
|
|
1217
|
+
return errorJson(404, "route_not_allowed", "Not found.");
|
|
1218
|
+
}
|
|
1219
|
+
await client.getSession(workspaceId, sessionId, call);
|
|
1220
|
+
if (op.length === 2) return await read(`${session}/artifacts/${op[1]}`);
|
|
1221
|
+
return retainedContent(
|
|
1222
|
+
await client.getSessionRetainedArtifactContent(workspaceId, sessionId, op[1]!, {
|
|
1223
|
+
...retainedRange(request),
|
|
1224
|
+
signal: request.signal,
|
|
1225
|
+
}),
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
if (op.length === 2 && op[0] === "human-input-requests" && method === "GET") {
|
|
1229
|
+
return await read(`${session}/human-input-requests/${op[1]}`);
|
|
1230
|
+
}
|
|
1231
|
+
if (
|
|
1232
|
+
op.length === 3 &&
|
|
1233
|
+
op[0] === "queue" &&
|
|
1234
|
+
QUEUE_OPERATIONS.has(op[2]!) &&
|
|
1235
|
+
method === "POST"
|
|
1236
|
+
) {
|
|
1237
|
+
return json(await client.requestJson("POST", `${session}/queue/${op[1]}/${op[2]}`, body));
|
|
1238
|
+
}
|
|
1239
|
+
return errorJson(404, "route_not_allowed", "Not found.");
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
return errorResponse(error, rejectedKeyNotice);
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Methods any forwarded route uses. Module-level so the public API inventory
|
|
1248
|
+
* attributes a verb only to the routes that actually forward it.
|
|
1249
|
+
*/
|
|
1250
|
+
const PROXY_METHODS: readonly string[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
1251
|
+
|
|
1252
|
+
const UPLOAD_FIELDS = ["scope", "filename", "contentType", "sizeBytes", "sha256"] as const;
|
|
1253
|
+
|
|
1254
|
+
type RealtimeRoute = "begin" | "connect" | "heartbeat" | "end" | "activate" | "sync";
|
|
1255
|
+
const REALTIME_CONNECT_ROUTES: ReadonlySet<string> = new Set(["webrtc", "gateway", "supergrok"]);
|
|
1256
|
+
/** Ledger entries that become (or join) messages and so accept `modelContext`. */
|
|
1257
|
+
const REALTIME_MESSAGE_KINDS: ReadonlySet<string> = new Set([
|
|
1258
|
+
"delegation_call",
|
|
1259
|
+
"user_transcript",
|
|
1260
|
+
"assistant_transcript",
|
|
1261
|
+
]);
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* The live voice routes a browser call uses, after `realtime/`: begin, provider
|
|
1265
|
+
* connect, heartbeat, end, connection activation, and transcript sync. Bodies
|
|
1266
|
+
* pass to Opengeni, which validates them and binds the call to the acting user.
|
|
1267
|
+
*/
|
|
1268
|
+
function realtimeRoute(method: string, rest: string[]): RealtimeRoute | null {
|
|
1269
|
+
if (rest.length === 0) return method === "POST" ? "begin" : null;
|
|
1270
|
+
if (rest.length === 1) {
|
|
1271
|
+
if (REALTIME_CONNECT_ROUTES.has(rest[0]!)) return method === "POST" ? "connect" : null;
|
|
1272
|
+
return method === "DELETE" ? "end" : null;
|
|
1273
|
+
}
|
|
1274
|
+
if (rest.length === 2 && rest[1] === "heartbeat") return method === "PATCH" ? "heartbeat" : null;
|
|
1275
|
+
if (rest.length === 2 && rest[1] === "sync") return method === "POST" ? "sync" : null;
|
|
1276
|
+
if (rest.length === 4 && rest[1] === "connections" && rest[3] === "activate") {
|
|
1277
|
+
return method === "POST" ? "activate" : null;
|
|
1278
|
+
}
|
|
1279
|
+
return null;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function hasRealtimeMessages(body: Record<string, unknown>): boolean {
|
|
1283
|
+
return (
|
|
1284
|
+
Array.isArray(body.entries) &&
|
|
1285
|
+
body.entries.some(
|
|
1286
|
+
(entry) =>
|
|
1287
|
+
entry !== null &&
|
|
1288
|
+
typeof entry === "object" &&
|
|
1289
|
+
REALTIME_MESSAGE_KINDS.has((entry as { kind?: unknown }).kind as string),
|
|
1290
|
+
)
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/** Server context goes before the browser's on each message-bearing entry. */
|
|
1295
|
+
function withRealtimeContext(
|
|
1296
|
+
body: Record<string, unknown>,
|
|
1297
|
+
serverContext: string | undefined,
|
|
1298
|
+
): Record<string, unknown> {
|
|
1299
|
+
if (!serverContext?.trim() || !Array.isArray(body.entries)) return body;
|
|
1300
|
+
return {
|
|
1301
|
+
...body,
|
|
1302
|
+
entries: body.entries.map((entry: unknown) => {
|
|
1303
|
+
if (entry === null || typeof entry !== "object") return entry;
|
|
1304
|
+
const record = entry as Record<string, unknown>;
|
|
1305
|
+
if (!REALTIME_MESSAGE_KINDS.has(record.kind as string)) return entry;
|
|
1306
|
+
const modelContext = joinContext(
|
|
1307
|
+
serverContext,
|
|
1308
|
+
typeof record.modelContext === "string" ? record.modelContext : undefined,
|
|
1309
|
+
);
|
|
1310
|
+
return modelContext ? { ...record, modelContext } : entry;
|
|
1311
|
+
}),
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
type NormalizedToolServer = SessionProxyToolServer & {
|
|
1316
|
+
url: string;
|
|
1317
|
+
id: string;
|
|
1318
|
+
secret: string;
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
function normalizeToolServer(toolServer: SessionProxyToolServer): NormalizedToolServer {
|
|
1322
|
+
const configured =
|
|
1323
|
+
toolServer.url ??
|
|
1324
|
+
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env
|
|
1325
|
+
?.OPENGENI_TOOL_SERVER_URL;
|
|
1326
|
+
let url: URL;
|
|
1327
|
+
try {
|
|
1328
|
+
url = new URL(configured ?? "");
|
|
1329
|
+
} catch {
|
|
1330
|
+
throw new TypeError(
|
|
1331
|
+
"toolServer.url (or OPENGENI_TOOL_SERVER_URL) must be an absolute https:// URL.",
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
if (url.protocol !== "https:") {
|
|
1335
|
+
// Opengeni calls the tool server from its own network; use a tunnel locally.
|
|
1336
|
+
throw new TypeError("toolServer.url must be an absolute https:// URL.");
|
|
1337
|
+
}
|
|
1338
|
+
const id = toolServer.id ?? "app";
|
|
1339
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
|
|
1340
|
+
throw new TypeError("toolServer.id may contain only letters, digits, _ and -.");
|
|
1341
|
+
}
|
|
1342
|
+
const ask = toolServer.approvals?.ask;
|
|
1343
|
+
if (ask !== undefined && ask !== true && !(Array.isArray(ask) && ask.every(isToolName))) {
|
|
1344
|
+
throw new TypeError("toolServer.approvals.ask must be true or a list of tool names.");
|
|
1345
|
+
}
|
|
1346
|
+
// Resolve now so a missing secret fails at startup, not on the first chat.
|
|
1347
|
+
return {
|
|
1348
|
+
...toolServer,
|
|
1349
|
+
url: configured!,
|
|
1350
|
+
id,
|
|
1351
|
+
secret: resolveToolTokenSecret(toolServer.secret),
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function isToolName(value: unknown): value is string {
|
|
1356
|
+
return typeof value === "string" && value.length > 0;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/** Attach the product tool server with a fresh per-user token. */
|
|
1360
|
+
function withToolServer(
|
|
1361
|
+
created: CreateSessionRequest,
|
|
1362
|
+
toolServer: NormalizedToolServer,
|
|
1363
|
+
token: string,
|
|
1364
|
+
): CreateSessionRequest {
|
|
1365
|
+
const servers = created.mcpServers ?? [];
|
|
1366
|
+
if (servers.some((server) => server.id === toolServer.id)) {
|
|
1367
|
+
throw new TypeError(
|
|
1368
|
+
`createSession already attaches an MCP server with the toolServer id "${toolServer.id}".`,
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
const ask = toolServer.approvals?.ask;
|
|
1372
|
+
const tools = created.tools;
|
|
1373
|
+
return {
|
|
1374
|
+
...created,
|
|
1375
|
+
mcpServers: [
|
|
1376
|
+
...servers,
|
|
1377
|
+
{
|
|
1378
|
+
id: toolServer.id,
|
|
1379
|
+
...(toolServer.name ? { name: toolServer.name } : {}),
|
|
1380
|
+
url: toolServer.url,
|
|
1381
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1382
|
+
...(ask === true ? { requireApproval: true } : ask?.length ? { requireApproval: ask } : {}),
|
|
1383
|
+
},
|
|
1384
|
+
],
|
|
1385
|
+
// Omitted tools keep workspace defaults; the attachment alone selects the
|
|
1386
|
+
// server. An explicit allow-list gets the server's tools on the first request.
|
|
1387
|
+
...(Array.isArray(tools) && !tools.some((tool) => tool.id === toolServer.id)
|
|
1388
|
+
? {
|
|
1389
|
+
tools: [...tools, { kind: "mcp" as const, id: toolServer.id, eager: true }],
|
|
1390
|
+
}
|
|
1391
|
+
: {}),
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const EDITABLE_ARTIFACT_LIVE_PATH = "/v1/editable-artifacts/live";
|
|
1396
|
+
const SITE_LIST_FIELDS = ["limit", "cursor", "status"] as const;
|
|
1397
|
+
const TICKET_FIELDS = [
|
|
1398
|
+
"replicaId",
|
|
1399
|
+
"modality",
|
|
1400
|
+
"liveProtocolVersion",
|
|
1401
|
+
"kernelVersion",
|
|
1402
|
+
"modelSchemaVersion",
|
|
1403
|
+
"snapshotVersion",
|
|
1404
|
+
"commandProtocolVersion",
|
|
1405
|
+
"committedTransactionProtocolVersion",
|
|
1406
|
+
] as const;
|
|
1407
|
+
// Same delivery contract as Opengeni's own route: the browser fetches the
|
|
1408
|
+
// HTML and renders it in a sandboxed frame; opening the URL downloads it.
|
|
1409
|
+
const SITE_HTML_HEADERS = {
|
|
1410
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1411
|
+
"Cache-Control": "private, no-store",
|
|
1412
|
+
"Content-Security-Policy": "sandbox allow-scripts",
|
|
1413
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
1414
|
+
"X-Content-Type-Options": "nosniff",
|
|
1415
|
+
"Content-Disposition": 'attachment; filename="site.html"',
|
|
1416
|
+
} as const;
|
|
1417
|
+
|
|
1418
|
+
function liveSocketUrl(value: string): string {
|
|
1419
|
+
const url = new URL(value);
|
|
1420
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
1421
|
+
else if (url.protocol === "http:") url.protocol = "ws:";
|
|
1422
|
+
if (url.protocol !== "wss:" && url.protocol !== "ws:") {
|
|
1423
|
+
throw new TypeError("editableLiveUrl must be an HTTP(S) or WS(S) URL");
|
|
1424
|
+
}
|
|
1425
|
+
return url.href;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function boundedSiteHtml(
|
|
1429
|
+
body: ReadableStream<Uint8Array> | null,
|
|
1430
|
+
signal: AbortSignal,
|
|
1431
|
+
): ReadableStream<Uint8Array> {
|
|
1432
|
+
const reader = body?.getReader();
|
|
1433
|
+
let bytes = 0;
|
|
1434
|
+
let settled = false;
|
|
1435
|
+
let controller: ReadableStreamDefaultController<Uint8Array>;
|
|
1436
|
+
const cleanup = () => signal.removeEventListener("abort", abort);
|
|
1437
|
+
const cancel = async (reason: unknown) => {
|
|
1438
|
+
if (settled) return;
|
|
1439
|
+
settled = true;
|
|
1440
|
+
cleanup();
|
|
1441
|
+
await reader?.cancel(reason).catch(() => undefined);
|
|
1442
|
+
reader?.releaseLock();
|
|
1443
|
+
};
|
|
1444
|
+
const abort = () => {
|
|
1445
|
+
if (settled) return;
|
|
1446
|
+
controller.error(signal.reason);
|
|
1447
|
+
void cancel(signal.reason);
|
|
1448
|
+
};
|
|
1449
|
+
return new ReadableStream<Uint8Array>(
|
|
1450
|
+
{
|
|
1451
|
+
start(value) {
|
|
1452
|
+
controller = value;
|
|
1453
|
+
if (signal.aborted) abort();
|
|
1454
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
1455
|
+
},
|
|
1456
|
+
async pull(value) {
|
|
1457
|
+
if (settled) return;
|
|
1458
|
+
try {
|
|
1459
|
+
const chunk = await reader?.read();
|
|
1460
|
+
if (settled) return;
|
|
1461
|
+
if (!chunk || chunk.done) {
|
|
1462
|
+
settled = true;
|
|
1463
|
+
cleanup();
|
|
1464
|
+
reader?.releaseLock();
|
|
1465
|
+
value.close();
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
bytes += chunk.value.byteLength;
|
|
1469
|
+
if (bytes > SESSION_PROXY_SITE_HTML_MAX_BYTES) {
|
|
1470
|
+
const error = new SessionProxySiteHtmlTooLargeError();
|
|
1471
|
+
value.error(error);
|
|
1472
|
+
await cancel(error);
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
value.enqueue(chunk.value);
|
|
1476
|
+
} catch (error) {
|
|
1477
|
+
if (settled) return;
|
|
1478
|
+
value.error(error);
|
|
1479
|
+
await cancel(error);
|
|
1480
|
+
}
|
|
1481
|
+
},
|
|
1482
|
+
cancel,
|
|
1483
|
+
},
|
|
1484
|
+
// Read only when the downstream consumer requests a chunk.
|
|
1485
|
+
{ highWaterMark: 0 },
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* The client-config `artifacts` capability a custom host proxy reports so
|
|
1491
|
+
* `SessionArtifactViewer` can open editable artifacts: the live socket URL
|
|
1492
|
+
* (ticket-authenticated, reached directly) and the proxied user's browser cache
|
|
1493
|
+
* partition. `client` acts as that user (for example `asUser(...)`).
|
|
1494
|
+
*/
|
|
1495
|
+
export async function artifactViewerCapability(input: {
|
|
1496
|
+
client: Pick<ProxyClient, "getAccessContext" | "apiUrl"> &
|
|
1497
|
+
Partial<Pick<ProxyClient, "requestJson">>;
|
|
1498
|
+
workspaceId: string;
|
|
1499
|
+
/** Stable namespace of the host's user identities, mixed into the partition. */
|
|
1500
|
+
source?: string | undefined;
|
|
1501
|
+
editableLiveUrl?: string | undefined;
|
|
1502
|
+
}): Promise<{
|
|
1503
|
+
editableLiveUrl: string;
|
|
1504
|
+
cachePartition: {
|
|
1505
|
+
accountId: string;
|
|
1506
|
+
principalId: string;
|
|
1507
|
+
authorizationEpoch: string;
|
|
1508
|
+
};
|
|
1509
|
+
}> {
|
|
1510
|
+
return {
|
|
1511
|
+
editableLiveUrl: liveSocketUrl(
|
|
1512
|
+
input.editableLiveUrl ?? input.client.apiUrl(EDITABLE_ARTIFACT_LIVE_PATH),
|
|
1513
|
+
),
|
|
1514
|
+
cachePartition: await cachePartition(
|
|
1515
|
+
input.client.requestJson
|
|
1516
|
+
? await getSessionProxyWorkspaceGrant(
|
|
1517
|
+
input.client as Pick<ProxyClient, "requestJson">,
|
|
1518
|
+
input.workspaceId,
|
|
1519
|
+
)
|
|
1520
|
+
: await legacyViewerGrant(input.client, input.workspaceId),
|
|
1521
|
+
input.workspaceId,
|
|
1522
|
+
input.source ?? "default",
|
|
1523
|
+
),
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/** The editor's browser cache partition for this proxied user and workspace. */
|
|
1528
|
+
async function cachePartition(
|
|
1529
|
+
grant: import("./types").AccessGrant,
|
|
1530
|
+
workspaceId: string,
|
|
1531
|
+
source: string,
|
|
1532
|
+
): Promise<{
|
|
1533
|
+
accountId: string;
|
|
1534
|
+
principalId: string;
|
|
1535
|
+
authorizationEpoch: string;
|
|
1536
|
+
}> {
|
|
1537
|
+
if (grant.workspaceId !== workspaceId) {
|
|
1538
|
+
reject(403, "workspace_not_allowed", "This workspace is not available.");
|
|
1539
|
+
}
|
|
1540
|
+
const digest = await crypto.subtle.digest(
|
|
1541
|
+
"SHA-256",
|
|
1542
|
+
new TextEncoder().encode(
|
|
1543
|
+
JSON.stringify({
|
|
1544
|
+
source,
|
|
1545
|
+
subjectId: grant.subjectId,
|
|
1546
|
+
accountId: grant.accountId,
|
|
1547
|
+
workspaceId,
|
|
1548
|
+
permissions: [...grant.permissions].sort(),
|
|
1549
|
+
// Effective external grants carry live identity/link revisions. Include
|
|
1550
|
+
// them even when a revoke/regrant restores an identical permission set.
|
|
1551
|
+
externalActor: grant.metadata?.externalActor ?? null,
|
|
1552
|
+
}),
|
|
1553
|
+
),
|
|
1554
|
+
);
|
|
1555
|
+
return {
|
|
1556
|
+
accountId: grant.accountId,
|
|
1557
|
+
principalId: grant.subjectId,
|
|
1558
|
+
authorizationEpoch: `sha256:${[...new Uint8Array(digest)]
|
|
1559
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
1560
|
+
.join("")}`,
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/** Preserve custom capability clients that supplied only the original two methods. */
|
|
1565
|
+
async function legacyViewerGrant(
|
|
1566
|
+
client: Pick<ProxyClient, "getAccessContext">,
|
|
1567
|
+
workspaceId: string,
|
|
1568
|
+
): Promise<import("./types").AccessGrant> {
|
|
1569
|
+
const access = await client.getAccessContext();
|
|
1570
|
+
const grant = access.workspaceGrants.find((candidate) => candidate.workspaceId === workspaceId);
|
|
1571
|
+
if (!grant) reject(403, "workspace_not_allowed", "This workspace is not available.");
|
|
1572
|
+
return grant;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/** The browser's byte range, passed through unchanged when well formed. */
|
|
1576
|
+
function retainedRange(request: Request): { range?: string } {
|
|
1577
|
+
const range = request.headers.get("range");
|
|
1578
|
+
if (range === null) return {};
|
|
1579
|
+
if (range.length > 128 || /[^\x20-\x7e]/.test(range)) {
|
|
1580
|
+
reject(400, "invalid_range", "Range must be at most 128 printable ASCII bytes.");
|
|
1581
|
+
}
|
|
1582
|
+
return { range };
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/** One bounded retained-artifact page, with the range facts the browser SDK verifies. */
|
|
1586
|
+
function retainedContent(content: RetainedArtifactContent): Response {
|
|
1587
|
+
return new Response(content.bytes as Uint8Array<ArrayBuffer>, {
|
|
1588
|
+
status: content.status,
|
|
1589
|
+
headers: {
|
|
1590
|
+
"Content-Type": content.contentType,
|
|
1591
|
+
"Content-Length": String(content.contentLength),
|
|
1592
|
+
...(content.contentRange ? { "Content-Range": content.contentRange } : {}),
|
|
1593
|
+
"Accept-Ranges": "bytes",
|
|
1594
|
+
"Cache-Control": "private, no-store",
|
|
1595
|
+
// Bytes for the conversation's own reader; never rendered on this origin.
|
|
1596
|
+
"Content-Security-Policy": "sandbox",
|
|
1597
|
+
"Content-Disposition": "attachment",
|
|
1598
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
1599
|
+
"X-Content-Type-Options": "nosniff",
|
|
1600
|
+
},
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/** Sandbox link download: one path, no route/target override. */
|
|
1605
|
+
function sandboxRead(body: Record<string, unknown> | undefined): Record<string, unknown> {
|
|
1606
|
+
const path = body?.path;
|
|
1607
|
+
const encoding = body?.encoding;
|
|
1608
|
+
const maxBytes = body?.maxBytes;
|
|
1609
|
+
if (typeof path !== "string" || !path || path.length > 4096) {
|
|
1610
|
+
reject(400, "invalid_body", "path is required.");
|
|
1611
|
+
}
|
|
1612
|
+
if (encoding !== undefined && encoding !== "utf8" && encoding !== "base64") {
|
|
1613
|
+
reject(400, "invalid_body", "encoding must be utf8 or base64.");
|
|
1614
|
+
}
|
|
1615
|
+
if (
|
|
1616
|
+
maxBytes !== undefined &&
|
|
1617
|
+
(typeof maxBytes !== "number" ||
|
|
1618
|
+
!Number.isSafeInteger(maxBytes) ||
|
|
1619
|
+
maxBytes < 1 ||
|
|
1620
|
+
maxBytes > 25 * 1024 * 1024)
|
|
1621
|
+
) {
|
|
1622
|
+
reject(400, "invalid_body", "maxBytes must be an integer from 1 to 26214400.");
|
|
1623
|
+
}
|
|
1624
|
+
return {
|
|
1625
|
+
path,
|
|
1626
|
+
workspaceOnly: true,
|
|
1627
|
+
...(encoding === undefined ? {} : { encoding }),
|
|
1628
|
+
...(maxBytes === undefined ? {} : { maxBytes }),
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
/** Native path segments after the mount prefix, starting at `v1`'s child; null when not a proxied path. */
|
|
1633
|
+
function routeSegments(pathname: string, basePath: string | undefined): string[] | null {
|
|
1634
|
+
let rest: string;
|
|
1635
|
+
if (basePath !== undefined) {
|
|
1636
|
+
const prefix = basePath.replace(/\/+$/, "");
|
|
1637
|
+
if (!pathname.startsWith(`${prefix}/`)) return null;
|
|
1638
|
+
rest = pathname.slice(prefix.length);
|
|
1639
|
+
} else {
|
|
1640
|
+
const index = pathname.indexOf("/v1/");
|
|
1641
|
+
if (index < 0) return null;
|
|
1642
|
+
rest = pathname.slice(index);
|
|
1643
|
+
}
|
|
1644
|
+
const raw = rest.replace(/\/+$/, "").split("/").slice(1);
|
|
1645
|
+
if (raw[0] !== "v1" || raw.length < 2) return null;
|
|
1646
|
+
const segments: string[] = [];
|
|
1647
|
+
for (const part of raw.slice(1)) {
|
|
1648
|
+
let decoded: string;
|
|
1649
|
+
try {
|
|
1650
|
+
decoded = decodeURIComponent(part);
|
|
1651
|
+
} catch {
|
|
1652
|
+
return null;
|
|
1653
|
+
}
|
|
1654
|
+
// Rejects empty, dot, and encoded-separator segments before any SDK path is built.
|
|
1655
|
+
if (!SEGMENT.test(decoded)) return null;
|
|
1656
|
+
segments.push(decoded);
|
|
1657
|
+
}
|
|
1658
|
+
return segments;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
async function readJsonBody(
|
|
1662
|
+
request: Request,
|
|
1663
|
+
maxBytes: number,
|
|
1664
|
+
optional = false,
|
|
1665
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
1666
|
+
const bytes = await readBoundedBytes(request, maxBytes);
|
|
1667
|
+
if (bytes.byteLength === 0) {
|
|
1668
|
+
if (optional) return undefined;
|
|
1669
|
+
reject(400, "invalid_body", "A JSON object body is required.");
|
|
1670
|
+
}
|
|
1671
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
1672
|
+
if (!/^application\/json\b/i.test(contentType)) {
|
|
1673
|
+
reject(415, "unsupported_media_type", "Send application/json.");
|
|
1674
|
+
}
|
|
1675
|
+
let parsed: unknown;
|
|
1676
|
+
try {
|
|
1677
|
+
parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
1678
|
+
} catch {
|
|
1679
|
+
reject(400, "invalid_body", "Body is not valid JSON.");
|
|
1680
|
+
}
|
|
1681
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1682
|
+
reject(400, "invalid_body", "A JSON object body is required.");
|
|
1683
|
+
}
|
|
1684
|
+
return parsed as Record<string, unknown>;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
async function readBoundedBytes(
|
|
1688
|
+
request: Request,
|
|
1689
|
+
maxBytes: number,
|
|
1690
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
1691
|
+
const declared = Number(request.headers.get("content-length") ?? "0");
|
|
1692
|
+
if (declared > maxBytes) reject(413, "body_too_large", "Request body is too large.");
|
|
1693
|
+
const reader = request.body?.getReader();
|
|
1694
|
+
const chunks: Uint8Array[] = [];
|
|
1695
|
+
let total = 0;
|
|
1696
|
+
if (reader) {
|
|
1697
|
+
for (;;) {
|
|
1698
|
+
const { done, value } = await reader.read();
|
|
1699
|
+
if (done) break;
|
|
1700
|
+
total += value.byteLength;
|
|
1701
|
+
if (total > maxBytes) {
|
|
1702
|
+
void reader.cancel().catch(() => undefined);
|
|
1703
|
+
reject(413, "body_too_large", "Request body is too large.");
|
|
1704
|
+
}
|
|
1705
|
+
chunks.push(value);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
const bytes = new Uint8Array(total);
|
|
1709
|
+
let offset = 0;
|
|
1710
|
+
for (const chunk of chunks) {
|
|
1711
|
+
bytes.set(chunk, offset);
|
|
1712
|
+
offset += chunk.byteLength;
|
|
1713
|
+
}
|
|
1714
|
+
return bytes;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function pick(
|
|
1718
|
+
body: Record<string, unknown> | undefined,
|
|
1719
|
+
fields: readonly string[],
|
|
1720
|
+
): Record<string, unknown> {
|
|
1721
|
+
const picked: Record<string, unknown> = {};
|
|
1722
|
+
for (const field of fields) {
|
|
1723
|
+
if (body && Object.hasOwn(body, field)) picked[field] = body[field];
|
|
1724
|
+
}
|
|
1725
|
+
return picked;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
function createInput(
|
|
1729
|
+
body: Record<string, unknown> | undefined,
|
|
1730
|
+
modelSelection: boolean,
|
|
1731
|
+
): SessionProxyCreateInput {
|
|
1732
|
+
for (const key of Object.keys(body ?? {})) {
|
|
1733
|
+
if (!CREATE_FIELDS.has(key) && !(modelSelection && isModelField(key))) {
|
|
1734
|
+
reject(
|
|
1735
|
+
400,
|
|
1736
|
+
"create_field_not_allowed",
|
|
1737
|
+
`The server chooses session configuration; "${key}" cannot be set from the browser.`,
|
|
1738
|
+
);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
const initialMessage = body?.initialMessage;
|
|
1742
|
+
const idempotencyKey = body?.idempotencyKey;
|
|
1743
|
+
const startMode = body?.startMode;
|
|
1744
|
+
if (startMode !== undefined && startMode !== "realtime") {
|
|
1745
|
+
reject(400, "invalid_start_mode", "startMode must be realtime when supplied.");
|
|
1746
|
+
}
|
|
1747
|
+
if (startMode === "realtime" && initialMessage !== undefined && initialMessage !== "") {
|
|
1748
|
+
reject(
|
|
1749
|
+
400,
|
|
1750
|
+
"initial_message_not_allowed",
|
|
1751
|
+
"Voice-first creation does not send an initial message.",
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
if (startMode !== "realtime" && (typeof initialMessage !== "string" || !initialMessage.trim())) {
|
|
1755
|
+
reject(400, "initial_message_required", "initialMessage is required.");
|
|
1756
|
+
}
|
|
1757
|
+
if (idempotencyKey !== undefined && (typeof idempotencyKey !== "string" || !idempotencyKey)) {
|
|
1758
|
+
reject(400, "invalid_idempotency_key", "idempotencyKey must be a non-empty string.");
|
|
1759
|
+
}
|
|
1760
|
+
const resources = createResources(body?.resources);
|
|
1761
|
+
if (startMode === "realtime" && resources.length > 0) {
|
|
1762
|
+
reject(400, "resource_not_allowed", "Keep attachments in the draft until a message is sent.");
|
|
1763
|
+
}
|
|
1764
|
+
const policy: Pick<SessionProxyCreateInput, "model" | "reasoningEffort" | "latencyMode"> = {};
|
|
1765
|
+
for (const field of MODEL_FIELDS) {
|
|
1766
|
+
const value = body?.[field];
|
|
1767
|
+
if (value === undefined) continue;
|
|
1768
|
+
if (typeof value !== "string" || !value) {
|
|
1769
|
+
reject(400, "invalid_model_policy", `${field} must be a non-empty string.`);
|
|
1770
|
+
}
|
|
1771
|
+
(policy as Record<string, string>)[field] = value;
|
|
1772
|
+
}
|
|
1773
|
+
return {
|
|
1774
|
+
initialMessage: startMode === "realtime" ? "" : (initialMessage as string),
|
|
1775
|
+
...(startMode === "realtime" ? { startMode } : {}),
|
|
1776
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
1777
|
+
...(resources.length > 0 ? { resources } : {}),
|
|
1778
|
+
...policy,
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
function isModelField(key: string): boolean {
|
|
1783
|
+
return (MODEL_FIELDS as readonly string[]).includes(key);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
/** First-message attachments: file references only, nothing else on them. */
|
|
1787
|
+
function createResources(value: unknown): FileResourceRef[] {
|
|
1788
|
+
if (value === undefined) return [];
|
|
1789
|
+
if (!Array.isArray(value)) {
|
|
1790
|
+
reject(403, "resource_not_allowed", "Only file attachments may be added from the browser.");
|
|
1791
|
+
}
|
|
1792
|
+
return value.map((resource: unknown) => {
|
|
1793
|
+
const file = resource as Partial<FileResourceRef> | null;
|
|
1794
|
+
if (
|
|
1795
|
+
!file ||
|
|
1796
|
+
typeof file !== "object" ||
|
|
1797
|
+
file.kind !== "file" ||
|
|
1798
|
+
typeof file.fileId !== "string" ||
|
|
1799
|
+
!file.fileId ||
|
|
1800
|
+
(file.mountPath !== undefined && typeof file.mountPath !== "string")
|
|
1801
|
+
) {
|
|
1802
|
+
reject(403, "resource_not_allowed", "Only file attachments may be added from the browser.");
|
|
1803
|
+
}
|
|
1804
|
+
return {
|
|
1805
|
+
kind: "file",
|
|
1806
|
+
fileId: file.fileId,
|
|
1807
|
+
...(file.mountPath !== undefined ? { mountPath: file.mountPath } : {}),
|
|
1808
|
+
};
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/** Browser attachments are added; explicit browser model choices win, as for messages. */
|
|
1813
|
+
function withBrowserCreateChoices(
|
|
1814
|
+
created: CreateSessionRequest,
|
|
1815
|
+
input: SessionProxyCreateInput,
|
|
1816
|
+
): CreateSessionRequest {
|
|
1817
|
+
const existing = created.resources ?? [];
|
|
1818
|
+
const attached = new Set(
|
|
1819
|
+
existing.flatMap((resource) => (resource.kind === "file" ? [resource.fileId] : [])),
|
|
1820
|
+
);
|
|
1821
|
+
const added = (input.resources ?? []).filter((resource) => !attached.has(resource.fileId));
|
|
1822
|
+
return {
|
|
1823
|
+
...created,
|
|
1824
|
+
// Preserve browser retry identity even when a simple host hook omits it;
|
|
1825
|
+
// an explicit server-owned key continues to take precedence.
|
|
1826
|
+
...(created.idempotencyKey === undefined && input.idempotencyKey
|
|
1827
|
+
? { idempotencyKey: input.idempotencyKey }
|
|
1828
|
+
: {}),
|
|
1829
|
+
...(input.startMode === "realtime" ? { startMode: "realtime", initialMessage: undefined } : {}),
|
|
1830
|
+
...(added.length > 0 ? { resources: [...existing, ...added] } : {}),
|
|
1831
|
+
...(input.model ? { model: input.model } : {}),
|
|
1832
|
+
...(input.reasoningEffort ? { reasoningEffort: input.reasoningEffort } : {}),
|
|
1833
|
+
...(input.latencyMode ? { latencyMode: input.latencyMode } : {}),
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/** One multipart recording: only the audio, its MIME type, and its duration pass. */
|
|
1838
|
+
async function transcriptionInput(request: Request): Promise<{
|
|
1839
|
+
audio: File;
|
|
1840
|
+
mimeType: string;
|
|
1841
|
+
durationSeconds?: number;
|
|
1842
|
+
signal: AbortSignal;
|
|
1843
|
+
}> {
|
|
1844
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
1845
|
+
if (!/^multipart\/form-data\b/i.test(contentType)) {
|
|
1846
|
+
reject(415, "unsupported_media_type", "Send multipart/form-data.");
|
|
1847
|
+
}
|
|
1848
|
+
const bytes = await readBoundedBytes(request, MAX_TRANSCRIPTION_BODY_BYTES);
|
|
1849
|
+
// Only the fields this route reads; structural, so DOM and React Native
|
|
1850
|
+
// typings of FormData both satisfy it.
|
|
1851
|
+
let form: { get(name: string): File | string | null };
|
|
1852
|
+
try {
|
|
1853
|
+
form = (await new Response(bytes, {
|
|
1854
|
+
headers: { "Content-Type": contentType },
|
|
1855
|
+
}).formData()) as unknown as { get(name: string): File | string | null };
|
|
1856
|
+
} catch {
|
|
1857
|
+
reject(400, "invalid_audio", "The recording could not be read.");
|
|
1858
|
+
}
|
|
1859
|
+
const audio = form.get("audio");
|
|
1860
|
+
const mimeType = form.get("mimeType");
|
|
1861
|
+
const duration = form.get("durationSeconds");
|
|
1862
|
+
if (!(audio instanceof File)) reject(400, "invalid_audio", "Audio file is required.");
|
|
1863
|
+
const durationSeconds = typeof duration === "string" && duration ? Number(duration) : undefined;
|
|
1864
|
+
if (
|
|
1865
|
+
durationSeconds !== undefined &&
|
|
1866
|
+
!(Number.isFinite(durationSeconds) && durationSeconds >= 0)
|
|
1867
|
+
) {
|
|
1868
|
+
reject(400, "invalid_audio", "durationSeconds must be a non-negative number.");
|
|
1869
|
+
}
|
|
1870
|
+
return {
|
|
1871
|
+
audio,
|
|
1872
|
+
mimeType: typeof mimeType === "string" && mimeType ? mimeType : audio.type,
|
|
1873
|
+
...(durationSeconds !== undefined ? { durationSeconds } : {}),
|
|
1874
|
+
signal: request.signal,
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/** Browser payloads cannot supply server-owned credential rotations. */
|
|
1879
|
+
function browserPayload(value: unknown): Record<string, unknown> {
|
|
1880
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1881
|
+
reject(400, "invalid_body", "A JSON object body is required.");
|
|
1882
|
+
}
|
|
1883
|
+
const input = { ...(value as Record<string, unknown>) };
|
|
1884
|
+
if (Object.hasOwn(input, "mcpCredentialUpdates")) {
|
|
1885
|
+
reject(403, "credential_update_not_allowed", "MCP credentials are server-owned.");
|
|
1886
|
+
}
|
|
1887
|
+
return input;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/** Browser message/draft input: no credential rotation, file resources only, optional model lock. */
|
|
1891
|
+
function sanitizeMessage(value: unknown, modelSelection: boolean): Record<string, unknown> {
|
|
1892
|
+
const input = browserPayload(value);
|
|
1893
|
+
if (input.resources !== undefined) {
|
|
1894
|
+
if (
|
|
1895
|
+
!Array.isArray(input.resources) ||
|
|
1896
|
+
input.resources.some(
|
|
1897
|
+
(resource) =>
|
|
1898
|
+
!resource ||
|
|
1899
|
+
typeof resource !== "object" ||
|
|
1900
|
+
(resource as { kind?: unknown }).kind !== "file",
|
|
1901
|
+
)
|
|
1902
|
+
) {
|
|
1903
|
+
reject(403, "resource_not_allowed", "Only file attachments may be added from the browser.");
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
if (!modelSelection) {
|
|
1907
|
+
for (const field of MODEL_FIELDS) delete input[field];
|
|
1908
|
+
}
|
|
1909
|
+
return input;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
function clientEvent(body: Record<string, unknown> | undefined): Record<string, unknown> & {
|
|
1913
|
+
type: string;
|
|
1914
|
+
} {
|
|
1915
|
+
switch (body?.type) {
|
|
1916
|
+
case "user.message":
|
|
1917
|
+
case "user.approvalDecision":
|
|
1918
|
+
case "user.humanInputResponse":
|
|
1919
|
+
return body as Record<string, unknown> & { type: string };
|
|
1920
|
+
default:
|
|
1921
|
+
return reject(403, "event_not_allowed", "This session event type is not available.");
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
function joinContext(...parts: Array<string | undefined>): string | undefined {
|
|
1926
|
+
const joined = parts
|
|
1927
|
+
.map((part) => part?.trim())
|
|
1928
|
+
.filter(Boolean)
|
|
1929
|
+
.join("\n\n");
|
|
1930
|
+
return joined || undefined;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
async function listEvents(
|
|
1934
|
+
client: ProxyClient,
|
|
1935
|
+
path: string,
|
|
1936
|
+
query: Record<string, string>,
|
|
1937
|
+
call: { signal: AbortSignal },
|
|
1938
|
+
): Promise<Response> {
|
|
1939
|
+
if (query.mode === "forensic") {
|
|
1940
|
+
reject(403, "forensic_events_not_allowed", "Forensic event reads are not proxied.");
|
|
1941
|
+
}
|
|
1942
|
+
const upstream = await client.requestJsonResponse(path, query, call);
|
|
1943
|
+
const headers: Record<string, string> = {};
|
|
1944
|
+
upstream.headers.forEach((value, name) => {
|
|
1945
|
+
const lower = name.toLowerCase();
|
|
1946
|
+
// Paging metadata only; the upstream contract revision stays behind the proxy.
|
|
1947
|
+
if (lower.startsWith("x-opengeni-") && lower !== OPENGENI_API_CONTRACT_HEADER) {
|
|
1948
|
+
headers[name] = value;
|
|
1949
|
+
}
|
|
1950
|
+
});
|
|
1951
|
+
return new Response(await upstream.text(), {
|
|
1952
|
+
headers: {
|
|
1953
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
1954
|
+
"Cache-Control": "no-store",
|
|
1955
|
+
...headers,
|
|
1956
|
+
},
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/** Re-emit the workspace control/interaction stream the React provider subscribes to. */
|
|
1961
|
+
function workspaceLiveStream(
|
|
1962
|
+
client: ProxyClient,
|
|
1963
|
+
workspaceId: string,
|
|
1964
|
+
params: URLSearchParams,
|
|
1965
|
+
request: Request,
|
|
1966
|
+
heartbeatMs: number,
|
|
1967
|
+
): Response {
|
|
1968
|
+
const cursor = (name: string): number => {
|
|
1969
|
+
const value = Number(params.get(name) ?? "0");
|
|
1970
|
+
return Number.isSafeInteger(value) && value > 0 ? value : 0;
|
|
1971
|
+
};
|
|
1972
|
+
const upstream = new AbortController();
|
|
1973
|
+
if (request.signal.aborted) upstream.abort();
|
|
1974
|
+
else
|
|
1975
|
+
request.signal.addEventListener("abort", () => upstream.abort(), {
|
|
1976
|
+
once: true,
|
|
1977
|
+
});
|
|
1978
|
+
const events = client.streamWorkspaceLiveEvents(workspaceId, {
|
|
1979
|
+
controlAfter: cursor("controlAfter"),
|
|
1980
|
+
interactionAfter: cursor("interactionAfter"),
|
|
1981
|
+
signal: upstream.signal,
|
|
1982
|
+
});
|
|
1983
|
+
const iterator = events[Symbol.asyncIterator]();
|
|
1984
|
+
const encoder = new TextEncoder();
|
|
1985
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
1986
|
+
const stop = () => {
|
|
1987
|
+
if (heartbeat !== undefined) clearInterval(heartbeat);
|
|
1988
|
+
heartbeat = undefined;
|
|
1989
|
+
};
|
|
1990
|
+
const body = new ReadableStream<Uint8Array>({
|
|
1991
|
+
start: (controller) => {
|
|
1992
|
+
heartbeat = setInterval(() => {
|
|
1993
|
+
try {
|
|
1994
|
+
controller.enqueue(encoder.encode(": ping\n\n"));
|
|
1995
|
+
} catch {
|
|
1996
|
+
stop();
|
|
1997
|
+
}
|
|
1998
|
+
}, heartbeatMs);
|
|
1999
|
+
},
|
|
2000
|
+
pull: async (controller) => {
|
|
2001
|
+
const next = await iterator.next().catch((error: unknown) => {
|
|
2002
|
+
stop();
|
|
2003
|
+
throw error;
|
|
2004
|
+
});
|
|
2005
|
+
if (next.done) {
|
|
2006
|
+
stop();
|
|
2007
|
+
controller.close();
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
const event = next.value;
|
|
2011
|
+
controller.enqueue(
|
|
2012
|
+
encoder.encode(
|
|
2013
|
+
`id: ${event.sequence}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`,
|
|
2014
|
+
),
|
|
2015
|
+
);
|
|
2016
|
+
},
|
|
2017
|
+
cancel: () => {
|
|
2018
|
+
stop();
|
|
2019
|
+
upstream.abort();
|
|
2020
|
+
void Promise.resolve(iterator.return?.(undefined)).catch(() => undefined);
|
|
2021
|
+
},
|
|
2022
|
+
});
|
|
2023
|
+
return new Response(body, {
|
|
2024
|
+
headers: {
|
|
2025
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
2026
|
+
"Cache-Control": "no-cache, no-transform",
|
|
2027
|
+
Connection: "keep-alive",
|
|
2028
|
+
},
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* The proxy always calls Opengeni with the server's own key, so an upstream
|
|
2034
|
+
* 401 means that key is wrong, expired or revoked, never that the end user is
|
|
2035
|
+
* signed out. Tell the developer once, in the server log, what to fix.
|
|
2036
|
+
*/
|
|
2037
|
+
function warnRejectedApiKey(error: OpenGeniApiError, notice: { sent: boolean }): void {
|
|
2038
|
+
if (notice.sent) return;
|
|
2039
|
+
notice.sent = true;
|
|
2040
|
+
console.warn(
|
|
2041
|
+
`[@opengeni/sdk] Opengeni rejected the session proxy's API key (401${
|
|
2042
|
+
error.correlationId ? `, reference ${error.correlationId}` : ""
|
|
2043
|
+
}). It may be expired, revoked or mistyped: create a new key in Opengeni under ` +
|
|
2044
|
+
"Organization settings > Developer and update OPENGENI_API_KEY on the server.",
|
|
2045
|
+
);
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
/** Preserve Opengeni's error envelope so the browser SDK keeps codes, retryability, and outcome facts. */
|
|
2049
|
+
function errorResponse(error: unknown, rejectedKeyNotice: { sent: boolean }): Response {
|
|
2050
|
+
if (error instanceof ProxyRejection) return errorJson(error.status, error.code, error.message);
|
|
2051
|
+
if (error instanceof OpenGeniSetupError) {
|
|
2052
|
+
return json(
|
|
2053
|
+
{
|
|
2054
|
+
error: {
|
|
2055
|
+
code: error.code,
|
|
2056
|
+
message: error.message,
|
|
2057
|
+
retryable: false,
|
|
2058
|
+
...(error.correlationId ? { requestId: error.correlationId } : {}),
|
|
2059
|
+
},
|
|
2060
|
+
},
|
|
2061
|
+
error.status,
|
|
2062
|
+
);
|
|
2063
|
+
}
|
|
2064
|
+
if (error instanceof OpenGeniApiError) {
|
|
2065
|
+
if (error.status === 401) warnRejectedApiKey(error, rejectedKeyNotice);
|
|
2066
|
+
const status = error.status >= 400 && error.status <= 599 ? error.status : 502;
|
|
2067
|
+
// A decoded upstream envelope is forwarded verbatim; the SDK only retains decodable bodies.
|
|
2068
|
+
if (error.body) {
|
|
2069
|
+
return new Response(error.body, {
|
|
2070
|
+
status,
|
|
2071
|
+
headers: {
|
|
2072
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
2073
|
+
"Cache-Control": "no-store",
|
|
2074
|
+
},
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2077
|
+
return json(
|
|
2078
|
+
{
|
|
2079
|
+
error: {
|
|
2080
|
+
code: error.code ?? "opengeni_api_error",
|
|
2081
|
+
message: error.message,
|
|
2082
|
+
retryable: error.retryable,
|
|
2083
|
+
outcomeUnknown: error.outcomeUnknown,
|
|
2084
|
+
...(error.correlationId ? { requestId: error.correlationId } : {}),
|
|
2085
|
+
...(error.details ? { details: error.details } : {}),
|
|
2086
|
+
},
|
|
2087
|
+
},
|
|
2088
|
+
status,
|
|
2089
|
+
);
|
|
2090
|
+
}
|
|
2091
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
2092
|
+
return errorJson(499, "aborted", "The request was aborted.");
|
|
2093
|
+
}
|
|
2094
|
+
// The browser gets no details; the host's server log needs the cause
|
|
2095
|
+
// (for example a missing OPENGENI_API_KEY).
|
|
2096
|
+
console.error("[@opengeni/sdk] Session proxy request failed:", error);
|
|
2097
|
+
return errorJson(500, "proxy_error", "Request could not be completed.");
|
|
2098
|
+
}
|