@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.0-preview.20260912

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 (166) hide show
  1. package/gui/dist/assets/index-D_t6sCWs.js +115 -0
  2. package/gui/dist/assets/index-EdoPnm9_.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/gui/dist/provider-icons/devin.svg +49 -0
  5. package/gui/dist/provider-icons/omo.svg +42 -0
  6. package/package.json +3 -1
  7. package/src/AGENTS.md +1 -1
  8. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
  9. package/src/adapters/command-code.ts +0 -1
  10. package/src/adapters/cursor/checkpoint-store.ts +37 -0
  11. package/src/adapters/cursor/live-transport.ts +74 -2
  12. package/src/adapters/cursor.ts +6 -0
  13. package/src/adapters/devin/cloud-direct/auth.ts +264 -0
  14. package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
  15. package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
  16. package/src/adapters/devin/cloud-direct/index.ts +65 -0
  17. package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
  18. package/src/adapters/devin/cloud-direct/wire.ts +206 -0
  19. package/src/adapters/devin/live-models.ts +133 -0
  20. package/src/adapters/devin-cli/acp.ts +204 -0
  21. package/src/adapters/devin-cli/adapter.ts +345 -0
  22. package/src/adapters/devin-cli/binary.ts +69 -0
  23. package/src/adapters/devin-cli/models.ts +57 -0
  24. package/src/adapters/devin.ts +326 -0
  25. package/src/adapters/google.ts +1 -1
  26. package/src/adapters/openai-chat.ts +31 -10
  27. package/src/adapters/openai-responses.ts +16 -1
  28. package/src/adapters/registry.ts +25 -1
  29. package/src/bridge.ts +8 -2
  30. package/src/claude/inbound-cache-stabilize.ts +130 -0
  31. package/src/claude/inbound.ts +45 -5
  32. package/src/cli/account-auth.ts +60 -1
  33. package/src/cli/account-extended.ts +23 -30
  34. package/src/cli/account.ts +2 -1
  35. package/src/cli/capabilities.ts +50 -8
  36. package/src/cli/config-command.ts +2 -2
  37. package/src/cli/dispatch.ts +14 -2
  38. package/src/cli/export-command.ts +11 -25
  39. package/src/cli/help.ts +2 -2
  40. package/src/cli/opencode.ts +5 -0
  41. package/src/cli/registry.ts +15 -4
  42. package/src/clients/config-export/cline.ts +71 -0
  43. package/src/clients/config-export/contracts.ts +10 -1
  44. package/src/clients/config-export/model-metadata.ts +33 -0
  45. package/src/clients/config-export/zcode.ts +23 -12
  46. package/src/clients/config-export.ts +156 -4
  47. package/src/codex/account-store.ts +7 -2
  48. package/src/codex/auth-context.ts +1 -1
  49. package/src/codex/catalog/effort.ts +4 -3
  50. package/src/codex/catalog/metadata.ts +8 -4
  51. package/src/codex/catalog/native-models.ts +24 -4
  52. package/src/codex/catalog/parsing.ts +19 -1
  53. package/src/codex/catalog/provider-fetch.ts +57 -0
  54. package/src/codex/catalog/sync.ts +1 -1
  55. package/src/codex/context-compat.ts +97 -0
  56. package/src/codex/context-owner.ts +201 -0
  57. package/src/codex/history-provider.ts +161 -14
  58. package/src/codex/inject-coordination.ts +3 -2
  59. package/src/codex/inject.ts +128 -9
  60. package/src/codex/pool-rotation.ts +8 -292
  61. package/src/codex/quota.ts +41 -12
  62. package/src/codex/retired-model-migration.ts +41 -0
  63. package/src/codex/routing.ts +327 -22
  64. package/src/codex/warmup.ts +2 -2
  65. package/src/combos/failover.ts +5 -0
  66. package/src/combos/resolve.ts +11 -15
  67. package/src/config.ts +46 -14
  68. package/src/generated/compatibility-version.json +293 -113
  69. package/src/grok/grpc-web.ts +120 -0
  70. package/src/grok/reset-coupon-ledger.ts +139 -0
  71. package/src/grok/reset-coupons.ts +278 -0
  72. package/src/integrations/catalog-refresh.ts +1 -1
  73. package/src/integrations/cline-document.ts +73 -0
  74. package/src/integrations/cline-io.ts +149 -0
  75. package/src/integrations/cline-transaction.ts +42 -0
  76. package/src/integrations/config-io.ts +21 -2
  77. package/src/integrations/journal.ts +43 -1
  78. package/src/integrations/omp-yaml-source.ts +123 -4
  79. package/src/integrations/ownership-policy.ts +7 -0
  80. package/src/integrations/ownership.ts +36 -0
  81. package/src/integrations/registry.ts +27 -0
  82. package/src/integrations/state.ts +5 -2
  83. package/src/integrations/store.ts +6 -0
  84. package/src/integrations/writer.ts +53 -9
  85. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  86. package/src/lib/abort.ts +36 -0
  87. package/src/lib/local-destinations.ts +1 -1
  88. package/src/lib/upstream-retry.ts +36 -1
  89. package/src/oauth/account-quota-rank.ts +11 -0
  90. package/src/oauth/callback-server.ts +10 -5
  91. package/src/oauth/devin/api-base.ts +63 -0
  92. package/src/oauth/devin/login.ts +1 -0
  93. package/src/oauth/devin/register-user.ts +186 -0
  94. package/src/oauth/devin/types.ts +71 -0
  95. package/src/oauth/devin-cli.ts +149 -0
  96. package/src/oauth/devin.ts +170 -0
  97. package/src/oauth/generic-account-failover.ts +169 -1
  98. package/src/oauth/index.ts +20 -1
  99. package/src/oauth/login-cli.ts +19 -5
  100. package/src/oauth/pool-kernel.ts +321 -0
  101. package/src/oauth/pool-settings-capability.ts +127 -9
  102. package/src/oauth/store.ts +7 -3
  103. package/src/oauth/token-guardian.ts +1 -1
  104. package/src/providers/codebuddy-models.ts +0 -3
  105. package/src/providers/command-code-efforts.ts +23 -4
  106. package/src/providers/default-aliases.ts +4 -0
  107. package/src/providers/derive.ts +8 -0
  108. package/src/providers/devin-cli-authmode-migration.ts +57 -0
  109. package/src/providers/key-failover.ts +175 -0
  110. package/src/providers/model-rename-startup.ts +21 -1
  111. package/src/providers/qoder-models.ts +0 -1
  112. package/src/providers/quota-key-accounts.ts +50 -0
  113. package/src/providers/quota-routing-cache.ts +65 -6
  114. package/src/providers/quota-types.ts +7 -0
  115. package/src/providers/quota.ts +113 -30
  116. package/src/providers/registry.ts +237 -77
  117. package/src/providers/stale-context-window-migration.ts +92 -0
  118. package/src/providers/zai-responses-migration.ts +45 -0
  119. package/src/quota/reset-observer.ts +2 -1
  120. package/src/quota/reset-seen-store.ts +9 -1
  121. package/src/remote-control/crypto.ts +442 -0
  122. package/src/remote-control/host.ts +175 -0
  123. package/src/remote-control/index.ts +100 -0
  124. package/src/remote-control/protocol.ts +200 -0
  125. package/src/remote-control/relay.ts +162 -0
  126. package/src/remote-control/workspace-agent-protocol.ts +246 -0
  127. package/src/remote-control/workspace-rpc-framing.ts +128 -0
  128. package/src/remote-control/workspace-tools.ts +237 -0
  129. package/src/remote-control/workspace-utf8.ts +24 -0
  130. package/src/responses/custom-tool-compat.ts +23 -0
  131. package/src/router.ts +6 -1
  132. package/src/routing/compatibility/behavior.ts +3 -0
  133. package/src/server/adapter-resolve.ts +6 -2
  134. package/src/server/auth-cors.ts +71 -6
  135. package/src/server/chat-completions.ts +5 -3
  136. package/src/server/chat-native.ts +9 -0
  137. package/src/server/claude-messages.ts +25 -30
  138. package/src/server/context-history.ts +207 -0
  139. package/src/server/images.ts +28 -1
  140. package/src/server/index.ts +42 -22
  141. package/src/server/live.ts +2 -1
  142. package/src/server/management/agent-settings-routes.ts +7 -1
  143. package/src/server/management/config-routes.ts +3 -3
  144. package/src/server/management/grok-coupon-routes.ts +287 -0
  145. package/src/server/management/integration-routes.ts +6 -1
  146. package/src/server/management/oauth-account-routes.ts +124 -7
  147. package/src/server/management/provider-routes.ts +77 -2
  148. package/src/server/management/route-registry.ts +6 -1
  149. package/src/server/management-api.ts +7 -0
  150. package/src/server/request-log.ts +4 -0
  151. package/src/server/responses/codex-ws-exchange.ts +90 -14
  152. package/src/server/responses/codex-ws-wire.ts +51 -1
  153. package/src/server/responses/compact.ts +8 -1
  154. package/src/server/responses/core.ts +188 -22
  155. package/src/server/responses/ws-upstream.ts +1 -1
  156. package/src/server/responses-undeclared-tool-guard.ts +261 -12
  157. package/src/server/zai-responses-startup.ts +21 -0
  158. package/src/types/config.ts +33 -3
  159. package/src/types/provider.ts +54 -4
  160. package/src/types/request.ts +1 -1
  161. package/src/types/tools.ts +36 -6
  162. package/src/update/job.ts +33 -11
  163. package/src/vision/plan.ts +40 -37
  164. package/src/web-search/index.ts +1 -0
  165. package/gui/dist/assets/index-BoBRSehJ.css +0 -1
  166. package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Shared types for the OAuth login flow + persisted credentials.
