@opengeni/api-router 2.6.4 → 2.10.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 (75) hide show
  1. package/dist/access-grant-rls.d.ts +3 -0
  2. package/dist/app.d.ts +2 -1
  3. package/dist/app.js +3 -1
  4. package/dist/auth/organization-user-setup.d.ts +23 -0
  5. package/dist/{chunk-XTLI3CBH.js → chunk-XXH7DW34.js} +6866 -3554
  6. package/dist/chunk-XXH7DW34.js.map +1 -0
  7. package/dist/codemode.d.ts +6 -0
  8. package/dist/github-access.d.ts +25 -2
  9. package/dist/http/request-source.d.ts +14 -0
  10. package/dist/index.js +19 -3
  11. package/dist/index.js.map +1 -1
  12. package/dist/integrations/oauth-client.d.ts +2 -11
  13. package/dist/integrations/personal-github-repositories.d.ts +41 -2
  14. package/dist/integrations/slack-interactions.d.ts +1 -1
  15. package/dist/mcp/server.d.ts +158 -1
  16. package/dist/mcp/session-view.d.ts +56 -2
  17. package/dist/mcp/session-wait.d.ts +1 -1
  18. package/dist/mcp-oauth.d.ts +19 -0
  19. package/dist/model-catalog.d.ts +5 -31
  20. package/dist/routes/codex.d.ts +27 -2
  21. package/dist/routes/github.d.ts +2 -0
  22. package/dist/routes/organization-model-providers.d.ts +3 -0
  23. package/dist/routes/workspace-artifacts.d.ts +2 -1
  24. package/dist/work-discovery-observability.d.ts +1 -1
  25. package/dist/workspace-artifact-content.d.ts +28 -0
  26. package/dist/workspace-artifact-provenance.d.ts +7 -0
  27. package/dist/workspace-deletion.d.ts +6 -0
  28. package/dist/workspace-tool-gateway-observability.d.ts +17 -0
  29. package/dist/workspace-tool-gateway.d.ts +118 -0
  30. package/package.json +19 -18
  31. package/src/access-grant-rls.ts +40 -0
  32. package/src/app.ts +296 -53
  33. package/src/auth/organization-user-setup.ts +95 -5
  34. package/src/codemode.ts +33 -4
  35. package/src/github-access.ts +117 -1
  36. package/src/http/auth.ts +11 -0
  37. package/src/http/request-source.ts +37 -0
  38. package/src/http/sse.ts +15 -7
  39. package/src/index.ts +18 -2
  40. package/src/integrations/oauth-client.ts +168 -203
  41. package/src/integrations/personal-github-repositories.ts +238 -9
  42. package/src/integrations/slack-app-home.ts +2 -2
  43. package/src/integrations/slack-interactions.ts +77 -37
  44. package/src/mcp/company-brain-governed-writes.ts +2 -2
  45. package/src/mcp/company-profile-agent-admin.ts +1 -1
  46. package/src/mcp/remember.ts +1 -1
  47. package/src/mcp/server.ts +504 -133
  48. package/src/mcp/session-view.ts +20 -1
  49. package/src/mcp/session-wait.ts +3 -0
  50. package/src/mcp-oauth.ts +539 -0
  51. package/src/model-catalog.ts +45 -337
  52. package/src/routes/automations.ts +178 -19
  53. package/src/routes/codex.ts +242 -73
  54. package/src/routes/connections.ts +271 -16
  55. package/src/routes/documents.ts +67 -19
  56. package/src/routes/files.ts +54 -6
  57. package/src/routes/github.ts +116 -15
  58. package/src/routes/organization-memberships.ts +59 -16
  59. package/src/routes/organization-model-providers.ts +231 -0
  60. package/src/routes/packs.ts +1 -0
  61. package/src/routes/personal-github.ts +39 -0
  62. package/src/routes/pr-review.ts +72 -4
  63. package/src/routes/scheduled-tasks.ts +40 -13
  64. package/src/routes/sessions.ts +153 -65
  65. package/src/routes/supergrok.ts +3 -2
  66. package/src/routes/workspace-artifacts.ts +176 -67
  67. package/src/routes/workspaces.ts +405 -62
  68. package/src/sandbox/channel-a.ts +17 -4
  69. package/src/work-discovery-observability.ts +3 -3
  70. package/src/workspace-artifact-content.ts +163 -0
  71. package/src/workspace-artifact-provenance.ts +104 -0
  72. package/src/workspace-deletion.ts +64 -0
  73. package/src/workspace-tool-gateway-observability.ts +100 -0
  74. package/src/workspace-tool-gateway.ts +691 -0
  75. package/dist/chunk-XTLI3CBH.js.map +0 -1
