@visual-z/xeno 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/README.md +498 -0
- package/docs/install.md +130 -0
- package/package.json +43 -0
- package/src/apps/approve.ts +49 -0
- package/src/apps/cli.ts +37 -0
- package/src/apps/commands.ts +226 -0
- package/src/apps/completions.ts +28 -0
- package/src/apps/input.ts +297 -0
- package/src/apps/render.ts +207 -0
- package/src/apps/shell.ts +2353 -0
- package/src/apps/startup.ts +1302 -0
- package/src/core/benchmark.ts +195 -0
- package/src/core/boundary.ts +174 -0
- package/src/core/churn.ts +59 -0
- package/src/core/completion-review.ts +66 -0
- package/src/core/context-policy.ts +60 -0
- package/src/core/context-window.ts +54 -0
- package/src/core/continuation.ts +114 -0
- package/src/core/cycle.ts +17 -0
- package/src/core/data-query.ts +201 -0
- package/src/core/deadline.ts +39 -0
- package/src/core/derive.ts +489 -0
- package/src/core/edit-plan.ts +200 -0
- package/src/core/environment.ts +29 -0
- package/src/core/event-validation.ts +99 -0
- package/src/core/events.ts +486 -0
- package/src/core/evidence-links.ts +71 -0
- package/src/core/evidence.ts +89 -0
- package/src/core/execution.ts +12 -0
- package/src/core/exhaustive.ts +18 -0
- package/src/core/experiment.ts +40 -0
- package/src/core/export.ts +169 -0
- package/src/core/file-references.ts +35 -0
- package/src/core/frontmatter.ts +213 -0
- package/src/core/handoff.ts +397 -0
- package/src/core/harness-comparison.ts +90 -0
- package/src/core/history-query.ts +103 -0
- package/src/core/history.ts +100 -0
- package/src/core/json-event.ts +135 -0
- package/src/core/json-pointer.ts +46 -0
- package/src/core/keymap.ts +241 -0
- package/src/core/observation-view.ts +87 -0
- package/src/core/outcome.ts +45 -0
- package/src/core/overflow.ts +59 -0
- package/src/core/permissions.ts +182 -0
- package/src/core/policy-evaluation.ts +55 -0
- package/src/core/progress.ts +101 -0
- package/src/core/project.ts +372 -0
- package/src/core/prompts.ts +83 -0
- package/src/core/queue.ts +60 -0
- package/src/core/read-bounds.ts +15 -0
- package/src/core/recall-analysis.ts +90 -0
- package/src/core/recall-trial.ts +153 -0
- package/src/core/response-recovery.ts +46 -0
- package/src/core/scheduling.ts +8 -0
- package/src/core/settings.ts +174 -0
- package/src/core/skills.ts +121 -0
- package/src/core/stall.ts +48 -0
- package/src/core/text-normalize.ts +81 -0
- package/src/core/theme.ts +179 -0
- package/src/core/tokens.ts +149 -0
- package/src/core/tool-contract.ts +102 -0
- package/src/core/tool-input.ts +38 -0
- package/src/core/transcript.ts +60 -0
- package/src/core/window-trial.ts +65 -0
- package/src/core/work-state.ts +103 -0
- package/src/core/yaml.ts +282 -0
- package/src/index.ts +10 -0
- package/src/runtime/auth.ts +255 -0
- package/src/runtime/benchmark.ts +187 -0
- package/src/runtime/catalog.ts +365 -0
- package/src/runtime/evidence-notes.ts +33 -0
- package/src/runtime/exec-sessions.ts +214 -0
- package/src/runtime/execution-control.ts +39 -0
- package/src/runtime/harness-comparison.ts +356 -0
- package/src/runtime/history-tools.ts +108 -0
- package/src/runtime/loop.ts +1983 -0
- package/src/runtime/model.ts +614 -0
- package/src/runtime/models-fetch.ts +155 -0
- package/src/runtime/oauth-device.ts +86 -0
- package/src/runtime/oauth.ts +759 -0
- package/src/runtime/openai-responses.ts +286 -0
- package/src/runtime/openai.ts +675 -0
- package/src/runtime/provider.ts +498 -0
- package/src/runtime/recall-trial.ts +226 -0
- package/src/runtime/resume.ts +66 -0
- package/src/runtime/retry.ts +297 -0
- package/src/runtime/tools.ts +1193 -0
- package/src/runtime/usage.ts +225 -0
- package/src/runtime/window-trial.ts +140 -0
- package/src/runtime/working-notes.ts +30 -0
- package/src/store/atomic.ts +31 -0
- package/src/store/auth-store.ts +50 -0
- package/src/store/blobs.ts +142 -0
- package/src/store/command.ts +75 -0
- package/src/store/config.ts +69 -0
- package/src/store/context-files.ts +356 -0
- package/src/store/file-index.ts +81 -0
- package/src/store/git-branch.ts +35 -0
- package/src/store/history-search.ts +19 -0
- package/src/store/log.ts +642 -0
- package/src/store/models.ts +41 -0
- package/src/store/observations.ts +181 -0
- package/src/store/query-source.ts +44 -0
- package/src/store/recovery.ts +39 -0
- package/src/store/replay.ts +63 -0
- package/src/store/resources.ts +361 -0
- package/src/store/restore.ts +302 -0
- package/src/store/search.ts +644 -0
- package/src/store/serial.ts +13 -0
- package/src/store/sessions.ts +463 -0
- package/src/store/trust.ts +69 -0
- package/src/store/workspace.ts +256 -0
- package/src/store/worktree.ts +233 -0
- package/src/tui/ansi.ts +67 -0
- package/src/tui/autocomplete.ts +29 -0
- package/src/tui/complete.ts +87 -0
- package/src/tui/diff.ts +160 -0
- package/src/tui/editor.ts +893 -0
- package/src/tui/exhaustive.ts +14 -0
- package/src/tui/fuzzy.ts +75 -0
- package/src/tui/host.ts +223 -0
- package/src/tui/input.ts +69 -0
- package/src/tui/keys.ts +378 -0
- package/src/tui/markdown.ts +17 -0
- package/src/tui/render.ts +153 -0
- package/src/tui/select.ts +240 -0
- package/src/tui/settings.ts +40 -0
- package/src/tui/statusline.ts +162 -0
- package/src/tui/style.ts +205 -0
- package/src/tui/terminal.ts +30 -0
- package/src/tui/text.ts +52 -0
- package/src/tui/toolview.ts +386 -0
- package/src/tui/transcript.ts +71 -0
- package/src/tui/tree.ts +405 -0
- package/src/tui/width.ts +567 -0
- package/src/vendor/pi-tui/LICENSE.upstream +21 -0
- package/src/vendor/pi-tui/VENDOR.md +23 -0
- package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
- package/src/vendor/pi-tui/autocomplete.ts +835 -0
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
- package/src/vendor/pi-tui/components/box.ts +167 -0
- package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
- package/src/vendor/pi-tui/components/editor.ts +2470 -0
- package/src/vendor/pi-tui/components/h-stack.ts +44 -0
- package/src/vendor/pi-tui/components/image.ts +127 -0
- package/src/vendor/pi-tui/components/input.ts +494 -0
- package/src/vendor/pi-tui/components/loader.ts +101 -0
- package/src/vendor/pi-tui/components/markdown.ts +1015 -0
- package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
- package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
- package/src/vendor/pi-tui/components/select-list.ts +273 -0
- package/src/vendor/pi-tui/components/settings-list.ts +328 -0
- package/src/vendor/pi-tui/components/spacer.ts +28 -0
- package/src/vendor/pi-tui/components/stack.ts +154 -0
- package/src/vendor/pi-tui/components/text.ts +107 -0
- package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
- package/src/vendor/pi-tui/components/v-stack.ts +33 -0
- package/src/vendor/pi-tui/editor-component.ts +74 -0
- package/src/vendor/pi-tui/fuzzy.ts +138 -0
- package/src/vendor/pi-tui/index.ts +156 -0
- package/src/vendor/pi-tui/keybindings.ts +320 -0
- package/src/vendor/pi-tui/keys.ts +1401 -0
- package/src/vendor/pi-tui/kill-ring.ts +46 -0
- package/src/vendor/pi-tui/latex.ts +1506 -0
- package/src/vendor/pi-tui/layout-node.ts +51 -0
- package/src/vendor/pi-tui/layout.ts +449 -0
- package/src/vendor/pi-tui/native-modifiers.ts +13 -0
- package/src/vendor/pi-tui/native-module-path.ts +31 -0
- package/src/vendor/pi-tui/native-platform.ts +63 -0
- package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
- package/src/vendor/pi-tui/terminal-colors.ts +73 -0
- package/src/vendor/pi-tui/terminal-image.ts +696 -0
- package/src/vendor/pi-tui/terminal.ts +547 -0
- package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
- package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
- package/src/vendor/pi-tui/tui.ts +1456 -0
- package/src/vendor/pi-tui/undo-stack.ts +28 -0
- package/src/vendor/pi-tui/utils.ts +1345 -0
- package/src/vendor/pi-tui/word-navigation.ts +117 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription OAuth: PKCE, authorization-code exchange, refresh, identity.
|
|
3
|
+
*
|
|
4
|
+
* Ported from pi (earendil-works/pi, MIT) — packages/ai/src/auth/oauth/pkce.ts
|
|
5
|
+
* and packages/ai/src/auth/oauth/anthropic.ts / openai-codex.ts / github-copilot.ts.
|
|
6
|
+
*
|
|
7
|
+
* D18 is the one place this is NOT a port: Anthropic OAuth requests send
|
|
8
|
+
* `Authorization: Bearer` and `anthropic-beta: oauth-2025-04-20`, identify as
|
|
9
|
+
* `xeno/<version>`, and never as `claude-cli`. A subscription endpoint that
|
|
10
|
+
* rejects an honest User-Agent is reported, not papered over.
|
|
11
|
+
*
|
|
12
|
+
* Tokens, refresh tokens and `code_verifier` stay out of errors and logs.
|
|
13
|
+
* Failures name the HTTP status, never the body that might echo a secret.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createServer, type Server } from "node:http";
|
|
17
|
+
import { pollDeviceCode } from "./oauth-device.ts";
|
|
18
|
+
|
|
19
|
+
/** Matches package.json. Sent as User-Agent so the server can see who we are (D18). */
|
|
20
|
+
export const XENO_VERSION = "0.1.0";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Minutes subtracted from server expiry so a request issued at the edge
|
|
24
|
+
* refreshes first. Copied from pi's Anthropic client; a zero margin fails
|
|
25
|
+
* at the boundary, which is the request that most needed to succeed.
|
|
26
|
+
*/
|
|
27
|
+
export const OAUTH_EXPIRY_MARGIN_MS = 5 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
export const SUBSCRIPTION_NOTICE =
|
|
30
|
+
"This uses first-party client credentials. The terms-of-service risk is on your account, not this project.";
|
|
31
|
+
|
|
32
|
+
export const SUBSCRIPTION_OPTIONS = [
|
|
33
|
+
{ id: "claude-pro", provider: "anthropic", label: "Claude Pro/Max" },
|
|
34
|
+
{ id: "codex", provider: "openai", label: "ChatGPT Plus/Pro (Codex)" },
|
|
35
|
+
{ id: "github-copilot", provider: "github-copilot", label: "GitHub Copilot" },
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
export type SubscriptionId = (typeof SUBSCRIPTION_OPTIONS)[number]["id"];
|
|
39
|
+
|
|
40
|
+
export interface OAuthCredential {
|
|
41
|
+
type: "oauth";
|
|
42
|
+
access: string;
|
|
43
|
+
refresh: string;
|
|
44
|
+
expires: number;
|
|
45
|
+
accountId?: string;
|
|
46
|
+
enterpriseUrl?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PkcePair {
|
|
50
|
+
verifier: string;
|
|
51
|
+
challenge: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Call signature only — `typeof fetch` also has `preconnect`, which a test spy does not. */
|
|
55
|
+
export type OAuthFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
56
|
+
|
|
57
|
+
export type CallbackOutcome = { ok: true; code: string } | { ok: false; reason: string };
|
|
58
|
+
|
|
59
|
+
export interface CallbackServer {
|
|
60
|
+
readonly host: string;
|
|
61
|
+
readonly port: number;
|
|
62
|
+
waitForCode(): Promise<CallbackOutcome>;
|
|
63
|
+
close(): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RefreshEffects {
|
|
67
|
+
fetch: OAuthFetch;
|
|
68
|
+
now: () => number;
|
|
69
|
+
signal?: AbortSignal;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface OAuthInteraction {
|
|
73
|
+
notify: (message: string) => void;
|
|
74
|
+
prompt: (message: string, signal: AbortSignal) => Promise<string>;
|
|
75
|
+
fetch: OAuthFetch;
|
|
76
|
+
signal: AbortSignal;
|
|
77
|
+
now?: () => number;
|
|
78
|
+
openBrowser?: (url: string) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const decode = (s: string) => atob(s);
|
|
82
|
+
// First-party client IDs, stored the same way pi stores them. They are not
|
|
83
|
+
// credentials we were issued; the ToS risk is the user's. See the card.
|
|
84
|
+
const ANTHROPIC_CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
|
85
|
+
const COPILOT_CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=");
|
|
86
|
+
const OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
87
|
+
|
|
88
|
+
const ANTHROPIC_AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
|
89
|
+
const ANTHROPIC_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
90
|
+
export const ANTHROPIC_CALLBACK_PORT = 53692;
|
|
91
|
+
export const ANTHROPIC_CALLBACK_PATH = "/callback";
|
|
92
|
+
export const ANTHROPIC_REDIRECT_URI = `http://localhost:${ANTHROPIC_CALLBACK_PORT}${ANTHROPIC_CALLBACK_PATH}`;
|
|
93
|
+
const ANTHROPIC_SCOPES =
|
|
94
|
+
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
|
95
|
+
|
|
96
|
+
const OPENAI_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
|
97
|
+
const OPENAI_TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
98
|
+
export const OPENAI_CALLBACK_PORT = 1455;
|
|
99
|
+
export const OPENAI_CALLBACK_PATH = "/auth/callback";
|
|
100
|
+
export const OPENAI_REDIRECT_URI = `http://localhost:${OPENAI_CALLBACK_PORT}${OPENAI_CALLBACK_PATH}`;
|
|
101
|
+
const OPENAI_SCOPE = "openid profile email offline_access";
|
|
102
|
+
const OPENAI_JWT_CLAIM = "https://api.openai.com/auth";
|
|
103
|
+
|
|
104
|
+
export function isSubscriptionLogin(id: string): boolean {
|
|
105
|
+
return SUBSCRIPTION_OPTIONS.some((o) => o.id === id);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function subscriptionById(id: string): (typeof SUBSCRIPTION_OPTIONS)[number] | undefined {
|
|
109
|
+
return SUBSCRIPTION_OPTIONS.find((o) => o.id === id);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function expiryFromExpiresIn(expiresIn: number, now: number): number {
|
|
113
|
+
return now + expiresIn * 1000 - OAUTH_EXPIRY_MARGIN_MS;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function base64urlEncode(bytes: Uint8Array): string {
|
|
117
|
+
let binary = "";
|
|
118
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
119
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Generate PKCE code verifier and challenge.
|
|
124
|
+
* Ported from pi (earendil-works/pi, MIT) — packages/ai/src/auth/oauth/pkce.ts
|
|
125
|
+
*/
|
|
126
|
+
export async function generatePKCE(): Promise<PkcePair> {
|
|
127
|
+
const verifierBytes = new Uint8Array(32);
|
|
128
|
+
crypto.getRandomValues(verifierBytes);
|
|
129
|
+
const verifier = base64urlEncode(verifierBytes);
|
|
130
|
+
const data = new TextEncoder().encode(verifier);
|
|
131
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
132
|
+
const challenge = base64urlEncode(new Uint8Array(hashBuffer));
|
|
133
|
+
return { verifier, challenge };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Independent of the PKCE verifier so the verifier is never printed in the URL. */
|
|
137
|
+
export function generateState(): string {
|
|
138
|
+
const bytes = new Uint8Array(16);
|
|
139
|
+
crypto.getRandomValues(bytes);
|
|
140
|
+
return base64urlEncode(bytes);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function anthropicOAuthHeaders(): Record<string, string> {
|
|
144
|
+
return {
|
|
145
|
+
"user-agent": `xeno/${XENO_VERSION}`,
|
|
146
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function dispatchAnthropicOAuthRequest(
|
|
151
|
+
accessToken: string,
|
|
152
|
+
opts: { fetch: OAuthFetch; url: string },
|
|
153
|
+
): Promise<Response> {
|
|
154
|
+
return opts.fetch(opts.url, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: {
|
|
157
|
+
Authorization: `Bearer ${accessToken}`,
|
|
158
|
+
...anthropicOAuthHeaders(),
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function anthropicAuthorizeUrl(opts: { challenge: string; state: string }): string {
|
|
164
|
+
const params = new URLSearchParams({
|
|
165
|
+
code: "true",
|
|
166
|
+
client_id: ANTHROPIC_CLIENT_ID,
|
|
167
|
+
response_type: "code",
|
|
168
|
+
redirect_uri: ANTHROPIC_REDIRECT_URI,
|
|
169
|
+
scope: ANTHROPIC_SCOPES,
|
|
170
|
+
code_challenge: opts.challenge,
|
|
171
|
+
code_challenge_method: "S256",
|
|
172
|
+
state: opts.state,
|
|
173
|
+
});
|
|
174
|
+
return `${ANTHROPIC_AUTHORIZE_URL}?${params.toString()}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function openaiCodexAuthorizeUrl(opts: { challenge: string; state: string }): string {
|
|
178
|
+
const url = new URL(OPENAI_AUTHORIZE_URL);
|
|
179
|
+
url.searchParams.set("response_type", "code");
|
|
180
|
+
url.searchParams.set("client_id", OPENAI_CODEX_CLIENT_ID);
|
|
181
|
+
url.searchParams.set("redirect_uri", OPENAI_REDIRECT_URI);
|
|
182
|
+
url.searchParams.set("scope", OPENAI_SCOPE);
|
|
183
|
+
url.searchParams.set("code_challenge", opts.challenge);
|
|
184
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
185
|
+
url.searchParams.set("state", opts.state);
|
|
186
|
+
url.searchParams.set("id_token_add_organizations", "true");
|
|
187
|
+
url.searchParams.set("codex_cli_simplified_flow", "true");
|
|
188
|
+
url.searchParams.set("originator", "xeno");
|
|
189
|
+
return url.toString();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function htmlPage(title: string, body: string): string {
|
|
193
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>${title}</title></head><body><h1>${title}</h1><p>${body}</p></body></html>`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function startCallbackServer(opts: {
|
|
197
|
+
expectedState: string;
|
|
198
|
+
host: string;
|
|
199
|
+
port: number;
|
|
200
|
+
path: string;
|
|
201
|
+
}): Promise<CallbackServer> {
|
|
202
|
+
// Binding anything other than loopback would expose the authorization code
|
|
203
|
+
// to the local network. localhost is refused too: it can resolve to ::1
|
|
204
|
+
// and listen on every interface depending on the stack.
|
|
205
|
+
if (opts.host !== "127.0.0.1") {
|
|
206
|
+
throw new Error("OAuth callback must bind 127.0.0.1");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let settle: ((value: CallbackOutcome) => void) | undefined;
|
|
210
|
+
const waitForCodePromise = new Promise<CallbackOutcome>((resolve) => {
|
|
211
|
+
let settled = false;
|
|
212
|
+
settle = (value) => {
|
|
213
|
+
if (settled) return;
|
|
214
|
+
settled = true;
|
|
215
|
+
resolve(value);
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const server: Server = createServer((req, res) => {
|
|
220
|
+
try {
|
|
221
|
+
const url = new URL(req.url ?? "", "http://127.0.0.1");
|
|
222
|
+
if (url.pathname !== opts.path) {
|
|
223
|
+
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
|
224
|
+
res.end(htmlPage("Not found", "Callback route not found."));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const error = url.searchParams.get("error");
|
|
228
|
+
if (error) {
|
|
229
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
230
|
+
res.end(htmlPage("Authentication failed", "The provider returned an error."));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const state = url.searchParams.get("state");
|
|
234
|
+
const code = url.searchParams.get("code");
|
|
235
|
+
if (!code || !state) {
|
|
236
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
237
|
+
res.end(htmlPage("Authentication failed", "Missing code or state."));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (state !== opts.expectedState) {
|
|
241
|
+
// CSRF: any local process can POST here. Rejecting is the whole point
|
|
242
|
+
// of state. Do not settle waitForCode with this code.
|
|
243
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
244
|
+
res.end(htmlPage("Authentication failed", "State mismatch."));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
248
|
+
res.end(htmlPage("Authentication successful", "You can close this window."));
|
|
249
|
+
settle?.({ ok: true, code });
|
|
250
|
+
} catch {
|
|
251
|
+
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
252
|
+
res.end("Internal error");
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await new Promise<void>((resolve, reject) => {
|
|
257
|
+
server.once("error", reject);
|
|
258
|
+
server.listen(opts.port, opts.host, () => resolve());
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const addr = server.address();
|
|
262
|
+
if (addr === null || typeof addr === "string") {
|
|
263
|
+
server.close();
|
|
264
|
+
throw new Error("OAuth callback server has no address");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let closed = false;
|
|
268
|
+
const close = (): Promise<void> => {
|
|
269
|
+
if (closed) return Promise.resolve();
|
|
270
|
+
closed = true;
|
|
271
|
+
settle?.({ ok: false, reason: "cancelled" });
|
|
272
|
+
return new Promise((resolve) => {
|
|
273
|
+
server.close(() => resolve());
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
host: opts.host,
|
|
279
|
+
port: addr.port,
|
|
280
|
+
waitForCode: () => waitForCodePromise,
|
|
281
|
+
close,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function readJson(
|
|
286
|
+
fetchImpl: OAuthFetch,
|
|
287
|
+
url: string,
|
|
288
|
+
init: RequestInit,
|
|
289
|
+
): Promise<{ ok: true; json: unknown } | { ok: false; status: number }> {
|
|
290
|
+
const response = await fetchImpl(url, init);
|
|
291
|
+
if (!response.ok) return { ok: false, status: response.status };
|
|
292
|
+
try {
|
|
293
|
+
return { ok: true, json: await response.json() };
|
|
294
|
+
} catch {
|
|
295
|
+
return { ok: false, status: response.status };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
300
|
+
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function tokenFields(json: unknown): { access: string; refresh: string; expiresIn: number } | null {
|
|
304
|
+
const rec = asRecord(json);
|
|
305
|
+
if (rec === null) return null;
|
|
306
|
+
const access = rec["access_token"];
|
|
307
|
+
const refresh = rec["refresh_token"];
|
|
308
|
+
const expiresIn = rec["expires_in"];
|
|
309
|
+
if (typeof access !== "string" || typeof refresh !== "string" || typeof expiresIn !== "number") {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
return { access, refresh, expiresIn };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function exchangeAnthropicCode(opts: {
|
|
316
|
+
code: string;
|
|
317
|
+
state: string;
|
|
318
|
+
verifier: string;
|
|
319
|
+
redirectUri: string;
|
|
320
|
+
fetch: OAuthFetch;
|
|
321
|
+
signal?: AbortSignal;
|
|
322
|
+
now?: () => number;
|
|
323
|
+
}): Promise<OAuthCredential> {
|
|
324
|
+
const init: RequestInit = {
|
|
325
|
+
method: "POST",
|
|
326
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
327
|
+
body: JSON.stringify({
|
|
328
|
+
grant_type: "authorization_code",
|
|
329
|
+
client_id: ANTHROPIC_CLIENT_ID,
|
|
330
|
+
code: opts.code,
|
|
331
|
+
state: opts.state,
|
|
332
|
+
redirect_uri: opts.redirectUri,
|
|
333
|
+
code_verifier: opts.verifier,
|
|
334
|
+
}),
|
|
335
|
+
};
|
|
336
|
+
if (opts.signal !== undefined) init.signal = opts.signal;
|
|
337
|
+
const got = await readJson(opts.fetch, ANTHROPIC_TOKEN_URL, init);
|
|
338
|
+
if (!got.ok) throw new Error(`Anthropic token exchange failed (HTTP ${got.status})`);
|
|
339
|
+
const fields = tokenFields(got.json);
|
|
340
|
+
if (fields === null) throw new Error("Anthropic token exchange returned an unexpected body");
|
|
341
|
+
const now = opts.now ?? Date.now;
|
|
342
|
+
return {
|
|
343
|
+
type: "oauth",
|
|
344
|
+
access: fields.access,
|
|
345
|
+
refresh: fields.refresh,
|
|
346
|
+
expires: expiryFromExpiresIn(fields.expiresIn, now()),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function refreshAnthropic(
|
|
351
|
+
refreshToken: string,
|
|
352
|
+
opts: RefreshEffects,
|
|
353
|
+
): Promise<{ ok: true; credential: OAuthCredential } | { ok: false; reason: string }> {
|
|
354
|
+
const init: RequestInit = {
|
|
355
|
+
method: "POST",
|
|
356
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
357
|
+
body: JSON.stringify({
|
|
358
|
+
grant_type: "refresh_token",
|
|
359
|
+
client_id: ANTHROPIC_CLIENT_ID,
|
|
360
|
+
refresh_token: refreshToken,
|
|
361
|
+
}),
|
|
362
|
+
};
|
|
363
|
+
if (opts.signal !== undefined) init.signal = opts.signal;
|
|
364
|
+
const got = await readJson(opts.fetch, ANTHROPIC_TOKEN_URL, init);
|
|
365
|
+
if (!got.ok) return { ok: false, reason: `HTTP ${got.status}` };
|
|
366
|
+
const fields = tokenFields(got.json);
|
|
367
|
+
if (fields === null) return { ok: false, reason: "unexpected body" };
|
|
368
|
+
return {
|
|
369
|
+
ok: true,
|
|
370
|
+
credential: {
|
|
371
|
+
type: "oauth",
|
|
372
|
+
access: fields.access,
|
|
373
|
+
refresh: fields.refresh,
|
|
374
|
+
expires: expiryFromExpiresIn(fields.expiresIn, opts.now()),
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export async function exchangeOpenAICode(opts: {
|
|
380
|
+
code: string;
|
|
381
|
+
verifier: string;
|
|
382
|
+
redirectUri: string;
|
|
383
|
+
fetch: OAuthFetch;
|
|
384
|
+
signal?: AbortSignal;
|
|
385
|
+
now?: () => number;
|
|
386
|
+
}): Promise<OAuthCredential> {
|
|
387
|
+
const init: RequestInit = {
|
|
388
|
+
method: "POST",
|
|
389
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
390
|
+
body: new URLSearchParams({
|
|
391
|
+
grant_type: "authorization_code",
|
|
392
|
+
client_id: OPENAI_CODEX_CLIENT_ID,
|
|
393
|
+
code: opts.code,
|
|
394
|
+
code_verifier: opts.verifier,
|
|
395
|
+
redirect_uri: opts.redirectUri,
|
|
396
|
+
}),
|
|
397
|
+
};
|
|
398
|
+
if (opts.signal !== undefined) init.signal = opts.signal;
|
|
399
|
+
const got = await readJson(opts.fetch, OPENAI_TOKEN_URL, init);
|
|
400
|
+
if (!got.ok) throw new Error(`OpenAI Codex token exchange failed (HTTP ${got.status})`);
|
|
401
|
+
const fields = tokenFields(got.json);
|
|
402
|
+
if (fields === null) throw new Error("OpenAI Codex token exchange returned an unexpected body");
|
|
403
|
+
return credentialsFromOpenAIToken(fields, opts.now ?? Date.now);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function refreshOpenAI(
|
|
407
|
+
refreshToken: string,
|
|
408
|
+
opts: RefreshEffects,
|
|
409
|
+
): Promise<{ ok: true; credential: OAuthCredential } | { ok: false; reason: string }> {
|
|
410
|
+
const init: RequestInit = {
|
|
411
|
+
method: "POST",
|
|
412
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
413
|
+
body: new URLSearchParams({
|
|
414
|
+
grant_type: "refresh_token",
|
|
415
|
+
refresh_token: refreshToken,
|
|
416
|
+
client_id: OPENAI_CODEX_CLIENT_ID,
|
|
417
|
+
}),
|
|
418
|
+
};
|
|
419
|
+
if (opts.signal !== undefined) init.signal = opts.signal;
|
|
420
|
+
const got = await readJson(opts.fetch, OPENAI_TOKEN_URL, init);
|
|
421
|
+
if (!got.ok) return { ok: false, reason: `HTTP ${got.status}` };
|
|
422
|
+
const fields = tokenFields(got.json);
|
|
423
|
+
if (fields === null) return { ok: false, reason: "unexpected body" };
|
|
424
|
+
try {
|
|
425
|
+
return { ok: true, credential: credentialsFromOpenAIToken(fields, opts.now) };
|
|
426
|
+
} catch {
|
|
427
|
+
return { ok: false, reason: "token missing account id" };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function credentialsFromOpenAIToken(
|
|
432
|
+
fields: { access: string; refresh: string; expiresIn: number },
|
|
433
|
+
now: () => number,
|
|
434
|
+
): OAuthCredential {
|
|
435
|
+
const accountId = accountIdFromJwt(fields.access);
|
|
436
|
+
const cred: OAuthCredential = {
|
|
437
|
+
type: "oauth",
|
|
438
|
+
access: fields.access,
|
|
439
|
+
refresh: fields.refresh,
|
|
440
|
+
expires: expiryFromExpiresIn(fields.expiresIn, now()),
|
|
441
|
+
};
|
|
442
|
+
if (accountId !== null) return { ...cred, accountId };
|
|
443
|
+
throw new Error("OpenAI Codex token missing account id");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function accountIdFromJwt(token: string): string | null {
|
|
447
|
+
try {
|
|
448
|
+
const parts = token.split(".");
|
|
449
|
+
const payload = parts[1];
|
|
450
|
+
if (parts.length !== 3 || payload === undefined) return null;
|
|
451
|
+
const json = JSON.parse(atob(payload)) as unknown;
|
|
452
|
+
const rec = asRecord(json);
|
|
453
|
+
const auth = rec === null ? null : asRecord(rec[OPENAI_JWT_CLAIM]);
|
|
454
|
+
const id = auth?.["chatgpt_account_id"];
|
|
455
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
456
|
+
} catch {
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function refreshCopilot(
|
|
462
|
+
githubToken: string,
|
|
463
|
+
opts: RefreshEffects,
|
|
464
|
+
enterpriseUrl?: string,
|
|
465
|
+
): Promise<{ ok: true; credential: OAuthCredential } | { ok: false; reason: string }> {
|
|
466
|
+
const domain = enterpriseUrl && enterpriseUrl.length > 0 ? enterpriseUrl : "github.com";
|
|
467
|
+
const url = `https://api.${domain}/copilot_internal/v2/token`;
|
|
468
|
+
const init: RequestInit = {
|
|
469
|
+
headers: {
|
|
470
|
+
Accept: "application/json",
|
|
471
|
+
Authorization: `Bearer ${githubToken}`,
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
if (opts.signal !== undefined) init.signal = opts.signal;
|
|
475
|
+
const got = await readJson(opts.fetch, url, init);
|
|
476
|
+
if (!got.ok) return { ok: false, reason: `HTTP ${got.status}` };
|
|
477
|
+
const rec = asRecord(got.json);
|
|
478
|
+
const token = rec?.["token"];
|
|
479
|
+
const expiresAt = rec?.["expires_at"];
|
|
480
|
+
if (typeof token !== "string" || typeof expiresAt !== "number") {
|
|
481
|
+
return { ok: false, reason: "unexpected body" };
|
|
482
|
+
}
|
|
483
|
+
const cred: OAuthCredential = {
|
|
484
|
+
type: "oauth",
|
|
485
|
+
access: token,
|
|
486
|
+
refresh: githubToken,
|
|
487
|
+
expires: expiresAt * 1000 - OAUTH_EXPIRY_MARGIN_MS,
|
|
488
|
+
};
|
|
489
|
+
if (enterpriseUrl !== undefined && enterpriseUrl.length > 0) {
|
|
490
|
+
return { ok: true, credential: { ...cred, enterpriseUrl } };
|
|
491
|
+
}
|
|
492
|
+
return { ok: true, credential: cred };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export async function refreshOAuthCredential(
|
|
496
|
+
provider: string,
|
|
497
|
+
credential: OAuthCredential,
|
|
498
|
+
opts: RefreshEffects,
|
|
499
|
+
): Promise<{ ok: true; credential: OAuthCredential } | { ok: false; reason: string }> {
|
|
500
|
+
switch (provider) {
|
|
501
|
+
case "anthropic":
|
|
502
|
+
return refreshAnthropic(credential.refresh, opts);
|
|
503
|
+
case "openai":
|
|
504
|
+
return refreshOpenAI(credential.refresh, opts);
|
|
505
|
+
case "github-copilot":
|
|
506
|
+
return refreshCopilot(credential.refresh, opts, credential.enterpriseUrl);
|
|
507
|
+
default:
|
|
508
|
+
return { ok: false, reason: `no oauth refresh for ${provider}` };
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function parseAuthorizationInput(input: string): { code?: string; state?: string } {
|
|
513
|
+
const value = input.trim();
|
|
514
|
+
if (!value) return {};
|
|
515
|
+
try {
|
|
516
|
+
const url = new URL(value);
|
|
517
|
+
const parsed: { code?: string; state?: string } = {};
|
|
518
|
+
const code = url.searchParams.get("code");
|
|
519
|
+
const state = url.searchParams.get("state");
|
|
520
|
+
if (code) parsed.code = code;
|
|
521
|
+
if (state) parsed.state = state;
|
|
522
|
+
return parsed;
|
|
523
|
+
} catch {
|
|
524
|
+
// not a URL
|
|
525
|
+
}
|
|
526
|
+
if (value.includes("#")) {
|
|
527
|
+
const [code, state] = value.split("#", 2);
|
|
528
|
+
const parsed: { code?: string; state?: string } = {};
|
|
529
|
+
if (code) parsed.code = code;
|
|
530
|
+
if (state) parsed.state = state;
|
|
531
|
+
return parsed;
|
|
532
|
+
}
|
|
533
|
+
if (value.includes("code=")) {
|
|
534
|
+
const params = new URLSearchParams(value);
|
|
535
|
+
const parsed: { code?: string; state?: string } = {};
|
|
536
|
+
const code = params.get("code");
|
|
537
|
+
const state = params.get("state");
|
|
538
|
+
if (code) parsed.code = code;
|
|
539
|
+
if (state) parsed.state = state;
|
|
540
|
+
return parsed;
|
|
541
|
+
}
|
|
542
|
+
return { code: value };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function raceCode(
|
|
546
|
+
server: CallbackServer,
|
|
547
|
+
interaction: OAuthInteraction,
|
|
548
|
+
expectedState: string,
|
|
549
|
+
): Promise<string> {
|
|
550
|
+
const manualAbort = new AbortController();
|
|
551
|
+
const onAbort = (): void => {
|
|
552
|
+
void server.close();
|
|
553
|
+
};
|
|
554
|
+
interaction.signal.addEventListener("abort", onAbort, { once: true });
|
|
555
|
+
if (interaction.signal.aborted) onAbort();
|
|
556
|
+
try {
|
|
557
|
+
const pastePromise = interaction
|
|
558
|
+
.prompt(
|
|
559
|
+
"Complete login in the browser, or paste the redirect URL here:",
|
|
560
|
+
manualAbort.signal,
|
|
561
|
+
)
|
|
562
|
+
.then((input) => ({ kind: "paste" as const, input }))
|
|
563
|
+
.catch((err: unknown) => ({ kind: "paste-error" as const, err }));
|
|
564
|
+
const callbackPromise = server.waitForCode().then((got) => ({ kind: "callback" as const, got }));
|
|
565
|
+
const first = await Promise.race([pastePromise, callbackPromise]);
|
|
566
|
+
if (first.kind === "callback" && first.got.ok) return first.got.code;
|
|
567
|
+
if (first.kind === "paste-error") {
|
|
568
|
+
throw first.err instanceof Error ? first.err : new Error("login cancelled");
|
|
569
|
+
}
|
|
570
|
+
if (first.kind === "paste") {
|
|
571
|
+
const parsed = parseAuthorizationInput(first.input);
|
|
572
|
+
if (parsed.state !== undefined && parsed.state !== expectedState) {
|
|
573
|
+
throw new Error("OAuth state mismatch");
|
|
574
|
+
}
|
|
575
|
+
if (!parsed.code) throw new Error("Missing authorization code");
|
|
576
|
+
return parsed.code;
|
|
577
|
+
}
|
|
578
|
+
// Callback settled without a code: wait for paste if it is still coming.
|
|
579
|
+
const paste = await pastePromise;
|
|
580
|
+
if (paste.kind === "paste-error") {
|
|
581
|
+
throw paste.err instanceof Error ? paste.err : new Error("login cancelled");
|
|
582
|
+
}
|
|
583
|
+
const parsed = parseAuthorizationInput(paste.input);
|
|
584
|
+
if (parsed.state !== undefined && parsed.state !== expectedState) {
|
|
585
|
+
throw new Error("OAuth state mismatch");
|
|
586
|
+
}
|
|
587
|
+
if (!parsed.code) throw new Error("Missing authorization code");
|
|
588
|
+
return parsed.code;
|
|
589
|
+
} finally {
|
|
590
|
+
interaction.signal.removeEventListener("abort", onAbort);
|
|
591
|
+
manualAbort.abort();
|
|
592
|
+
await server.close();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function tryOpen(url: string, openBrowser?: (url: string) => void): void {
|
|
597
|
+
if (openBrowser) {
|
|
598
|
+
openBrowser(url);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
Bun.spawn(["open", url], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });
|
|
603
|
+
} catch {
|
|
604
|
+
// Headless or remote: the printed URL is the fallback.
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export async function loginAnthropic(interaction: OAuthInteraction): Promise<OAuthCredential> {
|
|
609
|
+
const pkce = await generatePKCE();
|
|
610
|
+
const state = generateState();
|
|
611
|
+
const url = anthropicAuthorizeUrl({ challenge: pkce.challenge, state });
|
|
612
|
+
const server = await startCallbackServer({
|
|
613
|
+
expectedState: state,
|
|
614
|
+
host: "127.0.0.1",
|
|
615
|
+
port: ANTHROPIC_CALLBACK_PORT,
|
|
616
|
+
path: ANTHROPIC_CALLBACK_PATH,
|
|
617
|
+
});
|
|
618
|
+
interaction.notify(url);
|
|
619
|
+
interaction.notify("If the browser is on another machine, paste the final redirect URL.");
|
|
620
|
+
tryOpen(url, interaction.openBrowser);
|
|
621
|
+
const code = await raceCode(server, interaction, state);
|
|
622
|
+
const exchange: Parameters<typeof exchangeAnthropicCode>[0] = {
|
|
623
|
+
code,
|
|
624
|
+
state,
|
|
625
|
+
verifier: pkce.verifier,
|
|
626
|
+
redirectUri: ANTHROPIC_REDIRECT_URI,
|
|
627
|
+
fetch: interaction.fetch,
|
|
628
|
+
signal: interaction.signal,
|
|
629
|
+
};
|
|
630
|
+
if (interaction.now !== undefined) exchange.now = interaction.now;
|
|
631
|
+
return exchangeAnthropicCode(exchange);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export async function loginOpenAICodex(interaction: OAuthInteraction): Promise<OAuthCredential> {
|
|
635
|
+
const pkce = await generatePKCE();
|
|
636
|
+
const state = generateState();
|
|
637
|
+
const url = openaiCodexAuthorizeUrl({ challenge: pkce.challenge, state });
|
|
638
|
+
const server = await startCallbackServer({
|
|
639
|
+
expectedState: state,
|
|
640
|
+
host: "127.0.0.1",
|
|
641
|
+
port: OPENAI_CALLBACK_PORT,
|
|
642
|
+
path: OPENAI_CALLBACK_PATH,
|
|
643
|
+
});
|
|
644
|
+
interaction.notify(url);
|
|
645
|
+
interaction.notify("If the browser is on another machine, paste the final redirect URL.");
|
|
646
|
+
tryOpen(url, interaction.openBrowser);
|
|
647
|
+
const code = await raceCode(server, interaction, state);
|
|
648
|
+
const exchange: Parameters<typeof exchangeOpenAICode>[0] = {
|
|
649
|
+
code,
|
|
650
|
+
verifier: pkce.verifier,
|
|
651
|
+
redirectUri: OPENAI_REDIRECT_URI,
|
|
652
|
+
fetch: interaction.fetch,
|
|
653
|
+
signal: interaction.signal,
|
|
654
|
+
};
|
|
655
|
+
if (interaction.now !== undefined) exchange.now = interaction.now;
|
|
656
|
+
return exchangeOpenAICode(exchange);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export async function loginGitHubCopilot(interaction: OAuthInteraction): Promise<OAuthCredential> {
|
|
660
|
+
const init: RequestInit = {
|
|
661
|
+
method: "POST",
|
|
662
|
+
headers: {
|
|
663
|
+
Accept: "application/json",
|
|
664
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
665
|
+
},
|
|
666
|
+
body: new URLSearchParams({
|
|
667
|
+
client_id: COPILOT_CLIENT_ID,
|
|
668
|
+
scope: "read:user",
|
|
669
|
+
}),
|
|
670
|
+
signal: interaction.signal,
|
|
671
|
+
};
|
|
672
|
+
const started = await readJson(interaction.fetch, "https://github.com/login/device/code", init);
|
|
673
|
+
if (!started.ok) throw new Error(`GitHub device code request failed (HTTP ${started.status})`);
|
|
674
|
+
const rec = asRecord(started.json);
|
|
675
|
+
const deviceCode = rec?.["device_code"];
|
|
676
|
+
const userCode = rec?.["user_code"];
|
|
677
|
+
const verificationUri = rec?.["verification_uri"];
|
|
678
|
+
const interval = rec?.["interval"];
|
|
679
|
+
const expiresIn = rec?.["expires_in"];
|
|
680
|
+
if (
|
|
681
|
+
typeof deviceCode !== "string" ||
|
|
682
|
+
typeof userCode !== "string" ||
|
|
683
|
+
typeof verificationUri !== "string" ||
|
|
684
|
+
typeof expiresIn !== "number"
|
|
685
|
+
) {
|
|
686
|
+
throw new Error("GitHub device code response was incomplete");
|
|
687
|
+
}
|
|
688
|
+
let parsedUri: URL;
|
|
689
|
+
try {
|
|
690
|
+
parsedUri = new URL(verificationUri);
|
|
691
|
+
} catch {
|
|
692
|
+
throw new Error("Untrusted verification_uri in device code response");
|
|
693
|
+
}
|
|
694
|
+
if (parsedUri.protocol !== "https:" && parsedUri.protocol !== "http:") {
|
|
695
|
+
throw new Error("Untrusted verification_uri in device code response");
|
|
696
|
+
}
|
|
697
|
+
interaction.notify(`Open ${parsedUri.href} and enter code: ${userCode}`);
|
|
698
|
+
const pollOpts: Parameters<typeof pollDeviceCode<string>>[0] = {
|
|
699
|
+
expiresInSeconds: expiresIn,
|
|
700
|
+
waitBeforeFirstPoll: true,
|
|
701
|
+
signal: interaction.signal,
|
|
702
|
+
poll: async () => {
|
|
703
|
+
const tokenInit: RequestInit = {
|
|
704
|
+
method: "POST",
|
|
705
|
+
headers: {
|
|
706
|
+
Accept: "application/json",
|
|
707
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
708
|
+
},
|
|
709
|
+
body: new URLSearchParams({
|
|
710
|
+
client_id: COPILOT_CLIENT_ID,
|
|
711
|
+
device_code: deviceCode,
|
|
712
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
713
|
+
}),
|
|
714
|
+
signal: interaction.signal,
|
|
715
|
+
};
|
|
716
|
+
const raw = await readJson(interaction.fetch, "https://github.com/login/oauth/access_token", tokenInit);
|
|
717
|
+
if (!raw.ok) return { status: "failed", message: `GitHub device token failed (HTTP ${raw.status})` };
|
|
718
|
+
const body = asRecord(raw.json);
|
|
719
|
+
if (body !== null && typeof body["access_token"] === "string") {
|
|
720
|
+
return { status: "complete", value: body["access_token"] };
|
|
721
|
+
}
|
|
722
|
+
const err = body?.["error"];
|
|
723
|
+
if (err === "authorization_pending") return { status: "pending" };
|
|
724
|
+
if (err === "slow_down") {
|
|
725
|
+
const next = body?.["interval"];
|
|
726
|
+
return typeof next === "number" ? { status: "slow_down", intervalSeconds: next } : { status: "slow_down" };
|
|
727
|
+
}
|
|
728
|
+
if (typeof err === "string") return { status: "failed", message: `Device flow failed: ${err}` };
|
|
729
|
+
return { status: "failed", message: "Invalid device token response" };
|
|
730
|
+
},
|
|
731
|
+
};
|
|
732
|
+
if (typeof interval === "number") pollOpts.intervalSeconds = interval;
|
|
733
|
+
const githubToken = await pollDeviceCode(pollOpts);
|
|
734
|
+
const refreshed = await refreshCopilot(githubToken, {
|
|
735
|
+
fetch: interaction.fetch,
|
|
736
|
+
now: interaction.now ?? Date.now,
|
|
737
|
+
signal: interaction.signal,
|
|
738
|
+
});
|
|
739
|
+
if (!refreshed.ok) throw new Error(`GitHub Copilot token exchange failed (${refreshed.reason})`);
|
|
740
|
+
return refreshed.credential;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
export async function loginSubscription(
|
|
744
|
+
id: SubscriptionId,
|
|
745
|
+
interaction: OAuthInteraction,
|
|
746
|
+
): Promise<{ provider: string; credential: OAuthCredential }> {
|
|
747
|
+
switch (id) {
|
|
748
|
+
case "claude-pro":
|
|
749
|
+
return { provider: "anthropic", credential: await loginAnthropic(interaction) };
|
|
750
|
+
case "codex":
|
|
751
|
+
return { provider: "openai", credential: await loginOpenAICodex(interaction) };
|
|
752
|
+
case "github-copilot":
|
|
753
|
+
return { provider: "github-copilot", credential: await loginGitHubCopilot(interaction) };
|
|
754
|
+
default: {
|
|
755
|
+
const never: never = id;
|
|
756
|
+
throw new Error(`unknown subscription login: ${String(never)}`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|