3
+ *
4
+ * Two distinct token shapes appear in this codebase:
5
+ *
6
+ * - `firebaseIdToken` — the short-lived JWT minted by Auth0 / Firebase Auth
7
+ * during browser sign-in. Lives in the OAuth callback URL fragment/query.
8
+ * Treated as opaque and discarded once exchanged.
9
+ *
10
+ * - `apiKey` — the long-lived credential returned by
11
+ * `SeatManagementService.RegisterUser`. Used inside every Cascade RPC's
12
+ * `Metadata.api_key` field. Format is provider-defined:
13
+ * * Cognition era: `devin-session-token$<JWT>`
14
+ * * Codeium classic: bare UUID v4
15
+ * * Older Windsurf: `sk-ws-01-<...>` / `cog_<...>`
16
+ * The plugin treats it as an opaque string — only the cloud cares about format.
17
+ */
18
+
19
+ export interface OAuthLoginResult {
20
+ /** The opaque API key used as `Metadata.api_key` in every Cascade RPC. */
21
+ apiKey: string;
22
+ /** Human-readable account name (`Satvik Kapoor`). */
23
+ name: string;
24
+ /**
25
+ * Cloud API server (`https://server.codeium.com`, `https://eu.windsurf.com/_route/api_server`,
26
+ * `https://windsurf.fedstart.com/_route/api_server`). Driven by the user's
27
+ * tenant — language_server needs this as `--api_server_url`.
28
+ */
29
+ apiServerUrl: string;
30
+ /** Optional cleanup redirect URL returned by RegisterUser. Informational. */
31
+ redirectUrl?: string;
32
+ }
33
+
34
+ export interface PersistedCredentials extends OAuthLoginResult {
35
+ /** ISO timestamp the credentials were minted at — purely informational. */
36
+ issuedAt: string;
37
+ /** Optional tag tracking the OAuth client id used (so a future client rotation can invalidate). */
38
+ oauthClientId: string;
39
+ /**
40
+ * True when these credentials were written as part of the
41
+ * `opencode auth login` → authorize() flow (so opencode's auth.json is the
42
+ * authoritative copy and `opencode auth logout windsurf` should mirror-clear
43
+ * this file). False / absent for credentials written by our standalone
44
+ * `opencode-windsurf-auth login` CLI; those survive opencode auth state
45
+ * changes.
46
+ */
47
+ syncedViaOpencodeAuth?: boolean;
48
+ }
49
+
50
+ export interface WindsurfRegion {
51
+ /** Where to send users for browser sign-in. */
52
+ website: string;
53
+ /** Where to POST RegisterUser. */
54
+ registerApiServerUrl: string;
55
+ /** Auth0 client id passed in the OAuth URL. */
56
+ oauthClientId: string;
57
+ }
58
+
59
+ /**
60
+ * The single tenant (free / personal) configuration. EU, FedStart, and arbitrary
61
+ * portal URLs override `website` + `registerApiServerUrl` at runtime when the
62
+ * user passes `--portal-url` to the login command.
63
+ */
64
+ export const DEFAULT_REGION: WindsurfRegion = {
65
+ website: 'https://windsurf.com',
66
+ registerApiServerUrl: 'https://register.windsurf.com',
67
+ // From /Applications/Windsurf.app/.../extension.js — the public Windsurf
68
+ // Auth0 client. If Windsurf rotates this, sign-in will start failing until
69
+ // we re-extract it.
70
+ oauthClientId: '3GUryQ7ldAeKEuD2obYnppsnmj58eP5u',
71
+ };
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Devin CLI credential import.
3
+ *
4
+ * The installed CLI writes `credentials.toml` after `devin auth login`, and the
5
+ * `windsurf_api_key` in it is an ordinary `devin-session-token$<JWT>` — the same
6
+ * shape RegisterUser returns for `ocx login devin`, and the same one the
7
+ * cloud-direct client already speaks. Measured against a signed-in CLI: it mints
8
+ * a user_jwt, opens the full model catalog, and streams chat.
9
+ *
10
+ * So this is kiro's import-first login with the same substance: adopt a signed-in
11
+ * local CLI's own session rather than starting a browser flow the CLI already
12
+ * completed. No browser is ever opened, because there is nothing left for
13
+ * opencodex to authorize.
14
+ *
15
+ * The file also carries `devin_webapp_host` and `devin_api_url`, which belong to
16
+ * the Devin *session* product (`cog_` keys, agent VMs) rather than to model
17
+ * inference. Neither is read here.
18
+ */
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { posix, win32 } from "node:path";
22
+ import { DEVIN_CLI_INSTALL_HINT } from "../adapters/devin-cli/binary";
23
+ import { identityFromApiKey } from "./devin";
24
+ import { resolveDevinApiBaseUrl } from "./devin/api-base";
25
+ import type { OAuthController, OAuthCredentials } from "./types";
26
+
27
+ /**
28
+ * Structurally the `LoginOpts` from `./index`, restated here rather than imported.
29
+ * `index.ts` imports this module to register the provider, so importing the type
30
+ * back would close a cycle for one optional field this flow does not branch on:
31
+ * an import has nothing to force, so `forceLogin` is a no-op for it.
32
+ */
33
+ type DevinCliLoginOpts = { forceLogin?: boolean };
34
+
35
+ /** Absolute-path override, for a CLI installed somewhere this resolver does not model. */
36
+ export const DEVIN_CLI_CREDENTIALS_ENV = "OPENCODEX_DEVIN_CLI_CREDENTIALS";
37
+
38
+ export interface DevinCliLoginDeps {
39
+ env?: NodeJS.ProcessEnv;
40
+ platform?: NodeJS.Platform;
41
+ exists?: (path: string) => boolean;
42
+ read?: (path: string) => string;
43
+ }
44
+
45
+ /**
46
+ * Where the CLI keeps its own credential.
47
+ *
48
+ * Measured on a live install: `$XDG_DATA_HOME/devin/credentials.toml`, i.e.
49
+ * `~/.local/share/devin/...`, which `devin auth status` prints. Note this is the
50
+ * DATA dir, not the config dir — an earlier draft guessed `~/.config` and was
51
+ * wrong. The Windows branch mirrors the CLI's own installer.
52
+ */
53
+ export function devinCliCredentialsPath(
54
+ env: NodeJS.ProcessEnv = process.env,
55
+ platform: NodeJS.Platform = process.platform,
56
+ ): string {
57
+ const override = env[DEVIN_CLI_CREDENTIALS_ENV]?.trim();
58
+ // Absolute only. A relative override would resolve against whatever directory
59
+ // the proxy happens to be running in, which is not a location a user can mean.
60
+ if (override && (override.startsWith("/") || /^[A-Za-z]:[\\/]/.test(override))) return override;
61
+ const paths = platform === "win32" ? win32 : posix;
62
+ if (platform === "win32") {
63
+ const appData = env.APPDATA ?? paths.join(homedir(), "AppData", "Roaming");
64
+ return paths.join(appData, "devin", "credentials.toml");
65
+ }
66
+ const dataHome = env.XDG_DATA_HOME ?? paths.join(homedir(), ".local", "share");
67
+ return paths.join(dataHome, "devin", "credentials.toml");
68
+ }
69
+
70
+ export interface DevinCliCredentialFile {
71
+ apiKey: string;
72
+ apiServerUrl: string;
73
+ }
74
+
75
+ /**
76
+ * Read the two keys that matter, and nothing else.
77
+ *
78
+ * The measured file is four flat `key = "value"` lines: no tables, no comments,
79
+ * no single quotes. A line matcher is therefore enough and a TOML dependency is
80
+ * not, and the quoted form is required rather than optional — an unquoted
81
+ * matcher would pass its own fixtures and miss the real file.
82
+ *
83
+ * Returns undefined rather than throwing so the caller owns the one error
84
+ * message. Nothing here ever puts the file's contents into a thrown value.
85
+ */
86
+ export function readDevinCliCredentialFile(deps: DevinCliLoginDeps = {}): DevinCliCredentialFile | undefined {
87
+ const path = devinCliCredentialsPath(deps.env, deps.platform);
88
+ const exists = deps.exists ?? existsSync;
89
+ if (!exists(path)) return undefined;
90
+ let raw: string;
91
+ try {
92
+ raw = (deps.read ?? ((p: string) => readFileSync(p, "utf8")))(path);
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ const apiKey = raw.match(/^\s*windsurf_api_key\s*=\s*"([^"]+)"/m)?.[1]?.trim();
97
+ const apiServerUrl = raw.match(/^\s*api_server_url\s*=\s*"([^"]+)"/m)?.[1]?.trim();
98
+ if (!apiKey || !apiServerUrl) return undefined;
99
+ return { apiKey, apiServerUrl };
100
+ }
101
+
102
+ /** True when a signed-in CLI credential is readable. Used for status, never for auth. */
103
+ export function devinCliSignedIn(deps: DevinCliLoginDeps = {}): boolean {
104
+ return readDevinCliCredentialFile(deps) !== undefined;
105
+ }
106
+
107
+ export async function loginDevinCli(
108
+ ctrl: OAuthController,
109
+ _opts?: DevinCliLoginOpts,
110
+ deps: DevinCliLoginDeps = {},
111
+ ): Promise<OAuthCredentials> {
112
+ const file = readDevinCliCredentialFile(deps);
113
+ if (!file) {
114
+ // Deliberately names no path contents and no parsed value. A Connect error
115
+ // can echo a request, and redactSecretString does not recognise a bare JWT
116
+ // or a devin-session-token, which is why register-user.ts refuses to repeat
117
+ // error bodies; the same caution applies to anything thrown from here.
118
+ throw new Error(
119
+ `No signed-in Devin CLI session found. ${DEVIN_CLI_INSTALL_HINT} Then run \`devin auth login\` and try again.`,
120
+ );
121
+ }
122
+ // The host comes off disk and then receives the key, so it passes the same
123
+ // allowlist as the RegisterUser host. An unallowlisted value falls back to the
124
+ // default rather than becoming an exfiltration target.
125
+ const apiBaseUrl = resolveDevinApiBaseUrl(file.apiServerUrl);
126
+ ctrl.onProgress?.("Imported the signed-in Devin CLI session.");
127
+ return {
128
+ access: file.apiKey,
129
+ // Cognition issues a durable key and exposes no refresh endpoint. Carrying
130
+ // the key here rather than "" is the house pattern: an empty refresh makes
131
+ // detectOAuthWarning report stale_credentials from the moment of login.
132
+ refresh: file.apiKey,
133
+ expires: Number.MAX_SAFE_INTEGER,
134
+ source: "local-cli",
135
+ apiBaseUrl,
136
+ ...identityFromApiKey(file.apiKey),
137
+ };
138
+ }
139
+
140
+ export async function refreshDevinCliToken(
141
+ _refreshToken: string,
142
+ _signal?: AbortSignal,
143
+ _credential?: OAuthCredentials,
144
+ ): Promise<OAuthCredentials> {
145
+ // The CLI owns this session and Cognition has no refresh endpoint. Extending
146
+ // the stored expiry would make a revoked key look valid forever; throwing lets
147
+ // the request path mark the account needsReauth instead.
148
+ throw new Error("invalid_grant: the Devin CLI owns this session. Run devin auth login again.");
149
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Devin / Cognition OAuth.
3
+ *
4
+ * Login opens the Auth0 browser sign-in flow (windsurf.com/windsurf/signin
5
+ * with redirect_uri=show-auth-token), then exchanges the pasted Firebase ID
6
+ * token via Cognition's RegisterUser for a long-lived API key.
7
+ */
8
+ import { randomUUID } from "node:crypto";
9
+ import type { OAuthController, OAuthCredentials } from "./types";
10
+ import { DEFAULT_REGION, type WindsurfRegion } from "./devin/types";
11
+ import { registerUser } from "./devin/register-user";
12
+ import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiBaseUrl, validateDevinApiBaseUrl } from "./devin/api-base";
13
+ import { getCredential } from "./store";
14
+
15
+ export { DEVIN_DEFAULT_API_SERVER } from "./devin/api-base";
16
+
17
+ /**
18
+ * The api-server host this account must talk to.
19
+ *
20
+ * RegisterUser hands EU and FedStart tenants a host of their own and it is kept
21
+ * on the credential, so the signed-in account decides the destination. The
22
+ * configured provider baseUrl is the fallback, and the US default is the last
23
+ * resort; both are re-validated because neither is trusted more than the
24
+ * network value.
25
+ */
26
+ export function resolveDevinApiServer(configuredBaseUrl?: string, providerId = "devin"): string {
27
+ return (
28
+ // Provider-scoped. `devin` and `devin-cli` share this transport and this token
29
+ // format but sign in to different accounts, and an EU or FedStart tenant is
30
+ // recorded on the credential rather than in the registry. Reading a fixed
31
+ // "devin" slot sent one provider's key to the other's host.
32
+ validateDevinApiBaseUrl(getCredential(providerId)?.apiBaseUrl) ??
33
+ validateDevinApiBaseUrl(configuredBaseUrl) ??
34
+ DEVIN_DEFAULT_API_SERVER
35
+ );
36
+ }
37
+
38
+ function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
39
+ const parts = token.split(".");
40
+ const payload = parts[1];
41
+ if (parts.length < 2 || !payload) return undefined;
42
+ try {
43
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record<string, unknown>;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ export function identityFromApiKey(apiKey: string): { accountId?: string; email?: string } {
50
+ const jwtPart = apiKey.includes("$") ? apiKey.slice(apiKey.indexOf("$") + 1) : apiKey;
51
+ const payload = decodeJwtPayload(jwtPart);
52
+ const email = typeof payload?.email === "string" && payload.email.length > 0 ? payload.email : undefined;
53
+ const sub = typeof payload?.sub === "string" && payload.sub.length > 0 ? payload.sub : undefined;
54
+ const authUid = typeof payload?.auth_uid === "string" && payload.auth_uid.length > 0 ? payload.auth_uid : undefined;
55
+ return { ...(email ? { email } : {}), ...(sub || authUid ? { accountId: sub ?? authUid } : {}) };
56
+ }
57
+
58
+ function credentialsFromApiKey(
59
+ apiKey: string,
60
+ apiBaseUrl: string,
61
+ source: OAuthCredentials["source"] = "oauth",
62
+ ): OAuthCredentials {
63
+ const identity = identityFromApiKey(apiKey);
64
+ return {
65
+ access: apiKey,
66
+ // Cognition issues a durable key and exposes no refresh endpoint. Carrying
67
+ // the key here rather than "" is the house pattern for durable-key
68
+ // providers: an empty refresh makes detectOAuthWarning report
69
+ // stale_credentials for every Devin account from the moment it logs in.
70
+ refresh: apiKey,
71
+ // No expiry to model. A synthetic one-year deadline only produces a
72
+ // refresh attempt against an endpoint that does not exist.
73
+ expires: Number.MAX_SAFE_INTEGER,
74
+ source,
75
+ apiBaseUrl,
76
+ ...identity,
77
+ };
78
+ }
79
+
80
+ function buildSignInUrl(region: WindsurfRegion): string {
81
+ const params = new URLSearchParams({
82
+ response_type: "token",
83
+ client_id: region.oauthClientId,
84
+ redirect_uri: "show-auth-token",
85
+ state: randomUUID(),
86
+ prompt: "login",
87
+ });
88
+ return region.website + "/windsurf/signin?" + params.toString();
89
+ }
90
+
91
+ /**
92
+ * Shape of the value the sign-in page hands back.
93
+ *
94
+ * It is not always a JWT. A live free-tier sign-in against
95
+ * windsurf.com/windsurf/signin returns a 47-character one-time token of the
96
+ * form `ott$<base64url>`, and RegisterUser accepts it; an earlier JWT-only
97
+ * check here would have rejected every real login. So this is deliberately a
98
+ * shape check for "one opaque credential-looking word" rather than a format
99
+ * check: the point is to tell a token from a pasted URL or a sentence, not to
100
+ * second-guess what the vendor mints.
101
+ */
102
+ const TOKEN_SHAPE = /^[A-Za-z0-9._$~+/=-]{20,4096}$/;
103
+
104
+ const TOKEN_PARAM_NAMES = ["firebase_id_token", "access_token", "id_token", "token"] as const;
105
+
106
+ /**
107
+ * Turn whatever the user pasted into the Firebase ID token RegisterUser expects.
108
+ *
109
+ * The sign-in page shows a bare token, but a user who copies the address bar
110
+ * instead hands us a callback URL whose fragment carries it. Posting that URL
111
+ * as `firebase_id_token` produces an opaque server-side rejection, so pull the
112
+ * token out and refuse a paste that has none rather than sending something that
113
+ * cannot work.
114
+ */
115
+ export function parseDevinAuthPaste(raw: string): string {
116
+ const trimmed = raw.trim();
117
+ if (!trimmed) throw new Error("No auth token pasted; cannot complete Devin sign-in.");
118
+ if (/^https?:\/\//i.test(trimmed)) {
119
+ let url: URL;
120
+ try {
121
+ url = new URL(trimmed);
122
+ } catch {
123
+ throw new Error("That paste is not a usable Devin auth token or sign-in URL.");
124
+ }
125
+ const hash = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
126
+ for (const params of [new URLSearchParams(hash), url.searchParams]) {
127
+ for (const name of TOKEN_PARAM_NAMES) {
128
+ const value = params.get(name)?.trim();
129
+ if (value && TOKEN_SHAPE.test(value)) return value;
130
+ }
131
+ }
132
+ throw new Error("That sign-in URL carries no auth token. Paste the token shown on the Windsurf page instead.");
133
+ }
134
+ if (TOKEN_SHAPE.test(trimmed)) return trimmed;
135
+ throw new Error("That paste is not a Devin auth token. Copy the token shown on the Windsurf sign-in page.");
136
+ }
137
+
138
+ async function loginDevinBrowser(ctrl: OAuthController, region: WindsurfRegion): Promise<OAuthCredentials> {
139
+ const url = buildSignInUrl(region);
140
+ ctrl.onAuth?.({
141
+ url,
142
+ instructions: "Sign in with your Cognition/Devin account, then paste the on-screen auth token here.",
143
+ });
144
+ ctrl.onProgress?.("Waiting for the pasted auth token...");
145
+ const pasted = (await ctrl.onManualCodeInput?.())?.trim();
146
+ if (!pasted) throw new Error("No auth token pasted; cannot complete Devin sign-in.");
147
+ const firebaseIdToken = parseDevinAuthPaste(pasted);
148
+ const result = await registerUser(firebaseIdToken, region, ctrl.signal);
149
+ const credentials = credentialsFromApiKey(result.apiKey, resolveDevinApiBaseUrl(result.apiServerUrl), "oauth");
150
+ // The display name is not an identity. Use it only when the key carried no
151
+ // email, otherwise reauth compares a label against an address and mismatches.
152
+ if (!credentials.email && result.name) credentials.email = result.name;
153
+ return credentials;
154
+ }
155
+
156
+ export async function loginDevin(ctrl: OAuthController): Promise<OAuthCredentials> {
157
+ return loginDevinBrowser(ctrl, DEFAULT_REGION);
158
+ }
159
+
160
+ export async function refreshDevinToken(
161
+ _refreshToken: string,
162
+ _signal?: AbortSignal,
163
+ _credential?: OAuthCredentials,
164
+ ): Promise<OAuthCredentials> {
165
+ // Cognition has no refresh endpoint. Extending the stored expiry here is what
166
+ // the carried implementation did, and it makes a revoked key look valid
167
+ // forever. Throwing lets the request path mark the account needsReauth the
168
+ // first time a forced refresh happens.
169
+ throw new Error("invalid_grant: Devin API keys do not refresh. Run ocx login devin again.");
170
+ }
@@ -16,7 +16,21 @@
16
16
  */
17
17
  import { getAccountSet } from "./store";
18
18
  import { getValidAccessSnapshotForAccount, type OAuthAccessSnapshot } from "./index";
19
- import { exhaustedCooldownMs, hasHeadroomEvidence, isAccountQuotaExhausted, rankAccountsByHeadroom } from "./account-quota-rank";
19
+ import {
20
+ accountHeadroomPercent,
21
+ exhaustedCooldownMs,
22
+ hasHeadroomEvidence,
23
+ isAccountQuotaExhausted,
24
+ rankAccountsByHeadroom,
25
+ } from "./account-quota-rank";
26
+ import {
27
+ genericPoolKey,
28
+ normalizeAccountPoolStickyLimit,
29
+ notePoolRotationSuccess,
30
+ peekRoundRobinAccount,
31
+ pickRoundRobinAccount,
32
+ seedPoolRotationAccount,
33
+ } from "./pool-kernel";
20
34
  import { parseRetryAfterMs } from "../combos/failover";
21
35
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
22
36
  import type { OcxConfig, OcxProviderConfig } from "../types";
@@ -169,6 +183,113 @@ export function eligibleFailoverAccounts(providerName: string, now = Date.now())
169
183
  .map(account => account.id);
170
184
  }
