@nmzpy/pi-ember-stack 0.2.1 → 0.2.2
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/README.md +83 -83
- package/package.json +63 -59
- package/plugins/devin-auth/extensions/index.ts +23 -0
- package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -246
- package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -246
- package/plugins/devin-auth/src/cloud-direct/chat.ts +1096 -1091
- package/plugins/devin-auth/src/cloud-direct/index.ts +41 -41
- package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -78
- package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -202
- package/plugins/devin-auth/src/oauth/register-user.ts +174 -174
- package/plugins/devin-auth/src/oauth/types.ts +71 -71
- package/plugins/devin-auth/src/stream.ts +1 -1
- package/plugins/pi-compact-tools/index.ts +19 -1
- package/plugins/pi-compact-tools/renderer.ts +231 -61
- package/plugins/pi-custom-agents/index.ts +310 -102
- package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
- package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
- package/plugins/pi-custom-agents/subagent/extensions/index.ts +116 -224
- package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
- package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
- package/plugins/pi-custom-agents/subagent/extensions/runner.ts +241 -177
- package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
- package/plugins/pi-ember-fff/index.ts +275 -17
- package/plugins/pi-ember-fff/query.ts +170 -10
- package/plugins/pi-ember-fff/test/query.test.ts +157 -1
- package/plugins/pi-ember-fff/test/renderer.test.ts +367 -16
- package/plugins/pi-ember-tps/index.ts +27 -5
- package/plugins/pi-ember-ui/ember.json +3 -0
- package/plugins/pi-ember-ui/index.ts +223 -36
- package/plugins/pi-ember-ui/mode-colors.ts +36 -8
|
@@ -1,246 +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
|
-
}
|
|
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
|
+
}
|