@opengeni/api-router 0.30.3 → 2.2.0-canary.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.
Files changed (165) hide show
  1. package/dist/api-websocket.d.ts +18 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +6 -0
  4. package/dist/{chunk-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
  5. package/dist/chunk-3TP54PPX.js.map +1 -0
  6. package/dist/codemode.d.ts +8 -1
  7. package/dist/company-brain-okf.d.ts +24 -0
  8. package/dist/connected-machine-computer-access.d.ts +11 -0
  9. package/dist/connection-authority-owner.d.ts +59 -0
  10. package/dist/connection-ownership.d.ts +47 -0
  11. package/dist/controller-data-plane.d.ts +16 -0
  12. package/dist/editable-artifact-websocket.d.ts +5 -15
  13. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  14. package/dist/http/api-error.d.ts +10 -0
  15. package/dist/http/cors.d.ts +5 -0
  16. package/dist/http/interaction-control-error.d.ts +13 -0
  17. package/dist/http/sse.d.ts +2 -3
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.js +326 -82
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/atlassian.d.ts +17 -0
  22. package/dist/integrations/fiken.d.ts +7 -2
  23. package/dist/integrations/google-drive.d.ts +18 -0
  24. package/dist/integrations/oauth-client.d.ts +22 -3
  25. package/dist/integrations/oauth-profiles.d.ts +195 -0
  26. package/dist/integrations/personal-github-repositories.d.ts +45 -0
  27. package/dist/integrations/personal-github.d.ts +40 -0
  28. package/dist/integrations/pr-review-provider.d.ts +22 -0
  29. package/dist/integrations/provider-oauth.d.ts +8 -0
  30. package/dist/integrations/slack-app-home.d.ts +24 -0
  31. package/dist/integrations/slack-bot.d.ts +157 -9
  32. package/dist/integrations/slack-interactions.d.ts +39 -5
  33. package/dist/integrations/slack-routing.d.ts +120 -0
  34. package/dist/integrations/social-oauth.d.ts +5 -0
  35. package/dist/interaction-frame-proxy.d.ts +68 -0
  36. package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
  37. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  38. package/dist/mcp/documents.d.ts +1 -0
  39. package/dist/mcp/remember.d.ts +28 -0
  40. package/dist/mcp/request-abort.d.ts +19 -0
  41. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  42. package/dist/mcp/server.d.ts +15 -2
  43. package/dist/mcp/session-view.d.ts +4 -0
  44. package/dist/mcp/session-wait.d.ts +137 -0
  45. package/dist/routes/automations.d.ts +4 -0
  46. package/dist/routes/billing.d.ts +5 -0
  47. package/dist/routes/browser-sessions.d.ts +4 -1
  48. package/dist/routes/company-brain.d.ts +3 -0
  49. package/dist/routes/company-profile.d.ts +2 -1
  50. package/dist/routes/connection-authorities.d.ts +3 -0
  51. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  52. package/dist/routes/personal-github.d.ts +3 -0
  53. package/dist/routes/pr-review.d.ts +3 -0
  54. package/dist/routes/sessions.d.ts +1 -0
  55. package/dist/routes/user-resource-authorities.d.ts +3 -0
  56. package/dist/routes/workspace-capture.d.ts +10 -2
  57. package/dist/routes/workspace-learning.d.ts +3 -0
  58. package/dist/routes/workspaces.d.ts +19 -0
  59. package/dist/sandbox/channel-a.d.ts +11 -1
  60. package/dist/sandbox/connection-authority.d.ts +3 -0
  61. package/dist/sandbox/enrollment.d.ts +2 -0
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +10 -20
  64. package/dist/sandbox/viewer.d.ts +24 -1
  65. package/dist/sandbox-file-artifacts.d.ts +11 -0
  66. package/dist/scheduled-task-deletion.d.ts +14 -0
  67. package/dist/slack-reaction-files.d.ts +1 -1
  68. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  69. package/package.json +20 -18
  70. package/src/api-websocket.ts +23 -0
  71. package/src/app.ts +161 -23
  72. package/src/auth/managed-auth.ts +25 -3
  73. package/src/codemode.ts +53 -23
  74. package/src/codex-realtime.ts +8 -2
  75. package/src/company-brain-okf.ts +340 -0
  76. package/src/connected-machine-computer-access.ts +33 -0
  77. package/src/connection-authority-owner.ts +61 -0
  78. package/src/connection-ownership.ts +180 -0
  79. package/src/controller-data-plane.ts +47 -0
  80. package/src/editable-artifact-native-kernel.ts +26 -15
  81. package/src/editable-artifact-office-import.ts +28 -3
  82. package/src/editable-artifact-production.ts +13 -7
  83. package/src/editable-artifact-websocket.ts +11 -18
  84. package/src/editable-artifact-workspace-files.ts +31 -6
  85. package/src/http/api-error.ts +28 -0
  86. package/src/http/auth.ts +4 -0
  87. package/src/http/cors.ts +35 -0
  88. package/src/http/interaction-control-error.ts +164 -0
  89. package/src/http/sse.ts +112 -66
  90. package/src/index.ts +46 -6
  91. package/src/integrations/atlassian.ts +146 -35
  92. package/src/integrations/fiken.ts +102 -22
  93. package/src/integrations/google-drive.ts +301 -92
  94. package/src/integrations/oauth-client.ts +272 -143
  95. package/src/integrations/oauth-profiles.ts +477 -0
  96. package/src/integrations/personal-github-repositories.ts +445 -0
  97. package/src/integrations/personal-github.ts +705 -0
  98. package/src/integrations/pr-review-provider.ts +246 -0
  99. package/src/integrations/provider-oauth.ts +121 -5
  100. package/src/integrations/slack-app-home.ts +300 -0
  101. package/src/integrations/slack-bot.ts +342 -57
  102. package/src/integrations/slack-interactions.ts +1898 -259
  103. package/src/integrations/slack-routing.ts +337 -0
  104. package/src/integrations/social-oauth.ts +25 -0
  105. package/src/interaction-frame-proxy.ts +409 -0
  106. package/src/mcp/company-brain-governed-writes.ts +262 -0
  107. package/src/mcp/company-profile-agent-admin.ts +205 -0
  108. package/src/mcp/documents.ts +37 -6
  109. package/src/mcp/files.ts +16 -1
  110. package/src/mcp/remember.ts +181 -0
  111. package/src/mcp/request-abort.ts +44 -0
  112. package/src/mcp/scheduled-task-view.ts +1 -0
  113. package/src/mcp/server.ts +719 -369
  114. package/src/mcp/session-view.ts +54 -0
  115. package/src/mcp/session-wait.ts +554 -0
  116. package/src/model-catalog.ts +20 -12
  117. package/src/routes/api-integrations.ts +4 -0
  118. package/src/routes/api-keys.ts +1 -0
  119. package/src/routes/automations.ts +534 -0
  120. package/src/routes/billing.ts +57 -1
  121. package/src/routes/browser-sessions.ts +329 -79
  122. package/src/routes/channels.ts +17 -1
  123. package/src/routes/codex.ts +50 -0
  124. package/src/routes/company-brain.ts +367 -0
  125. package/src/routes/company-profile.ts +1 -1
  126. package/src/routes/computer-sessions.ts +320 -93
  127. package/src/routes/connection-authorities.ts +139 -0
  128. package/src/routes/connections.ts +144 -34
  129. package/src/routes/documents.ts +353 -4
  130. package/src/routes/editable-artifacts.ts +11 -3
  131. package/src/routes/enrollments.ts +116 -26
  132. package/src/routes/environments.ts +127 -55
  133. package/src/routes/files.ts +72 -11
  134. package/src/routes/install.ts +82 -28
  135. package/src/routes/integration-facets.ts +29 -0
  136. package/src/routes/interaction-resources.ts +20 -2
  137. package/src/routes/machines.ts +275 -13
  138. package/src/routes/organization-memberships.ts +717 -28
  139. package/src/routes/packs.ts +4 -3
  140. package/src/routes/personal-github-git-broker.ts +785 -0
  141. package/src/routes/personal-github.ts +319 -0
  142. package/src/routes/pr-review.ts +531 -0
  143. package/src/routes/rigs.ts +104 -39
  144. package/src/routes/scheduled-tasks.ts +19 -21
  145. package/src/routes/sessions.ts +775 -38
  146. package/src/routes/social.ts +14 -2
  147. package/src/routes/supergrok.ts +23 -4
  148. package/src/routes/transcription-recordings.ts +8 -2
  149. package/src/routes/user-resource-authorities.ts +138 -0
  150. package/src/routes/workspace-artifacts.ts +4 -1
  151. package/src/routes/workspace-capture.ts +21 -0
  152. package/src/routes/workspace-learning.ts +228 -0
  153. package/src/routes/workspaces.ts +59 -6
  154. package/src/sandbox/auth-callout.ts +38 -12
  155. package/src/sandbox/channel-a.ts +242 -19
  156. package/src/sandbox/connection-authority.ts +3 -0
  157. package/src/sandbox/enrollment.ts +15 -3
  158. package/src/sandbox/machines.ts +186 -64
  159. package/src/sandbox/metrics-ingestion.ts +220 -58
  160. package/src/sandbox/viewer.ts +243 -19
  161. package/src/sandbox-file-artifacts.ts +329 -0
  162. package/src/scheduled-task-deletion.ts +127 -0
  163. package/src/slack-reaction-files.ts +16 -5
  164. package/src/temporal-schedule-cleanup.ts +13 -0
  165. package/dist/chunk-WCXHI3FF.js.map +0 -1
@@ -0,0 +1,409 @@
1
+ import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "node:crypto";
2
+ import { canonicalPublicOrigin } from "@opengeni/config";
3
+ import { WebSocket as UpstreamWebSocket } from "ws";
4
+ import type {
5
+ ApiWebSocketConnection,
6
+ ApiWebSocketLike,
7
+ ApiWebSocketUpgradeServer,
8
+ } from "./api-websocket";
9
+
10
+ export const INTERACTION_FRAME_PROXY_PATH = "/v1/interaction/frame-proxy";
11
+ export const INTERACTION_FRAME_PROXY_PROTOCOL_PREFIX = "opengeni-frame-proxy.";
12
+
13
+ const TOKEN_VERSION = 1;
14
+ const TOKEN_IV_BYTES = 12;
15
+ const TOKEN_TAG_BYTES = 16;
16
+ const TOKEN_MAX_BYTES = 8 * 1024;
17
+ const TOKEN_KEY_CONTEXT = "OpenGeni interaction frame proxy v1";
18
+ const MAX_QUEUED_MESSAGES = 64;
19
+ const MAX_QUEUED_BYTES = 12 * 1024 * 1024;
20
+
21
+ type ProxyGrant = Readonly<{
22
+ version: 1;
23
+ upstreamUrl: string;
24
+ upstreamProtocols: readonly string[];
25
+ responseProtocol: string;
26
+ origin: string | null;
27
+ expiresAt: number;
28
+ }>;
29
+
30
+ export type InteractionFrameProxyAttachment = Readonly<{
31
+ url: string;
32
+ protocols: readonly string[];
33
+ }>;
34
+
35
+ /** Docker boxes cannot carry browserd's WebSocket subprotocol grant to the
36
+ * viewer. Unsigned OpenSandbox Channel B still uses the lifecycle proxy, so
37
+ * it needs the same hatch. Signed URI-mode ingress keeps native subprotocols;
38
+ * `openSandboxInteractionFrameProxy` is an emergency override only. */
39
+ export function placementUsesInteractionFrameProxy(
40
+ backend: string | null | undefined,
41
+ options?: {
42
+ openSandboxSignedEndpoints?: boolean;
43
+ openSandboxInteractionFrameProxy?: boolean;
44
+ },
45
+ ): boolean {
46
+ if (backend === "docker") return true;
47
+ if (backend !== "opensandbox") return false;
48
+ if (typeof options?.openSandboxInteractionFrameProxy === "boolean") {
49
+ return options.openSandboxInteractionFrameProxy;
50
+ }
51
+ return options?.openSandboxSignedEndpoints !== true;
52
+ }
53
+
54
+ /** Public origin the browser can open. TLS-terminating reverse proxies make
55
+ * `requestUrl` `http://127.0.0.1` / the API container; that would mint `ws://`
56
+ * and Chrome blocks it from an `https://` console. */
57
+ export function resolveInteractionFrameProxyRequestUrl(input: {
58
+ requestUrl: string;
59
+ publicBaseUrl?: string | undefined;
60
+ webBaseUrl?: string | undefined;
61
+ forwardedProto?: string | null | undefined;
62
+ forwardedHost?: string | null | undefined;
63
+ }): string {
64
+ const publicOrigin =
65
+ canonicalPublicOrigin(input.publicBaseUrl) ??
66
+ (input.webBaseUrl?.startsWith("https://") ? canonicalPublicOrigin(input.webBaseUrl) : null);
67
+ if (publicOrigin) return `${publicOrigin}/`;
68
+ const request = new URL(input.requestUrl);
69
+ const forwardedProto = firstForwardedValue(input.forwardedProto)?.toLowerCase();
70
+ const protocol =
71
+ forwardedProto === "https" ? "https:" : forwardedProto === "http" ? "http:" : request.protocol;
72
+ const forwardedHost = firstForwardedValue(input.forwardedHost);
73
+ const host = forwardedHost && isSafeForwardedHost(forwardedHost) ? forwardedHost : request.host;
74
+ try {
75
+ return new URL(`${protocol}//${host}/`).toString();
76
+ } catch {
77
+ return request.toString();
78
+ }
79
+ }
80
+
81
+ export function createInteractionFrameProxyAttachment(input: {
82
+ requestUrl: string;
83
+ publicBaseUrl?: string | undefined;
84
+ webBaseUrl?: string | undefined;
85
+ forwardedProto?: string | null | undefined;
86
+ forwardedHost?: string | null | undefined;
87
+ rootSecret: string;
88
+ upstreamUrl: string;
89
+ upstreamProtocols: readonly string[];
90
+ origin: string | null;
91
+ expiresAt: string;
92
+ }): InteractionFrameProxyAttachment {
93
+ const grant = validateGrant({
94
+ version: TOKEN_VERSION,
95
+ upstreamUrl: input.upstreamUrl,
96
+ upstreamProtocols: input.upstreamProtocols,
97
+ responseProtocol: input.upstreamProtocols[0] ?? "",
98
+ origin: input.origin,
99
+ expiresAt: Date.parse(input.expiresAt),
100
+ });
101
+ const token = encryptGrant(grant, input.rootSecret);
102
+ const url = new URL(
103
+ resolveInteractionFrameProxyRequestUrl({
104
+ requestUrl: input.requestUrl,
105
+ publicBaseUrl: input.publicBaseUrl,
106
+ webBaseUrl: input.webBaseUrl,
107
+ forwardedProto: input.forwardedProto,
108
+ forwardedHost: input.forwardedHost,
109
+ }),
110
+ );
111
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
112
+ url.pathname = INTERACTION_FRAME_PROXY_PATH;
113
+ url.search = "";
114
+ url.hash = "";
115
+ return Object.freeze({
116
+ url: url.toString(),
117
+ protocols: Object.freeze([
118
+ grant.responseProtocol,
119
+ `${INTERACTION_FRAME_PROXY_PROTOCOL_PREFIX}${token}`,
120
+ ]),
121
+ });
122
+ }
123
+
124
+ export class InteractionFrameProxyTransport {
125
+ constructor(
126
+ private readonly rootSecret: string | undefined,
127
+ private readonly clock: () => number = Date.now,
128
+ ) {}
129
+
130
+ handles(request: Request): boolean {
131
+ return new URL(request.url).pathname === INTERACTION_FRAME_PROXY_PATH;
132
+ }
133
+
134
+ upgrade(request: Request, server: ApiWebSocketUpgradeServer): Response | undefined {
135
+ if (!this.handles(request)) return undefined;
136
+ if (request.method !== "GET") {
137
+ return new Response("Method Not Allowed", { status: 405, headers: { allow: "GET" } });
138
+ }
139
+ if (!this.rootSecret) return new Response("Service Unavailable", { status: 503 });
140
+ const offered = offeredProtocols(request.headers.get("sec-websocket-protocol"));
141
+ const proxyProtocol = offered.find((protocol) =>
142
+ protocol.startsWith(INTERACTION_FRAME_PROXY_PROTOCOL_PREFIX),
143
+ );
144
+ if (!proxyProtocol) return new Response("WebSocket proxy grant required", { status: 426 });
145
+ let grant: ProxyGrant;
146
+ try {
147
+ grant = decryptGrant(
148
+ proxyProtocol.slice(INTERACTION_FRAME_PROXY_PROTOCOL_PREFIX.length),
149
+ this.rootSecret,
150
+ );
151
+ } catch {
152
+ return new Response("Invalid WebSocket proxy grant", { status: 401 });
153
+ }
154
+ if (grant.expiresAt <= this.clock()) {
155
+ return new Response("WebSocket proxy grant expired", { status: 401 });
156
+ }
157
+ if (!offered.includes(grant.responseProtocol)) {
158
+ return new Response("WebSocket protocol required", { status: 426 });
159
+ }
160
+ const origin = normalizedOrigin(request.headers.get("origin"));
161
+ if (origin !== grant.origin) {
162
+ return new Response("WebSocket origin is not allowed", { status: 403 });
163
+ }
164
+ const connection = new InteractionFrameProxyConnection(grant);
165
+ const upgraded = server.upgrade(request, {
166
+ data: connection,
167
+ headers: { "sec-websocket-protocol": grant.responseProtocol },
168
+ });
169
+ return upgraded ? undefined : new Response("Bad Request", { status: 400 });
170
+ }
171
+ }
172
+
173
+ export class InteractionFrameProxyConnection implements ApiWebSocketConnection {
174
+ private socket: ApiWebSocketLike | null = null;
175
+ private upstream: UpstreamWebSocket | null = null;
176
+ private upstreamOpen = false;
177
+ private terminal = false;
178
+ private queued: Uint8Array[] = [];
179
+ private queuedBytes = 0;
180
+
181
+ constructor(private readonly grant: ProxyGrant) {}
182
+
183
+ attach(socket: ApiWebSocketLike): void {
184
+ if (this.socket || this.terminal) {
185
+ socket.close(4400, "invalid connection state");
186
+ return;
187
+ }
188
+ this.socket = socket;
189
+ let upstream: UpstreamWebSocket;
190
+ try {
191
+ upstream = new UpstreamWebSocket(this.grant.upstreamUrl, [...this.grant.upstreamProtocols], {
192
+ ...(this.grant.origin ? { headers: { origin: this.grant.origin } } : {}),
193
+ });
194
+ } catch {
195
+ this.fail(1011, "upstream unavailable");
196
+ return;
197
+ }
198
+ this.upstream = upstream;
199
+ upstream.binaryType = "arraybuffer";
200
+ upstream.once("open", () => {
201
+ if (this.terminal) {
202
+ upstream.close(1000, "viewer closed");
203
+ return;
204
+ }
205
+ this.upstreamOpen = true;
206
+ for (const message of this.queued) upstream.send(webSocketBinary(message));
207
+ this.queued = [];
208
+ this.queuedBytes = 0;
209
+ });
210
+ upstream.on("message", (data) => {
211
+ if (this.terminal || !this.socket) return;
212
+ const bytes = binaryMessage(data);
213
+ if (!bytes || this.socket.send(bytes, false) <= 0) {
214
+ this.fail(1011, "stream unavailable");
215
+ }
216
+ });
217
+ upstream.on("error", () => this.fail(1011, "upstream unavailable"));
218
+ upstream.once("close", (code, reason) => {
219
+ this.fail(safeCloseCode(code), boundedReason(reason.toString("utf8")));
220
+ });
221
+ }
222
+
223
+ receive(message: string | Uint8Array | ArrayBuffer): void {
224
+ if (this.terminal) return;
225
+ if (typeof message === "string") {
226
+ this.fail(4400, "binary frames required");
227
+ return;
228
+ }
229
+ const bytes =
230
+ message instanceof Uint8Array ? Uint8Array.from(message) : new Uint8Array(message).slice();
231
+ if (this.upstreamOpen && this.upstream) {
232
+ this.upstream.send(webSocketBinary(bytes));
233
+ return;
234
+ }
235
+ if (
236
+ this.queued.length + 1 > MAX_QUEUED_MESSAGES ||
237
+ this.queuedBytes + bytes.byteLength > MAX_QUEUED_BYTES
238
+ ) {
239
+ this.fail(4409, "viewer is too fast");
240
+ return;
241
+ }
242
+ this.queued.push(bytes);
243
+ this.queuedBytes += bytes.byteLength;
244
+ }
245
+
246
+ transportClosed(): void {
247
+ if (this.terminal) return;
248
+ this.terminal = true;
249
+ this.upstream?.close(1000, "viewer closed");
250
+ this.clear();
251
+ }
252
+
253
+ private fail(code: number, reason: string): void {
254
+ if (this.terminal) return;
255
+ this.terminal = true;
256
+ this.upstream?.close(code === 1000 ? 1000 : 1011, reason);
257
+ this.socket?.close(code, reason);
258
+ this.clear();
259
+ }
260
+
261
+ private clear(): void {
262
+ this.queued = [];
263
+ this.queuedBytes = 0;
264
+ this.socket = null;
265
+ this.upstream = null;
266
+ }
267
+ }
268
+
269
+ function encryptGrant(grant: ProxyGrant, rootSecret: string): string {
270
+ const iv = randomBytes(TOKEN_IV_BYTES);
271
+ const cipher = createCipheriv("aes-256-gcm", tokenKey(rootSecret), iv);
272
+ cipher.setAAD(Buffer.from(TOKEN_KEY_CONTEXT));
273
+ const ciphertext = Buffer.concat([cipher.update(JSON.stringify(grant), "utf8"), cipher.final()]);
274
+ const tag = cipher.getAuthTag();
275
+ return Buffer.concat([iv, tag, ciphertext]).toString("base64url");
276
+ }
277
+
278
+ function decryptGrant(token: string, rootSecret: string): ProxyGrant {
279
+ const bytes = Buffer.from(token, "base64url");
280
+ if (
281
+ token.length === 0 ||
282
+ token.length > TOKEN_MAX_BYTES ||
283
+ bytes.byteLength <= TOKEN_IV_BYTES + TOKEN_TAG_BYTES
284
+ ) {
285
+ throw new Error("invalid proxy grant");
286
+ }
287
+ const iv = bytes.subarray(0, TOKEN_IV_BYTES);
288
+ const tag = bytes.subarray(TOKEN_IV_BYTES, TOKEN_IV_BYTES + TOKEN_TAG_BYTES);
289
+ const ciphertext = bytes.subarray(TOKEN_IV_BYTES + TOKEN_TAG_BYTES);
290
+ const decipher = createDecipheriv("aes-256-gcm", tokenKey(rootSecret), iv);
291
+ decipher.setAAD(Buffer.from(TOKEN_KEY_CONTEXT));
292
+ decipher.setAuthTag(tag);
293
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
294
+ return validateGrant(JSON.parse(plaintext));
295
+ }
296
+
297
+ function tokenKey(rootSecret: string): Buffer {
298
+ if (rootSecret.length === 0) throw new Error("proxy authority is empty");
299
+ return createHmac("sha256", rootSecret).update(TOKEN_KEY_CONTEXT).digest();
300
+ }
301
+
302
+ function validateGrant(value: unknown): ProxyGrant {
303
+ if (!value || typeof value !== "object") throw new Error("invalid proxy grant");
304
+ const grant = value as Record<string, unknown>;
305
+ if (grant.version !== TOKEN_VERSION) throw new Error("invalid proxy grant version");
306
+ const upstreamUrl = requireUpstreamUrl(grant.upstreamUrl);
307
+ const upstreamProtocols = requireProtocols(grant.upstreamProtocols);
308
+ if (
309
+ typeof grant.responseProtocol !== "string" ||
310
+ grant.responseProtocol !== upstreamProtocols[0]
311
+ ) {
312
+ throw new Error("invalid proxy response protocol");
313
+ }
314
+ const origin = grant.origin === null ? null : normalizedOrigin(grant.origin);
315
+ if (grant.origin !== null && !origin) throw new Error("invalid proxy origin");
316
+ if (!Number.isSafeInteger(grant.expiresAt) || (grant.expiresAt as number) <= 0) {
317
+ throw new Error("invalid proxy expiry");
318
+ }
319
+ return Object.freeze({
320
+ version: TOKEN_VERSION,
321
+ upstreamUrl,
322
+ upstreamProtocols: Object.freeze(upstreamProtocols),
323
+ responseProtocol: upstreamProtocols[0]!,
324
+ origin,
325
+ expiresAt: grant.expiresAt as number,
326
+ });
327
+ }
328
+
329
+ function requireUpstreamUrl(value: unknown): string {
330
+ if (typeof value !== "string" || value.length === 0 || value.length > 4096) {
331
+ throw new Error("invalid upstream URL");
332
+ }
333
+ const url = new URL(value);
334
+ if (!["ws:", "wss:"].includes(url.protocol) || url.username || url.password) {
335
+ throw new Error("invalid upstream URL");
336
+ }
337
+ return url.toString();
338
+ }
339
+
340
+ function requireProtocols(value: unknown): string[] {
341
+ if (!Array.isArray(value) || value.length === 0 || value.length > 4) {
342
+ throw new Error("invalid upstream protocols");
343
+ }
344
+ return value.map((protocol) => {
345
+ if (
346
+ typeof protocol !== "string" ||
347
+ protocol.length === 0 ||
348
+ protocol.length > 2048 ||
349
+ protocol.includes(",")
350
+ ) {
351
+ throw new Error("invalid upstream protocol");
352
+ }
353
+ return protocol;
354
+ });
355
+ }
356
+
357
+ function offeredProtocols(value: string | null): string[] {
358
+ return value
359
+ ? value
360
+ .split(",")
361
+ .map((part) => part.trim())
362
+ .filter(Boolean)
363
+ : [];
364
+ }
365
+
366
+ function normalizedOrigin(value: unknown): string | null {
367
+ if (typeof value !== "string" || value.length === 0 || value.length > 2048) return null;
368
+ try {
369
+ const url = new URL(value);
370
+ return url.pathname === "/" && !url.search && !url.hash ? url.origin : null;
371
+ } catch {
372
+ return null;
373
+ }
374
+ }
375
+
376
+ function firstForwardedValue(value: string | null | undefined): string | null {
377
+ if (!value) return null;
378
+ const part = value.split(",")[0]?.trim() ?? "";
379
+ return part.length > 0 ? part : null;
380
+ }
381
+
382
+ function isSafeForwardedHost(host: string): boolean {
383
+ return host.length > 0 && host.length <= 253 && !/[\s/@?#\\]/.test(host);
384
+ }
385
+
386
+ function binaryMessage(value: unknown): Uint8Array | null {
387
+ if (value instanceof ArrayBuffer) return new Uint8Array(value).slice();
388
+ if (ArrayBuffer.isView(value)) {
389
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice();
390
+ }
391
+ return null;
392
+ }
393
+
394
+ function webSocketBinary(bytes: Uint8Array): ArrayBuffer {
395
+ const buffer = new ArrayBuffer(bytes.byteLength);
396
+ new Uint8Array(buffer).set(bytes);
397
+ return buffer;
398
+ }
399
+
400
+ function safeCloseCode(code: number): number {
401
+ return Number.isInteger(code) && (code === 1000 || (code >= 3000 && code <= 4999)) ? code : 1011;
402
+ }
403
+
404
+ function boundedReason(reason: string): string {
405
+ const normalized = reason.trim();
406
+ return normalized.length > 0 && Buffer.byteLength(normalized) <= 123
407
+ ? normalized
408
+ : "upstream closed";
409
+ }
@@ -0,0 +1,262 @@
1
+ import {
2
+ AGENT_AUTHORED_DURABLE_TEXT_STYLE,
3
+ AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS,
4
+ AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_TOO_LONG_MESSAGE,
5
+ AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS,
6
+ AGENT_AUTHORED_PREFERENCE_CONTENT_TOO_LONG_MESSAGE,
7
+ PREFERENCE_REGISTRY_DESCRIPTOR_DESCRIPTION_MAX_CHARS,
8
+ PREFERENCE_REGISTRY_STABLE_KEY_MAX_CHARS,
9
+ PREFERENCE_REGISTRY_TITLE_MAX_CHARS,
10
+ WorkspaceInstructionPolicyTarget,
11
+ type CompanyBrainGovernedWriteAttempt,
12
+ } from "@opengeni/contracts";
13
+ import { createCompanyBrainLearningPolicyRouter } from "@opengeni/core";
14
+ import type { Database } from "@opengeni/db";
15
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import * as z from "zod/v4";
17
+
18
+ type JsonResult = (value: unknown) => {
19
+ content: { type: "text"; text: string }[];
20
+ };
21
+
22
+ const reason = z.string().trim().min(1).max(4_096);
23
+ const evidence = {
24
+ operationId: z.string().uuid(),
25
+ claimId: z.string().uuid(),
26
+ evidenceId: z.string().uuid(),
27
+ };
28
+ const taskNotePromotion = {
29
+ operationId: z.string().uuid(),
30
+ noteId: z.string().uuid(),
31
+ expectedNoteVersion: z.literal(1),
32
+ entityType: z
33
+ .string()
34
+ .trim()
35
+ .min(1)
36
+ .max(96)
37
+ .regex(/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/),
38
+ normalizedKey: z.string().trim().min(1).max(512),
39
+ displayName: z.string().trim().min(1).max(512),
40
+ predicateKey: z
41
+ .string()
42
+ .trim()
43
+ .min(1)
44
+ .max(128)
45
+ .regex(/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/),
46
+ confidenceBps: z.number().int().min(0).max(10_000),
47
+ reason,
48
+ };
49
+
50
+ export type RegisterCompanyBrainGovernedWriteToolsInput = {
51
+ server: McpServer;
52
+ db: Database;
53
+ attempt: CompanyBrainGovernedWriteAttempt;
54
+ authorize: () => Promise<void>;
55
+ json: JsonResult;
56
+ router?: Pick<ReturnType<typeof createCompanyBrainLearningPolicyRouter>, "write">;
57
+ };
58
+
59
+ /**
60
+ * Register explicit proposal-only Company Brain tools. The signed host attempt
61
+ * owns tenant/session authority and the exact evidence id owns the learning
62
+ * policy source override. Tool input cannot select scope, active authority, or
63
+ * another policy source.
64
+ */
65
+ export function registerCompanyBrainGovernedWriteTools(
66
+ input: RegisterCompanyBrainGovernedWriteToolsInput,
67
+ ): void {
68
+ const router = input.router ?? createCompanyBrainLearningPolicyRouter({ db: input.db });
69
+ input.server.registerTool(
70
+ "knowledge_propose",
71
+ {
72
+ description:
73
+ "Propose one existing evidence-backed workspace Knowledge claim through the frozen learning policy. Off creates nothing; Suggest and Automatic both create a proposal that only the human Knowledge review lifecycle can accept.",
74
+ inputSchema: { ...evidence, reason },
75
+ },
76
+ async (request) => {
77
+ await input.authorize();
78
+ return input.json(
79
+ await router.write({
80
+ attempt: input.attempt,
81
+ request: { kind: "propose_knowledge", ...request },
82
+ }),
83
+ );
84
+ },
85
+ );
86
+
87
+ input.server.registerTool(
88
+ "knowledge_correct",
89
+ {
90
+ description:
91
+ "Propose an evidence-backed replacement for another workspace Knowledge claim. The old claim remains in immutable history and the correction never widens scope.",
92
+ inputSchema: { ...evidence, replacesClaimId: z.string().uuid(), reason },
93
+ },
94
+ async (request) => {
95
+ await input.authorize();
96
+ return input.json(
97
+ await router.write({
98
+ attempt: input.attempt,
99
+ request: { kind: "correct_knowledge", ...request },
100
+ }),
101
+ );
102
+ },
103
+ );
104
+
105
+ input.server.registerTool(
106
+ "task_note_promote_knowledge",
107
+ {
108
+ description:
109
+ "Promote one still-active note from this exact root task tree into a normalized workspace Knowledge proposal. The immutable note bytes remain source evidence; this never activates the claim or widens it to personal/organization scope.",
110
+ inputSchema: taskNotePromotion,
111
+ },
112
+ async (request) => {
113
+ await input.authorize();
114
+ return input.json(
115
+ await router.write({
116
+ attempt: input.attempt,
117
+ request: { kind: "promote_task_note_knowledge", ...request },
118
+ }),
119
+ );
120
+ },
121
+ );
122
+
123
+ input.server.registerTool(
124
+ "task_note_promote_instruction_policy",
125
+ {
126
+ description:
127
+ "Atomically promote one still-active note from this exact root task tree into an inactive workspace instruction-policy draft. The note bytes remain exact evidence and draft content. " +
128
+ `Once a human activates the draft, those bytes are composed verbatim into the prompt of every session the target applies to, so a note over ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters is rejected here rather than truncated: write a fresh short imperative note instead of promoting a long working note. ` +
129
+ "The frozen learning policy records a decision receipt, but mandatory policy still requires human activation even under Automatic; this never widens scope.",
130
+ inputSchema: {
131
+ ...taskNotePromotion,
132
+ target: WorkspaceInstructionPolicyTarget,
133
+ expectedCurrentRevisionId: z.string().uuid().nullable(),
134
+ expectedActivationVersion: z.number().int().nonnegative(),
135
+ },
136
+ },
137
+ async (request) => {
138
+ await input.authorize();
139
+ return input.json(
140
+ await router.write({
141
+ attempt: input.attempt,
142
+ request: { kind: "promote_task_note_instruction_policy", ...request },
143
+ }),
144
+ );
145
+ },
146
+ );
147
+
148
+ input.server.registerTool(
149
+ "task_note_promote_preference",
150
+ {
151
+ description:
152
+ "Atomically promote one still-active note from this exact root task tree into a workspace preference proposal. The note bytes remain exact evidence and full proposal content. " +
153
+ `The title and description you supply are what gets composed into every session prompt, so write them as one short imperative statement; the note content is retrieved on demand and a note over ${AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS} characters is rejected here rather than truncated. ` +
154
+ "Under Suggest the proposal waits for human review; under Automatic an eligible decision is activated through the preference lifecycle and remains undoable. This never widens scope.",
155
+ inputSchema: {
156
+ ...taskNotePromotion,
157
+ stableKey: z.string().trim().min(1).max(PREFERENCE_REGISTRY_STABLE_KEY_MAX_CHARS),
158
+ title: z.string().trim().min(1).max(PREFERENCE_REGISTRY_TITLE_MAX_CHARS),
159
+ description: z
160
+ .string()
161
+ .trim()
162
+ .min(1)
163
+ .max(PREFERENCE_REGISTRY_DESCRIPTOR_DESCRIPTION_MAX_CHARS),
164
+ precedenceRank: z.number().int().min(-1_000).max(1_000).optional().default(0),
165
+ conflictStrategy: z
166
+ .enum(["override", "merge", "reject", "inform"])
167
+ .optional()
168
+ .default("override"),
169
+ conflictsWith: z.array(z.string().min(1)).max(32).optional().default([]),
170
+ expiresAt: z.string().datetime({ offset: true }).nullable().optional().default(null),
171
+ },
172
+ },
173
+ async (request) => {
174
+ await input.authorize();
175
+ return input.json(
176
+ await router.write({
177
+ attempt: input.attempt,
178
+ request: { kind: "promote_task_note_preference", ...request },
179
+ }),
180
+ );
181
+ },
182
+ );
183
+
184
+ input.server.registerTool(
185
+ "instruction_policy_propose",
186
+ {
187
+ description:
188
+ "Materialize an evidence-backed inactive workspace instruction-policy draft. " +
189
+ `Once a human activates it, this content is composed verbatim into the prompt of every session the target applies to (every session in this workspace for a global charter or policy, every session bound to the role for a role policy), so keep it under ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters. ${AGENT_AUTHORED_DURABLE_TEXT_STYLE} ` +
190
+ "The frozen learning policy records a decision receipt, but this tool cannot activate mandatory behavior, including when learning mode is Automatic; a human must activate the draft.",
191
+ inputSchema: {
192
+ ...evidence,
193
+ target: WorkspaceInstructionPolicyTarget,
194
+ content: z
195
+ .string()
196
+ .trim()
197
+ .min(1)
198
+ .max(
199
+ AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS,
200
+ AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_TOO_LONG_MESSAGE,
201
+ ),
202
+ expectedCurrentRevisionId: z.string().uuid().nullable(),
203
+ expectedActivationVersion: z.number().int().nonnegative(),
204
+ reason,
205
+ },
206
+ },
207
+ async (request) => {
208
+ await input.authorize();
209
+ return input.json(
210
+ await router.write({
211
+ attempt: input.attempt,
212
+ request: { kind: "propose_instruction_policy", ...request },
213
+ }),
214
+ );
215
+ },
216
+ );
217
+
218
+ input.server.registerTool(
219
+ "preference_propose",
220
+ {
221
+ description:
222
+ "Materialize an evidence-backed workspace preference proposal. " +
223
+ `Its short title and description are what get composed into every session prompt; the content is retrieved on demand, so its length is retrieval cost rather than standing prompt cost. Keep the content under ${AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS} characters. ${AGENT_AUTHORED_DURABLE_TEXT_STYLE} ` +
224
+ "Under Suggest it stays inactive for human review; under Automatic an eligible decision is activated through the governed preference lifecycle with an undoable receipt. It never creates mandatory authority.",
225
+ inputSchema: {
226
+ ...evidence,
227
+ stableKey: z.string().trim().min(1).max(PREFERENCE_REGISTRY_STABLE_KEY_MAX_CHARS),
228
+ title: z.string().trim().min(1).max(PREFERENCE_REGISTRY_TITLE_MAX_CHARS),
229
+ description: z
230
+ .string()
231
+ .trim()
232
+ .min(1)
233
+ .max(PREFERENCE_REGISTRY_DESCRIPTOR_DESCRIPTION_MAX_CHARS),
234
+ content: z
235
+ .string()
236
+ .trim()
237
+ .min(1)
238
+ .max(
239
+ AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS,
240
+ AGENT_AUTHORED_PREFERENCE_CONTENT_TOO_LONG_MESSAGE,
241
+ ),
242
+ precedenceRank: z.number().int().min(-1_000).max(1_000).optional().default(0),
243
+ conflictStrategy: z
244
+ .enum(["override", "merge", "reject", "inform"])
245
+ .optional()
246
+ .default("override"),
247
+ conflictsWith: z.array(z.string().min(1)).max(32).optional().default([]),
248
+ expiresAt: z.string().datetime({ offset: true }).nullable().optional().default(null),
249
+ reason,
250
+ },
251
+ },
252
+ async (request) => {
253
+ await input.authorize();
254
+ return input.json(
255
+ await router.write({
256
+ attempt: input.attempt,
257
+ request: { kind: "propose_preference", ...request },
258
+ }),
259
+ );
260
+ },
261
+ );
262
+ }