@opengeni/api-router 2.10.0-canary.0 → 2.10.0-canary.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "2.10.0-canary.0",
3
+ "version": "2.10.0-canary.2",
4
4
  "description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -43,24 +43,24 @@
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@llamaindex/liteparse": "2.14.2",
45
45
  "@modelcontextprotocol/sdk": "^1.29.0",
46
- "@opengeni/agent-proto": "^0.5.1-canary.9",
47
- "@opengeni/artifact-tool": "^0.3.19-canary.0",
48
- "@opengeni/capabilities": "^0.3.2-canary.0",
49
- "@opengeni/codemode": "^0.4.27-canary.0",
50
- "@opengeni/codex": "^0.2.21-canary.0",
51
- "@opengeni/config": "^1.0.0-canary.0",
52
- "@opengeni/contracts": "^2.13.0-canary.0",
53
- "@opengeni/core": "^2.7.5-canary.0",
54
- "@opengeni/db": "^4.0.0-canary.0",
55
- "@opengeni/documents": "^0.8.19-canary.0",
56
- "@opengeni/events": "^0.4.17-canary.0",
57
- "@opengeni/github": "^0.7.3-canary.0",
58
- "@opengeni/network": "^0.3.0-canary.0",
59
- "@opengeni/observability": "^0.8.19-canary.0",
60
- "@opengeni/runtime": "^2.3.0-canary.0",
61
- "@opengeni/storage": "^0.2.120-canary.0",
62
- "@opengeni/tool-gateway": "^0.1.0-canary.0",
63
- "@opengeni/xai-subscription": "^0.1.3-canary.0",
46
+ "@opengeni/agent-proto": "^0.5.1-canary.11",
47
+ "@opengeni/artifact-tool": "^0.3.19-canary.2",
48
+ "@opengeni/capabilities": "^0.3.2-canary.2",
49
+ "@opengeni/codemode": "^0.4.27-canary.2",
50
+ "@opengeni/codex": "^0.2.21-canary.2",
51
+ "@opengeni/config": "^1.0.0-canary.2",
52
+ "@opengeni/contracts": "^2.13.0-canary.2",
53
+ "@opengeni/core": "^2.7.5-canary.2",
54
+ "@opengeni/db": "^4.0.0-canary.2",
55
+ "@opengeni/documents": "^0.8.19-canary.2",
56
+ "@opengeni/events": "^0.4.17-canary.2",
57
+ "@opengeni/github": "^0.7.3-canary.2",
58
+ "@opengeni/network": "^0.3.0-canary.2",
59
+ "@opengeni/observability": "^0.8.19-canary.2",
60
+ "@opengeni/runtime": "^2.3.0-canary.2",
61
+ "@opengeni/storage": "^0.2.120-canary.2",
62
+ "@opengeni/tool-gateway": "^0.1.0-canary.2",
63
+ "@opengeni/xai-subscription": "^0.1.3-canary.2",
64
64
  "@temporalio/client": "^1.17.0",
65
65
  "better-auth": "^1.6.14",
66
66
  "hono": "^4.12.18",
package/src/app.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { codemodeSessionRequest } from "./codemode";
1
2
  import {
2
3
  canonicalizeConfiguredModelId,
3
4
  configuredAllowedModels,
@@ -895,6 +896,7 @@ export function createAppComposition(deps: AppDependencies): {
895
896
  : {}),
896
897
  },
897
898
  productAccessMode: deps.settings.productAccessMode,
899
+ billingMode: deps.settings.billingMode,
898
900
  managedAuthSessionSetMode: deps.settings.managedAuthSessionSetMode,
899
901
  auth: clientAuthConfig(deps.settings),
900
902
  analytics: clientAnalyticsConfig(deps.settings),
@@ -1127,6 +1129,20 @@ export function createAppComposition(deps: AppDependencies): {
1127
1129
  }
1128
1130
  });
1129
1131
 
