@stage-labs/metro 0.1.0-beta.308 → 0.1.0-beta.309
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/package.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -9
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +11 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +7 -6
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +1 -6
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +53 -29
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +55 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +40 -62
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +82 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +47 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +18 -19
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +36 -64
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +99 -213
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +80 -283
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-import.ts +125 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -17
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-v2.ts +79 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +0 -79
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/org-models.ts +231 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +4 -44
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/text.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +37 -57
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +0 -13
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +3 -4
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +0 -219
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +0 -119
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +0 -160
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-client.ts +0 -46
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-setup.ts +0 -150
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +0 -139
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +0 -236
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +0 -208
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +0 -157
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +0 -82
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-login.ts +0 -192
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-signin.ts +0 -64
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/pkce.ts +0 -36
package/runtime/runtime.json
CHANGED
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
-
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
5
|
-
import { ticketStore } from '@metro-labs/core/tickets';
|
|
6
|
-
import { agentsDir } from '../agents/files.js';
|
|
7
|
-
import { newPkce } from './pkce.js';
|
|
8
|
-
import { nonEmpty } from './text.js';
|
|
9
|
-
|
|
10
|
-
export const CODEX_ISSUER = 'https://auth.openai.com';
|
|
11
|
-
export const CODEX_REDIRECT = 'http://127.0.0.1:1455/auth/callback';
|
|
12
|
-
export const OPENAI_API = 'https://api.openai.com/v1';
|
|
13
|
-
export const NEW_CLIENT = 'dynamic_agent_client';
|
|
14
|
-
export const APP_NAME = 'Metro';
|
|
15
|
-
export const PLAN_SCOPE = 'chatgpt.tokens.use.direct';
|
|
16
|
-
export const CODEX_SIGN_IN = 'Codex is not signed in: sign in with ChatGPT on the Model page.';
|
|
17
|
-
const SCOPE = `openid profile email offline_access resource.invoke ${PLAN_SCOPE}`;
|
|
18
|
-
const PROFILE_CLAIM = 'https://api.openai.com/profile';
|
|
19
|
-
const HOST_FILE = 'chatgpt-host.json';
|
|
20
|
-
const HOST_RE = /^urn:uuid:[0-9a-f-]{36}$/;
|
|
21
|
-
const PENDING_TTL_MS = 10 * 60_000;
|
|
22
|
-
const DEFAULT_TTL_MS = 60 * 60_000;
|
|
23
|
-
const RENEW_BEFORE_MS = 2 * 60_000;
|
|
24
|
-
const AGAIN = 'press Continue with ChatGPT again';
|
|
25
|
-
|
|
26
|
-
export type CodexMethod = 'chatgpt' | 'code';
|
|
27
|
-
|
|
28
|
-
export interface CodexCredential {
|
|
29
|
-
email: string | null;
|
|
30
|
-
accessToken: string;
|
|
31
|
-
refreshToken: string;
|
|
32
|
-
expiresAt: number;
|
|
33
|
-
savedAt: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface ChatgptTokens extends CodexCredential {
|
|
37
|
-
method: 'chatgpt';
|
|
38
|
-
clientId: string;
|
|
39
|
-
subject: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface CodeTokens extends CodexCredential {
|
|
43
|
-
method: 'code';
|
|
44
|
-
accountId: string;
|
|
45
|
-
plan: string | null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export type CodexTokens = ChatgptTokens | CodeTokens;
|
|
49
|
-
|
|
50
|
-
export class CodexAuthError extends Error {}
|
|
51
|
-
|
|
52
|
-
interface Pending {
|
|
53
|
-
verifier: string;
|
|
54
|
-
nonce: string;
|
|
55
|
-
clientId: string | null;
|
|
56
|
-
subject: string | null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const pending = ticketStore<Pending>(PENDING_TTL_MS, 24);
|
|
60
|
-
|
|
61
|
-
export function chatgptHostId(dir = agentsDir()): string {
|
|
62
|
-
const path = join(dir, HOST_FILE);
|
|
63
|
-
const saved = readJson<unknown>(path, null);
|
|
64
|
-
if (isRecord(saved) && typeof saved.hostId === 'string' && HOST_RE.test(saved.hostId)) return saved.hostId;
|
|
65
|
-
const made = `urn:uuid:${randomUUID()}`;
|
|
66
|
-
writeSecure(path, JSON.stringify({ hostId: made }));
|
|
67
|
-
return made;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function returning(previous: ChatgptTokens | null): Record<string, string> {
|
|
71
|
-
if (previous === null) return { client_id: NEW_CLIENT, agent_name_hint: APP_NAME };
|
|
72
|
-
return { client_id: previous.clientId, ...(previous.email === null ? {} : { login_hint: previous.email }) };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function beginLogin(previous: ChatgptTokens | null, hostId: string, issuer = CODEX_ISSUER, now = Date.now()): string {
|
|
76
|
-
const { verifier, challenge } = newPkce();
|
|
77
|
-
const nonce = randomBytes(32).toString('base64url');
|
|
78
|
-
const { ticket: state } = pending.mint({ verifier, nonce, clientId: previous?.clientId ?? null, subject: previous?.subject ?? null }, now);
|
|
79
|
-
const query = new URLSearchParams({
|
|
80
|
-
...returning(previous),
|
|
81
|
-
ext_agent_host_id: hostId,
|
|
82
|
-
response_type: 'code',
|
|
83
|
-
redirect_uri: CODEX_REDIRECT,
|
|
84
|
-
scope: SCOPE,
|
|
85
|
-
resource: OPENAI_API,
|
|
86
|
-
state,
|
|
87
|
-
nonce,
|
|
88
|
-
code_challenge_method: 'S256',
|
|
89
|
-
code_challenge: challenge,
|
|
90
|
-
});
|
|
91
|
-
return `${issuer}/api/accounts/authorize?${query.toString()}`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function queryOf(raw: string): URLSearchParams {
|
|
95
|
-
const text = raw.trim();
|
|
96
|
-
try {
|
|
97
|
-
return new URL(text).searchParams;
|
|
98
|
-
} catch {
|
|
99
|
-
return new URLSearchParams(text.replace(/^\?/, ''));
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const param = (query: URLSearchParams, name: string): string => query.get(name)?.trim() ?? '';
|
|
104
|
-
|
|
105
|
-
function assertAllowed(query: URLSearchParams): void {
|
|
106
|
-
const error = param(query, 'error');
|
|
107
|
-
if (error === 'access_denied') throw new CodexAuthError('you did not allow Metro to use your ChatGPT plan, so nothing was saved');
|
|
108
|
-
if (error !== '') throw new CodexAuthError(`ChatGPT stopped the sign-in (${query.get('error_description') ?? error}); ${AGAIN}`);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function parseCallback(raw: string): { code: string; state: string; clientId: string | null } {
|
|
112
|
-
const query = queryOf(raw);
|
|
113
|
-
assertAllowed(query);
|
|
114
|
-
const code = param(query, 'code');
|
|
115
|
-
const state = param(query, 'state');
|
|
116
|
-
if (code === '' || state === '') throw new CodexAuthError('paste the whole address the browser landed on; it carries code= and state=');
|
|
117
|
-
return { code, state, clientId: nonEmpty(param(query, 'client_id')) };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function decodeJwtPayload(jwt: string): Record<string, unknown> {
|
|
121
|
-
const part = jwt.split('.')[1] ?? '';
|
|
122
|
-
try {
|
|
123
|
-
const parsed: unknown = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
|
124
|
-
return isRecord(parsed) ? parsed : {};
|
|
125
|
-
} catch {
|
|
126
|
-
return {};
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function emailOf(claims: Record<string, unknown>): string | null {
|
|
131
|
-
const profile = isRecord(claims[PROFILE_CLAIM]) ? claims[PROFILE_CLAIM] : {};
|
|
132
|
-
return nonEmpty(claims.email) ?? nonEmpty(profile.email);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function identityOf(idToken: unknown, expected: { issuer: string; clientId: string; nonce: string }): { subject: string; email: string | null } {
|
|
136
|
-
const claims = decodeJwtPayload(typeof idToken === 'string' ? idToken : '');
|
|
137
|
-
const audience: unknown[] = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
138
|
-
const subject = nonEmpty(claims.sub);
|
|
139
|
-
if (subject === null || claims.iss !== expected.issuer || !audience.includes(expected.clientId) || claims.nonce !== expected.nonce)
|
|
140
|
-
throw new CodexAuthError(`OpenAI sent back an identity that does not match this sign-in; ${AGAIN}`);
|
|
141
|
-
return { subject, email: emailOf(claims) };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
interface Encoded {
|
|
145
|
-
type: string;
|
|
146
|
-
body: string;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export const asForm = (fields: Record<string, string>): Encoded => ({ type: 'application/x-www-form-urlencoded', body: new URLSearchParams(fields).toString() });
|
|
150
|
-
|
|
151
|
-
export const asJson = (fields: Record<string, string>): Encoded => ({ type: 'application/json', body: JSON.stringify(fields) });
|
|
152
|
-
|
|
153
|
-
export async function tokenCall(url: string, sent: Encoded, fetchImpl: typeof fetch): Promise<Record<string, unknown>> {
|
|
154
|
-
const res = await fetchImpl(url, { method: 'POST', headers: { 'content-type': sent.type, accept: 'application/json' }, body: sent.body });
|
|
155
|
-
const raw = await res.text();
|
|
156
|
-
let parsed: unknown = null;
|
|
157
|
-
try {
|
|
158
|
-
parsed = JSON.parse(raw);
|
|
159
|
-
} catch {
|
|
160
|
-
parsed = null;
|
|
161
|
-
}
|
|
162
|
-
const body = isRecord(parsed) ? parsed : {};
|
|
163
|
-
if (!res.ok) throw new CodexAuthError(`OpenAI refused the token request (${String(res.status)}): ${nonEmpty(body.error_description) ?? nonEmpty(body.error) ?? raw.slice(0, 300)}`);
|
|
164
|
-
return body;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export function grantOf(body: Record<string, unknown>, keptRefresh: string, now: number): Omit<CodexCredential, 'email'> {
|
|
168
|
-
const accessToken = nonEmpty(body.access_token) ?? '';
|
|
169
|
-
const refreshToken = nonEmpty(body.refresh_token) ?? keptRefresh;
|
|
170
|
-
if (accessToken === '' || refreshToken === '') throw new CodexAuthError('OpenAI returned no usable tokens; sign in again');
|
|
171
|
-
const ttl = typeof body.expires_in === 'number' && body.expires_in > 0 ? body.expires_in * 1000 : DEFAULT_TTL_MS;
|
|
172
|
-
return { accessToken, refreshToken, expiresAt: now + ttl, savedAt: new Date(now).toISOString() };
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const tokensFrom = (body: Record<string, unknown>, who: Pick<ChatgptTokens, 'clientId' | 'subject' | 'email'>, keptRefresh: string, now: number): ChatgptTokens => ({
|
|
176
|
-
method: 'chatgpt',
|
|
177
|
-
clientId: who.clientId,
|
|
178
|
-
subject: who.subject,
|
|
179
|
-
email: who.email,
|
|
180
|
-
...grantOf(body, keptRefresh, now),
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
const officialToken = (issuer: string): string => `${issuer}/api/accounts/oauth/token`;
|
|
184
|
-
|
|
185
|
-
function clientOf(started: Pending, returned: string | null): string {
|
|
186
|
-
const clientId = started.clientId ?? returned;
|
|
187
|
-
if (clientId === null || clientId === NEW_CLIENT) throw new CodexAuthError(`ChatGPT did not finish registering Metro; ${AGAIN}`);
|
|
188
|
-
if (returned !== null && returned !== clientId) throw new CodexAuthError(`ChatGPT answered for another Metro registration; ${AGAIN}`);
|
|
189
|
-
return clientId;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export async function finishLogin(callback: string, issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<ChatgptTokens> {
|
|
193
|
-
const { code, state, clientId: returned } = parseCallback(callback);
|
|
194
|
-
const started = pending.take(state, now);
|
|
195
|
-
if (started === undefined) throw new CodexAuthError(`this sign-in link has expired or was started elsewhere; ${AGAIN}`);
|
|
196
|
-
const clientId = clientOf(started, returned);
|
|
197
|
-
const body = await tokenCall(
|
|
198
|
-
officialToken(issuer),
|
|
199
|
-
asForm({ grant_type: 'authorization_code', client_id: clientId, code, code_verifier: started.verifier, redirect_uri: CODEX_REDIRECT, resource: OPENAI_API }),
|
|
200
|
-
fetchImpl,
|
|
201
|
-
);
|
|
202
|
-
const who = identityOf(body.id_token, { issuer, clientId, nonce: started.nonce });
|
|
203
|
-
if (started.subject !== null && who.subject !== started.subject)
|
|
204
|
-
throw new CodexAuthError('this is another ChatGPT account than the one this connection signed in with; add a new connection for it');
|
|
205
|
-
const scopes = (nonEmpty(body.scope) ?? '').split(/\s+/);
|
|
206
|
-
if (!scopes.includes(PLAN_SCOPE)) throw new CodexAuthError('ChatGPT did not allow Metro to use your plan: sign in again and allow it (it needs ChatGPT Plus or Pro)');
|
|
207
|
-
return tokensFrom(body, { clientId, ...who }, '', now);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export async function refreshTokens(previous: ChatgptTokens, issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<ChatgptTokens> {
|
|
211
|
-
const body = await tokenCall(
|
|
212
|
-
officialToken(issuer),
|
|
213
|
-
asForm({ grant_type: 'refresh_token', client_id: previous.clientId, refresh_token: previous.refreshToken, resource: OPENAI_API }),
|
|
214
|
-
fetchImpl,
|
|
215
|
-
);
|
|
216
|
-
return tokensFrom(body, previous, previous.refreshToken, now);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export const tokensStale = (tokens: CodexTokens, now = Date.now()): boolean => now >= tokens.expiresAt - RENEW_BEFORE_MS;
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
-
import { errMsg } from '@metro-labs/core/log';
|
|
4
|
-
import { stringOf } from '@metro-labs/http/api-http';
|
|
5
|
-
import { asForm, asJson, CODEX_ISSUER, CodexAuthError, decodeJwtPayload, emailOf, grantOf, tokenCall, type CodeTokens } from './codex-auth.js';
|
|
6
|
-
import { nonEmpty } from './text.js';
|
|
7
|
-
|
|
8
|
-
const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
9
|
-
const AUTH_CLAIM = 'https://api.openai.com/auth';
|
|
10
|
-
const VERIFY_PATH = '/codex/device';
|
|
11
|
-
const DEVICE_TTL_MS = 15 * 60_000;
|
|
12
|
-
const DEFAULT_INTERVAL_S = 5;
|
|
13
|
-
const ID_BYTES = 18;
|
|
14
|
-
const AGAIN = 'press Sign in with a code again';
|
|
15
|
-
|
|
16
|
-
export interface DeviceLogin {
|
|
17
|
-
id: string;
|
|
18
|
-
userCode: string;
|
|
19
|
-
verifyUrl: string;
|
|
20
|
-
interval: number;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export type DeviceStatus = { status: 'pending' } | { status: 'done'; tokens: CodeTokens } | { status: 'failed'; error: string };
|
|
24
|
-
|
|
25
|
-
interface Entry {
|
|
26
|
-
deviceAuthId: string;
|
|
27
|
-
userCode: string;
|
|
28
|
-
interval: number;
|
|
29
|
-
issuer: string;
|
|
30
|
-
startedAt: number;
|
|
31
|
-
polledAt: number | null;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const logins = new Map<string, Entry>();
|
|
35
|
-
|
|
36
|
-
function claimsOf(idToken: string): { accountId: string | null; email: string | null; plan: string | null } {
|
|
37
|
-
const payload = decodeJwtPayload(idToken);
|
|
38
|
-
const auth = isRecord(payload[AUTH_CLAIM]) ? payload[AUTH_CLAIM] : {};
|
|
39
|
-
return { accountId: nonEmpty(auth.chatgpt_account_id), email: emailOf(payload), plan: nonEmpty(auth.chatgpt_plan_type) };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function codeTokensFrom(body: Record<string, unknown>, previous: CodeTokens | null, now: number): CodeTokens {
|
|
43
|
-
const fresh = claimsOf(nonEmpty(body.id_token) ?? '');
|
|
44
|
-
const kept = previous ?? { accountId: null, email: null, plan: null, refreshToken: '' };
|
|
45
|
-
const accountId = fresh.accountId ?? kept.accountId ?? '';
|
|
46
|
-
if (accountId === '') throw new CodexAuthError('the id token carries no ChatGPT account id; sign in with a ChatGPT account that has Codex');
|
|
47
|
-
return { method: 'code', accountId, email: fresh.email ?? kept.email, plan: fresh.plan ?? kept.plan, ...grantOf(body, kept.refreshToken, now) };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const cliToken = (issuer: string): string => `${issuer}/oauth/token`;
|
|
51
|
-
|
|
52
|
-
export async function refreshCodeTokens(previous: CodeTokens, issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<CodeTokens> {
|
|
53
|
-
const body = await tokenCall(cliToken(issuer), asJson({ client_id: CODEX_CLIENT_ID, grant_type: 'refresh_token', refresh_token: previous.refreshToken }), fetchImpl);
|
|
54
|
-
return codeTokensFrom(body, previous, now);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function intervalOf(value: unknown): number {
|
|
58
|
-
const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
|
|
59
|
-
return Number.isFinite(n) && n >= 1 ? Math.round(n) : DEFAULT_INTERVAL_S;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function sweep(now: number): void {
|
|
63
|
-
for (const [id, entry] of logins) if (now - entry.startedAt > DEVICE_TTL_MS) logins.delete(id);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const post = (url: string, body: unknown, fetchImpl: typeof fetch): Promise<Response> =>
|
|
67
|
-
fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
68
|
-
|
|
69
|
-
export async function beginDeviceLogin(issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<DeviceLogin> {
|
|
70
|
-
sweep(now);
|
|
71
|
-
const res = await post(`${issuer}/api/accounts/deviceauth/usercode`, { client_id: CODEX_CLIENT_ID }, fetchImpl);
|
|
72
|
-
if (res.status === 404) throw new CodexAuthError('OpenAI is not offering the code sign-in right now; use Continue with ChatGPT instead');
|
|
73
|
-
if (!res.ok) throw new CodexAuthError(`OpenAI refused to start the code sign-in (${String(res.status)})`);
|
|
74
|
-
const body: unknown = await res.json();
|
|
75
|
-
const parsed = isRecord(body) ? body : {};
|
|
76
|
-
const deviceAuthId = stringOf(parsed.device_auth_id);
|
|
77
|
-
const userCode = stringOf(parsed.user_code) || stringOf(parsed.usercode);
|
|
78
|
-
if (deviceAuthId === '' || userCode === '') throw new CodexAuthError('OpenAI answered without a device code');
|
|
79
|
-
const id = randomBytes(ID_BYTES).toString('base64url');
|
|
80
|
-
const interval = intervalOf(parsed.interval);
|
|
81
|
-
logins.set(id, { deviceAuthId, userCode, interval, issuer, startedAt: now, polledAt: null });
|
|
82
|
-
return { id, userCode, verifyUrl: `${issuer}${VERIFY_PATH}`, interval };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async function claim(entry: Entry, fetchImpl: typeof fetch, now: number): Promise<DeviceStatus> {
|
|
86
|
-
const res = await post(`${entry.issuer}/api/accounts/deviceauth/token`, { device_auth_id: entry.deviceAuthId, user_code: entry.userCode }, fetchImpl);
|
|
87
|
-
if (res.status === 403 || res.status === 404) {
|
|
88
|
-
await res.body?.cancel();
|
|
89
|
-
return { status: 'pending' };
|
|
90
|
-
}
|
|
91
|
-
if (!res.ok) return { status: 'failed', error: `OpenAI ended the code sign-in (${String(res.status)})` };
|
|
92
|
-
const body: unknown = await res.json();
|
|
93
|
-
const parsed = isRecord(body) ? body : {};
|
|
94
|
-
const code = stringOf(parsed.authorization_code);
|
|
95
|
-
const verifier = stringOf(parsed.code_verifier);
|
|
96
|
-
if (code === '' || verifier === '') return { status: 'failed', error: 'OpenAI approved the sign-in but sent no code back' };
|
|
97
|
-
const granted = await tokenCall(
|
|
98
|
-
cliToken(entry.issuer),
|
|
99
|
-
asForm({ grant_type: 'authorization_code', code, redirect_uri: `${entry.issuer}/deviceauth/callback`, client_id: CODEX_CLIENT_ID, code_verifier: verifier }),
|
|
100
|
-
fetchImpl,
|
|
101
|
-
);
|
|
102
|
-
return { status: 'done', tokens: codeTokensFrom(granted, null, now) };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export async function pollDeviceLogin(id: string, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<DeviceStatus> {
|
|
106
|
-
sweep(now);
|
|
107
|
-
const entry = logins.get(id);
|
|
108
|
-
if (entry === undefined) throw new CodexAuthError(`this sign-in has expired or already finished; ${AGAIN}`);
|
|
109
|
-
if (entry.polledAt !== null && now - entry.polledAt < entry.interval * 1000) return { status: 'pending' };
|
|
110
|
-
entry.polledAt = now;
|
|
111
|
-
let result: DeviceStatus;
|
|
112
|
-
try {
|
|
113
|
-
result = await claim(entry, fetchImpl, now);
|
|
114
|
-
} catch (err) {
|
|
115
|
-
result = { status: 'failed', error: errMsg(err) };
|
|
116
|
-
}
|
|
117
|
-
if (result.status !== 'pending') logins.delete(id);
|
|
118
|
-
return result;
|
|
119
|
-
}
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
-
import { PendingLogins } from './pkce.js';
|
|
3
|
-
import { nonEmpty } from './text.js';
|
|
4
|
-
|
|
5
|
-
const GEMINI_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
|
6
|
-
const GEMINI_CLIENT_SECRET = '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';
|
|
7
|
-
const GEMINI_AUTH_BASE = 'https://accounts.google.com';
|
|
8
|
-
const GEMINI_TOKEN_BASE = 'https://oauth2.googleapis.com';
|
|
9
|
-
const GEMINI_REDIRECT = 'http://localhost:51121/oauth-callback';
|
|
10
|
-
const SCOPE = [
|
|
11
|
-
'https://www.googleapis.com/auth/cloud-platform',
|
|
12
|
-
'https://www.googleapis.com/auth/userinfo.email',
|
|
13
|
-
'https://www.googleapis.com/auth/userinfo.profile',
|
|
14
|
-
'https://www.googleapis.com/auth/cclog',
|
|
15
|
-
'https://www.googleapis.com/auth/experimentsandconfigs',
|
|
16
|
-
].join(' ');
|
|
17
|
-
const EXPIRY_MARGIN_MS = 5 * 60_000;
|
|
18
|
-
const DEFAULT_TTL_MS = 55 * 60_000;
|
|
19
|
-
|
|
20
|
-
export interface GeminiTokens {
|
|
21
|
-
accessToken: string;
|
|
22
|
-
refreshToken: string;
|
|
23
|
-
expiresAt: number;
|
|
24
|
-
email: string | null;
|
|
25
|
-
project: string;
|
|
26
|
-
tier: string | null;
|
|
27
|
-
savedAt: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export class GeminiAuthError extends Error {}
|
|
31
|
-
|
|
32
|
-
const pending = new PendingLogins(16);
|
|
33
|
-
|
|
34
|
-
export function authorizeUrl(state: string, challenge: string, base = GEMINI_AUTH_BASE): string {
|
|
35
|
-
const params = new URLSearchParams({
|
|
36
|
-
client_id: GEMINI_CLIENT_ID,
|
|
37
|
-
redirect_uri: GEMINI_REDIRECT,
|
|
38
|
-
response_type: 'code',
|
|
39
|
-
scope: SCOPE,
|
|
40
|
-
access_type: 'offline',
|
|
41
|
-
prompt: 'consent',
|
|
42
|
-
code_challenge: challenge,
|
|
43
|
-
code_challenge_method: 'S256',
|
|
44
|
-
state,
|
|
45
|
-
});
|
|
46
|
-
return `${base}/o/oauth2/v2/auth?${params.toString()}`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function beginLogin(base = GEMINI_AUTH_BASE, now = Date.now()): { url: string; state: string } {
|
|
50
|
-
const { state, challenge } = pending.begin(now);
|
|
51
|
-
return { url: authorizeUrl(state, challenge, base), state };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const refusalIn = (body: Record<string, unknown>): string => nonEmpty(body.error_description) ?? nonEmpty(body.error) ?? 'Google answered without an access token';
|
|
56
|
-
|
|
57
|
-
function grantedTokens(body: unknown, previous: GeminiTokens | null): { accessToken: string; refreshToken: string; ttl: number } {
|
|
58
|
-
if (!isRecord(body)) throw new GeminiAuthError('Google answered without tokens');
|
|
59
|
-
const accessToken = nonEmpty(body.access_token);
|
|
60
|
-
if (accessToken === null) throw new GeminiAuthError(refusalIn(body));
|
|
61
|
-
const refreshToken = nonEmpty(body.refresh_token) ?? previous?.refreshToken ?? '';
|
|
62
|
-
if (refreshToken === '') throw new GeminiAuthError('Google issued no refresh token; sign in again');
|
|
63
|
-
return { accessToken, refreshToken, ttl: typeof body.expires_in === 'number' ? body.expires_in * 1000 : DEFAULT_TTL_MS };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function tokensOf(body: unknown, previous: GeminiTokens | null, now: number): GeminiTokens {
|
|
67
|
-
const granted = grantedTokens(body, previous);
|
|
68
|
-
return {
|
|
69
|
-
accessToken: granted.accessToken,
|
|
70
|
-
refreshToken: granted.refreshToken,
|
|
71
|
-
expiresAt: now + granted.ttl,
|
|
72
|
-
email: previous?.email ?? null,
|
|
73
|
-
project: previous?.project ?? '',
|
|
74
|
-
tier: previous?.tier ?? null,
|
|
75
|
-
savedAt: new Date(now).toISOString(),
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function tokenCall(form: Record<string, string>, base: string, fetchImpl: typeof fetch): Promise<unknown> {
|
|
80
|
-
let res: Response;
|
|
81
|
-
try {
|
|
82
|
-
res = await fetchImpl(`${base}/token`, {
|
|
83
|
-
method: 'POST',
|
|
84
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
85
|
-
body: new URLSearchParams(form).toString(),
|
|
86
|
-
signal: AbortSignal.timeout(30_000),
|
|
87
|
-
});
|
|
88
|
-
} catch (err) {
|
|
89
|
-
throw new GeminiAuthError(`could not reach Google: ${err instanceof Error ? err.message : String(err)}`);
|
|
90
|
-
}
|
|
91
|
-
const body: unknown = await res.json().catch(() => null);
|
|
92
|
-
if (!res.ok) throw new GeminiAuthError(isRecord(body) ? (nonEmpty(body.error_description) ?? nonEmpty(body.error) ?? `Google answered ${String(res.status)}`) : `Google answered ${String(res.status)}`);
|
|
93
|
-
return body;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function codeIn(pasted: string, state: string): string {
|
|
97
|
-
const trimmed = pasted.trim();
|
|
98
|
-
if (trimmed === '') throw new GeminiAuthError('paste the address the browser landed on, or the code in it');
|
|
99
|
-
if (!/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
100
|
-
let url: URL;
|
|
101
|
-
try {
|
|
102
|
-
url = new URL(trimmed);
|
|
103
|
-
} catch {
|
|
104
|
-
throw new GeminiAuthError('that is not a full address');
|
|
105
|
-
}
|
|
106
|
-
const code = url.searchParams.get('code') ?? '';
|
|
107
|
-
const carried = url.searchParams.get('state');
|
|
108
|
-
if (code === '') throw new GeminiAuthError(`that address carries no code${url.searchParams.get('error') === null ? '' : ` (Google said ${url.searchParams.get('error') ?? ''})`}`);
|
|
109
|
-
if (carried !== null && carried !== state) throw new GeminiAuthError('that address belongs to another sign-in; start again and paste the new one');
|
|
110
|
-
return code;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export async function exchangeCode(pasted: string, state: string, base = GEMINI_TOKEN_BASE, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<GeminiTokens> {
|
|
114
|
-
const verifier = pending.take(state, now);
|
|
115
|
-
if (verifier === null) throw new GeminiAuthError('that sign-in has expired; start it again');
|
|
116
|
-
const code = codeIn(pasted, state);
|
|
117
|
-
const body = await tokenCall(
|
|
118
|
-
{ client_id: GEMINI_CLIENT_ID, client_secret: GEMINI_CLIENT_SECRET, grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: GEMINI_REDIRECT },
|
|
119
|
-
base,
|
|
120
|
-
fetchImpl,
|
|
121
|
-
);
|
|
122
|
-
return tokensOf(body, null, now);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export async function refreshTokens(previous: GeminiTokens, base = GEMINI_TOKEN_BASE, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<GeminiTokens> {
|
|
126
|
-
const body = await tokenCall(
|
|
127
|
-
{ client_id: GEMINI_CLIENT_ID, client_secret: GEMINI_CLIENT_SECRET, grant_type: 'refresh_token', refresh_token: previous.refreshToken },
|
|
128
|
-
base,
|
|
129
|
-
fetchImpl,
|
|
130
|
-
);
|
|
131
|
-
return tokensOf(body, previous, now);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export const tokensStale = (tokens: GeminiTokens, now = Date.now()): boolean => tokens.expiresAt - EXPIRY_MARGIN_MS <= now;
|
|
135
|
-
|
|
136
|
-
export async function userEmail(tokens: GeminiTokens, base = 'https://www.googleapis.com', fetchImpl: typeof fetch = fetch): Promise<string | null> {
|
|
137
|
-
try {
|
|
138
|
-
const res = await fetchImpl(`${base}/oauth2/v2/userinfo`, { headers: { authorization: `Bearer ${tokens.accessToken}` }, signal: AbortSignal.timeout(15_000) });
|
|
139
|
-
const body: unknown = await res.json().catch(() => null);
|
|
140
|
-
return res.ok && isRecord(body) ? nonEmpty(body.email) : null;
|
|
141
|
-
} catch {
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export function tokensFromDisk(raw: unknown): GeminiTokens | null {
|
|
147
|
-
if (!isRecord(raw)) return null;
|
|
148
|
-
const accessToken = nonEmpty(raw.accessToken);
|
|
149
|
-
const refreshToken = nonEmpty(raw.refreshToken);
|
|
150
|
-
if (accessToken === null || refreshToken === null) return null;
|
|
151
|
-
return {
|
|
152
|
-
accessToken,
|
|
153
|
-
refreshToken,
|
|
154
|
-
expiresAt: typeof raw.expiresAt === 'number' ? raw.expiresAt : 0,
|
|
155
|
-
email: nonEmpty(raw.email),
|
|
156
|
-
project: nonEmpty(raw.project) ?? '',
|
|
157
|
-
tier: nonEmpty(raw.tier),
|
|
158
|
-
savedAt: nonEmpty(raw.savedAt) ?? '',
|
|
159
|
-
};
|
|
160
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
const CODE_ASSIST_BASE = 'https://cloudcode-pa.googleapis.com';
|
|
4
|
-
const CODE_ASSIST_DAILY = 'https://daily-cloudcode-pa.googleapis.com';
|
|
5
|
-
export const API = 'v1internal';
|
|
6
|
-
const APP_VERSION = '2.0.3';
|
|
7
|
-
const CLIENT_VERSION = '1.110.0';
|
|
8
|
-
export const CLIENT_NAME = 'antigravity';
|
|
9
|
-
const IDE_TYPE = 9;
|
|
10
|
-
const PLUGIN_TYPE = 2;
|
|
11
|
-
export const SYSTEM_PREFIX =
|
|
12
|
-
'You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**';
|
|
13
|
-
|
|
14
|
-
const PLATFORMS: Record<string, number> = { 'darwin-x64': 1, 'darwin-arm64': 2, 'linux-x64': 3, 'linux-arm64': 4, 'win32-x64': 5 };
|
|
15
|
-
|
|
16
|
-
const osName = (): string => (process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux');
|
|
17
|
-
|
|
18
|
-
const platformCode = (): number => PLATFORMS[`${process.platform}-${process.arch}`] ?? 0;
|
|
19
|
-
|
|
20
|
-
export const clientMetadata = (project: string | null): Record<string, unknown> => ({
|
|
21
|
-
ideType: IDE_TYPE,
|
|
22
|
-
platform: platformCode(),
|
|
23
|
-
pluginType: PLUGIN_TYPE,
|
|
24
|
-
...(project === null ? {} : { duetProject: project }),
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
export const clientHeaders = (): Record<string, string> => ({
|
|
28
|
-
'user-agent': `${CLIENT_NAME}/${APP_VERSION} ${osName()}/${process.arch}`,
|
|
29
|
-
'x-client-name': CLIENT_NAME,
|
|
30
|
-
'x-client-version': CLIENT_VERSION,
|
|
31
|
-
'x-goog-api-client': 'gl-node/18.18.2 fire/0.8.6 grpc/1.10.x',
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
export type Bases = string | string[];
|
|
35
|
-
|
|
36
|
-
const listed = (override: Bases): string[] => (typeof override === 'string' ? [override] : override);
|
|
37
|
-
|
|
38
|
-
export const generateBases = (override?: Bases): string[] => (override === undefined ? [CODE_ASSIST_DAILY, CODE_ASSIST_BASE] : listed(override));
|
|
39
|
-
|
|
40
|
-
export const setupBases = (override?: Bases): string[] => (override === undefined ? [CODE_ASSIST_BASE, CODE_ASSIST_DAILY] : listed(override));
|
|
41
|
-
|
|
42
|
-
export const requestId = (): string => `agent-${randomUUID()}`;
|
|
43
|
-
|
|
44
|
-
const sessionId = randomUUID();
|
|
45
|
-
|
|
46
|
-
export const machineSessionId = (): string => sessionId;
|