pi-devin-auth 0.1.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.
- package/AGENTS.md +63 -0
- package/LICENSE +21 -0
- package/README.md +64 -0
- package/extensions/index.ts +69 -0
- package/package.json +58 -0
- package/src/cloud-direct/auth.ts +246 -0
- package/src/cloud-direct/catalog.ts +246 -0
- package/src/cloud-direct/chat.ts +1091 -0
- package/src/cloud-direct/index.ts +40 -0
- package/src/cloud-direct/metadata.ts +78 -0
- package/src/cloud-direct/wire.ts +202 -0
- package/src/context-map.ts +170 -0
- package/src/models.ts +135 -0
- package/src/oauth/login.ts +95 -0
- package/src/oauth/register-user.ts +174 -0
- package/src/oauth/types.ts +71 -0
- package/src/stream.ts +341 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# AGENTS.md — pi-devin-auth
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
A [pi](https://pi.dev) coding agent extension that registers the `devin` provider (Cognition / Windsurf) with OAuth login and native streaming. Reuses the cloud-direct gRPC layer from `opencode-windsurf-auth`.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
extensions/index.ts # pi extension entry — registerProvider("devin", { oauth, streamSimple, models })
|
|
11
|
+
src/oauth/
|
|
12
|
+
login.ts # ADAPTED — pi OAuthLoginCallbacks (manual-paste flow)
|
|
13
|
+
register-user.ts # COPIED verbatim — RegisterUser RPC at register.windsurf.com
|
|
14
|
+
types.ts # COPIED verbatim — WindsurfRegion, DEFAULT_REGION
|
|
15
|
+
src/cloud-direct/ # COPIED verbatim — gRPC to server.codeium.com
|
|
16
|
+
auth.ts # GetUserJwt (short-lived JWT mint + in-memory cache)
|
|
17
|
+
chat.ts # GetChatMessage streaming (Connect-RPC + manual protobuf)
|
|
18
|
+
catalog.ts # GetCascadeModelConfigs (per-account model catalog)
|
|
19
|
+
metadata.ts # Metadata proto builder
|
|
20
|
+
wire.ts # Protobuf + Connect-streaming envelope helpers
|
|
21
|
+
index.ts # Public re-exports
|
|
22
|
+
src/context-map.ts # NEW — pi Message[]/Tool[] -> ChatHistoryItem[]/ToolDef[]
|
|
23
|
+
src/stream.ts # NEW — streamDevin: streamSimple impl (CloudChatEvent -> pi events)
|
|
24
|
+
src/models.ts # NEW — dynamic catalog -> ProviderModelConfig[] + fallback
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Build & Test
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install # install deps
|
|
31
|
+
npx tsc --noEmit # typecheck
|
|
32
|
+
npm test # run unit tests (bun test or node --test)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
pi loads extensions via jiti (no build step needed for runtime use).
|
|
36
|
+
|
|
37
|
+
## Key Design Decisions
|
|
38
|
+
|
|
39
|
+
1. **Native streamSimple** (Option B): No background proxy. `streamDevin()` calls `streamChatEvents()` directly and emits pi's `AssistantMessageEventStream` events.
|
|
40
|
+
2. **Manual-paste OAuth**: pi's `OAuthLoginCallbacks` doesn't support loopback servers. We use `redirect_uri=show-auth-token` so the Windsurf SPA renders the token for the user to paste via `callbacks.onPrompt()`.
|
|
41
|
+
3. **Non-expiring token shape**: Windsurf's `RegisterUser` returns a long-lived `api_key` with no refresh token. We set `OAuthCredentials = { refresh: "", access: apiKey, expires: now + 365 days }`. `refreshToken()` is a no-op.
|
|
42
|
+
4. **Dynamic model catalog**: Fetched from `GetCascadeModelConfigs` via `oauth.modifyModels()` after login. Static fallback for offline.
|
|
43
|
+
5. **In-memory JWT cache only**: No disk persistence for the short-lived `user_jwt`. Re-mint cost (~200ms) is negligible.
|
|
44
|
+
|
|
45
|
+
## Token Shapes
|
|
46
|
+
|
|
47
|
+
- **firebaseIdToken**: Short-lived JWT from Auth0 browser sign-in. Exchanged via RegisterUser, then discarded.
|
|
48
|
+
- **api_key**: Long-lived credential from RegisterUser. Format: `devin-session-token$<JWT>`. Used as `Metadata.api_key` in every gRPC call. Stored by pi in `~/.pi/agent/auth.json`.
|
|
49
|
+
- **user_jwt**: Short-lived (~24 min) JWT minted from `GetUserJwt`. Cached in-memory per (apiKey, host). Required alongside api_key for chat RPCs.
|
|
50
|
+
|
|
51
|
+
## Cloud-Direct gRPC
|
|
52
|
+
|
|
53
|
+
All RPCs hit `https://server.codeium.com` over HTTPS with Connect-RPC framing:
|
|
54
|
+
- `GetUserJwt` — unary, `application/proto`
|
|
55
|
+
- `GetCascadeModelConfigs` — unary, `application/proto`
|
|
56
|
+
- `GetChatMessage` — streaming, `application/connect+proto` (gzip-compressed frames)
|
|
57
|
+
|
|
58
|
+
Manual protobuf encoding (no protobuf library). Field numbers hardcoded from mitm captures of Windsurf's language_server traffic.
|
|
59
|
+
|
|
60
|
+
## Related
|
|
61
|
+
|
|
62
|
+
- [opencode-windsurf-auth](https://github.com/rsvedant/opencode-windsurf-auth) — the opencode plugin this is derived from
|
|
63
|
+
- [pi custom-provider docs](https://pi.dev/docs/latest/custom-provider)
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nmzpy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# pi-devin-auth
|
|
2
|
+
|
|
3
|
+
A [pi](https://pi.dev) coding agent extension that adds the **Devin** (Cognition / Windsurf) provider with browser-based OAuth login and native streaming.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
### Via pi (recommended)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:pi-devin-auth
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then enable the extension:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi config
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Manual / local dev
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pi -e ./extensions/index.ts
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or copy `extensions/index.ts` into `~/.pi/agent/extensions/` for auto-discovery.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
### Login
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
/login devin
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
This opens `https://windsurf.com/windsurf/signin` in your browser. After signing in, the page displays an auth token — paste it into the pi prompt. The extension exchanges it for a long-lived Devin API key via `register.windsurf.com`.
|
|
36
|
+
|
|
37
|
+
### Select a model
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
/model devin/swe-1-6
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Logout
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
/logout devin
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
pi --login--> windsurf.com (Auth0) --token--> register.windsurf.com (RegisterUser) --api_key--> ~/.pi/agent/auth.json
|
|
53
|
+
pi --chat--> streamDevin() --> cloud-direct/streamChatEvents() --> server.codeium.com (GetChatMessage gRPC) --> pi events
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The extension reuses the battle-tested cloud-direct gRPC layer from [opencode-windsurf-auth](https://github.com/rsvedant/opencode-windsurf-auth) and wraps it in pi's native `streamSimple` + `oauth` extension API.
|
|
57
|
+
|
|
58
|
+
## Models
|
|
59
|
+
|
|
60
|
+
Models are fetched dynamically from Cognition's `GetCascadeModelConfigs` RPC after login, so the list always reflects what your account tier can access. A static fallback set is included for offline use.
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi extension entry point for the Devin (Cognition) provider.
|
|
3
|
+
*
|
|
4
|
+
* Registers the `devin` provider with pi's ExtensionAPI, wiring up:
|
|
5
|
+
* - OAuth login via Windsurf's browser sign-in flow (`loginDevin`)
|
|
6
|
+
* - A no-op token refresh (Windsurf api_keys are long-lived)
|
|
7
|
+
* - Static model list (dynamic catalog fetch planned for a future version
|
|
8
|
+
* once we've inspected the gRPC GetCascadeModelConfigs response shape)
|
|
9
|
+
* - Streaming chat completions through Devin Cloud (`streamDevin`)
|
|
10
|
+
*
|
|
11
|
+
* The provider does not set baseUrl/apiKey at the provider level —
|
|
12
|
+
* `streamSimple` handles all routing and auth internally using the
|
|
13
|
+
* OAuth-issued api_key.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
17
|
+
import type {
|
|
18
|
+
Api,
|
|
19
|
+
Model,
|
|
20
|
+
OAuthCredentials,
|
|
21
|
+
OAuthLoginCallbacks,
|
|
22
|
+
} from '@earendil-works/pi-ai';
|
|
23
|
+
import { streamDevin } from '../src/stream.js';
|
|
24
|
+
import { loginDevin } from '../src/oauth/login.js';
|
|
25
|
+
import { FALLBACK_MODELS } from '../src/models.js';
|
|
26
|
+
import { DEFAULT_REGION } from '../src/oauth/types.js';
|
|
27
|
+
|
|
28
|
+
const PROVIDER_ID = 'devin';
|
|
29
|
+
const PROVIDER_NAME = 'Devin (Cognition)';
|
|
30
|
+
const OAUTH_NAME = 'Devin (Cognition / Windsurf)';
|
|
31
|
+
const API_IDENTIFIER = 'devin-cloud';
|
|
32
|
+
|
|
33
|
+
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
34
|
+
pi.registerProvider(PROVIDER_ID, {
|
|
35
|
+
name: PROVIDER_NAME,
|
|
36
|
+
api: API_IDENTIFIER,
|
|
37
|
+
// No baseUrl/apiKey at the provider level — streamSimple handles
|
|
38
|
+
// all routing and auth internally via the OAuth-issued api_key.
|
|
39
|
+
models: FALLBACK_MODELS,
|
|
40
|
+
oauth: {
|
|
41
|
+
name: OAUTH_NAME,
|
|
42
|
+
async login(
|
|
43
|
+
callbacks: OAuthLoginCallbacks,
|
|
44
|
+
): Promise<OAuthCredentials> {
|
|
45
|
+
return loginDevin(callbacks, DEFAULT_REGION);
|
|
46
|
+
},
|
|
47
|
+
async refreshToken(
|
|
48
|
+
credentials: OAuthCredentials,
|
|
49
|
+
): Promise<OAuthCredentials> {
|
|
50
|
+
// Windsurf's RegisterUser returns a long-lived api_key
|
|
51
|
+
// with no refresh token. The key is effectively
|
|
52
|
+
// non-expiring, so refreshToken is a no-op — return the
|
|
53
|
+
// same credentials.
|
|
54
|
+
return credentials;
|
|
55
|
+
},
|
|
56
|
+
getApiKey(credentials: OAuthCredentials): string {
|
|
57
|
+
return credentials.access;
|
|
58
|
+
},
|
|
59
|
+
// modifyModels is sync in pi v0.80.x, so we can't do an async
|
|
60
|
+
// catalog fetch here. The static FALLBACK_MODELS list is used
|
|
61
|
+
// for v0.1.0. Dynamic catalog fetch will be added in a future
|
|
62
|
+
// version once we've verified the gRPC response shape.
|
|
63
|
+
modifyModels(models: Model<Api>[]): Model<Api>[] {
|
|
64
|
+
return models;
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
streamSimple: streamDevin,
|
|
68
|
+
});
|
|
69
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-devin-auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi coding agent extension for Devin/Cognition (Windsurf) OAuth + streaming",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"devin",
|
|
9
|
+
"cognition",
|
|
10
|
+
"windsurf",
|
|
11
|
+
"oauth",
|
|
12
|
+
"pi-extension",
|
|
13
|
+
"custom-provider"
|
|
14
|
+
],
|
|
15
|
+
"author": "nmzpy",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/nmzpy/pi-devin-auth.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/nmzpy/pi-devin-auth#readme",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/nmzpy/pi-devin-auth/issues"
|
|
24
|
+
},
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./extensions/index.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"extensions/",
|
|
32
|
+
"src/",
|
|
33
|
+
"README.md",
|
|
34
|
+
"AGENTS.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "node --test --import tsx tests/stream.test.ts"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@earendil-works/pi-ai": "*",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
44
|
+
"@earendil-works/pi-agent-core": "*",
|
|
45
|
+
"@earendil-works/pi-tui": "*",
|
|
46
|
+
"typebox": "*"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@earendil-works/pi-ai": "*",
|
|
50
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
51
|
+
"@types/node": "^25.0.0",
|
|
52
|
+
"tsx": "^4.19.0",
|
|
53
|
+
"typescript": "^5.7.0"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=18.0.0"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint the short-lived `user_jwt` that every chat RPC needs alongside the
|
|
3
|
+
* persistent OAuth-issued `api_key`.
|
|
4
|
+
*
|
|
5
|
+
* POST https://server.codeium.com/exa.auth_pb.AuthService/GetUserJwt
|
|
6
|
+
* Content-Type: application/proto ← unary, NOT streaming
|
|
7
|
+
* Body: GetUserJwtRequest { metadata: Metadata }
|
|
8
|
+
* Response: GetUserJwtResponse { user_jwt: string } (field 1)
|
|
9
|
+
*
|
|
10
|
+
* The returned JWT has a payload like:
|
|
11
|
+
* {
|
|
12
|
+
* "api_key": "devin-synthetic-apikey$account-…$user-…",
|
|
13
|
+
* "auth_uid": "devin-auth-uid$…",
|
|
14
|
+
* "email": "user@example.com",
|
|
15
|
+
* "exp": <unix-seconds>, ← ~24 minute TTL
|
|
16
|
+
* "pro": true,
|
|
17
|
+
* "teams_tier": "TEAMS_TIER_DEVIN_PRO",
|
|
18
|
+
* ...
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* The JWT is signed HS256 by the server — can't be forged client-side. We
|
|
22
|
+
* cache it and refresh shortly before `exp`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import * as crypto from 'crypto';
|
|
26
|
+
import { encodeMessage, iterFields } from './wire.js';
|
|
27
|
+
import { buildMetadata } from './metadata.js';
|
|
28
|
+
|
|
29
|
+
const DEFAULT_HOST = 'https://server.codeium.com';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Polyfill for `AbortSignal.any` — composes multiple signals so the result
|
|
33
|
+
* aborts when ANY input aborts. Built-in in Node ≥20.3 / Bun ≥1.0. Our
|
|
34
|
+
* `engines.node` is `>=18.0.0`, so we ship the fallback ourselves; without
|
|
35
|
+
* it the caller's cancel signal silently disappears on older runtimes
|
|
36
|
+
* (chat-cancel during a `GetUserJwt` mint would keep the network request
|
|
37
|
+
* alive for up to the full 30s timeout).
|
|
38
|
+
*/
|
|
39
|
+
function anySignal(signals: AbortSignal[]): AbortSignal {
|
|
40
|
+
const builtin = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any;
|
|
41
|
+
if (typeof builtin === 'function') return builtin(signals);
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const onAbort = (reason: unknown): void => {
|
|
44
|
+
if (!controller.signal.aborted) controller.abort(reason);
|
|
45
|
+
};
|
|
46
|
+
for (const s of signals) {
|
|
47
|
+
if (s.aborted) {
|
|
48
|
+
onAbort(s.reason);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
s.addEventListener('abort', () => onAbort(s.reason), { once: true });
|
|
52
|
+
}
|
|
53
|
+
return controller.signal;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MintedUserJwt {
|
|
57
|
+
jwt: string;
|
|
58
|
+
/** Unix epoch seconds when the JWT expires. */
|
|
59
|
+
expiresAt: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class CloudAuthError extends Error {
|
|
63
|
+
constructor(message: string, public readonly status?: number) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = 'CloudAuthError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Default mint timeout — 30s is generous (the endpoint responds in ~200ms
|
|
71
|
+
* in steady state) but enough headroom for slow networks. Callers can pass
|
|
72
|
+
* a tighter `signal` to override.
|
|
73
|
+
*/
|
|
74
|
+
const MINT_TIMEOUT_MS = 30_000;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mint a fresh user_jwt by calling exa.auth_pb.AuthService/GetUserJwt.
|
|
78
|
+
* `host` defaults to https://server.codeium.com — pass your tenant URL if your
|
|
79
|
+
* RegisterUser response gave a different host.
|
|
80
|
+
*
|
|
81
|
+
* Always applies an internal 30s timeout so a network stall here can't
|
|
82
|
+
* deadlock every concurrent chat request. If the caller passes a `signal`,
|
|
83
|
+
* we honor whichever fires first via AbortSignal.any.
|
|
84
|
+
*/
|
|
85
|
+
export async function mintUserJwt(
|
|
86
|
+
apiKey: string,
|
|
87
|
+
host: string = DEFAULT_HOST,
|
|
88
|
+
signal?: AbortSignal,
|
|
89
|
+
): Promise<MintedUserJwt> {
|
|
90
|
+
const metadata = buildMetadata({
|
|
91
|
+
apiKey,
|
|
92
|
+
sessionId: crypto.randomUUID(),
|
|
93
|
+
requestId: BigInt(Date.now()),
|
|
94
|
+
triggerId: crypto.randomUUID(),
|
|
95
|
+
});
|
|
96
|
+
// GetUserJwtRequest { metadata: Metadata } — Metadata is field 1
|
|
97
|
+
const req = encodeMessage(1, metadata);
|
|
98
|
+
|
|
99
|
+
// Compose caller signal with our internal timeout via `anySignal` — a
|
|
100
|
+
// small polyfill of `AbortSignal.any` for runtimes (Node 18 / older
|
|
101
|
+
// Bun) that lack the built-in. The previous fallback silently dropped
|
|
102
|
+
// the CALLER's signal on those runtimes, so a chat-cancel during a
|
|
103
|
+
// GetUserJwt mint would keep the network request alive for up to the
|
|
104
|
+
// full 30s timeout.
|
|
105
|
+
const timeoutSignal = AbortSignal.timeout(MINT_TIMEOUT_MS);
|
|
106
|
+
const combinedSignal: AbortSignal = signal
|
|
107
|
+
? anySignal([signal, timeoutSignal])
|
|
108
|
+
: timeoutSignal;
|
|
109
|
+
|
|
110
|
+
const resp = await fetch(`${host.replace(/\/$/, '')}/exa.auth_pb.AuthService/GetUserJwt`, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: {
|
|
113
|
+
'Content-Type': 'application/proto',
|
|
114
|
+
'Connect-Protocol-Version': '1',
|
|
115
|
+
},
|
|
116
|
+
body: req,
|
|
117
|
+
signal: combinedSignal,
|
|
118
|
+
});
|
|
119
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
120
|
+
|
|
121
|
+
if (!resp.ok) {
|
|
122
|
+
const text = buf.toString('utf8');
|
|
123
|
+
throw new CloudAuthError(`GetUserJwt HTTP ${resp.status}: ${text.slice(0, 400)}`, resp.status);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Response is GetUserJwtResponse { user_jwt: string } where user_jwt is
|
|
127
|
+
// field 1, length-delimited. Decode the field properly instead of
|
|
128
|
+
// regex-scanning the whole buffer — the previous regex would pick up
|
|
129
|
+
// any JWT-shaped substring in the response (trace IDs, signature
|
|
130
|
+
// headers, any cached token inadvertently logged) and could even land
|
|
131
|
+
// on a non-user_jwt if Cognition ever embeds another JWT in a sibling
|
|
132
|
+
// field.
|
|
133
|
+
let jwt: string | null = null;
|
|
134
|
+
for (const f of iterFields(buf)) {
|
|
135
|
+
if (f.num === 1 && f.wire === 2 && Buffer.isBuffer(f.value)) {
|
|
136
|
+
const s = (f.value as Buffer).toString('utf8');
|
|
137
|
+
// Sanity-check the shape — defensive: if the cloud ever moves user_jwt
|
|
138
|
+
// out from field 1 we want a clean error, not silently wrong creds.
|
|
139
|
+
// base64url with OPTIONAL `=` padding on each segment. Most modern
|
|
140
|
+
// JWTs omit the `=`, but the spec allows it and a future server-side
|
|
141
|
+
// change could re-introduce it; either way it's still a valid token.
|
|
142
|
+
if (/^eyJ[A-Za-z0-9_-]{10,}={0,2}\.[A-Za-z0-9_-]+={0,2}\.[A-Za-z0-9_-]+={0,2}$/.test(s)) {
|
|
143
|
+
jwt = s;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!jwt) {
|
|
149
|
+
throw new CloudAuthError(
|
|
150
|
+
`GetUserJwt 200 but no field-1 JWT found (${buf.length} bytes): ${buf.toString('utf8').slice(0, 200)}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Decode the payload to get the expiry.
|
|
155
|
+
let expiresAt = Math.floor(Date.now() / 1000) + 600; // fallback: 10 min
|
|
156
|
+
try {
|
|
157
|
+
const parts = jwt.split('.');
|
|
158
|
+
const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4);
|
|
159
|
+
const payload = JSON.parse(
|
|
160
|
+
Buffer.from(pad(parts[1]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'),
|
|
161
|
+
);
|
|
162
|
+
if (typeof payload.exp === 'number') expiresAt = payload.exp;
|
|
163
|
+
} catch { /* fall back to default */ }
|
|
164
|
+
|
|
165
|
+
return { jwt, expiresAt };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ----------------------------------------------------------------------------
|
|
169
|
+
// In-memory cache — refresh ~60s before expiry
|
|
170
|
+
// ----------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
interface CacheEntry {
|
|
173
|
+
jwt: string;
|
|
174
|
+
expiresAt: number;
|
|
175
|
+
apiKey: string;
|
|
176
|
+
host: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Cache is keyed by (apiKey, host). A single shared `cache` slot only holds
|
|
181
|
+
* the MOST RECENTLY USED entry — common case is one account at a time, so
|
|
182
|
+
* a single slot is enough. inFlight is a per-key map so a JWT mint for
|
|
183
|
+
* account A doesn't get returned to a concurrent request for account B.
|
|
184
|
+
*
|
|
185
|
+
* Previously `inFlight` was a singleton — if account A's mint was in flight
|
|
186
|
+
* and a request for account B arrived, B got A's JWT. That's the M1
|
|
187
|
+
* "concurrent requests after account switch get wrong JWT" bug.
|
|
188
|
+
*/
|
|
189
|
+
let cache: CacheEntry | null = null;
|
|
190
|
+
const inFlight = new Map<string, Promise<MintedUserJwt>>();
|
|
191
|
+
/**
|
|
192
|
+
* Monotonic epoch counter. Incremented on every `clearCachedUserJwt()`
|
|
193
|
+
* call so an in-flight mint that started BEFORE the clear can't
|
|
194
|
+
* repopulate the cache after-the-fact. Without this, a logout that
|
|
195
|
+
* happened concurrently with a mint would silently get its just-
|
|
196
|
+
* invalidated JWT cached and served for the next ~24 minutes.
|
|
197
|
+
*/
|
|
198
|
+
let cacheEpoch = 0;
|
|
199
|
+
|
|
200
|
+
function flightKey(apiKey: string, host: string): string {
|
|
201
|
+
return `${host}\x1f${apiKey}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get a cached user_jwt or mint a new one. Refreshes when the cached JWT is
|
|
206
|
+
* within 60s of expiry. Multiple concurrent callers for the SAME (apiKey, host)
|
|
207
|
+
* share the same in-flight mint; concurrent callers for DIFFERENT keys each
|
|
208
|
+
* get their own mint.
|
|
209
|
+
*/
|
|
210
|
+
export async function getCachedUserJwt(apiKey: string, host: string = DEFAULT_HOST, signal?: AbortSignal): Promise<string> {
|
|
211
|
+
const now = Math.floor(Date.now() / 1000);
|
|
212
|
+
if (cache && cache.apiKey === apiKey && cache.host === host && cache.expiresAt > now + 60) {
|
|
213
|
+
return cache.jwt;
|
|
214
|
+
}
|
|
215
|
+
const key = flightKey(apiKey, host);
|
|
216
|
+
const existing = inFlight.get(key);
|
|
217
|
+
if (existing) return (await existing).jwt;
|
|
218
|
+
const promise = mintUserJwt(apiKey, host, signal);
|
|
219
|
+
inFlight.set(key, promise);
|
|
220
|
+
// Snapshot the epoch BEFORE awaiting the mint. If clearCachedUserJwt()
|
|
221
|
+
// fires while we're awaiting (logout-during-mint), the epoch changes
|
|
222
|
+
// and we won't repopulate the cache with the just-invalidated JWT.
|
|
223
|
+
const epochAtStart = cacheEpoch;
|
|
224
|
+
try {
|
|
225
|
+
const minted = await promise;
|
|
226
|
+
if (cacheEpoch === epochAtStart) {
|
|
227
|
+
cache = { jwt: minted.jwt, expiresAt: minted.expiresAt, apiKey, host };
|
|
228
|
+
}
|
|
229
|
+
return minted.jwt;
|
|
230
|
+
} finally {
|
|
231
|
+
inFlight.delete(key);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Drop the in-memory JWT cache. Call after credential changes (logout,
|
|
237
|
+
* account switch) so long-running opencode processes don't keep using a
|
|
238
|
+
* JWT minted from a now-invalid api_key. Also bumps the cache epoch so
|
|
239
|
+
* any in-flight mint racing with this clear can't repopulate cache
|
|
240
|
+
* with the stale JWT after-the-fact.
|
|
241
|
+
*/
|
|
242
|
+
export function clearCachedUserJwt(): void {
|
|
243
|
+
cache = null;
|
|
244
|
+
inFlight.clear();
|
|
245
|
+
cacheEpoch++;
|
|
246
|
+
}
|