1132
+ app.all("/v1/workspaces/:workspaceId/codemode/sdk/*", async (c) => {
1133
+ const workspaceId = c.req.param("workspaceId");
1134
+ const grant = await requireAccessGrant(c, routeDeps, workspaceId);
1135
+ const url = new URL(c.req.url);
1136
+ const prefix = `/v1/workspaces/${workspaceId}/codemode/sdk`;
1137
+ const forwarded = await codemodeSessionRequest(
1138
+ routeDeps,
1139
+ grant,
1140
+ c.req.raw,
1141
+ url.pathname.slice(prefix.length) + url.search,
1142
+ );
1143
+ return app.fetch(forwarded);
1144
+ });
1145
+
1130
1146
  app.get("/v1/workspaces/:workspaceId/codemode/catalog", async (c) => {
1131
1147
  const workspaceId = c.req.param("workspaceId");
1132
1148
  const grant = await requireAccessGrant(c, routeDeps, workspaceId);
@@ -2147,6 +2163,10 @@ const routeLabelPatterns: Array<{
2147
2163
  pattern: /^\/v1\/workspaces\/[^/]+\/github\/app$/,
2148
2164
  label: "/v1/workspaces/:workspaceId/github/app",
2149
2165
  },
2166
+ {
2167
+ pattern: /^\/v1\/workspaces\/[^/]+\/github\/action-policies$/,
2168
+ label: "/v1/workspaces/:workspaceId/github/action-policies",
2169
+ },
2150
2170
  {
2151
2171
  pattern: /^\/v1\/workspaces\/[^/]+\/github\/repositories$/,
2152
2172
  label: "/v1/workspaces/:workspaceId/github/repositories",
package/src/codemode.ts CHANGED
@@ -20,6 +20,58 @@ import {
20
20
  getCodemodeOperation,
21
21
  submitCodemodeOperation,
22
22
  } from "@opengeni/db";
23
+ import { getSession } from "@opengeni/db";
24
+ import { resolveFirstPartyDelegationSecret } from "@opengeni/config";
25
+ import {
26
+ DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
27
+ signDelegatedAccessToken,
28
+ siteSessionPath,
29
+ OPENGENI_API_CONTRACT_HEADER,
30
+ OPENGENI_API_CONTRACT_REVISION,
31
+ } from "@opengeni/contracts";
32
+
33
+ /** Reuse normal REST handlers, including their resource/command authorization.
34
+ * The exact attempt is checked before issuing this internal-only credential. */
35
+ export async function codemodeSessionRequest(
36
+ deps: ApiRouteDeps,
37
+ grant: AccessGrant,
38
+ request: Request,
39
+ path: string,
40
+ ): Promise<Request> {
41
+ siteSessionPath(path, grant.workspaceId, request.method);
42
+ const { authority } = await requireActiveCodemodeCatalog(deps, grant);
43
+ const session = await getSession(deps.db, authority.workspaceId, authority.sessionId);
44
+ const secret = resolveFirstPartyDelegationSecret(deps.settings);
45
+ if (!session || !secret) throw new CodemodeAuthorityError("invalid_grant");
46
+ const permissions = (
47
+ session.firstPartyMcpPermissions ?? [...DEFAULT_FIRST_PARTY_MCP_PERMISSIONS]
48
+ ).filter((p) =>
49
+ ["workspace:read", "sessions:read", "sessions:create", "sessions:control"].includes(p),
50
+ );
51
+ const token = await signDelegatedAccessToken(secret, {
52
+ ...authority,
53
+ permissions,
54
+ principalKind: "agent_attempt",
55
+ exp: Math.floor(Date.now() / 1000) + 60,
56
+ });
57
+ const target = new URL(request.url);
58
+ const rewritten = siteSessionPath(path, authority.workspaceId, request.method);
59
+ const url = new URL(rewritten, target.origin);
60
+ const headers = new Headers({
61
+ authorization: `Bearer ${token}`,
62
+ [OPENGENI_API_CONTRACT_HEADER]: OPENGENI_API_CONTRACT_REVISION,
63
+ });
64
+ for (const name of ["content-type", "accept", "last-event-id"]) {
65
+ const value = request.headers.get(name);
66
+ if (value) headers.set(name, value);
67
+ }
68
+ return new Request(url, {
69
+ method: request.method,
70
+ headers,
71
+ signal: request.signal,
72
+ ...(request.body ? { body: await request.text() } : {}),
73
+ });
74
+ }
23
75
 
24
76
  export type CodemodeGrantAuthority = {
25
77
  accountId: string;
@@ -15,6 +15,7 @@ import {
15
15
  createConnection,
16
16
  decryptEnvironmentValue,
17
17
  encryptEnvironmentValue,
18
+ findActiveWorkspaceApiKeyById,
18
19
  getConnectionMetadata,
19
20
  getGlobalCatalogOAuthProfile,
20
21
  getWorkspaceGrant,
@@ -191,6 +192,13 @@ class OAuthStartStageError extends Error {
191
192
  }
192
193
  }
193
194
 
195
+ class OAuthMetadataUpstreamError extends Error {
196
+ constructor(readonly upstreamStatus: number) {
197
+ super(`OAuth metadata endpoint returned HTTP ${upstreamStatus}`);
198
+ this.name = "OAuthMetadataUpstreamError";
199
+ }
200
+ }
201
+
194
202
  class OAuthStartDeadline {
195
203
  readonly signal: AbortSignal;
196
204
  private readonly controller = new AbortController();
@@ -811,7 +819,7 @@ async function requireOAuthCallbackGrant(db: Database, state: OAuthStatePayload)
811
819
  ? membershipGrant
812
820
  : state.personalOwnerVerified
813
821
  ? await resolveNamedManagedPersonalWorkspaceGrant(db, state)
814
- : null;
822
+ : await apiKeyCallbackGrant(db, state);
815
823
  if (
816
824
  !grant ||
817
825
  grant.accountId !== state.accountId ||
@@ -823,6 +831,25 @@ async function requireOAuthCallbackGrant(db: Database, state: OAuthStatePayload)
823
831
  }
824
832
  }
825
833
 
834
+ async function apiKeyCallbackGrant(
835
+ db: Database,
836
+ state: OAuthStatePayload,
837
+ ): Promise<{ accountId: string; permissions: Parameters<typeof hasPermission>[0] } | null> {
838
+ const prefix = "api_key:";
839
+ if (!state.subjectId.startsWith(prefix)) {
840
+ return null;
841
+ }
842
+ const apiKey = await findActiveWorkspaceApiKeyById(db, {
843
+ accountId: state.accountId,
844
+ workspaceId: state.workspaceId,
845
+ apiKeyId: state.subjectId.slice(prefix.length),
846
+ });
847
+ if (!apiKey) {
848
+ return null;
849
+ }
850
+ return { accountId: apiKey.accountId, permissions: apiKey.permissions };
851
+ }
852
+
826
853
  /** The hosted-Slack profile's origin pins, kept exported for its tests. */
827
854
  export function assertSlackAuthorizationServer(as: AuthorizationServerMetadata): void {
828
855
  const pins = builtInOAuthProfileByKey("hosted-slack-mcp").authorizationServer;
@@ -928,7 +955,7 @@ async function fetchOAuthMetadata(
928
955
  }
929
956
  if (!response.ok) {
930
957
  await cancelResponseBody(response);
931
- throw new Error(`OAuth metadata endpoint returned HTTP ${response.status}`);
958
+ throw new OAuthMetadataUpstreamError(response.status);
932
959
  }
933
960
  const payload = await readResponseJsonBounded<unknown>(
934
961
  response,
@@ -1711,6 +1738,9 @@ function logOAuthStartFailure(
1711
1738
  function oauthStartFailureReason(error: unknown): string {
1712
1739
  if (error instanceof RequestDeadlineError) return "timeout";
1713
1740
  if (error instanceof DestinationPolicyError) return error.reason;
1741
+ if (error instanceof OAuthMetadataUpstreamError) {
1742
+ return `upstream_http_${error.upstreamStatus}`;
1743
+ }
1714
1744
  if (error instanceof HTTPException) return `http_${error.status}`;
1715
1745
  if (error instanceof SyntaxError) return "invalid_response";
1716
1746
  return "request_failed";
@@ -1754,15 +1784,29 @@ function isDatabaseStatementTimeout(error: unknown): boolean {
1754
1784
 
1755
1785
  function oauthStartApiError(error: OAuthStartStageError): ApiHttpError {
1756
1786
  const timeout = error.reason === "timeout";
1757
- const status = timeout ? 408 : error.cause instanceof HTTPException ? error.cause.status : 422;
1787
+ const metadataUpstream = error.cause instanceof OAuthMetadataUpstreamError ? error.cause : null;
1788
+ const status = timeout
1789
+ ? 408
1790
+ : metadataUpstream
1791
+ ? 502
1792
+ : error.cause instanceof HTTPException
1793
+ ? error.cause.status
1794
+ : 422;
1758
1795
  return new ApiHttpError(status, {
1759
1796
  code: timeout || status >= 500 ? "upstream_unavailable" : "validation_failed",
1760
- retryable: timeout || status === 429 || status >= 500,
1797
+ retryable:
1798
+ timeout ||
1799
+ status === 429 ||
1800
+ (metadataUpstream
1801
+ ? metadataUpstream.upstreamStatus === 429 || metadataUpstream.upstreamStatus >= 500
1802
+ : status >= 500),
1761
1803
  message: timeout
1762
1804
  ? oauthStartTimeoutMessage(error.stage)
1763
- : error.cause instanceof HTTPException
1764
- ? error.cause.message
1765
- : `Connection setup failed during ${oauthStartStageLabel(error.stage)}.`,
1805
+ : metadataUpstream
1806
+ ? `OAuth provider returned HTTP ${metadataUpstream.upstreamStatus} during ${oauthStartStageLabel(error.stage)}.`
1807
+ : error.cause instanceof HTTPException
1808
+ ? error.cause.message
1809
+ : `Connection setup failed during ${oauthStartStageLabel(error.stage)}.`,
1766
1810
  details: {
1767
1811
  oauthStage: error.stage,
1768
1812
  oauthReason: error.reason,
@@ -0,0 +1,109 @@
1
+ import type { SessionEvent } from "@opengeni/contracts";
2
+ import { COMMAND_OUTPUT_MAX_BYTES } from "@opengeni/db/session-background-commands";
3
+
4
+ export const COMMAND_READ_MAX_WAIT_SECONDS = 50;
5
+ export const COMMAND_WAIT_DEFAULT_SECONDS = 45;
6
+
7
+ /** Shared provider-neutral command_read/command_wait operation. Notifications
8
+ * are only wake hints. Every result, including timeout, comes from durable DB
9
+ * state. Subscribe before reading to close the finish/subscription race, and
10
+ * periodically recheck durable state because live hints may be lost. */
11
+ export async function readCommandWithWait<
12
+ T extends { terminal: boolean; chunks: unknown[]; hasMore?: boolean },
13
+ >(input: {
14
+ commandId: string;
15
+ waitSeconds?: number;
16
+ maxOutputBytes?: number | undefined;
17
+ signal?: AbortSignal;
18
+ read: () => Promise<T>;
19
+ subscribe: (onEvents: (events: SessionEvent[]) => void) => Promise<() => void>;
20
+ }) {
21
+ const seconds = input.waitSeconds ?? 0;
22
+ if (!Number.isInteger(seconds) || seconds < 0 || seconds > COMMAND_READ_MAX_WAIT_SECONDS) {
23
+ throw new Error("waitSeconds must be between 0 and 50");
24
+ }
25
+ if (
26
+ input.maxOutputBytes !== undefined &&
27
+ (!Number.isInteger(input.maxOutputBytes) ||
28
+ input.maxOutputBytes < 4 ||
29
+ input.maxOutputBytes > COMMAND_OUTPUT_MAX_BYTES)
30
+ ) {
31
+ throw new Error("maxOutputBytes must be between 4 and 65536");
32
+ }
33
+ const started = Date.now();
34
+ const deadline = started + seconds * 1000;
35
+ let unsubscribe: (() => void) | undefined;
36
+ let wake: (() => void) | undefined;
37
+ let changed = false;
38
+ let liveFanout = true;
39
+ const notify = () => {
40
+ changed = true;
41
+ wake?.();
42
+ };
43
+ input.signal?.addEventListener("abort", notify);
44
+ try {
45
+ if (seconds > 0) {
46
+ try {
47
+ unsubscribe = await input.subscribe((events) => {
48
+ if (
49
+ events.some(
50
+ (event) =>
51
+ (event.type === "session.command.finished" ||
52
+ event.type === "sandbox.command.output.delta") &&
53
+ event.payload !== null &&
54
+ typeof event.payload === "object" &&
55
+ (event.payload as Record<string, unknown>).commandId === input.commandId,
56
+ )
57
+ )
58
+ notify();
59
+ });
60
+ } catch {
61
+ liveFanout = false;
62
+ }
63
+ }
64
+ while (true) {
65
+ changed = false;
66
+ const result = await input.read();
67
+ const expired = Date.now() >= deadline;
68
+ if (
69
+ result.terminal ||
70
+ result.chunks.length > 0 ||
71
+ result.hasMore ||
72
+ expired ||
73
+ input.signal?.aborted
74
+ ) {
75
+ return {
76
+ ...result,
77
+ waitedMs: Date.now() - started,
78
+ timedOut:
79
+ seconds > 0 &&
80
+ expired &&
81
+ !result.terminal &&
82
+ result.chunks.length === 0 &&
83
+ !result.hasMore,
84
+ aborted: input.signal?.aborted ?? false,
85
+ liveFanout,
86
+ };
87
+ }
88
+ if (changed) continue;
89
+ await new Promise<void>((resolve) => {
90
+ const timer = setTimeout(
91
+ () => {
92
+ wake = undefined;
93
+ resolve();
94
+ },
95
+ Math.max(0, Math.min(1_000, deadline - Date.now())),
96
+ );
97
+ wake = () => {
98
+ clearTimeout(timer);
99
+ wake = undefined;
100
+ resolve();
101
+ };
102
+ if (changed || input.signal?.aborted) wake();
103
+ });
104
+ }
105
+ } finally {
106
+ input.signal?.removeEventListener("abort", notify);
107
+ unsubscribe?.();
108
+ }
109
+ }