@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/app.js +1 -1
- package/dist/{chunk-J4JS2F7L.js → chunk-R5PDSH2A.js} +177 -99
- package/dist/chunk-R5PDSH2A.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/package.json +12 -12
- package/src/mcp/server.ts +4 -79
- package/src/mcp/toolspace.ts +91 -69
- package/src/routes/codex.ts +138 -8
- package/dist/chunk-J4JS2F7L.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -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" | "
|
|
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.
|
|
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.
|
|
47
|
-
"@opengeni/config": "^0.10.
|
|
48
|
-
"@opengeni/contracts": "^0.38.
|
|
49
|
-
"@opengeni/core": "^0.20.
|
|
50
|
-
"@opengeni/db": "^0.27.
|
|
51
|
-
"@opengeni/documents": "^0.5.
|
|
52
|
-
"@opengeni/events": "^0.3.
|
|
53
|
-
"@opengeni/github": "^0.4.
|
|
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.
|
|
56
|
-
"@opengeni/runtime": "^0.18.
|
|
57
|
-
"@opengeni/storage": "^0.2.
|
|
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
|
|
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
|
|
453
|
-
//
|
|
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.
|
package/src/mcp/toolspace.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
844
|
-
// construction
|
|
845
|
-
//
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
):
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
:
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
+
}
|
package/src/routes/codex.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
);
|