@opengeni/api-router 0.21.10 → 0.21.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createAppComposition,
3
3
  startSlackInteractionPump
4
- } from "./chunk-J4JS2F7L.js";
4
+ } from "./chunk-R5PDSH2A.js";
5
5
 
6
6
  // src/index.ts
7
7
  import {
@@ -13,7 +13,7 @@ export declare const SLACK_TASK_INSTRUCTIONS: string;
13
13
  * selection at session creation while keeping Slack mutations out of the model
14
14
  * surface; interaction delivery remains owned by the durable delivery pump.
15
15
  */
16
- export declare const SLACK_TASK_FIRST_PARTY_MCP_TOOLS: ("artifacts_create" | "artifacts_get_source" | "artifacts_list" | "artifacts_publish" | "artifacts_rollback" | "environment_list" | "environment_set_variable" | "github_connect_link" | "github_repositories_list" | "github_token" | "goal_complete" | "goal_pause" | "goal_set" | "goal_update" | "memory_correct" | "memory_save" | "memory_search" | "preference_registry_get" | "preference_registry_summary" | "rig_get" | "rig_list" | "rig_promote" | "rig_propose_change" | "rig_verify" | "run_on" | "sandbox_attach" | "sandbox_provision" | "sandbox_swap" | "sandboxes_list" | "scheduled_task_runs_list" | "scheduled_tasks_create" | "scheduled_tasks_delete" | "scheduled_tasks_get" | "scheduled_tasks_list" | "scheduled_tasks_pause" | "scheduled_tasks_resume" | "scheduled_tasks_trigger" | "scheduled_tasks_update" | "session_create" | "session_events" | "session_get" | "session_pause" | "session_resume" | "session_send_message" | "session_steer" | "sessions_list" | "set_other_session_title" | "set_session_title" | "slack_bot_channel_history" | "slack_bot_delete_message" | "slack_bot_file_content" | "slack_bot_file_info" | "slack_bot_list_channels" | "slack_bot_list_files" | "slack_bot_list_users" | "slack_bot_post_message" | "slack_bot_thread_replies" | "social_connections_list" | "social_daily_analysis_context" | "social_mentions_live" | "social_post_reply" | "social_posts_recent" | "social_posts_sync" | "social_search_live" | "social_thread_fetch" | "variable_set_list" | "variable_set_set_variable")[];
16
+ export declare const SLACK_TASK_FIRST_PARTY_MCP_TOOLS: ("artifacts_create" | "artifacts_get_source" | "artifacts_list" | "artifacts_publish" | "artifacts_rollback" | "environment_list" | "environment_set_variable" | "github_connect_link" | "github_repositories_list" | "goal_complete" | "goal_pause" | "goal_set" | "goal_update" | "memory_correct" | "memory_save" | "memory_search" | "preference_registry_get" | "preference_registry_summary" | "rig_get" | "rig_list" | "rig_promote" | "rig_propose_change" | "rig_verify" | "run_on" | "sandbox_attach" | "sandbox_provision" | "sandbox_swap" | "sandboxes_list" | "scheduled_task_runs_list" | "scheduled_tasks_create" | "scheduled_tasks_delete" | "scheduled_tasks_get" | "scheduled_tasks_list" | "scheduled_tasks_pause" | "scheduled_tasks_resume" | "scheduled_tasks_trigger" | "scheduled_tasks_update" | "session_create" | "session_events" | "session_get" | "session_pause" | "session_resume" | "session_send_message" | "session_steer" | "sessions_list" | "set_other_session_title" | "set_session_title" | "slack_bot_channel_history" | "slack_bot_delete_message" | "slack_bot_file_content" | "slack_bot_file_info" | "slack_bot_list_channels" | "slack_bot_list_files" | "slack_bot_list_users" | "slack_bot_post_message" | "slack_bot_thread_replies" | "social_connections_list" | "social_daily_analysis_context" | "social_mentions_live" | "social_post_reply" | "social_posts_recent" | "social_posts_sync" | "social_search_live" | "social_thread_fetch" | "variable_set_list" | "variable_set_set_variable")[];
17
17
  export type NormalizedSlackInteraction = {
18
18
  providerEventId: string;
19
19
  providerMessageId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "0.21.10",
3
+ "version": "0.21.14",
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,18 +43,18 @@
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@modelcontextprotocol/sdk": "^1.29.0",
45
45
  "@opengeni/agent-proto": "^0.3.0",
46
- "@opengeni/codex": "^0.2.10",
47
- "@opengeni/config": "^0.10.12",
48
- "@opengeni/contracts": "^0.38.2",
49
- "@opengeni/core": "^0.20.12",
50
- "@opengeni/db": "^0.27.9",
51
- "@opengeni/documents": "^0.5.5",
52
- "@opengeni/events": "^0.3.75",
53
- "@opengeni/github": "^0.4.28",
46
+ "@opengeni/codex": "^0.2.11",
47
+ "@opengeni/config": "^0.10.14",
48
+ "@opengeni/contracts": "^0.38.3",
49
+ "@opengeni/core": "^0.20.16",
50
+ "@opengeni/db": "^0.27.11",
51
+ "@opengeni/documents": "^0.5.7",
52
+ "@opengeni/events": "^0.3.77",
53
+ "@opengeni/github": "^0.4.30",
54
54
  "@opengeni/network": "^0.2.0",
55
- "@opengeni/observability": "^0.4.15",
56
- "@opengeni/runtime": "^0.18.11",
57
- "@opengeni/storage": "^0.2.65",
55
+ "@opengeni/observability": "^0.4.16",
56
+ "@opengeni/runtime": "^0.18.15",
57
+ "@opengeni/storage": "^0.2.67",
58
58
  "@temporalio/client": "^1.17.0",
59
59
  "better-auth": "^1.6.14",
60
60
  "hono": "^4.12.18",
package/src/mcp/server.ts CHANGED
@@ -43,7 +43,6 @@ import {
43
43
  getPreferenceRegistryFullContent,
44
44
  getVariableSet,
45
45
  getVariableSetByName,
46
- areGitHubRepositoriesAllowedForWorkspace,
47
46
  listScheduledTaskRuns,
48
47
  listScheduledTasks,
49
48
  listSessionEventPage,
@@ -84,7 +83,6 @@ import {
84
83
  import { appendAndPublishEvents, publishDurableSessionEvents } from "@opengeni/events";
85
84
  import {
86
85
  createSignedState,
87
- createGitHubAppInstallationToken,
88
86
  GitHubAppConfigurationError,
89
87
  githubAppMissingSettings,
90
88
  } from "@opengeni/github";
@@ -233,7 +231,6 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
233
231
  variable_set_set_variable: { allOf: ["variable-sets:manage"] },
234
232
  environment_set_variable: { allOf: ["variable-sets:manage"] },
235
233
  github_connect_link: { allOf: ["github:use"] },
236
- github_token: { sessionRequired: true, allOf: ["github:use"] },
237
234
  github_repositories_list: { allOf: ["github:use"] },
238
235
  social_connections_list: { allOf: ["connections:read"] },
239
236
  social_posts_recent: { allOf: ["connections:read"] },
@@ -443,26 +440,21 @@ export function buildOpenGeniMcpServer(
443
440
  registerSlackBotTools(server, deps, grant, sessionId, json);
444
441
  }
445
442
 
446
- // Orchestration, variableSet, and GitHub status/token tools are permission-gated
443
+ // Orchestration, variableSet, and GitHub status tools are permission-gated
447
444
  // at registration: a grant without the permission does not see the tool.
448
445
  // Sandboxed workers reach this server with the first-party delegated
449
446
  // permission set (firstPartyMcpPermissions in @opengeni/runtime), which is
450
447
  // POWERFUL BY DEFAULT — it carries sessions:*, variable sets:*, and github:use,
451
448
  // so agents can spawn/read sessions, manage variable set variables, inspect
452
- // GitHub connection availability, and refresh scoped tokens for already-bound
453
- // repositories out of the box. A user DEMOTES a specific
449
+ // GitHub connection availability, and use already-bound repository resources
450
+ // out of the box. GitHub installation credentials are refreshed host-side and
451
+ // never returned through a model-visible MCP tool. A user DEMOTES a specific
454
452
  // session by setting a narrower session.firstPartyMcpPermissions (capped to
455
453
  // the creator's own grant); operators still cap what any session can be given.
456
454
  registerWorkspaceOrchestrationTools(server, deps, grant, can, sessionId, toolspaceMode, json);
457
455
  registerVariableSetTools(server, deps, grant, can, json);
458
456
  if (can("github:use")) {
459
457
  registerGitHubConnectTool(server, deps, grant, options, json);
460
- // TOKEN-BROKER (B1): the agent-refreshable git token. Session-scoped (keys off the
461
- // worker-signed sessionId claim so it mints for THIS session's repos), gated on
462
- // the same github:use capability as github_connect_link.
463
- if (sessionId !== null) {
464
- registerGitHubTokenTool(server, deps, grant, sessionId, json);
465
- }
466
458
  }
467
459
 
468
460
  if (!toolspaceMode || can("github:use")) {
@@ -2960,73 +2952,6 @@ function registerGitHubConnectTool(
2960
2952
  );
2961
2953
  }
2962
2954
 
2963
- // TOKEN-BROKER (B1): mint a FRESH short-lived GitHub App installation token for the
2964
- // session's repository resources. The agent calls this to refresh git auth before
2965
- // the current token expires. The MCP server CANNOT write the box, so the tool RETURNS
2966
- // the token as JSON; the agent writes it to the token file (via exec) to refresh
2967
- // GIT_ASKPASS. Same github:use capability gate as github_connect_link.
2968
- function registerGitHubTokenTool(
2969
- server: McpServer,
2970
- deps: ApiRouteDeps,
2971
- grant: AccessGrant,
2972
- sessionId: string,
2973
- json: JsonResult,
2974
- ): void {
2975
- server.registerTool(
2976
- "github_token",
2977
- {
2978
- description:
2979
- "Mint a fresh short-lived GitHub token for this session's repositories. Write it to $OPENGENI_GIT_TOKEN_FILE (default $HOME/.opengeni/git-token) to refresh git auth before the current token expires.",
2980
- inputSchema: {},
2981
- },
2982
- async () => {
2983
- const session = await requireSession(deps.db, grant.workspaceId, sessionId);
2984
- // Resolve the run-scoped installation + repository ids from THIS session's
2985
- // repository resources (same shape sandboxEnvironmentForRun mints against). Only
2986
- // private GitHub-App repos carry the installation/repository ids.
2987
- const selected = (session.resources ?? []).flatMap((resource) => {
2988
- if (resource.kind !== "repository") {
2989
- return [];
2990
- }
2991
- const installationId = resource.githubInstallationId;
2992
- const repositoryId = resource.githubRepositoryId;
2993
- return typeof installationId === "number" &&
2994
- installationId > 0 &&
2995
- typeof repositoryId === "number" &&
2996
- repositoryId > 0
2997
- ? [{ installationId, repositoryId }]
2998
- : [];
2999
- });
3000
- if (selected.length === 0) {
3001
- throw new Error("this session has no GitHub App repository resources to mint a token for");
3002
- }
3003
- const installationId = selected[0]!.installationId;
3004
- if (selected.some((item) => item.installationId !== installationId)) {
3005
- throw new Error("GitHub App repository resources must belong to one installation");
3006
- }
3007
- const repositoryIds = selected.map((item) => item.repositoryId);
3008
- if (
3009
- !(await areGitHubRepositoriesAllowedForWorkspace(
3010
- deps.db,
3011
- grant.workspaceId,
3012
- installationId,
3013
- repositoryIds,
3014
- ))
3015
- ) {
3016
- throw new Error("this workspace no longer authorizes the session's GitHub repositories");
3017
- }
3018
- const token = await createGitHubAppInstallationToken(deps.settings, {
3019
- installationId,
3020
- repositoryIds,
3021
- });
3022
- return json({
3023
- token,
3024
- tokenFile: "$OPENGENI_GIT_TOKEN_FILE (default $HOME/.opengeni/git-token)",
3025
- });
3026
- },
3027
- );
3028
- }
3029
-
3030
2955
  // Defense-in-depth for invariant "agents cannot self-attach": the worker's
3031
2956
  // first-party delegated token never carries variable-sets:use, so sandboxed
3032
2957
  // agents calling these MCP tools cannot attach a variable set.
@@ -45,7 +45,10 @@ import {
45
45
  boundedParallelMap,
46
46
  cancelMcpResponseBody,
47
47
  guardedMcpFetch,
48
+ mcpJsonRpcErrorPayloadForRequest,
49
+ mcpRequestReplayInfo,
48
50
  mcpSerializedSizeBytes,
51
+ type McpRequestReplayInfo,
49
52
  } from "@opengeni/runtime/mcp-network";
50
53
  import { Buffer } from "node:buffer";
51
54
  import { createHash } from "node:crypto";
@@ -85,13 +88,21 @@ const TOOLSPACE_AUTH_NEEDED_ERROR = {
85
88
  code: 40_101,
86
89
  message: "Authentication required - a connection link was posted to the session.",
87
90
  } as const;
91
+ const TOOLSPACE_TOOL_OUTCOME_UNCERTAIN_ERROR = {
92
+ code: 40_102,
93
+ message:
94
+ "Tool outcome uncertain: the provider returned 401 after receiving the request. OpenGeni did not replay this call. Do not retry automatically; verify provider state before any new attempt.",
95
+ } as const;
88
96
  const TOOLSPACE_NO_ACTIVE_TURN_MESSAGE =
89
97
  "no active turn - toolspace calls require an in-flight turn";
90
98
  // First-party OpenGeni MCP proxies (files/docs) route back through the same
91
99
  // /mcp mount. They are excluded from the toolspace surface by construction so a
92
100
  // toolspace principal can never re-enter /mcp as a first-party caller, even if
93
101
  // a future grant carried files:read / documents:search (see docs invariants).
94
- const FIRST_PARTY_PROXY_IDS = new Set(["files", "docs"]);
102
+ // Codex Apps is also excluded: its dynamic designated-owner authorization and
103
+ // wire sanitizer live on the model MCP path and must never degrade into static
104
+ // Toolspace headers.
105
+ const FIRST_PARTY_PROXY_IDS = new Set(["files", "docs", "codex_apps"]);
95
106
  // In-process cache of the per-session upstream tool listing. Keyed on the set of
96
107
  // proxyable server ids + their credential versions, so a credential rotation
97
108
  // busts the entry; a short TTL bounds staleness for everything else. This is
@@ -754,6 +765,9 @@ async function callRemoteTool(
754
765
  if (isToolspaceAuthNeededError(error)) {
755
766
  return mcpError(TOOLSPACE_AUTH_NEEDED_ERROR.message);
756
767
  }
768
+ if (isToolspaceOutcomeUncertainError(error)) {
769
+ return mcpError(TOOLSPACE_TOOL_OUTCOME_UNCERTAIN_ERROR.message);
770
+ }
757
771
  // The raw upstream error can carry provider-specific detail; log it
758
772
  // server-side and return only a generic result to the sandbox so no header
759
773
  // or credential material can ride the message back out.
@@ -840,9 +854,9 @@ function selectedMcpServerIds(tools: ToolRef[], sessionServerIds: string[]): Set
840
854
  }
841
855
 
842
856
  // Whether a selected server id may enter the toolspace proxy at all. The
843
- // first-party OpenGeni tool server and the files/docs proxies are excluded by
844
- // construction: they route back through /mcp, so admitting them would let a
845
- // toolspace principal re-enter as a first-party caller (recursion guard).
857
+ // The first-party OpenGeni tool server and files/docs proxies are excluded by
858
+ // construction because they route back through /mcp (recursion guard). Codex
859
+ // Apps is excluded because Toolspace does not own its dynamic authorization.
846
860
  export function toolspaceCanProxyServerId(serverId: string): boolean {
847
861
  return serverId !== "opengeni" && !FIRST_PARTY_PROXY_IDS.has(serverId);
848
862
  }
@@ -891,12 +905,6 @@ function toolspaceRequestOptions(config: McpServerConfig): {
891
905
  return config.timeoutMs ? { timeout: config.timeoutMs, maxTotalTimeout: config.timeoutMs } : {};
892
906
  }
893
907
 
894
- type McpRequestInfo = {
895
- method?: string;
896
- id?: string | number | null;
897
- toolName?: string;
898
- };
899
-
900
908
  function mcpRequestDestinationUrl(input: string | URL | Request): string {
901
909
  return new URL(input instanceof Request ? input.url : input.toString()).toString();
902
910
  }
@@ -950,7 +958,7 @@ export function connectionBrokerFetch(
950
958
  ownerHasWorkspaceMembership: delegatedOwnerHasMembership,
951
959
  });
952
960
  return async (requestInput, init) => {
953
- const request = await mcpRequestInfo(requestInput, init);
961
+ const request = await mcpRequestReplayInfo(requestInput, init);
954
962
  const destinationUrl = mcpRequestDestinationUrl(requestInput);
955
963
  const resolverSubjectId =
956
964
  connectionRef.subjectScope !== "subject" && hostCredentialPort
@@ -978,10 +986,22 @@ export function connectionBrokerFetch(
978
986
  );
979
987
  if (response.status === 401) {
980
988
  await cancelMcpResponseBody(response);
981
- const refreshed = await resolve(true);
989
+ let refreshed: ResolveConnectionCredentialResult;
990
+ try {
991
+ refreshed = await resolve(true);
992
+ } catch {
993
+ refreshed = authNeededFromStatus(input.config, first, "refresh_failed");
994
+ }
982
995
  if (refreshed.status === "auth_needed") {
996
+ if (!request.replaySafeAfter401) {
997
+ await publishToolspaceAuthNeeded(input, request, refreshed);
998
+ return toolspaceMcpOutcomeUncertainResponse(request);
999
+ }
983
1000
  return await authNeededFetchResponse(input, request, refreshed);
984
1001
  }
1002
+ if (!request.replaySafeAfter401) {
1003
+ return toolspaceMcpOutcomeUncertainResponse(request);
1004
+ }
985
1005
  const retry = await baseFetch(
986
1006
  fetchInputForAttempt(requestInput),
987
1007
  withConnectionHeaders(requestInput, init, refreshed.headers),
@@ -1044,9 +1064,27 @@ async function authNeededFetchResponse(
1044
1064
  sessionId: string;
1045
1065
  turn: SessionTurn;
1046
1066
  },
1047
- request: McpRequestInfo,
1067
+ request: McpRequestReplayInfo,
1048
1068
  auth: Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }>,
1049
1069
  ): Promise<Response> {
1070
+ await publishToolspaceAuthNeeded(input, request, auth);
1071
+ if (request.method === "tools/call") {
1072
+ return toolspaceMcpErrorResponse(request.id, TOOLSPACE_AUTH_NEEDED_ERROR);
1073
+ }
1074
+ return new Response("Authentication required for MCP server connection", { status: 401 });
1075
+ }
1076
+
1077
+ async function publishToolspaceAuthNeeded(
1078
+ input: {
1079
+ deps: ApiRouteDeps;
1080
+ grant: AccessGrant;
1081
+ config: McpServerConfig;
1082
+ sessionId: string;
1083
+ turn: SessionTurn;
1084
+ },
1085
+ request: McpRequestReplayInfo,
1086
+ auth: Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }>,
1087
+ ): Promise<void> {
1050
1088
  await appendAndPublishEvents(
1051
1089
  input.deps.db,
1052
1090
  input.deps.bus,
@@ -1072,64 +1110,35 @@ async function authNeededFetchResponse(
1072
1110
  },
1073
1111
  ],
1074
1112
  ).catch(() => undefined);
1075
- if (request.method === "tools/call") {
1076
- return new Response(
1077
- JSON.stringify({
1078
- jsonrpc: "2.0",
1079
- id: request.id ?? null,
1080
- error: {
1081
- code: TOOLSPACE_AUTH_NEEDED_ERROR.code,
1082
- message: TOOLSPACE_AUTH_NEEDED_ERROR.message,
1083
- },
1084
- }),
1085
- {
1086
- status: 200,
1087
- headers: { "content-type": "application/json" },
1088
- },
1089
- );
1090
- }
1091
- return new Response("Authentication required for MCP server connection", { status: 401 });
1092
1113
  }
1093
1114
 
1094
- async function mcpRequestInfo(
1095
- input: string | URL | Request,
1096
- init?: RequestInit,
1097
- ): Promise<McpRequestInfo> {
1098
- const body =
1099
- typeof init?.body === "string"
1100
- ? init.body
1101
- : input instanceof Request && (init?.method ?? input.method).toUpperCase() === "POST"
1102
- ? await input
1103
- .clone()
1104
- .text()
1105
- .catch(() => "")
1106
- : "";
1107
- if (!body) {
1108
- return {};
1109
- }
1110
- try {
1111
- const parsed = JSON.parse(body) as {
1112
- id?: unknown;
1113
- method?: unknown;
1114
- params?: { name?: unknown };
1115
- };
1116
- const method = typeof parsed.method === "string" ? parsed.method : undefined;
1117
- const id =
1118
- typeof parsed.id === "string" || typeof parsed.id === "number" || parsed.id === null
1119
- ? parsed.id
1120
- : undefined;
1121
- const toolName =
1122
- method === "tools/call" && typeof parsed.params?.name === "string"
1123
- ? parsed.params.name
1124
- : undefined;
1125
- return {
1126
- ...(method ? { method } : {}),
1127
- ...(id !== undefined ? { id } : {}),
1128
- ...(toolName ? { toolName } : {}),
1129
- };
1130
- } catch {
1131
- return {};
1132
- }
1115
+ function toolspaceMcpErrorResponse(
1116
+ id: string | number | null | undefined,
1117
+ error: { code: number; message: string },
1118
+ ): Response {
1119
+ return new Response(
1120
+ JSON.stringify({
1121
+ jsonrpc: "2.0",
1122
+ id: id ?? null,
1123
+ error,
1124
+ }),
1125
+ {
1126
+ status: 200,
1127
+ headers: { "content-type": "application/json" },
1128
+ },
1129
+ );
1130
+ }
1131
+
1132
+ function toolspaceMcpOutcomeUncertainResponse(request: McpRequestReplayInfo): Response {
1133
+ return new Response(
1134
+ JSON.stringify(
1135
+ mcpJsonRpcErrorPayloadForRequest(request, TOOLSPACE_TOOL_OUTCOME_UNCERTAIN_ERROR),
1136
+ ),
1137
+ {
1138
+ status: 200,
1139
+ headers: { "content-type": "application/json" },
1140
+ },
1141
+ );
1133
1142
  }
1134
1143
 
1135
1144
  function withConnectionHeaders(
@@ -1162,3 +1171,16 @@ function isToolspaceAuthNeededError(error: unknown): boolean {
1162
1171
  `MCP error ${TOOLSPACE_AUTH_NEEDED_ERROR.code}: ${TOOLSPACE_AUTH_NEEDED_ERROR.message}`)
1163
1172
  );
1164
1173
  }
1174
+
1175
+ function isToolspaceOutcomeUncertainError(error: unknown): boolean {
1176
+ if (!(error instanceof Error)) {
1177
+ return false;
1178
+ }
1179
+ const code = (error as { code?: unknown }).code;
1180
+ return (
1181
+ code === TOOLSPACE_TOOL_OUTCOME_UNCERTAIN_ERROR.code &&
1182
+ (error.message === TOOLSPACE_TOOL_OUTCOME_UNCERTAIN_ERROR.message ||
1183
+ error.message ===
1184
+ `MCP error ${TOOLSPACE_TOOL_OUTCOME_UNCERTAIN_ERROR.code}: ${TOOLSPACE_TOOL_OUTCOME_UNCERTAIN_ERROR.message}`)
1185
+ );
1186
+ }
@@ -35,6 +35,8 @@ import {
35
35
  buildCodexTokenResolver,
36
36
  claimCodexResetRedemption,
37
37
  completeCodexResetRedemption,
38
+ clearCodexAppsCredential,
39
+ designateCodexAppsCredential,
38
40
  disconnectAllCodexAccounts,
39
41
  disconnectCodexAccount,
40
42
  encryptEnvironmentValue,
@@ -44,6 +46,7 @@ import {
44
46
  fenceCodexResetRedemptionSend,
45
47
  getCodexResetRedemptionAttempt,
46
48
  getCodexCredentialStatus,
49
+ getCodexAppsSettings,
47
50
  getCodexRotationSettings,
48
51
  listPendingCodexCapacityWakeTargets,
49
52
  listCodexAccountStatuses,
@@ -68,7 +71,14 @@ const CODEX_PROVIDER_LABEL = "Codex subscription · no credits";
68
71
  // The wire shape for one Codex account (metadata only; never the secret column).
69
72
  // P2: fiveHour/weekly ride along, built from the CACHED usage columns (zero
70
73
  // provider calls, zero decrypts) so the bars render instantly off this read.
71
- function codexAccountJson(row: CodexAccountStatus) {
74
+ function codexAccountJson(
75
+ row: CodexAccountStatus,
76
+ options: {
77
+ appsCredentialId?: string | null;
78
+ canManageApps?: boolean;
79
+ humanSubjectId?: string | null;
80
+ } = {},
81
+ ) {
72
82
  return {
73
83
  id: row.id,
74
84
  chatgptAccountId: row.chatgptAccountId,
@@ -98,6 +108,14 @@ function codexAccountJson(row: CodexAccountStatus) {
98
108
  resetCreditsCheckedAt: row.resetCreditsCheckedAt,
99
109
  // P3 rotation cooldown: when set and in the future, this account is cooling-down.
100
110
  exhaustedUntil: row.exhaustedUntil,
111
+ appsDesignated: options.appsCredentialId === row.id,
112
+ canEnableApps:
113
+ options.appsCredentialId === null &&
114
+ options.canManageApps === true &&
115
+ options.humanSubjectId !== null &&
116
+ options.humanSubjectId !== undefined &&
117
+ row.connectedBySubjectId === options.humanSubjectId &&
118
+ row.status === "active",
101
119
  };
102
120
  }
103
121
 
@@ -242,6 +260,28 @@ async function requireRedemptionHuman(
242
260
  return { human, accountId: grant.accountId };
243
261
  }
244
262
 
263
+ async function requireCodexAppsHuman(
264
+ c: Context,
265
+ deps: ApiRouteDeps,
266
+ workspaceId: string,
267
+ ): Promise<{ human: ManagedCookieHuman; accountId: string }> {
268
+ if (c.req.header("authorization")) {
269
+ throw new HTTPException(403, {
270
+ message: "authorization bearer is not allowed for Codex Apps designation",
271
+ });
272
+ }
273
+ requireSameOriginBrowserMutation(c, deps);
274
+ const human = await managedCookieHuman(c, deps);
275
+ if (!human) {
276
+ throw new HTTPException(401, { message: "managed browser session required" });
277
+ }
278
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
279
+ if (grant.subjectId !== human.subjectId) {
280
+ throw new HTTPException(403, { message: "managed browser identity mismatch" });
281
+ }
282
+ return { human, accountId: grant.accountId };
283
+ }
284
+
245
285
  function cachedUsage(row: CodexAccountStatus): CodexUsagePayload | null {
246
286
  const fiveHour = buildCodexUsageWindowFromCache(
247
287
  row.primaryUsedPercent,
@@ -721,15 +761,33 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
721
761
  // workspace active pointer + rotation settings. Read access.
722
762
  app.get("/v1/workspaces/:workspaceId/codex/accounts", async (c) => {
723
763
  const workspaceId = c.req.param("workspaceId");
724
- await requireAccessGrant(c, deps, workspaceId, "workspace:read");
725
- const [accounts, rotation] = await Promise.all([
764
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
765
+ const [accounts, rotation, apps, human] = await Promise.all([
726
766
  listCodexAccountStatuses(db, workspaceId),
727
767
  getCodexRotationSettings(db, workspaceId),
768
+ getCodexAppsSettings(db, workspaceId),
769
+ managedCookieHuman(c, deps),
728
770
  ]);
729
771
  const activeAccountId = rotation?.activeCredentialId ?? null;
772
+ const humanSubjectId = human?.subjectId === grant.subjectId ? human.subjectId : null;
773
+ const canManageApps =
774
+ humanSubjectId !== null && hasPermission(grant.permissions, "connections:write");
730
775
  return c.json({
731
- accounts: accounts.map(codexAccountJson),
776
+ accounts: accounts.map((account) =>
777
+ codexAccountJson(account, {
778
+ appsCredentialId: apps.credentialId,
779
+ canManageApps: settings.codexConnectedAppsEnabled && canManageApps,
780
+ humanSubjectId,
781
+ }),
782
+ ),
732
783
  activeAccountId,
784
+ apps: {
785
+ available: settings.codexConnectedAppsEnabled,
786
+ credentialId: apps.credentialId,
787
+ version: apps.version,
788
+ designatedAt: apps.designatedAt,
789
+ canDisable: canManageApps && apps.credentialId !== null,
790
+ },
733
791
  settings: {
734
792
  rotationEnabled: rotation?.rotationEnabled ?? false,
735
793
  // sharded-rotation policy: rotation-enabled always behaves as sticky-sharded; report the
@@ -740,6 +798,78 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
740
798
  });
741
799
  });
742
800
 
801
+ app.post("/v1/workspaces/:workspaceId/codex/apps", async (c) => {
802
+ const workspaceId = c.req.param("workspaceId");
803
+ if (!settings.codexConnectedAppsEnabled) {
804
+ throw new HTTPException(409, { message: "Codex Apps is disabled for this deployment" });
805
+ }
806
+ const { human, accountId } = await requireCodexAppsHuman(c, deps, workspaceId);
807
+ const parsed = z
808
+ .object({
809
+ accountId: z.string().uuid(),
810
+ expectedVersion: z.number().int().nonnegative(),
811
+ })
812
+ .safeParse(await c.req.json().catch(() => null));
813
+ if (!parsed.success) {
814
+ throw new HTTPException(400, { message: "accountId and expectedVersion are required" });
815
+ }
816
+ const result = await designateCodexAppsCredential(db, {
817
+ accountId,
818
+ workspaceId,
819
+ credentialId: parsed.data.accountId,
820
+ subjectId: human.subjectId,
821
+ expectedVersion: parsed.data.expectedVersion,
822
+ });
823
+ if (result.kind === "not_found") {
824
+ throw new HTTPException(404, { message: "codex account not found" });
825
+ }
826
+ if (result.kind === "not_owner") {
827
+ throw new HTTPException(403, {
828
+ message: "only the managed human who connected this subscription may designate it",
829
+ });
830
+ }
831
+ if (result.kind === "forbidden") {
832
+ throw new HTTPException(403, { message: "missing permission: connections:write" });
833
+ }
834
+ if (result.kind === "unavailable") {
835
+ throw new HTTPException(409, { message: "codex account requires relogin" });
836
+ }
837
+ const response = {
838
+ credentialId: result.credentialId,
839
+ version: result.version,
840
+ designatedAt: result.designatedAt,
841
+ changed: result.kind === "updated",
842
+ };
843
+ return result.kind === "updated" ? c.json(response) : c.json(response, 409);
844
+ });
845
+
846
+ app.delete("/v1/workspaces/:workspaceId/codex/apps", async (c) => {
847
+ const workspaceId = c.req.param("workspaceId");
848
+ const { human, accountId } = await requireCodexAppsHuman(c, deps, workspaceId);
849
+ const parsed = z
850
+ .object({ expectedVersion: z.number().int().nonnegative() })
851
+ .safeParse(await c.req.json().catch(() => null));
852
+ if (!parsed.success) {
853
+ throw new HTTPException(400, { message: "expectedVersion is required" });
854
+ }
855
+ const result = await clearCodexAppsCredential(db, {
856
+ accountId,
857
+ workspaceId,
858
+ subjectId: human.subjectId,
859
+ expectedVersion: parsed.data.expectedVersion,
860
+ });
861
+ if (result.kind === "forbidden") {
862
+ throw new HTTPException(403, { message: "missing permission: connections:write" });
863
+ }
864
+ const response = {
865
+ credentialId: result.credentialId,
866
+ version: result.version,
867
+ designatedAt: result.designatedAt,
868
+ changed: result.kind === "updated",
869
+ };
870
+ return result.kind === "conflict" ? c.json(response, 409) : c.json(response);
871
+ });
872
+
743
873
  // Manually switch the workspace ACTIVE account (the one unpinned sessions use).
744
874
  // Pure pointer flip; in-flight turns pick it up on their next token fetch.
745
875
  app.post("/v1/workspaces/:workspaceId/codex/accounts/:accountId/activate", async (c) => {
@@ -872,13 +1002,13 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
872
1002
  // row was active (FK ON DELETE SET NULL + re-pick in the same RLS txn).
873
1003
  app.delete("/v1/workspaces/:workspaceId/codex/accounts/:accountId", async (c) => {
874
1004
  const workspaceId = c.req.param("workspaceId");
875
- await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
1005
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
876
1006
  const accountId = c.req.param("accountId");
877
1007
  const mutation = await withCodexCapacityMutation(
878
1008
  db,
879
1009
  { workspaceId, reason: "codex_credential_disconnected" },
880
1010
  async (tx) => {
881
- const result = await disconnectCodexAccount(tx, workspaceId, accountId);
1011
+ const result = await disconnectCodexAccount(tx, workspaceId, accountId, grant.subjectId);
882
1012
  return { result, changed: result.removed };
883
1013
  },
884
1014
  );
@@ -897,12 +1027,12 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
897
1027
  // the by-id route above.
898
1028
  app.delete("/v1/workspaces/:workspaceId/codex", async (c) => {
899
1029
  const workspaceId = c.req.param("workspaceId");
900
- await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
1030
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
901
1031
  const mutation = await withCodexCapacityMutation(
902
1032
  db,
903
1033
  { workspaceId, reason: "codex_credentials_disconnected" },
904
1034
  async (tx) => {
905
- const result = await disconnectAllCodexAccounts(tx, workspaceId);
1035
+ const result = await disconnectAllCodexAccounts(tx, workspaceId, grant.subjectId);
906
1036
  return { result, changed: result.removed > 0 };
907
1037
  },
908
1038
  );