@@ -7,7 +7,7 @@
7
7
  // is added to isAuthExempt. Secrets never leave the server: status/usage read the
8
8
  // decrypted token only to call the codex backend; the token is never returned.
9
9
 
10
- import { environmentsEncryptionKeyBytes } from "@opengeni/config";
10
+ import { environmentsEncryptionKeyBytes, productLabelForModelId } from "@opengeni/config";
11
11
  import {
12
12
  accessTokenExpiry,
13
13
  buildCodexUsageWindowFromCache,
@@ -134,6 +134,55 @@ function codexAccountJson(
134
134
  };
135
135
  }
136
136
 
137
+ /**
138
+ * Derive the two distinct cached worker-readiness meanings exposed by the
139
+ * status route. `poolReady` means at least one effective-pool account passes
140
+ * the worker's cached admission predicate. `workerRoutable` additionally
141
+ * honors rotation-off's active-pointer-only rule; it says nothing about a
142
+ * session-specific manual pin.
143
+ */
144
+ export function codexWorkerReadiness(input: {
145
+ effectiveSource: "workspace" | "organization" | "disabled";
146
+ rotationEnabled: boolean;
147
+ activeCredentialId: string | null;
148
+ accounts: ReadonlyArray<
149
+ Pick<
150
+ CodexAccountStatus,
151
+ | "id"
152
+ | "status"
153
+ | "allocatorEnabled"
154
+ | "primaryUsedPercent"
155
+ | "primaryResetAt"
156
+ | "secondaryUsedPercent"
157
+ | "secondaryResetAt"
158
+ | "exhaustedUntil"
159
+ >
160
+ >;
161
+ now: Date;
162
+ }): { poolReady: boolean; workerRoutable: boolean } {
163
+ if (input.effectiveSource === "disabled") {
164
+ return { poolReady: false, workerRoutable: false };
165
+ }
166
+ const windowUsed = (used: number | null, resetAt: Date | null): number =>
167
+ resetAt !== null && resetAt.getTime() <= input.now.getTime() ? 0 : (used ?? 0);
168
+ const eligible = (account: (typeof input.accounts)[number]): boolean =>
169
+ account.status === "active" &&
170
+ account.allocatorEnabled &&
171
+ (account.exhaustedUntil === null || account.exhaustedUntil.getTime() <= input.now.getTime()) &&
172
+ Math.max(
173
+ windowUsed(account.primaryUsedPercent, account.primaryResetAt),
174
+ windowUsed(account.secondaryUsedPercent, account.secondaryResetAt),
175
+ ) < 100;
176
+ const poolReady = input.accounts.some(eligible);
177
+ const activePointerReady = input.accounts.some(
178
+ (account) => account.id === input.activeCredentialId && eligible(account),
179
+ );
180
+ return {
181
+ poolReady,
182
+ workerRoutable: input.rotationEnabled ? poolReady : activePointerReady,
183
+ };
184
+ }
185
+
137
186
  // The /codex/usage{,/refresh,/:id} wire wrapper: the rich normalized payload
138
187
  // carries its own `status`, surfaced at the top level for back-compat with the
139
188
  // existing CodexUsage = { status; usage } shape.
@@ -144,21 +193,16 @@ function codexUsageJson(payload: CodexUsagePayload): {
144
193
  return { status: payload.status, usage: payload };
145
194
  }
146
195
 