171
185
 
186
+ /** Generic pool strategies the kernel can actually run. `quota` IS the pre-kernel path. */
187
+ type ActiveGenericStrategy = "round-robin" | "fill-first";
188
+
189
+ /** Matches the Codex and Anthropic pools; the DTO still reports `null` for "not stored". */
190
+ const DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD = 80;
191
+
192
+ /**
193
+ * The strategy this provider's pool actually runs, or null for today's behaviour.
194
+ *
195
+ * Three different inputs answer null and they all mean the same thing to a caller: the flag is
196
+ * off, no strategy is stored, or the stored strategy is `quota` — which is precisely what the
197
+ * unflagged code already does. Collapsing them here is what keeps every call site a two-way
198
+ * branch instead of a four-way one.
199
+ */
200
+ function activeGenericStrategy(config: OcxConfig, providerName: string): ActiveGenericStrategy | null {
201
+ if (config.pool?.kernel !== true) return null;
202
+ const raw = config.providers?.[providerName]?.oauthAccountFailover?.strategy;
203
+ return raw === "round-robin" || raw === "fill-first" ? raw : null;
204
+ }
205
+
206
+ function genericStickyLimit(config: OcxConfig, providerName: string): number {
207
+ return normalizeAccountPoolStickyLimit(config.providers?.[providerName]?.oauthAccountFailover?.stickyLimit);
208
+ }
209
+
210
+ /**
211
+ * The FULL roster in a stable order, not the eligible subset.
212
+ *
213
+ * Two load-bearing reasons. The store holds accounts in LOGIN order, so two operators who added
214
+ * the same accounts in a different sequence would otherwise rotate differently; sorting makes
215
+ * the ring a property of the accounts rather than of the history. And walking the eligible
216
+ * subset instead of the full roster changes the wrap order whenever an ineligible id sits
217
+ * between two eligible ones — the bug the Codex and Anthropic copies carry a `stableAll`
218
+ * argument to avoid.
219
+ */
220
+ function stableGenericRoster(providerName: string): string[] {
221
+ const set = getAccountSet(providerName);
222
+ if (!set) return [];
223
+ return set.accounts.map(account => account.id).sort((left, right) => left.localeCompare(right));
224
+ }
225
+
226
+ /**
227
+ * Has this account spent enough of its allowance for fill-first to move on?
228
+ *
229
+ * An unmeasured account reads as UNDER the threshold, matching the Codex pool: a threshold is a
230
+ * statement about observed usage, and treating "no observation" as "spent" would evacuate every
231
+ * quota-less provider off its active account on the very first request.
232
+ */
233
+ function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number): boolean {
234
+ const headroom = accountHeadroomPercent(providerName, accountId);
235
+ if (headroom === null) return false;
236
+ return 100 - headroom >= threshold;
237
+ }
238
+
239
+ /**
240
+ * Fill-first: stay on the active account until it crosses its threshold, then take the next
241
+ * eligible account in the stable ring. Null means "keep the active account".
242
+ */
243
+ function pickFillFirstGenericAccount(
244
+ config: OcxConfig,
245
+ providerName: string,
246
+ activeId: string | undefined,
247
+ now: number,
248
+ ): string | null {
249
+ const stableAll = stableGenericRoster(providerName);
250
+ if (stableAll.length < 2) return null;
251
+ const eligible = new Set(eligibleFailoverAccounts(providerName, now));
252
+ const stored = config.providers?.[providerName]?.oauthAccountFailover?.autoSwitchThreshold;
253
+ const threshold = typeof stored === "number" && Number.isInteger(stored) && stored >= 0 && stored <= 100
254
+ ? stored
255
+ : DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD;
256
+ if (activeId && eligible.has(activeId) && !isOverAutoSwitchThreshold(providerName, activeId, threshold)) {
257
+ return null;
258
+ }
259
+ const start = activeId ? stableAll.indexOf(activeId) : -1;
260
+ const ring = start >= 0 ? [...stableAll.slice(start + 1), ...stableAll.slice(0, start)] : stableAll;
261
+ for (const id of ring) {
262
+ if (id !== activeId && eligible.has(id)) return id;
263
+ }
264
+ return null;
265
+ }
266
+
267
+ /**
268
+ * Advance the round-robin cursor once a dispatch has actually been admitted on this account.
269
+ *
270
+ * The early return is the whole safety story for the core path: this is reached on EVERY
271
+ * generic first dispatch, including quota pools and the fallback after a preferred account was
272
+ * dropped, so anything but round-robin must leave the cursor untouched.
273
+ *
274
+ * The live pick belongs here rather than in the proposal, and that is not stylistic.
275
+ * `peekRoundRobinAccount` never creates the pool state and `notePoolRotationSuccess` returns
276
+ * immediately when there is none, so a peek-only path would leave the ring with nothing to
277
+ * advance and round-robin would propose the same account forever. This is the same shape
278
+ * `commitAnthropicSelectionRouting` already commits with.
279
+ */
280
+ export function noteGenericPoolSelection(config: OcxConfig, providerName: string, accountId: string): void {
281
+ if (activeGenericStrategy(config, providerName) !== "round-robin") return;
282
+ const poolKey = genericPoolKey(providerName);
283
+ const limit = genericStickyLimit(config, providerName);
284
+ const picked = pickRoundRobinAccount(poolKey, eligibleFailoverAccounts(providerName), limit);
285
+ // The resolver may have admitted a different account than the ring proposed: a removal, a
286
+ // reauth verdict or a manual selection can land during credential resolution. Realign the
287
+ // cursor onto what actually served rather than leaving it on a road not taken.
288
+ if (picked !== accountId) seedPoolRotationAccount(poolKey, accountId);
289
+ notePoolRotationSuccess(poolKey, accountId, limit);
290
+ }
291
+
292
+
172
293
  /**
173
294
  * Cool the account that actually 429'd and name the next eligible one, or null.
174
295
  *
@@ -212,6 +333,30 @@ export function rotateGenericOAuthAccountOn429(
212
333
  const ring = start >= 0 ? [...order.slice(start + 1), ...order.slice(0, start)] : order;
213
334
  const candidates = ring.filter(id => id !== failedAccountId && eligible.includes(id));
214
335
  if (candidates.length === 0) return null;
336
+ // The 429 path branches too. Leaving it on the quota ranking would make a configured
337
+ // strategy inert in practice the moment anything actually failed, which is the case the
338
+ // operator chose the strategy for.
339
+ const strategy = activeGenericStrategy(config, providerName);
340
+ if (strategy === "round-robin") {
341
+ // PICK here, not peek: the failure already happened and this answer is the one being used,
342
+ // so the ring genuinely advances.
343
+ return pickRoundRobinAccount(
344
+ genericPoolKey(providerName),
345
+ candidates,
346
+ genericStickyLimit(config, providerName),
347
+ );
348
+ }
349
+ if (strategy === "fill-first") {
350
+ // Not "keep the active account": the one that just 429'd is cooled, so fill-first takes
351
+ // the next eligible account in the stable ring rather than its usual hold.
352
+ const stableAll = stableGenericRoster(providerName);
353
+ const from = stableAll.indexOf(failedAccountId);
354
+ const walk = from >= 0 ? [...stableAll.slice(from + 1), ...stableAll.slice(0, from)] : stableAll;
355
+ for (const id of walk) {
356
+ if (id !== failedAccountId && candidates.includes(id)) return id;
357
+ }
358
+ return null;
359
+ }
215
360
  // With no quota evidence this returns the ring untouched, so providers without
216
361
  // per-account quota keep exactly the traversal they have today.
217
362
  return rankAccountsByHeadroom(providerName, candidates)[0] ?? null;
@@ -259,6 +404,29 @@ export function preferredInitialAccount(
259
404
  const order = selected.accounts.filter(account => account.needsReauth !== true).map(account => account.id);
260
405
  if (order.length < 2) return null;
261
406
 
407
+ // A configured strategy answers this question itself. Both guards below exist to protect the
408
+ // QUOTA answer, and both are fatal to the other two: hasHeadroomEvidence refuses every
409
+ // provider with no quota data, which is exactly where round-robin is the point, and the
410
+ // healthy-active return fires before autoSwitchThreshold can ever be read, so fill-first
411
+ // would never reach its own test. Cooldowns and reauth are still honoured inside each pick.
412
+ const strategy = activeGenericStrategy(config, providerName);
413
+ if (strategy === "round-robin") {
414
+ const eligibleNow = eligibleFailoverAccounts(providerName, now);
415
+ if (eligibleNow.length === 0) return null;
416
+ // PEEK, not pick: this proposal is discardable, and advancing the ring for an account the
417
+ // resolver then rejects would skip a turn for nothing. noteGenericPoolSelection commits.
418
+ const picked = peekRoundRobinAccount(
419
+ genericPoolKey(providerName),
420
+ eligibleNow,
421
+ genericStickyLimit(config, providerName),
422
+ );
423
+ return picked && picked !== active ? picked : null;
424
+ }
425
+ if (strategy === "fill-first") {
426
+ const picked = pickFillFirstGenericAccount(config, providerName, active, now);
427
+ return picked && picked !== active ? picked : null;
428
+ }
429
+
262
430
  const activeRow = selected.accounts.find(account => account.id === active);
263
431
  if (activeRow && activeRow.needsReauth !== true
264
432
  && !isCooled(providerName, activeRow.id, now)
@@ -38,6 +38,8 @@ import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, Re
38
38
  import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt";
39
39
  import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
40
40
  import { loginCursor, refreshCursorToken } from "./cursor";
41
+ import { loginDevin, refreshDevinToken } from "./devin";
42
+ import { loginDevinCli, refreshDevinCliToken } from "./devin-cli";
41
43
  import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
42
44
  import { loginCommandCode, refreshCommandCodeToken } from "./command-code";
43
45
  import { loginMetaMuse, refreshMetaMuseToken } from "./meta-muse";
@@ -308,6 +310,23 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
308
310
  providerConfig: oauthConfig("cursor"),
309
311
  defaultModel: oauthDefaultModel("cursor"),
310
312
  },
313
+ devin: {
314
+ login: (ctrl) => loginDevin(ctrl),
315
+ refresh: refreshDevinToken,
316
+ providerConfig: oauthConfig("devin"),
317
+ defaultModel: oauthDefaultModel("devin"),
318
+ defaultRefreshPolicy: "disabled",
319
+ },
320
+ "devin-cli": {
321
+ // Import-first, the kiro shape: adopt the credential the installed CLI
322
+ // already holds instead of starting a browser flow it has already completed.
323
+ login: (ctrl, opts) => loginDevinCli(ctrl, opts),
324
+ refresh: refreshDevinCliToken,
325
+ providerConfig: oauthConfig("devin-cli"),
326
+ defaultModel: oauthDefaultModel("devin-cli"),
327
+ // The CLI owns the session and Cognition exposes no refresh endpoint.
328
+ defaultRefreshPolicy: "disabled",
329
+ },
311
330
  "github-copilot": {
312
331
  login: (ctrl) => loginGithubCopilot(ctrl),
313
332
  refresh: (rt, signal) => refreshGithubCopilotToken(rt, signal),
@@ -320,7 +339,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
320
339
  login: (ctrl, opts) => loginChatGPT(ctrl, { forceLogin: opts?.forceLogin, flow: opts?.flow }),
321
340
  refresh: (rt) => refreshChatGPTToken(rt),
322
341
  providerConfig: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const },
323
- defaultModel: "gpt-5.4",
342
+ defaultModel: "gpt-5.6-luna",
324
343
  },
325
344
  };
326
345
 
@@ -65,15 +65,29 @@ export function warnIfLiveReloadSkipped(result: LocalProviderReloadResult | null
65
65
  );
66
66
  }
67
67
 
68
+ /**
69
+ * The provider wall is the first thing an unfamiliar user sees, so it names the Codex
70
+ * route before the ~90 provider ids. 'codex' is not in either list on purpose: it is
71
+ * routed to the account-pool login in dispatch.ts, and 'chatgpt' stays off the public
72
+ * OAuth surface (isPublicOAuthProvider) because the pool owns that credential.
73
+ *
74
+ * It names 'openai-apikey' for the same reason it exists at all: 'openai' now routes to
75
+ * the pool, so someone who typed it looking for a platform key no longer sees the list
76
+ * that used to be their only pointer to it.
77
+ */
78
+ export function loginUsageMessage(): string {
79
+ return `Usage: ocx login <provider>\n`
80
+ + ` Codex / ChatGPT: ocx login codex (account pool, needs a running proxy; 'chatgpt' and\n`
81
+ + ` 'openai' are the same route. An OpenAI platform key is 'openai-apikey'.)\n`
82
+ + ` OAuth login: ${listOAuthProviders().join(", ")}\n`
83
+ + ` API-key login: ${Object.keys(KEY_LOGIN_PROVIDERS).join(", ")}`;
84
+ }
85
+
68
86
  export async function handleLogin(provider?: string): Promise<void> {
69
87
  const name = (provider ?? "").trim().toLowerCase();
70
88
  if (isPublicOAuthProvider(name)) return handleOAuthLogin(name);
71
89
  if (isKeyLoginProvider(name)) return handleKeyLogin(name);
72
- console.error(
73
- `Usage: ocx login <provider>\n` +
74
- ` OAuth login: ${listOAuthProviders().join(", ")}\n` +
75
- ` API-key login: ${Object.keys(KEY_LOGIN_PROVIDERS).join(", ")}`,
76
- );
90
+ console.error(loginUsageMessage());
77
91
  process.exit(1);
78
92
  }
79
93