@visual-z/xeno 0.8.3 → 0.9.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/CHANGELOG.md +24 -0
- package/README.md +31 -746
- package/bin/xeno.exe +26 -0
- package/install.cjs +88 -0
- package/package.json +18 -48
- package/docs/app-server.md +0 -115
- package/docs/foundations.md +0 -88
- package/docs/install.md +0 -244
- package/docs/providers.md +0 -172
- package/docs/research/foundation-clients-2026-10-09.md +0 -27
- package/docs/research/foundation-editing-2026-10-09.md +0 -56
- package/docs/research/foundation-network-2026-10-09.md +0 -37
- package/docs/research/foundation-workflow-2026-10-09.md +0 -37
- package/src/apps/acp-server.ts +0 -41
- package/src/apps/agent-profiles.ts +0 -19
- package/src/apps/app-server.ts +0 -30
- package/src/apps/approve.ts +0 -49
- package/src/apps/cli.ts +0 -99
- package/src/apps/commands.ts +0 -319
- package/src/apps/completions.ts +0 -87
- package/src/apps/control-transport.ts +0 -90
- package/src/apps/export-cli.ts +0 -24
- package/src/apps/goal-flow.ts +0 -70
- package/src/apps/image-draft.ts +0 -46
- package/src/apps/input.ts +0 -312
- package/src/apps/model-catalog.ts +0 -69
- package/src/apps/preferences.ts +0 -20
- package/src/apps/project-trust.ts +0 -9
- package/src/apps/provider-connect.ts +0 -346
- package/src/apps/question-ui.ts +0 -27
- package/src/apps/render.ts +0 -181
- package/src/apps/resources.ts +0 -66
- package/src/apps/review.ts +0 -109
- package/src/apps/session-hub.ts +0 -96
- package/src/apps/session-lifecycle.ts +0 -64
- package/src/apps/shell.ts +0 -2066
- package/src/apps/startup-input.ts +0 -135
- package/src/apps/startup.ts +0 -1677
- package/src/apps/ui/app.ts +0 -1713
- package/src/apps/ui/control-model.ts +0 -43
- package/src/apps/ui/extension-host.ts +0 -194
- package/src/apps/ui/image-cache.ts +0 -121
- package/src/apps/ui/palette.ts +0 -88
- package/src/apps/ui/project.ts +0 -127
- package/src/apps/ui/standalone.ts +0 -148
- package/src/apps/ui/status-model.ts +0 -68
- package/src/apps/ui/surface.ts +0 -91
- package/src/apps/ui/theme.ts +0 -86
- package/src/apps/update-check.ts +0 -112
- package/src/apps/workflow.ts +0 -58
- package/src/core/acceptance.ts +0 -139
- package/src/core/action-defaults.ts +0 -58
- package/src/core/activity-status.ts +0 -52
- package/src/core/agent-profile.ts +0 -130
- package/src/core/attachment-input.ts +0 -17
- package/src/core/blind-checks.ts +0 -72
- package/src/core/boundary.ts +0 -174
- package/src/core/branch-receipt.ts +0 -26
- package/src/core/catalog-merge.ts +0 -19
- package/src/core/checkpoints.ts +0 -85
- package/src/core/churn.ts +0 -61
- package/src/core/command-artifacts.ts +0 -94
- package/src/core/command-contract.ts +0 -151
- package/src/core/completion-action.ts +0 -20
- package/src/core/completion-review.ts +0 -98
- package/src/core/context-detail.ts +0 -37
- package/src/core/context-policy.ts +0 -133
- package/src/core/context-window.ts +0 -54
- package/src/core/continuation.ts +0 -130
- package/src/core/control-protocol.ts +0 -69
- package/src/core/control-view.ts +0 -39
- package/src/core/cycle.ts +0 -17
- package/src/core/data-query.ts +0 -201
- package/src/core/deadline.ts +0 -40
- package/src/core/derive.ts +0 -516
- package/src/core/draft-images.ts +0 -56
- package/src/core/edit-plan.ts +0 -200
- package/src/core/environment.ts +0 -181
- package/src/core/event-validation.ts +0 -220
- package/src/core/events.ts +0 -670
- package/src/core/evidence-ladder.ts +0 -62
- package/src/core/evidence-links.ts +0 -68
- package/src/core/evidence.ts +0 -240
- package/src/core/execution.ts +0 -18
- package/src/core/exhaustive.ts +0 -18
- package/src/core/export.ts +0 -179
- package/src/core/extension-flags.ts +0 -14
- package/src/core/external-effects.ts +0 -488
- package/src/core/failure-recovery.ts +0 -173
- package/src/core/file-precondition.ts +0 -78
- package/src/core/file-references.ts +0 -35
- package/src/core/frontmatter.ts +0 -213
- package/src/core/git-status.ts +0 -29
- package/src/core/goal-view.ts +0 -64
- package/src/core/goal.ts +0 -200
- package/src/core/handoff.ts +0 -479
- package/src/core/history-query.ts +0 -139
- package/src/core/history.ts +0 -237
- package/src/core/image-limits.ts +0 -72
- package/src/core/images.ts +0 -46
- package/src/core/integration-health.ts +0 -34
- package/src/core/json-event.ts +0 -131
- package/src/core/json-pointer.ts +0 -46
- package/src/core/keymap.ts +0 -153
- package/src/core/lsp-framing.ts +0 -34
- package/src/core/mcp-config.ts +0 -68
- package/src/core/model-picker.ts +0 -64
- package/src/core/model-preferences.ts +0 -28
- package/src/core/model-replay.ts +0 -22
- package/src/core/model-scope.ts +0 -53
- package/src/core/note-guidance.ts +0 -34
- package/src/core/oauth-fields.ts +0 -44
- package/src/core/observation-view.ts +0 -152
- package/src/core/outcome.ts +0 -44
- package/src/core/overflow.ts +0 -59
- package/src/core/packages.ts +0 -47
- package/src/core/patch.ts +0 -130
- package/src/core/path-suggestions.ts +0 -59
- package/src/core/permissions.ts +0 -410
- package/src/core/process-receipt.ts +0 -105
- package/src/core/process-state.ts +0 -37
- package/src/core/program-status.ts +0 -40
- package/src/core/progress.ts +0 -150
- package/src/core/project.ts +0 -431
- package/src/core/prompt-policy.ts +0 -46
- package/src/core/prompts.ts +0 -111
- package/src/core/question.ts +0 -94
- package/src/core/queue.ts +0 -78
- package/src/core/read-bounds.ts +0 -24
- package/src/core/read-observation.ts +0 -106
- package/src/core/recovery-sources.ts +0 -74
- package/src/core/regex-dialect.ts +0 -25
- package/src/core/request-budget.ts +0 -23
- package/src/core/request-progress.ts +0 -87
- package/src/core/request-state.ts +0 -273
- package/src/core/resource-selection.ts +0 -36
- package/src/core/response-recovery.ts +0 -59
- package/src/core/resume.ts +0 -59
- package/src/core/review.ts +0 -68
- package/src/core/ripgrep-release.ts +0 -64
- package/src/core/runtime-target.ts +0 -94
- package/src/core/scheduling.ts +0 -8
- package/src/core/search-observation.ts +0 -97
- package/src/core/secrets.ts +0 -188
- package/src/core/session-archive.ts +0 -4
- package/src/core/session-browser.ts +0 -82
- package/src/core/session-entry.ts +0 -61
- package/src/core/session-exit.ts +0 -42
- package/src/core/session-format.ts +0 -169
- package/src/core/session-position.ts +0 -26
- package/src/core/session-tree.ts +0 -147
- package/src/core/settings.ts +0 -192
- package/src/core/skills.ts +0 -165
- package/src/core/sql-query.ts +0 -211
- package/src/core/stall.ts +0 -128
- package/src/core/stop-report.ts +0 -68
- package/src/core/text-normalize.ts +0 -81
- package/src/core/theme.ts +0 -166
- package/src/core/thinking.ts +0 -26
- package/src/core/tokens.ts +0 -151
- package/src/core/tool-contract.ts +0 -153
- package/src/core/tool-discovery.ts +0 -87
- package/src/core/tool-failure.ts +0 -81
- package/src/core/tool-input.ts +0 -44
- package/src/core/transcript.ts +0 -108
- package/src/core/tui-mode.ts +0 -29
- package/src/core/update-check.ts +0 -157
- package/src/core/version.ts +0 -55
- package/src/core/web-content.ts +0 -80
- package/src/core/work-state.ts +0 -154
- package/src/core/workflow-mode.ts +0 -34
- package/src/core/working-set.ts +0 -119
- package/src/core/yaml.ts +0 -282
- package/src/runtime/acp-agent.ts +0 -289
- package/src/runtime/agent-profiles.ts +0 -21
- package/src/runtime/auth.ts +0 -367
- package/src/runtime/blind-check.ts +0 -36
- package/src/runtime/branch-jobs.ts +0 -36
- package/src/runtime/catalog.ts +0 -285
- package/src/runtime/clipboard.ts +0 -125
- package/src/runtime/command-artifacts.ts +0 -64
- package/src/runtime/connection-reuse.ts +0 -40
- package/src/runtime/control-client.ts +0 -149
- package/src/runtime/control-connection.ts +0 -139
- package/src/runtime/counter-check.ts +0 -74
- package/src/runtime/environment-tools.ts +0 -60
- package/src/runtime/evidence-notes.ts +0 -35
- package/src/runtime/exec-sessions.ts +0 -456
- package/src/runtime/execution-control.ts +0 -40
- package/src/runtime/extension-loader.ts +0 -37
- package/src/runtime/extension-ui.ts +0 -47
- package/src/runtime/extensions.ts +0 -370
- package/src/runtime/formatter-tool.ts +0 -38
- package/src/runtime/formatter.ts +0 -50
- package/src/runtime/foundation.ts +0 -116
- package/src/runtime/history-cache.ts +0 -57
- package/src/runtime/history-tools.ts +0 -168
- package/src/runtime/images.ts +0 -80
- package/src/runtime/integrations.ts +0 -216
- package/src/runtime/loop.ts +0 -3001
- package/src/runtime/lsp-client.ts +0 -201
- package/src/runtime/mcp-client.ts +0 -264
- package/src/runtime/mcp-oauth.ts +0 -168
- package/src/runtime/mcp-schema.ts +0 -51
- package/src/runtime/model-config.ts +0 -232
- package/src/runtime/model-data.LICENSE +0 -27
- package/src/runtime/model-data.ts +0 -21
- package/src/runtime/model-discovery.ts +0 -208
- package/src/runtime/model-replay.ts +0 -10
- package/src/runtime/model-scope.ts +0 -20
- package/src/runtime/model-sources.ts +0 -151
- package/src/runtime/model.ts +0 -686
- package/src/runtime/models-fetch.ts +0 -57
- package/src/runtime/oauth-device.ts +0 -86
- package/src/runtime/oauth.ts +0 -853
- package/src/runtime/open-image.ts +0 -42
- package/src/runtime/openai-responses.ts +0 -304
- package/src/runtime/openai.ts +0 -697
- package/src/runtime/output-schema.ts +0 -26
- package/src/runtime/package-manager.ts +0 -125
- package/src/runtime/patch-tool.ts +0 -38
- package/src/runtime/permission-resources.ts +0 -18
- package/src/runtime/pi-provider.ts +0 -423
- package/src/runtime/program-status.ts +0 -38
- package/src/runtime/progress.ts +0 -26
- package/src/runtime/provider-access.ts +0 -21
- package/src/runtime/provider-connect.ts +0 -109
- package/src/runtime/provider-presets.ts +0 -98
- package/src/runtime/provider-protocols.ts +0 -29
- package/src/runtime/provider-request.ts +0 -87
- package/src/runtime/provider-tool-policy.ts +0 -33
- package/src/runtime/provider.ts +0 -580
- package/src/runtime/question-tool.ts +0 -62
- package/src/runtime/retry.ts +0 -447
- package/src/runtime/review-tool.ts +0 -48
- package/src/runtime/rich-text.ts +0 -36
- package/src/runtime/runtime-target.ts +0 -149
- package/src/runtime/secrets.ts +0 -79
- package/src/runtime/self-update.ts +0 -74
- package/src/runtime/session-control.ts +0 -265
- package/src/runtime/session-hub.ts +0 -85
- package/src/runtime/stream-idle.ts +0 -80
- package/src/runtime/tool-broker.ts +0 -137
- package/src/runtime/tool-discovery.ts +0 -58
- package/src/runtime/tool-queue.ts +0 -77
- package/src/runtime/tools.ts +0 -1820
- package/src/runtime/turn-effects.ts +0 -31
- package/src/runtime/update-check.ts +0 -48
- package/src/runtime/usage.ts +0 -253
- package/src/runtime/version.ts +0 -12
- package/src/runtime/web-http.ts +0 -56
- package/src/runtime/web-tools.ts +0 -88
- package/src/runtime/working-notes.ts +0 -60
- package/src/runtime/workspace-lifecycle.ts +0 -141
- package/src/store/acceptance.ts +0 -15
- package/src/store/agent-profile-files.ts +0 -13
- package/src/store/atomic.ts +0 -31
- package/src/store/auth-store.ts +0 -50
- package/src/store/blind-checks.ts +0 -32
- package/src/store/blobs.ts +0 -143
- package/src/store/checkpoint-history.ts +0 -32
- package/src/store/command-artifacts.ts +0 -96
- package/src/store/command.ts +0 -111
- package/src/store/config.ts +0 -69
- package/src/store/context-files.ts +0 -436
- package/src/store/file-checkpoints.ts +0 -108
- package/src/store/file-index.ts +0 -174
- package/src/store/foundation-config.ts +0 -24
- package/src/store/git-checkpoints.ts +0 -67
- package/src/store/git-status.ts +0 -15
- package/src/store/history-search.ts +0 -19
- package/src/store/log.ts +0 -725
- package/src/store/lsp-document.ts +0 -30
- package/src/store/mcp-credentials.ts +0 -85
- package/src/store/models.ts +0 -60
- package/src/store/observations.ts +0 -224
- package/src/store/packages.ts +0 -98
- package/src/store/patch.ts +0 -92
- package/src/store/permission-paths.ts +0 -34
- package/src/store/process-receipts.ts +0 -34
- package/src/store/query-source.ts +0 -49
- package/src/store/recovery.ts +0 -44
- package/src/store/replay.ts +0 -76
- package/src/store/resource-index.ts +0 -170
- package/src/store/resources.ts +0 -285
- package/src/store/restore.ts +0 -110
- package/src/store/review.ts +0 -109
- package/src/store/runtime-target.ts +0 -64
- package/src/store/search.ts +0 -728
- package/src/store/secrets.ts +0 -44
- package/src/store/serial.ts +0 -13
- package/src/store/session-archive.ts +0 -165
- package/src/store/session-browser.ts +0 -68
- package/src/store/session-position.ts +0 -27
- package/src/store/sessions.ts +0 -525
- package/src/store/source-cancellation.ts +0 -6
- package/src/store/tool-binaries.ts +0 -90
- package/src/store/trust.ts +0 -82
- package/src/store/update-cache.ts +0 -36
- package/src/store/user-settings.ts +0 -30
- package/src/store/workspace-clone.ts +0 -42
- package/src/store/workspace-observation.ts +0 -113
- package/src/store/workspace.ts +0 -115
- package/src/store/worktree.ts +0 -278
- package/src/tui/ansi-spans.ts +0 -126
- package/src/tui/diff.ts +0 -160
- package/src/tui/exhaustive.ts +0 -14
- package/src/tui/fuzzy.ts +0 -75
- package/src/tui/style.ts +0 -208
- package/src/tui/ui/atomic-draft.ts +0 -121
- package/src/tui/ui/canvas.ts +0 -96
- package/src/tui/ui/colors.ts +0 -70
- package/src/tui/ui/composer.ts +0 -200
- package/src/tui/ui/control-panel.ts +0 -25
- package/src/tui/ui/control-sidebar.ts +0 -49
- package/src/tui/ui/dialog.ts +0 -398
- package/src/tui/ui/draft.ts +0 -23
- package/src/tui/ui/duration.ts +0 -16
- package/src/tui/ui/edge.ts +0 -186
- package/src/tui/ui/image-size.ts +0 -44
- package/src/tui/ui/ink.ts +0 -210
- package/src/tui/ui/items.ts +0 -42
- package/src/tui/ui/keys.ts +0 -40
- package/src/tui/ui/layout.ts +0 -74
- package/src/tui/ui/list-model.ts +0 -86
- package/src/tui/ui/list-view.ts +0 -172
- package/src/tui/ui/mascot.ts +0 -67
- package/src/tui/ui/messages.ts +0 -518
- package/src/tui/ui/motion.ts +0 -28
- package/src/tui/ui/paths.ts +0 -10
- package/src/tui/ui/pictures.ts +0 -108
- package/src/tui/ui/screen.ts +0 -548
- package/src/tui/ui/status.ts +0 -403
- package/src/tui/ui/styled.ts +0 -85
- package/src/tui/ui/tool-card.ts +0 -150
- package/src/tui/width.ts +0 -567
- package/src/vendor/pi-rich/LICENSE.upstream +0 -21
- package/src/vendor/pi-rich/UPSTREAM.json +0 -17
- package/src/vendor/pi-rich/clipboard-command.ts +0 -44
- package/src/vendor/pi-rich/exif-orientation.ts +0 -182
- package/src/vendor/pi-rich/html.ts +0 -51
- package/src/vendor/pi-rich/image-convert.ts +0 -49
- package/src/vendor/pi-rich/image-resize-core.ts +0 -164
- package/src/vendor/pi-rich/image-resize-worker.ts +0 -42
- package/src/vendor/pi-rich/image-resize.ts +0 -123
- package/src/vendor/pi-rich/mime.ts +0 -116
- package/src/vendor/pi-rich/photon.ts +0 -139
- package/src/vendor/pi-rich/syntax-highlight.ts +0 -214
package/src/runtime/oauth.ts
DELETED
|
@@ -1,853 +0,0 @@
|
|
|
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
|
-
import { parseDeviceAuthorization, parseOAuthTokens } from "../core/oauth-fields.ts";
|
|
19
|
-
import { XENO_VERSION } from "./version.ts";
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Minutes subtracted from server expiry so a request issued at the edge
|
|
23
|
-
* refreshes first. Copied from pi's Anthropic client; a zero margin fails
|
|
24
|
-
* at the boundary, which is the request that most needed to succeed.
|
|
25
|
-
*/
|
|
26
|
-
export const OAUTH_EXPIRY_MARGIN_MS = 5 * 60 * 1000;
|
|
27
|
-
|
|
28
|
-
export const SUBSCRIPTION_NOTICE =
|
|
29
|
-
"This uses first-party client credentials. The terms-of-service risk is on your account, not this project.";
|
|
30
|
-
|
|
31
|
-
export const SUBSCRIPTION_OPTIONS = [
|
|
32
|
-
{ id: "claude-pro", provider: "anthropic", label: "Claude Pro/Max" },
|
|
33
|
-
{ id: "codex", provider: "openai-codex", label: "ChatGPT Plus/Pro (Codex)" },
|
|
34
|
-
{ id: "github-copilot", provider: "github-copilot", label: "GitHub Copilot" },
|
|
35
|
-
{ id: "grok", provider: "xai-grok", label: "Grok (official account)" },
|
|
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
|
-
// Official Grok Build config.rs / device_code.rs, verified 2026-10-07.
|
|
105
|
-
// This is the public first-party client, with xeno attribution retained (D18).
|
|
106
|
-
const GROK_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
|
107
|
-
const GROK_AUTH_ORIGIN = "https://auth.x.ai";
|
|
108
|
-
const GROK_TOKEN_URL = `${GROK_AUTH_ORIGIN}/oauth2/token`;
|
|
109
|
-
// Only inference and refresh scopes: xeno does not store remote conversations/workspaces.
|
|
110
|
-
const GROK_SCOPE = "openid profile email offline_access grok-cli:access api:access";
|
|
111
|
-
export const GROK_PROVIDER = "xai-grok";
|
|
112
|
-
export const GROK_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
|
|
113
|
-
/** Official HTTP timeout: issuer requests cannot keep a cancelled or expired grant hanging indefinitely. */
|
|
114
|
-
const GROK_REQUEST_TIMEOUT_MS = 15_000;
|
|
115
|
-
/**
|
|
116
|
-
* The proxy's version gate reads only `x-grok-client-version`
|
|
117
|
-
* (grok-build xai-grok-sampler/src/client.rs; agent/proxy_headers.rs: "required
|
|
118
|
-
* by the proxy's version-gate check"); without it every inference is HTTP 426
|
|
119
|
-
* "version (none) is outdated". It names the protocol revision xeno speaks,
|
|
120
|
-
* not xeno's own version -- the User-Agent and client identifier still say
|
|
121
|
-
* xeno (D18). Pinned, not fetched: a registry lookup would put a network
|
|
122
|
-
* call in front of every session. 1.0.46 is what CLIProxyAPI and Cherry
|
|
123
|
-
* Studio send (minimum 1.0.13 as of 2026-10-09; stable 1.0.50). When the
|
|
124
|
-
* minimum passes it, 426 comes back and grokVersionHint() says so.
|
|
125
|
-
*/
|
|
126
|
-
export const GROK_PROTOCOL_VERSION = "1.0.46";
|
|
127
|
-
/** Shown when the proxy's version gate refuses a request. */
|
|
128
|
-
export function grokVersionHint(status: number | undefined, provider: string): string | undefined {
|
|
129
|
-
return status === 426 && provider === GROK_PROVIDER
|
|
130
|
-
? `the Grok proxy now requires a newer client protocol than xeno sends (${GROK_PROTOCOL_VERSION}); update xeno, or use the xai provider with XAI_API_KEY meanwhile`
|
|
131
|
-
: undefined;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
async function grokPost(url: string, fields: Record<string, string>, effects: Pick<RefreshEffects, "fetch" | "signal">): Promise<{ status: number; json: unknown }> {
|
|
135
|
-
const timeout = AbortSignal.timeout(GROK_REQUEST_TIMEOUT_MS);
|
|
136
|
-
const signal = effects.signal ? AbortSignal.any([effects.signal, timeout]) : timeout;
|
|
137
|
-
try {
|
|
138
|
-
const response = await effects.fetch(url, { method: "POST", redirect: "error", signal,
|
|
139
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": `xeno/${XENO_VERSION}` },
|
|
140
|
-
body: new URLSearchParams(fields) });
|
|
141
|
-
let json: unknown;
|
|
142
|
-
try { json = await response.json(); } catch { json = undefined; }
|
|
143
|
-
return { status: response.status, json };
|
|
144
|
-
} catch {
|
|
145
|
-
// Network errors can include request details; never expose a credential-bearing body.
|
|
146
|
-
throw new Error(effects.signal?.aborted ? "Login cancelled" : "Grok authentication request failed");
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export async function loginGrok(interaction: OAuthInteraction, sleep?: Parameters<typeof pollDeviceCode>[0]["sleep"]): Promise<OAuthCredential> {
|
|
151
|
-
const started = await grokPost(`${GROK_AUTH_ORIGIN}/oauth2/device/code`,
|
|
152
|
-
{ client_id: GROK_CLIENT_ID, scope: GROK_SCOPE, referrer: "xeno" }, interaction);
|
|
153
|
-
if (started.status < 200 || started.status >= 300) throw new Error(`Grok device code request failed (HTTP ${started.status})`);
|
|
154
|
-
const device = parseDeviceAuthorization(started.json, [GROK_AUTH_ORIGIN, "https://accounts.x.ai"]);
|
|
155
|
-
if (!device) throw new Error("Grok device code response was incomplete or untrusted");
|
|
156
|
-
interaction.signal.throwIfAborted();
|
|
157
|
-
const url = device.verificationUriComplete ?? device.verificationUri;
|
|
158
|
-
interaction.notify(`Open ${url}\nand enter code: ${device.userCode}`);
|
|
159
|
-
tryOpen(url, interaction.openBrowser);
|
|
160
|
-
return pollDeviceCode<OAuthCredential>({
|
|
161
|
-
expiresInSeconds: device.expiresIn, waitBeforeFirstPoll: true, signal: interaction.signal,
|
|
162
|
-
...(device.interval !== undefined ? { intervalSeconds: device.interval } : {}), ...(sleep ? { sleep } : {}),
|
|
163
|
-
poll: async () => {
|
|
164
|
-
const got = await grokPost(GROK_TOKEN_URL,
|
|
165
|
-
{ client_id: GROK_CLIENT_ID, device_code: device.deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" }, interaction);
|
|
166
|
-
const body = asRecord(got.json);
|
|
167
|
-
// RFC 8628 errors use HTTP 400; they are polling states, never completed logins.
|
|
168
|
-
if (got.status === 400 && body?.["error"] === "authorization_pending") return { status: "pending" };
|
|
169
|
-
if (got.status === 400 && body?.["error"] === "slow_down") return { status: "slow_down" };
|
|
170
|
-
if (got.status < 200 || got.status >= 300) return { status: "failed", message: `Grok device login failed (HTTP ${got.status})` };
|
|
171
|
-
const fields = parseOAuthTokens(got.json);
|
|
172
|
-
if (!fields) return { status: "failed", message: "Grok token response was incomplete" };
|
|
173
|
-
interaction.signal.throwIfAborted();
|
|
174
|
-
return { status: "complete", value: { type: "oauth", access: fields.access, refresh: fields.refresh,
|
|
175
|
-
expires: expiryFromExpiresIn(fields.expiresIn, (interaction.now ?? Date.now)()) } };
|
|
176
|
-
},
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
async function refreshGrok(refresh: string, effects: RefreshEffects): Promise<{ ok: true; credential: OAuthCredential } | { ok: false; reason: string }> {
|
|
181
|
-
try {
|
|
182
|
-
const got = await grokPost(GROK_TOKEN_URL, { client_id: GROK_CLIENT_ID, grant_type: "refresh_token", refresh_token: refresh }, effects);
|
|
183
|
-
if (effects.signal?.aborted) return { ok: false, reason: "Login cancelled" };
|
|
184
|
-
if (got.status < 200 || got.status >= 300) return { ok: false, reason: `HTTP ${got.status}` };
|
|
185
|
-
const fields = parseOAuthTokens(got.json, refresh);
|
|
186
|
-
if (!fields) return { ok: false, reason: "unexpected body" };
|
|
187
|
-
return { ok: true, credential: { type: "oauth", access: fields.access, refresh: fields.refresh,
|
|
188
|
-
expires: expiryFromExpiresIn(fields.expiresIn, effects.now()) } };
|
|
189
|
-
} catch { return { ok: false, reason: "Grok token refresh failed" }; }
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export function isSubscriptionLogin(id: string): boolean {
|
|
193
|
-
return subscriptionById(id) !== undefined;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
export function subscriptionById(id: string): (typeof SUBSCRIPTION_OPTIONS)[number] | undefined {
|
|
197
|
-
// Separate account providers accept their catalog identity too; anthropic still means an API key.
|
|
198
|
-
return SUBSCRIPTION_OPTIONS.find(o => o.id === id || (o.provider === id && o.provider !== "anthropic"));
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export function expiryFromExpiresIn(expiresIn: number, now: number): number {
|
|
202
|
-
return now + expiresIn * 1000 - OAUTH_EXPIRY_MARGIN_MS;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function base64urlEncode(bytes: Uint8Array): string {
|
|
206
|
-
let binary = "";
|
|
207
|
-
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
208
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Generate PKCE code verifier and challenge.
|
|
213
|
-
* Ported from pi (earendil-works/pi, MIT) — packages/ai/src/auth/oauth/pkce.ts
|
|
214
|
-
*/
|
|
215
|
-
export async function generatePKCE(): Promise<PkcePair> {
|
|
216
|
-
const verifierBytes = new Uint8Array(32);
|
|
217
|
-
crypto.getRandomValues(verifierBytes);
|
|
218
|
-
const verifier = base64urlEncode(verifierBytes);
|
|
219
|
-
const data = new TextEncoder().encode(verifier);
|
|
220
|
-
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
221
|
-
const challenge = base64urlEncode(new Uint8Array(hashBuffer));
|
|
222
|
-
return { verifier, challenge };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Independent of the PKCE verifier so the verifier is never printed in the URL. */
|
|
226
|
-
export function generateState(): string {
|
|
227
|
-
const bytes = new Uint8Array(16);
|
|
228
|
-
crypto.getRandomValues(bytes);
|
|
229
|
-
return base64urlEncode(bytes);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export function anthropicOAuthHeaders(): Record<string, string> {
|
|
233
|
-
return {
|
|
234
|
-
"user-agent": `xeno/${XENO_VERSION}`,
|
|
235
|
-
"anthropic-beta": "oauth-2025-04-20",
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
export async function dispatchAnthropicOAuthRequest(
|
|
240
|
-
accessToken: string,
|
|
241
|
-
opts: { fetch: OAuthFetch; url: string },
|
|
242
|
-
): Promise<Response> {
|
|
243
|
-
return opts.fetch(opts.url, {
|
|
244
|
-
method: "POST",
|
|
245
|
-
headers: {
|
|
246
|
-
Authorization: `Bearer ${accessToken}`,
|
|
247
|
-
...anthropicOAuthHeaders(),
|
|
248
|
-
},
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
export function anthropicAuthorizeUrl(opts: { challenge: string; state: string }): string {
|
|
253
|
-
const params = new URLSearchParams({
|
|
254
|
-
code: "true",
|
|
255
|
-
client_id: ANTHROPIC_CLIENT_ID,
|
|
256
|
-
response_type: "code",
|
|
257
|
-
redirect_uri: ANTHROPIC_REDIRECT_URI,
|
|
258
|
-
scope: ANTHROPIC_SCOPES,
|
|
259
|
-
code_challenge: opts.challenge,
|
|
260
|
-
code_challenge_method: "S256",
|
|
261
|
-
state: opts.state,
|
|
262
|
-
});
|
|
263
|
-
return `${ANTHROPIC_AUTHORIZE_URL}?${params.toString()}`;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
export function openaiCodexAuthorizeUrl(opts: { challenge: string; state: string }): string {
|
|
267
|
-
const url = new URL(OPENAI_AUTHORIZE_URL);
|
|
268
|
-
url.searchParams.set("response_type", "code");
|
|
269
|
-
url.searchParams.set("client_id", OPENAI_CODEX_CLIENT_ID);
|
|
270
|
-
url.searchParams.set("redirect_uri", OPENAI_REDIRECT_URI);
|
|
271
|
-
url.searchParams.set("scope", OPENAI_SCOPE);
|
|
272
|
-
url.searchParams.set("code_challenge", opts.challenge);
|
|
273
|
-
url.searchParams.set("code_challenge_method", "S256");
|
|
274
|
-
url.searchParams.set("state", opts.state);
|
|
275
|
-
url.searchParams.set("id_token_add_organizations", "true");
|
|
276
|
-
url.searchParams.set("codex_cli_simplified_flow", "true");
|
|
277
|
-
url.searchParams.set("originator", "xeno");
|
|
278
|
-
return url.toString();
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function htmlPage(title: string, body: string): string {
|
|
282
|
-
return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>${title}</title></head><body><h1>${title}</h1><p>${body}</p></body></html>`;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
export async function startCallbackServer(opts: {
|
|
286
|
-
expectedState: string;
|
|
287
|
-
host: string;
|
|
288
|
-
port: number;
|
|
289
|
-
path: string;
|
|
290
|
-
}): Promise<CallbackServer> {
|
|
291
|
-
// Binding anything other than loopback would expose the authorization code
|
|
292
|
-
// to the local network. localhost is refused too: it can resolve to ::1
|
|
293
|
-
// and listen on every interface depending on the stack.
|
|
294
|
-
if (opts.host !== "127.0.0.1") {
|
|
295
|
-
throw new Error("OAuth callback must bind 127.0.0.1");
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
let settle: ((value: CallbackOutcome) => void) | undefined;
|
|
299
|
-
const waitForCodePromise = new Promise<CallbackOutcome>((resolve) => {
|
|
300
|
-
let settled = false;
|
|
301
|
-
settle = (value) => {
|
|
302
|
-
if (settled) return;
|
|
303
|
-
settled = true;
|
|
304
|
-
resolve(value);
|
|
305
|
-
};
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
const server: Server = createServer((req, res) => {
|
|
309
|
-
try {
|
|
310
|
-
const url = new URL(req.url ?? "", "http://127.0.0.1");
|
|
311
|
-
if (url.pathname !== opts.path) {
|
|
312
|
-
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
|
313
|
-
res.end(htmlPage("Not found", "Callback route not found."));
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
const error = url.searchParams.get("error");
|
|
317
|
-
if (error) {
|
|
318
|
-
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
319
|
-
res.end(htmlPage("Authentication failed", "The provider returned an error."));
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
const state = url.searchParams.get("state");
|
|
323
|
-
const code = url.searchParams.get("code");
|
|
324
|
-
if (!code || !state) {
|
|
325
|
-
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
326
|
-
res.end(htmlPage("Authentication failed", "Missing code or state."));
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
if (state !== opts.expectedState) {
|
|
330
|
-
// CSRF: any local process can POST here. Rejecting is the whole point
|
|
331
|
-
// of state. Do not settle waitForCode with this code.
|
|
332
|
-
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
333
|
-
res.end(htmlPage("Authentication failed", "State mismatch."));
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
337
|
-
res.end(htmlPage("Authentication successful", "You can close this window."));
|
|
338
|
-
settle?.({ ok: true, code });
|
|
339
|
-
} catch {
|
|
340
|
-
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
341
|
-
res.end("Internal error");
|
|
342
|
-
}
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
await new Promise<void>((resolve, reject) => {
|
|
346
|
-
server.once("error", reject);
|
|
347
|
-
server.listen(opts.port, opts.host, () => resolve());
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
const addr = server.address();
|
|
351
|
-
if (addr === null || typeof addr === "string") {
|
|
352
|
-
server.close();
|
|
353
|
-
throw new Error("OAuth callback server has no address");
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
let closed = false;
|
|
357
|
-
const close = (): Promise<void> => {
|
|
358
|
-
if (closed) return Promise.resolve();
|
|
359
|
-
closed = true;
|
|
360
|
-
settle?.({ ok: false, reason: "cancelled" });
|
|
361
|
-
return new Promise((resolve) => {
|
|
362
|
-
server.close(() => resolve());
|
|
363
|
-
});
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
return {
|
|
367
|
-
host: opts.host,
|
|
368
|
-
port: addr.port,
|
|
369
|
-
waitForCode: () => waitForCodePromise,
|
|
370
|
-
close,
|
|
371
|
-
};
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
async function readJson(
|
|
375
|
-
fetchImpl: OAuthFetch,
|
|
376
|
-
url: string,
|
|
377
|
-
init: RequestInit,
|
|
378
|
-
): Promise<{ ok: true; json: unknown } | { ok: false; status: number }> {
|
|
379
|
-
const response = await fetchImpl(url, init);
|
|
380
|
-
if (!response.ok) return { ok: false, status: response.status };
|
|
381
|
-
try {
|
|
382
|
-
return { ok: true, json: await response.json() };
|
|
383
|
-
} catch {
|
|
384
|
-
return { ok: false, status: response.status };
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
389
|
-
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function tokenFields(json: unknown): { access: string; refresh: string; expiresIn: number } | null {
|
|
393
|
-
const rec = asRecord(json);
|
|
394
|
-
if (rec === null) return null;
|
|
395
|
-
const access = rec["access_token"];
|
|
396
|
-
const refresh = rec["refresh_token"];
|
|
397
|
-
const expiresIn = rec["expires_in"];
|
|
398
|
-
if (typeof access !== "string" || typeof refresh !== "string" || typeof expiresIn !== "number") {
|
|
399
|
-
return null;
|
|
400
|
-
}
|
|
401
|
-
return { access, refresh, expiresIn };
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
export async function exchangeAnthropicCode(opts: {
|
|
405
|
-
code: string;
|
|
406
|
-
state: string;
|
|
407
|
-
verifier: string;
|
|
408
|
-
redirectUri: string;
|
|
409
|
-
fetch: OAuthFetch;
|
|
410
|
-
signal?: AbortSignal;
|
|
411
|
-
now?: () => number;
|
|
412
|
-
}): Promise<OAuthCredential> {
|
|
413
|
-
const init: RequestInit = {
|
|
414
|
-
method: "POST",
|
|
415
|
-
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
416
|
-
body: JSON.stringify({
|
|
417
|
-
grant_type: "authorization_code",
|
|
418
|
-
client_id: ANTHROPIC_CLIENT_ID,
|
|
419
|
-
code: opts.code,
|
|
420
|
-
state: opts.state,
|
|
421
|
-
redirect_uri: opts.redirectUri,
|
|
422
|
-
code_verifier: opts.verifier,
|
|
423
|
-
}),
|
|
424
|
-
};
|
|
425
|
-
if (opts.signal !== undefined) init.signal = opts.signal;
|
|
426
|
-
const got = await readJson(opts.fetch, ANTHROPIC_TOKEN_URL, init);
|
|
427
|
-
if (!got.ok) throw new Error(`Anthropic token exchange failed (HTTP ${got.status})`);
|
|
428
|
-
const fields = tokenFields(got.json);
|
|
429
|
-
if (fields === null) throw new Error("Anthropic token exchange returned an unexpected body");
|
|
430
|
-
const now = opts.now ?? Date.now;
|
|
431
|
-
return {
|
|
432
|
-
type: "oauth",
|
|
433
|
-
access: fields.access,
|
|
434
|
-
refresh: fields.refresh,
|
|
435
|
-
expires: expiryFromExpiresIn(fields.expiresIn, now()),
|
|
436
|
-
};
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
async function refreshAnthropic(
|
|
440
|
-
refreshToken: string,
|
|
441
|
-
opts: RefreshEffects,
|
|
442
|
-
): Promise<{ ok: true; credential: OAuthCredential } | { ok: false; reason: string }> {
|
|
443
|
-
const init: RequestInit = {
|
|
444
|
-
method: "POST",
|
|
445
|
-
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
446
|
-
body: JSON.stringify({
|
|
447
|
-
grant_type: "refresh_token",
|
|
448
|
-
client_id: ANTHROPIC_CLIENT_ID,
|
|
449
|
-
refresh_token: refreshToken,
|
|
450
|
-
}),
|
|
451
|
-
};
|
|
452
|
-
if (opts.signal !== undefined) init.signal = opts.signal;
|
|
453
|
-
const got = await readJson(opts.fetch, ANTHROPIC_TOKEN_URL, init);
|
|
454
|
-
if (!got.ok) return { ok: false, reason: `HTTP ${got.status}` };
|
|
455
|
-
const fields = tokenFields(got.json);
|
|
456
|
-
if (fields === null) return { ok: false, reason: "unexpected body" };
|
|
457
|
-
return {
|
|
458
|
-
ok: true,
|
|
459
|
-
credential: {
|
|
460
|
-
type: "oauth",
|
|
461
|
-
access: fields.access,
|
|
462
|
-
refresh: fields.refresh,
|
|
463
|
-
expires: expiryFromExpiresIn(fields.expiresIn, opts.now()),
|
|
464
|
-
},
|
|
465
|
-
};
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
export async function exchangeOpenAICode(opts: {
|
|
469
|
-
code: string;
|
|
470
|
-
verifier: string;
|
|
471
|
-
redirectUri: string;
|
|
472
|
-
fetch: OAuthFetch;
|
|
473
|
-
signal?: AbortSignal;
|
|
474
|
-
now?: () => number;
|
|
475
|
-
}): Promise<OAuthCredential> {
|
|
476
|
-
const init: RequestInit = {
|
|
477
|
-
method: "POST",
|
|
478
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
479
|
-
body: new URLSearchParams({
|
|
480
|
-
grant_type: "authorization_code",
|
|
481
|
-
client_id: OPENAI_CODEX_CLIENT_ID,
|
|
482
|
-
code: opts.code,
|
|
483
|
-
code_verifier: opts.verifier,
|
|
484
|
-
redirect_uri: opts.redirectUri,
|
|
485
|
-
}),
|
|
486
|
-
};
|
|
487
|
-
if (opts.signal !== undefined) init.signal = opts.signal;
|
|
488
|
-
const got = await readJson(opts.fetch, OPENAI_TOKEN_URL, init);
|
|
489
|
-
if (!got.ok) throw new Error(`OpenAI Codex token exchange failed (HTTP ${got.status})`);
|
|
490
|
-
const fields = tokenFields(got.json);
|
|
491
|
-
if (fields === null) throw new Error("OpenAI Codex token exchange returned an unexpected body");
|
|
492
|
-
return credentialsFromOpenAIToken(fields, opts.now ?? Date.now);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
async function refreshOpenAI(
|
|
496
|
-
refreshToken: string,
|
|
497
|
-
opts: RefreshEffects,
|
|
498
|
-
): Promise<{ ok: true; credential: OAuthCredential } | { ok: false; reason: string }> {
|
|
499
|
-
const init: RequestInit = {
|
|
500
|
-
method: "POST",
|
|
501
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
502
|
-
body: new URLSearchParams({
|
|
503
|
-
grant_type: "refresh_token",
|
|
504
|
-
refresh_token: refreshToken,
|
|
505
|
-
client_id: OPENAI_CODEX_CLIENT_ID,
|
|
506
|
-
}),
|
|
507
|
-
};
|
|
508
|
-
if (opts.signal !== undefined) init.signal = opts.signal;
|
|
509
|
-
const got = await readJson(opts.fetch, OPENAI_TOKEN_URL, init);
|
|
510
|
-
if (!got.ok) return { ok: false, reason: `HTTP ${got.status}` };
|
|
511
|
-
const fields = tokenFields(got.json);
|
|
512
|
-
if (fields === null) return { ok: false, reason: "unexpected body" };
|
|
513
|
-
try {
|
|
514
|
-
return { ok: true, credential: credentialsFromOpenAIToken(fields, opts.now) };
|
|
515
|
-
} catch {
|
|
516
|
-
return { ok: false, reason: "token missing account id" };
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function credentialsFromOpenAIToken(
|
|
521
|
-
fields: { access: string; refresh: string; expiresIn: number },
|
|
522
|
-
now: () => number,
|
|
523
|
-
): OAuthCredential {
|
|
524
|
-
const accountId = accountIdFromJwt(fields.access);
|
|
525
|
-
const cred: OAuthCredential = {
|
|
526
|
-
type: "oauth",
|
|
527
|
-
access: fields.access,
|
|
528
|
-
refresh: fields.refresh,
|
|
529
|
-
expires: expiryFromExpiresIn(fields.expiresIn, now()),
|
|
530
|
-
};
|
|
531
|
-
if (accountId !== null) return { ...cred, accountId };
|
|
532
|
-
throw new Error("OpenAI Codex token missing account id");
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function accountIdFromJwt(token: string): string | null {
|
|
536
|
-
try {
|
|
537
|
-
const parts = token.split(".");
|
|
538
|
-
const payload = parts[1];
|
|
539
|
-
if (parts.length !== 3 || payload === undefined) return null;
|
|
540
|
-
const json = JSON.parse(atob(payload)) as unknown;
|
|
541
|
-
const rec = asRecord(json);
|
|
542
|
-
const auth = rec === null ? null : asRecord(rec[OPENAI_JWT_CLAIM]);
|
|
543
|
-
const id = auth?.["chatgpt_account_id"];
|
|
544
|
-
return typeof id === "string" && id.length > 0 ? id : null;
|
|
545
|
-
} catch {
|
|
546
|
-
return null;
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
async function refreshCopilot(
|
|
551
|
-
githubToken: string,
|
|
552
|
-
opts: RefreshEffects,
|
|
553
|
-
enterpriseUrl?: string,
|
|
554
|
-
): Promise<{ ok: true; credential: OAuthCredential } | { ok: false; reason: string }> {
|
|
555
|
-
const domain = enterpriseUrl && enterpriseUrl.length > 0 ? enterpriseUrl : "github.com";
|
|
556
|
-
const url = `https://api.${domain}/copilot_internal/v2/token`;
|
|
557
|
-
const init: RequestInit = {
|
|
558
|
-
headers: {
|
|
559
|
-
Accept: "application/json",
|
|
560
|
-
Authorization: `Bearer ${githubToken}`,
|
|
561
|
-
},
|
|
562
|
-
};
|
|
563
|
-
if (opts.signal !== undefined) init.signal = opts.signal;
|
|
564
|
-
const got = await readJson(opts.fetch, url, init);
|
|
565
|
-
if (!got.ok) return { ok: false, reason: `HTTP ${got.status}` };
|
|
566
|
-
const rec = asRecord(got.json);
|
|
567
|
-
const token = rec?.["token"];
|
|
568
|
-
const expiresAt = rec?.["expires_at"];
|
|
569
|
-
if (typeof token !== "string" || typeof expiresAt !== "number") {
|
|
570
|
-
return { ok: false, reason: "unexpected body" };
|
|
571
|
-
}
|
|
572
|
-
const cred: OAuthCredential = {
|
|
573
|
-
type: "oauth",
|
|
574
|
-
access: token,
|
|
575
|
-
refresh: githubToken,
|
|
576
|
-
expires: expiresAt * 1000 - OAUTH_EXPIRY_MARGIN_MS,
|
|
577
|
-
};
|
|
578
|
-
if (enterpriseUrl !== undefined && enterpriseUrl.length > 0) {
|
|
579
|
-
return { ok: true, credential: { ...cred, enterpriseUrl } };
|
|
580
|
-
}
|
|
581
|
-
return { ok: true, credential: cred };
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
export async function refreshOAuthCredential(
|
|
585
|
-
provider: string,
|
|
586
|
-
credential: OAuthCredential,
|
|
587
|
-
opts: RefreshEffects,
|
|
588
|
-
): Promise<{ ok: true; credential: OAuthCredential } | { ok: false; reason: string }> {
|
|
589
|
-
switch (provider) {
|
|
590
|
-
case "anthropic":
|
|
591
|
-
return refreshAnthropic(credential.refresh, opts);
|
|
592
|
-
case "openai":
|
|
593
|
-
case "openai-codex":
|
|
594
|
-
return refreshOpenAI(credential.refresh, opts);
|
|
595
|
-
case "github-copilot":
|
|
596
|
-
return refreshCopilot(credential.refresh, opts, credential.enterpriseUrl);
|
|
597
|
-
case GROK_PROVIDER:
|
|
598
|
-
return refreshGrok(credential.refresh, opts);
|
|
599
|
-
default:
|
|
600
|
-
return { ok: false, reason: `no oauth refresh for ${provider}` };
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
export function parseAuthorizationInput(input: string): { code?: string; state?: string } {
|
|
605
|
-
const value = input.trim();
|
|
606
|
-
if (!value) return {};
|
|
607
|
-
try {
|
|
608
|
-
const url = new URL(value);
|
|
609
|
-
const parsed: { code?: string; state?: string } = {};
|
|
610
|
-
const code = url.searchParams.get("code");
|
|
611
|
-
const state = url.searchParams.get("state");
|
|
612
|
-
if (code) parsed.code = code;
|
|
613
|
-
if (state) parsed.state = state;
|
|
614
|
-
return parsed;
|
|
615
|
-
} catch {
|
|
616
|
-
// not a URL
|
|
617
|
-
}
|
|
618
|
-
if (value.includes("#")) {
|
|
619
|
-
const [code, state] = value.split("#", 2);
|
|
620
|
-
const parsed: { code?: string; state?: string } = {};
|
|
621
|
-
if (code) parsed.code = code;
|
|
622
|
-
if (state) parsed.state = state;
|
|
623
|
-
return parsed;
|
|
624
|
-
}
|
|
625
|
-
if (value.includes("code=")) {
|
|
626
|
-
const params = new URLSearchParams(value);
|
|
627
|
-
const parsed: { code?: string; state?: string } = {};
|
|
628
|
-
const code = params.get("code");
|
|
629
|
-
const state = params.get("state");
|
|
630
|
-
if (code) parsed.code = code;
|
|
631
|
-
if (state) parsed.state = state;
|
|
632
|
-
return parsed;
|
|
633
|
-
}
|
|
634
|
-
return { code: value };
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
async function raceCode(
|
|
638
|
-
server: CallbackServer,
|
|
639
|
-
interaction: OAuthInteraction,
|
|
640
|
-
expectedState: string,
|
|
641
|
-
): Promise<string> {
|
|
642
|
-
const manualAbort = new AbortController();
|
|
643
|
-
const onAbort = (): void => {
|
|
644
|
-
void server.close();
|
|
645
|
-
};
|
|
646
|
-
interaction.signal.addEventListener("abort", onAbort, { once: true });
|
|
647
|
-
if (interaction.signal.aborted) onAbort();
|
|
648
|
-
try {
|
|
649
|
-
const pastePromise = interaction
|
|
650
|
-
.prompt(
|
|
651
|
-
"Complete login in the browser, or paste the redirect URL here:",
|
|
652
|
-
manualAbort.signal,
|
|
653
|
-
)
|
|
654
|
-
.then((input) => ({ kind: "paste" as const, input }))
|
|
655
|
-
.catch((err: unknown) => ({ kind: "paste-error" as const, err }));
|
|
656
|
-
const callbackPromise = server.waitForCode().then((got) => ({ kind: "callback" as const, got }));
|
|
657
|
-
const first = await Promise.race([pastePromise, callbackPromise]);
|
|
658
|
-
if (first.kind === "callback" && first.got.ok) return first.got.code;
|
|
659
|
-
if (first.kind === "paste-error") {
|
|
660
|
-
throw first.err instanceof Error ? first.err : new Error("login cancelled");
|
|
661
|
-
}
|
|
662
|
-
if (first.kind === "paste") {
|
|
663
|
-
const parsed = parseAuthorizationInput(first.input);
|
|
664
|
-
if (parsed.state !== undefined && parsed.state !== expectedState) {
|
|
665
|
-
throw new Error("OAuth state mismatch");
|
|
666
|
-
}
|
|
667
|
-
if (!parsed.code) throw new Error("Missing authorization code");
|
|
668
|
-
return parsed.code;
|
|
669
|
-
}
|
|
670
|
-
// Callback settled without a code: wait for paste if it is still coming.
|
|
671
|
-
const paste = await pastePromise;
|
|
672
|
-
if (paste.kind === "paste-error") {
|
|
673
|
-
throw paste.err instanceof Error ? paste.err : new Error("login cancelled");
|
|
674
|
-
}
|
|
675
|
-
const parsed = parseAuthorizationInput(paste.input);
|
|
676
|
-
if (parsed.state !== undefined && parsed.state !== expectedState) {
|
|
677
|
-
throw new Error("OAuth state mismatch");
|
|
678
|
-
}
|
|
679
|
-
if (!parsed.code) throw new Error("Missing authorization code");
|
|
680
|
-
return parsed.code;
|
|
681
|
-
} finally {
|
|
682
|
-
interaction.signal.removeEventListener("abort", onAbort);
|
|
683
|
-
manualAbort.abort();
|
|
684
|
-
await server.close();
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
function tryOpen(url: string, openBrowser?: (url: string) => void): void {
|
|
689
|
-
if (openBrowser) {
|
|
690
|
-
openBrowser(url);
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
try {
|
|
694
|
-
Bun.spawn(["open", url], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });
|
|
695
|
-
} catch {
|
|
696
|
-
// Headless or remote: the printed URL is the fallback.
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
export async function loginAnthropic(interaction: OAuthInteraction): Promise<OAuthCredential> {
|
|
701
|
-
const pkce = await generatePKCE();
|
|
702
|
-
const state = generateState();
|
|
703
|
-
const url = anthropicAuthorizeUrl({ challenge: pkce.challenge, state });
|
|
704
|
-
const server = await startCallbackServer({
|
|
705
|
-
expectedState: state,
|
|
706
|
-
host: "127.0.0.1",
|
|
707
|
-
port: ANTHROPIC_CALLBACK_PORT,
|
|
708
|
-
path: ANTHROPIC_CALLBACK_PATH,
|
|
709
|
-
});
|
|
710
|
-
interaction.notify(url);
|
|
711
|
-
interaction.notify("If the browser is on another machine, paste the final redirect URL.");
|
|
712
|
-
tryOpen(url, interaction.openBrowser);
|
|
713
|
-
const code = await raceCode(server, interaction, state);
|
|
714
|
-
const exchange: Parameters<typeof exchangeAnthropicCode>[0] = {
|
|
715
|
-
code,
|
|
716
|
-
state,
|
|
717
|
-
verifier: pkce.verifier,
|
|
718
|
-
redirectUri: ANTHROPIC_REDIRECT_URI,
|
|
719
|
-
fetch: interaction.fetch,
|
|
720
|
-
signal: interaction.signal,
|
|
721
|
-
};
|
|
722
|
-
if (interaction.now !== undefined) exchange.now = interaction.now;
|
|
723
|
-
return exchangeAnthropicCode(exchange);
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
export async function loginOpenAICodex(interaction: OAuthInteraction): Promise<OAuthCredential> {
|
|
727
|
-
const pkce = await generatePKCE();
|
|
728
|
-
const state = generateState();
|
|
729
|
-
const url = openaiCodexAuthorizeUrl({ challenge: pkce.challenge, state });
|
|
730
|
-
const server = await startCallbackServer({
|
|
731
|
-
expectedState: state,
|
|
732
|
-
host: "127.0.0.1",
|
|
733
|
-
port: OPENAI_CALLBACK_PORT,
|
|
734
|
-
path: OPENAI_CALLBACK_PATH,
|
|
735
|
-
});
|
|
736
|
-
interaction.notify(url);
|
|
737
|
-
interaction.notify("If the browser is on another machine, paste the final redirect URL.");
|
|
738
|
-
tryOpen(url, interaction.openBrowser);
|
|
739
|
-
const code = await raceCode(server, interaction, state);
|
|
740
|
-
const exchange: Parameters<typeof exchangeOpenAICode>[0] = {
|
|
741
|
-
code,
|
|
742
|
-
verifier: pkce.verifier,
|
|
743
|
-
redirectUri: OPENAI_REDIRECT_URI,
|
|
744
|
-
fetch: interaction.fetch,
|
|
745
|
-
signal: interaction.signal,
|
|
746
|
-
};
|
|
747
|
-
if (interaction.now !== undefined) exchange.now = interaction.now;
|
|
748
|
-
return exchangeOpenAICode(exchange);
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
export async function loginGitHubCopilot(interaction: OAuthInteraction): Promise<OAuthCredential> {
|
|
752
|
-
const init: RequestInit = {
|
|
753
|
-
method: "POST",
|
|
754
|
-
headers: {
|
|
755
|
-
Accept: "application/json",
|
|
756
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
757
|
-
},
|
|
758
|
-
body: new URLSearchParams({
|
|
759
|
-
client_id: COPILOT_CLIENT_ID,
|
|
760
|
-
scope: "read:user",
|
|
761
|
-
}),
|
|
762
|
-
signal: interaction.signal,
|
|
763
|
-
};
|
|
764
|
-
const started = await readJson(interaction.fetch, "https://github.com/login/device/code", init);
|
|
765
|
-
if (!started.ok) throw new Error(`GitHub device code request failed (HTTP ${started.status})`);
|
|
766
|
-
const rec = asRecord(started.json);
|
|
767
|
-
const deviceCode = rec?.["device_code"];
|
|
768
|
-
const userCode = rec?.["user_code"];
|
|
769
|
-
const verificationUri = rec?.["verification_uri"];
|
|
770
|
-
const interval = rec?.["interval"];
|
|
771
|
-
const expiresIn = rec?.["expires_in"];
|
|
772
|
-
if (
|
|
773
|
-
typeof deviceCode !== "string" ||
|
|
774
|
-
typeof userCode !== "string" ||
|
|
775
|
-
typeof verificationUri !== "string" ||
|
|
776
|
-
typeof expiresIn !== "number"
|
|
777
|
-
) {
|
|
778
|
-
throw new Error("GitHub device code response was incomplete");
|
|
779
|
-
}
|
|
780
|
-
let parsedUri: URL;
|
|
781
|
-
try {
|
|
782
|
-
parsedUri = new URL(verificationUri);
|
|
783
|
-
} catch {
|
|
784
|
-
throw new Error("Untrusted verification_uri in device code response");
|
|
785
|
-
}
|
|
786
|
-
if (parsedUri.protocol !== "https:" && parsedUri.protocol !== "http:") {
|
|
787
|
-
throw new Error("Untrusted verification_uri in device code response");
|
|
788
|
-
}
|
|
789
|
-
interaction.notify(`Open ${parsedUri.href} and enter code: ${userCode}`);
|
|
790
|
-
const pollOpts: Parameters<typeof pollDeviceCode<string>>[0] = {
|
|
791
|
-
expiresInSeconds: expiresIn,
|
|
792
|
-
waitBeforeFirstPoll: true,
|
|
793
|
-
signal: interaction.signal,
|
|
794
|
-
poll: async () => {
|
|
795
|
-
const tokenInit: RequestInit = {
|
|
796
|
-
method: "POST",
|
|
797
|
-
headers: {
|
|
798
|
-
Accept: "application/json",
|
|
799
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
800
|
-
},
|
|
801
|
-
body: new URLSearchParams({
|
|
802
|
-
client_id: COPILOT_CLIENT_ID,
|
|
803
|
-
device_code: deviceCode,
|
|
804
|
-
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
805
|
-
}),
|
|
806
|
-
signal: interaction.signal,
|
|
807
|
-
};
|
|
808
|
-
const raw = await readJson(interaction.fetch, "https://github.com/login/oauth/access_token", tokenInit);
|
|
809
|
-
if (!raw.ok) return { status: "failed", message: `GitHub device token failed (HTTP ${raw.status})` };
|
|
810
|
-
const body = asRecord(raw.json);
|
|
811
|
-
if (body !== null && typeof body["access_token"] === "string") {
|
|
812
|
-
return { status: "complete", value: body["access_token"] };
|
|
813
|
-
}
|
|
814
|
-
const err = body?.["error"];
|
|
815
|
-
if (err === "authorization_pending") return { status: "pending" };
|
|
816
|
-
if (err === "slow_down") {
|
|
817
|
-
const next = body?.["interval"];
|
|
818
|
-
return typeof next === "number" ? { status: "slow_down", intervalSeconds: next } : { status: "slow_down" };
|
|
819
|
-
}
|
|
820
|
-
if (typeof err === "string") return { status: "failed", message: `Device flow failed: ${err}` };
|
|
821
|
-
return { status: "failed", message: "Invalid device token response" };
|
|
822
|
-
},
|
|
823
|
-
};
|
|
824
|
-
if (typeof interval === "number") pollOpts.intervalSeconds = interval;
|
|
825
|
-
const githubToken = await pollDeviceCode(pollOpts);
|
|
826
|
-
const refreshed = await refreshCopilot(githubToken, {
|
|
827
|
-
fetch: interaction.fetch,
|
|
828
|
-
now: interaction.now ?? Date.now,
|
|
829
|
-
signal: interaction.signal,
|
|
830
|
-
});
|
|
831
|
-
if (!refreshed.ok) throw new Error(`GitHub Copilot token exchange failed (${refreshed.reason})`);
|
|
832
|
-
return refreshed.credential;
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
export async function loginSubscription(
|
|
836
|
-
id: SubscriptionId,
|
|
837
|
-
interaction: OAuthInteraction,
|
|
838
|
-
): Promise<{ provider: string; credential: OAuthCredential }> {
|
|
839
|
-
switch (id) {
|
|
840
|
-
case "claude-pro":
|
|
841
|
-
return { provider: "anthropic", credential: await loginAnthropic(interaction) };
|
|
842
|
-
case "codex":
|
|
843
|
-
return { provider: "openai-codex", credential: await loginOpenAICodex(interaction) };
|
|
844
|
-
case "github-copilot":
|
|
845
|
-
return { provider: "github-copilot", credential: await loginGitHubCopilot(interaction) };
|
|
846
|
-
case "grok":
|
|
847
|
-
return { provider: GROK_PROVIDER, credential: await loginGrok(interaction) };
|
|
848
|
-
default: {
|
|
849
|
-
const never: never = id;
|
|
850
|
-
throw new Error(`unknown subscription login: ${String(never)}`);
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
}
|