147
- export function codexModelsForPicker(liveSlugs: readonly string[]): Array<{
196
+ export function codexModelsForPicker(): Array<{
148
197
  id: string;
149
198
  label: string;
150
199
  provider: string;
151
200
  providerLabel: string;
152
201
  api: "responses";
153
202
  }> {
154
- const available = new Set(liveSlugs);
155
- const missing = CODEX_FALLBACK_MODEL_SLUGS.filter((slug) => !available.has(slug));
156
- if (missing.length > 0) {
157
- throw new Error(`Codex catalog is missing required models: ${missing.join(", ")}`);
158
- }
159
203
  return CODEX_FALLBACK_MODEL_SLUGS.map((slug) => ({
160
204
  id: `${CODEX_MODEL_ID_PREFIX}${slug}`,
161
- label: slug.replace(/^gpt-/, "GPT-"),
205
+ label: productLabelForModelId(slug),
162
206
  provider: CODEX_PROVIDER_ID,
163
207
  providerLabel: CODEX_PROVIDER_LABEL,
164
208
  api: "responses" as const,
@@ -223,7 +267,7 @@ async function managedCookieHuman(
223
267
  };
224
268
  }
225
269
 
226
- async function requireOrganizationCodexHuman(
270
+ export async function requireOrganizationCodexHuman(
227
271
  c: Context,
228
272
  deps: ApiRouteDeps,
229
273
  organizationId: string,
@@ -239,7 +283,9 @@ async function requireOrganizationCodexHuman(
239
283
  };
240
284
  }
241
285
  if (!human) {
242
- throw new HTTPException(401, { message: "organization administrator session required" });
286
+ throw new HTTPException(401, {
287
+ message: "organization administrator session required",
288
+ });
243
289
  }
244
290
  try {
245
291
  await getOrganizationCodexRotationSettings(deps.db, {
@@ -249,7 +295,9 @@ async function requireOrganizationCodexHuman(
249
295
  } catch (error) {
250
296
  const state = nestedPostgresSqlState(error);
251
297
  if (state === "42501") {
252
- throw new HTTPException(403, { message: "organization administration is not authorized" });
298
+ throw new HTTPException(403, {
299
+ message: "organization administration is not authorized",
300
+ });
253
301
  }
254
302
  if (state === "P0002") {
255
303
  throw new HTTPException(404, { message: "organization not found" });
@@ -270,11 +318,13 @@ async function requireWorkspaceCodexManagementSource(
270
318
  });
271
319
  }
272
320
  if (source.effectiveSource === "disabled") {
273
- throw new HTTPException(409, { message: "Codex is disabled for this workspace" });
321
+ throw new HTTPException(409, {
322
+ message: "Codex is disabled for this workspace",
323
+ });
274
324
  }
275
325
  }
276
326
 
277
- function requireSameOriginBrowserMutation(c: Context, deps: ApiRouteDeps): void {
327
+ export function requireSameOriginBrowserMutation(c: Context, deps: ApiRouteDeps): void {
278
328
  const contentType = c.req.header("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
279
329
  if (contentType !== "application/json") {
280
330
  throw new HTTPException(403, {
@@ -287,16 +337,28 @@ function requireSameOriginBrowserMutation(c: Context, deps: ApiRouteDeps): void
287
337
  });
288
338
  }
289
339
  const origin = c.req.header("origin");
340
+ const localOriginMatches =
341
+ deps.settings.productAccessMode === "local" && localBrowserOriginMatchesRequest(c, origin);
290
342
  if (
291
343
  deps.settings.productAccessMode === "local"
292
- ? !localBrowserOriginMatchesRequest(c, origin)
344
+ ? !localOriginMatches
293
345
  : origin !== new URL(deps.settings.publicBaseUrl!).origin
294
346
  ) {
295
347
  throw new HTTPException(403, {
296
348
  message: "same-origin browser request required",
297
349
  });
298
350
  }
299
- if (c.req.header("sec-fetch-site")?.toLowerCase() !== "same-origin") {
351
+ const fetchSite = c.req.header("sec-fetch-site")?.toLowerCase();
352
+ const localFetchSiteMatches =
353
+ localOriginMatches &&
354
+ (fetchSite === "same-origin" ||
355
+ fetchSite === "same-site" ||
356
+ (fetchSite === "cross-site" && localLoopbackOriginMatchesRequest(c, origin)));
357
+ if (
358
+ deps.settings.productAccessMode === "local"
359
+ ? !localFetchSiteMatches
360
+ : fetchSite !== "same-origin"
361
+ ) {
300
362
  throw new HTTPException(403, {
301
363
  message: "same-origin fetch metadata required",
302
364
  });
@@ -333,11 +395,35 @@ function localBrowserOriginMatchesRequest(c: Context, value: string | undefined)
333
395
  }
334
396
  return (
335
397
  origin.protocol === request.protocol &&
336
- origin.hostname === request.hostname &&
337
- (request.port === "" || origin.port === request.port)
398
+ (origin.hostname === request.hostname ||
399
+ (isLoopbackHostname(origin.hostname) && isLoopbackHostname(request.hostname)))
338
400
  );
339
401
  }
340
402
 
403
+ function localLoopbackOriginMatchesRequest(c: Context, value: string | undefined): boolean {
404
+ if (!value) return false;
405
+ try {
406
+ const origin = new URL(value);
407
+ const forwardedProtocol = c.req.header("x-forwarded-proto")?.trim().toLowerCase();
408
+ const protocol = forwardedProtocol ? `${forwardedProtocol}:` : new URL(c.req.url).protocol;
409
+ const forwardedHost = c.req.header("x-forwarded-host") ?? c.req.header("host");
410
+ if (!forwardedHost || /[\s,/?#@\\]/u.test(forwardedHost)) return false;
411
+ const request = new URL(`${protocol}//${forwardedHost}`);
412
+ return (
413
+ origin.origin === value &&
414
+ origin.protocol === request.protocol &&
415
+ isLoopbackHostname(origin.hostname) &&
416
+ isLoopbackHostname(request.hostname)
417
+ );
418
+ } catch {
419
+ return false;
420
+ }
421
+ }
422
+
423
+ function isLoopbackHostname(value: string): boolean {
424
+ return value === "localhost" || value === "[::1]" || /^127(?:\.[0-9]{1,3}){3}$/u.test(value);
425
+ }
426
+
341
427
  async function requireRedemptionHuman(
342
428
  c: Context,
343
429
  deps: ApiRouteDeps,
@@ -386,11 +472,15 @@ async function requireCodexAppsHuman(
386
472
  requireSameOriginBrowserMutation(c, deps);
387
473
  const human = await managedCookieHuman(c, deps);
388
474
  if (!human) {
389
- throw new HTTPException(401, { message: "managed browser session required" });
475
+ throw new HTTPException(401, {
476
+ message: "managed browser session required",
477
+ });
390
478
  }
391
479
  const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
392
480
  if (grant.subjectId !== human.subjectId) {
393
- throw new HTTPException(403, { message: "managed browser identity mismatch" });
481
+ throw new HTTPException(403, {
482
+ message: "managed browser identity mismatch",
483
+ });
394
484
  }
395
485
  return { human, accountId: grant.accountId };
396
486
  }
@@ -715,10 +805,14 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
715
805
  const workspaceId = c.req.param("workspaceId");
716
806
  const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
717
807
  const parsed = z
718
- .object({ mode: z.enum(["automatic", "workspace", "organization", "disabled"]) })
808
+ .object({
809
+ mode: z.enum(["automatic", "workspace", "organization", "disabled"]),
810
+ })
719
811
  .safeParse(await c.req.json().catch(() => null));
720
812
  if (!parsed.success) {
721
- throw new HTTPException(400, { message: "a valid Codex source mode is required" });
813
+ throw new HTTPException(400, {
814
+ message: "a valid Codex source mode is required",
815
+ });
722
816
  }
723
817
  try {
724
818
  return c.json(
@@ -795,7 +889,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
795
889
  const organizationId = c.req.param("organizationId");
796
890
  requireSameOriginBrowserMutation(c, deps);
797
891
  const human = await requireOrganizationCodexHuman(c, deps, organizationId);
798
- const { state } = (await c.req.json().catch(() => null)) as { state?: string };
892
+ const { state } = (await c.req.json().catch(() => null)) as {
893
+ state?: string;
894
+ };
799
895
  const payload = (state
800
896
  ? readSignedState(state, githubStateSecret)
801
897
  : null) as unknown as CodexConnectState | null;
@@ -806,7 +902,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
806
902
  !payload.deviceAuthId ||
807
903
  !payload.userCode
808
904
  ) {
809
- throw new HTTPException(400, { message: "codex connect state is invalid or expired" });
905
+ throw new HTTPException(400, {
906
+ message: "codex connect state is invalid or expired",
907
+ });
810
908
  }
811
909
  if (
812
910
  typeof payload.iat === "number" &&
@@ -849,26 +947,39 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
849
947
  organizationId,
850
948
  actorSubjectId: human.subjectId,
851
949
  });
852
- const upserted = await upsertOrganizationCodexSubscriptionCredential(db, {
853
- organizationId,
854
- actorSubjectId: human.subjectId,
855
- credentialEncrypted: encryptEnvironmentValue(
856
- key,
857
- JSON.stringify({
858
- access_token: tokens.accessToken,
859
- refresh_token: tokens.refreshToken,
860
- id_token: tokens.idToken,
861
- }),
862
- ),
863
- chatgptAccountId: id.chatgptAccountId,
864
- scopes: null,
865
- planType: id.planType,
866
- isFedramp: id.isFedramp,
867
- expiresAt: accessTokenExpiry(tokens.accessToken),
868
- lastRefreshAt: new Date(),
869
- accountEmail: id.email ?? null,
870
- label: id.email ?? id.chatgptAccountId ?? null,
871
- });
950
+ let upserted: Awaited<ReturnType<typeof upsertOrganizationCodexSubscriptionCredential>>;
951
+ try {
952
+ upserted = await upsertOrganizationCodexSubscriptionCredential(db, {
953
+ organizationId,
954
+ actorSubjectId: human.subjectId,
955
+ credentialEncrypted: encryptEnvironmentValue(
956
+ key,
957
+ JSON.stringify({
958
+ access_token: tokens.accessToken,
959
+ refresh_token: tokens.refreshToken,
960
+ id_token: tokens.idToken,
961
+ }),
962
+ ),
963
+ chatgptAccountId: id.chatgptAccountId,
964
+ scopes: null,
965
+ planType: id.planType,
966
+ isFedramp: id.isFedramp,
967
+ expiresAt: accessTokenExpiry(tokens.accessToken),
968
+ lastRefreshAt: new Date(),
969
+ accountEmail: id.email ?? null,
970
+ label: id.email ?? id.chatgptAccountId ?? null,
971
+ });
972
+ } catch (error) {
973
+ const cause = (error as { cause?: unknown } | null)?.cause;
974
+ const message =
975
+ cause instanceof Error ? cause.message : error instanceof Error ? error.message : "";
976
+ if (message.includes("active turns are using it")) {
977
+ throw new HTTPException(409, {
978
+ message: "Codex subscription source cannot change while active turns are using it",
979
+ });
980
+ }
981
+ throw error;
982
+ }
872
983
  await signalCodexCapacityTargets(deps, upserted.wakeTargets);
873
984
  const rotation = await getOrganizationCodexRotationSettings(db, {
874
985
  organizationId,
@@ -927,7 +1038,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
927
1038
  const organizationId = c.req.param("organizationId");
928
1039
  requireSameOriginBrowserMutation(c, deps);
929
1040
  const human = await requireOrganizationCodexHuman(c, deps, organizationId);
930
- const body = (await c.req.json().catch(() => null)) as { label?: unknown } | null;
1041
+ const body = (await c.req.json().catch(() => null)) as {
1042
+ label?: unknown;
1043
+ } | null;
931
1044
  const renamed = await renameOrganizationCodexAccount(db, {
932
1045
  organizationId,
933
1046
  actorSubjectId: human.subjectId,
@@ -967,7 +1080,10 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
967
1080
  throw error;
968
1081
  }
969
1082
  await signalCodexCapacityTargets(deps, result.wakeTargets);
970
- return c.json({ disconnected: result.removed, newActiveId: result.newActiveCredentialId });
1083
+ return c.json({
1084
+ disconnected: result.removed,
1085
+ newActiveId: result.newActiveCredentialId,
1086
+ });
971
1087
  });
972
1088
 
973
1089
  // Begin device-code login: returns the user code + verification URL and a
@@ -1065,6 +1181,8 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1065
1181
  upserted: Awaited<ReturnType<typeof upsertCodexSubscriptionCredential>>;
1066
1182
  isActive: boolean;
1067
1183
  }>(db, { workspaceId, reason: "codex_credential_connected" }, async (tx) => {
1184
+ // The capacity mutation holds the source lock before entering this callback.
1185
+ const sourceBeforeConnect = await getWorkspaceCodexSubscriptionSource(tx, workspaceId);
1068
1186
  const upserted = await upsertCodexSubscriptionCredential(tx, {
1069
1187
  accountId: grant.accountId,
1070
1188
  workspaceId,
@@ -1092,12 +1210,12 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1092
1210
  }
1093
1211
  await ensureCodexRotationSettings(tx, grant.accountId, workspaceId);
1094
1212
  await setInitialActiveCodexCredential(tx, workspaceId, upserted.id);
1095
- const source = await getWorkspaceCodexSubscriptionSource(tx, workspaceId);
1096
1213
  await setWorkspaceCodexSubscriptionModeInTransaction(tx, {
1097
1214
  accountId: grant.accountId,
1098
1215
  workspaceId,
1099
1216
  subjectId: grant.subjectId,
1100
- mode: source.workspaceKind === "personal" ? "automatic" : "workspace",
1217
+ mode: sourceBeforeConnect.workspaceKind === "personal" ? "automatic" : "workspace",
1218
+ effectiveSourceBeforeMutation: sourceBeforeConnect.effectiveSource,
1101
1219
  });
1102
1220
  const rotation = await getCodexRotationSettings(tx, workspaceId);
1103
1221
  return {
@@ -1107,6 +1225,14 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1107
1225
  },
1108
1226
  changed: true,
1109
1227
  };
1228
+ }).catch((error: unknown) => {
1229
+ const cause = (error as { cause?: unknown } | null)?.cause;
1230
+ const message =
1231
+ cause instanceof Error ? cause.message : error instanceof Error ? error.message : "";
1232
+ if (message.includes("active turns are using it")) {
1233
+ throw new HTTPException(409, { message });
1234
+ }
1235
+ throw error;
1110
1236
  });
1111
1237
  const { upserted, isActive } = mutation.result;
1112
1238
  if (upserted.kind === "unresolved_redemption") {
@@ -1116,7 +1242,12 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1116
1242
  });
1117
1243
  }
1118
1244
  await signalCodexCapacityTargets(deps, mutation.wakeTargets);
1119
- return c.json({ status: "connected", plan: id.planType, accountId: upserted.id, isActive });
1245
+ return c.json({
1246
+ status: "connected",
1247
+ plan: id.planType,
1248
+ accountId: upserted.id,
1249
+ isActive,
1250
+ });
1120
1251
  });
1121
1252
 
1122
1253
  // Connection health: the cheapest real call is GET /codex/models (a 200 proves
@@ -1124,15 +1255,13 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1124
1255
  app.get("/v1/workspaces/:workspaceId/codex/status", async (c) => {
1125
1256
  const workspaceId = c.req.param("workspaceId");
1126
1257
  await requireAccessGrant(c, deps, workspaceId, "workspace:read");
1127
- const status = await getCodexCredentialStatus(db, workspaceId);
1128
- if (!status) {
1129
- return c.json({ connected: false });
1130
- }
1131
- const [accounts, source] = await Promise.all([
1258
+ const [status, accounts, source, rotation] = await Promise.all([
1259
+ getCodexCredentialStatus(db, workspaceId),
1132
1260
  listCodexAccountStatuses(db, workspaceId),
1133
1261
  getWorkspaceCodexSubscriptionSource(db, workspaceId),
1262
+ getCodexRotationSettings(db, workspaceId),
1134
1263
  ]);
1135
- const activeRow = accounts.find((account) => account.id === status.credentialId) ?? null;
1264
+ const activeRow = accounts.find((account) => account.id === status?.credentialId) ?? null;
1136
1265
  const activeAccount = activeRow
1137
1266
  ? {
1138
1267
  id: activeRow.id,
@@ -1144,11 +1273,19 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1144
1273
  chatgptAccountId: activeRow.chatgptAccountId,
1145
1274
  }
1146
1275
  : null;
1276
+ const activeCredentialId = status?.credentialId ?? rotation?.activeCredentialId ?? null;
1277
+ const readiness = codexWorkerReadiness({
1278
+ effectiveSource: source.effectiveSource,
1279
+ rotationEnabled: rotation?.rotationEnabled ?? false,
1280
+ activeCredentialId,
1281
+ accounts,
1282
+ now: new Date(),
1283
+ });
1147
1284
  let valid = false;
1148
- let models: ReturnType<typeof codexModelsForPicker> = [];
1285
+ const models = codexModelsForPicker();
1149
1286
  let catalogError: string | null = null;
1150
1287
  try {
1151
- const cred = status.credentialId
1288
+ const cred = status?.credentialId
1152
1289
  ? await loadCodexCredentialForRun(db, settings, workspaceId, status.credentialId)
1153
1290
  : null;
1154
1291
  if (cred) {
@@ -1159,7 +1296,6 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1159
1296
  clientVersion: CODEX_CLIENT_VERSION,
1160
1297
  });
1161
1298
  if (live.ok) {
1162
- models = codexModelsForPicker(live.slugs);
1163
1299
  valid = true;
1164
1300
  } else {
1165
1301
  catalogError = `Codex models request failed with status ${live.status}`;
@@ -1170,11 +1306,14 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1170
1306
  catalogError = error instanceof Error ? error.message : String(error);
1171
1307
  }
1172
1308
  return c.json({
1173
- connected: status.connected,
1174
- plan: status.planType,
1309
+ connected: status?.connected ?? false,
1310
+ plan: status?.planType ?? null,
1175
1311
  valid,
1176
- expiresAt: status.expiresAt,
1177
- lastError: catalogError ?? status.lastError,
1312
+ activeAccountValid: valid,
1313
+ poolReady: readiness.poolReady,
1314
+ workerRoutable: readiness.workerRoutable,
1315
+ expiresAt: status?.expiresAt ?? null,
1316
+ lastError: catalogError ?? status?.lastError ?? null,
1178
1317
  models, // ClientModel[] the picker surfaces under the "no credits" group
1179
1318
  activeAccount, // the account a session runs on when unpinned (label for the indicator)
1180
1319
  accountCount: accounts.length,
@@ -1229,7 +1368,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1229
1368
  const workspaceId = c.req.param("workspaceId");
1230
1369
  await requireWorkspaceCodexManagementSource(deps, workspaceId);
1231
1370
  if (!settings.codexConnectedAppsEnabled) {
1232
- throw new HTTPException(409, { message: "Codex Apps is disabled for this deployment" });
1371
+ throw new HTTPException(409, {
1372
+ message: "Codex Apps is disabled for this deployment",
1373
+ });
1233
1374
  }
1234
1375
  const { human, accountId } = await requireCodexAppsHuman(c, deps, workspaceId);
1235
1376
  const parsed = z
@@ -1239,7 +1380,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1239
1380
  })
1240
1381
  .safeParse(await c.req.json().catch(() => null));
1241
1382
  if (!parsed.success) {
1242
- throw new HTTPException(400, { message: "accountId and expectedVersion are required" });
1383
+ throw new HTTPException(400, {
1384
+ message: "accountId and expectedVersion are required",
1385
+ });
1243
1386
  }
1244
1387
  const result = await designateCodexAppsCredential(db, {
1245
1388
  accountId,
@@ -1257,10 +1400,14 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1257
1400
  });
1258
1401
  }
1259
1402
  if (result.kind === "forbidden") {
1260
- throw new HTTPException(403, { message: "missing permission: connections:write" });
1403
+ throw new HTTPException(403, {
1404
+ message: "missing permission: connections:write",
1405
+ });
1261
1406
  }
1262
1407
  if (result.kind === "unavailable") {
1263
- throw new HTTPException(409, { message: "codex account requires relogin" });
1408
+ throw new HTTPException(409, {
1409
+ message: "codex account requires relogin",
1410
+ });
1264
1411
  }
1265
1412
  const response = {
1266
1413
  credentialId: result.credentialId,
@@ -1288,7 +1435,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1288
1435
  expectedVersion: parsed.data.expectedVersion,
1289
1436
  });
1290
1437
  if (result.kind === "forbidden") {
1291
- throw new HTTPException(403, { message: "missing permission: connections:write" });
1438
+ throw new HTTPException(403, {
1439
+ message: "missing permission: connections:write",
1440
+ });
1292
1441
  }
1293
1442
  const response = {
1294
1443
  credentialId: result.credentialId,
@@ -1345,7 +1494,10 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1345
1494
  if (patch.rotationEnabled === undefined) {
1346
1495
  // Strategy-only writes are a deprecated no-op (no db touch): report the
1347
1496
  // (only) truth. Callers that also flip rotationEnabled fall through.
1348
- return c.json({ rotationStrategy: "sharded", rotationStrategyDeprecated: true });
1497
+ return c.json({
1498
+ rotationStrategy: "sharded",
1499
+ rotationStrategyDeprecated: true,
1500
+ });
1349
1501
  }
1350
1502
  await ensureCodexRotationSettings(db, grant.accountId, workspaceId);
1351
1503
  const mutation = await withCodexCapacityMutation(
@@ -1454,7 +1606,10 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1454
1606
  });
1455
1607
  }
1456
1608
  await signalCodexCapacityTargets(deps, mutation.wakeTargets);
1457
- return c.json({ disconnected: result.removed, newActiveId: result.newActiveCredentialId });
1609
+ return c.json({
1610
+ disconnected: result.removed,
1611
+ newActiveId: result.newActiveCredentialId,
1612
+ });
1458
1613
  });
1459
1614
 
1460
1615
  // Legacy "disconnect all" (old workspace-wide behavior), deprecated in favor of
@@ -1752,10 +1907,14 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1752
1907
  });
1753
1908
  }
1754
1909
  if (adoption.kind === "not_found") {
1755
- throw new HTTPException(409, { message: "redemption recovery state changed" });
1910
+ throw new HTTPException(409, {
1911
+ message: "redemption recovery state changed",
1912
+ });
1756
1913
  }
1757
1914
  if (adoption.kind === "forbidden") {
1758
- throw new HTTPException(403, { message: "redemption owner is unavailable" });
1915
+ throw new HTTPException(403, {
1916
+ message: "redemption owner is unavailable",
1917
+ });
1759
1918
  }
1760
1919
  if (adoption.kind === "conflict") {
1761
1920
  throw new HTTPException(409, {
@@ -1769,7 +1928,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1769
1928
  // and a lost HTTP response must remain replayable after a later health
1770
1929
  // transition without another consume call.
1771
1930
  if (account.status !== "active" && existing?.status !== "completed") {
1772
- throw new HTTPException(403, { message: "redemption credential is unavailable" });
1931
+ throw new HTTPException(403, {
1932
+ message: "redemption credential is unavailable",
1933
+ });
1773
1934
  }
1774
1935
  const secret = settings.betterAuthSecret;
1775
1936
  if (!secret) {
@@ -1968,13 +2129,21 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1968
2129
  if (fenced.kind !== "ready") {
1969
2130
  if (fenced.reason === "confirmation_expired") {
1970
2131
  return c.json(
1971
- { status: "confirmation_expired", attemptId: attempt.id, retryable: true },
2132
+ {
2133
+ status: "confirmation_expired",
2134
+ attemptId: attempt.id,
2135
+ retryable: true,
2136
+ },
1972
2137
  403,
1973
2138
  );
1974
2139
  }
1975
2140
  if (fenced.reason === "credential_unavailable") {
1976
2141
  return c.json(
1977
- { status: "provider_unavailable", attemptId: attempt.id, retryable: true },
2142
+ {
2143
+ status: "provider_unavailable",
2144
+ attemptId: attempt.id,
2145
+ retryable: true,
2146
+ },
1978
2147
  503,
1979
2148
  );
1980
2149
  }