-
Notifications
You must be signed in to change notification settings - Fork 133
feat: [AI-7520] Altimate LLM Gateway CLI signup + onboarding UX #1001
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
71d68d5
43fc015
9c481ec
df61fdf
d7f0f2c
e46fdd4
f5596b4
7e7ac7c
40c8036
e6ffd25
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,229 @@ | ||
| import type { Hooks, PluginInput } from "@opencode-ai/plugin" | ||
| import { createServer } from "http" | ||
| import { randomBytes } from "crypto" | ||
| import open from "open" | ||
| import { AltimateApi } from "../api/client" | ||
|
|
||
| // Loopback port the CLI listens on for the browser to deliver the gateway | ||
| // credential after sign-in. Must match the redirect the web authorize page posts | ||
| // back to. 7317 is otherwise unused in this codebase. | ||
| const CALLBACK_PORT = 7317 | ||
|
|
||
| // Web app that hosts the signup/login (authorize) page. Overridable for | ||
| // dev/staging via ALTIMATE_WEB_URL. | ||
| const DEFAULT_WEB_URL = "https://app.myaltimate.com" | ||
| // Fallback gateway API base if the callback omits one. | ||
| const DEFAULT_API_URL = "https://api.myaltimate.com" | ||
|
|
||
| // Escape reflected values before interpolating them into the callback HTML — the | ||
| // error text originates from the URL query string, so it must not be trusted. | ||
| function escapeHtml(s: string): string { | ||
| return s.replace( | ||
| /[&<>"']/g, | ||
| (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string, | ||
| ) | ||
| } | ||
|
|
||
| // Neutral copy: the loopback returns this as soon as it RECEIVES the token, before | ||
| // the CLI has exchanged/persisted it — so don't claim "Signed in" (the terminal is | ||
| // the source of truth for actual success/failure). | ||
| const HTML_SUCCESS = `<!doctype html><meta charset="utf-8"><title>Altimate Code</title> | ||
| <body style="font-family:system-ui;text-align:center;padding:64px"> | ||
| <h2>Authorization received</h2><p>Return to your terminal to finish connecting.</p> | ||
| <script>setTimeout(()=>window.close(),1500)</script></body>` | ||
|
|
||
| const HTML_ERROR = (msg: string) => `<!doctype html><meta charset="utf-8"><title>Altimate Code</title> | ||
| <body style="font-family:system-ui;text-align:center;padding:64px"> | ||
| <h2>Connection failed</h2><p>${escapeHtml(msg)}</p><p>Please return to your terminal and try again.</p></body>` | ||
|
|
||
| interface CallbackResult { | ||
| api_url: string | ||
| instance: string | ||
| // Short-lived, one-time login_token delivered by the browser. Exchanged for | ||
| // the gateway auth_token in callback() — the raw api_key never rides in the URL. | ||
| token: string | ||
| } | ||
|
|
||
| interface Pending { | ||
| resolve: (creds: CallbackResult) => void | ||
| reject: (err: Error) => void | ||
| } | ||
|
|
||
| let server: ReturnType<typeof createServer> | undefined | ||
| // Pending flows keyed by the unguessable `state`. Registered synchronously in | ||
| // authorize() BEFORE the browser opens, so an instant redirect (an already | ||
| // signed-in user) is matched instead of dropped; keying by state also lets two | ||
| // concurrent /auth flows coexist without clobbering each other. | ||
| const pending = new Map<string, Pending>() | ||
|
|
||
| async function startCallbackServer(): Promise<void> { | ||
| if (server) return | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If port 7317 is busy, sign-in silently hangs forever with no retry MAJOR — Port collision leaves a broken, silently-failing server. Fix: in the error handler set
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| server = createServer((req, res) => { | ||
| const url = new URL(req.url || "/", `http://localhost:${CALLBACK_PORT}`) | ||
| if (url.pathname !== "/callback") { | ||
| res.writeHead(404) | ||
| res.end("Not found") | ||
| return | ||
| } | ||
|
|
||
| const html = (status: number, body: string) => { | ||
| res.writeHead(status, { "Content-Type": "text/html" }) | ||
| res.end(body) | ||
| } | ||
|
|
||
| // Validate `state` FIRST — before honoring `error` — so a request without a | ||
| // known state can neither cancel an in-progress flow nor deliver anything. | ||
| const state = url.searchParams.get("state") | ||
| const entry = state ? pending.get(state) : undefined | ||
| if (!state || !entry) { | ||
| html(400, HTML_ERROR("Invalid or unknown sign-in state")) | ||
| return | ||
| } | ||
| pending.delete(state) | ||
|
|
||
| const error = url.searchParams.get("error") | ||
| if (error) { | ||
| entry.reject(new Error(error)) | ||
| html(200, HTML_ERROR(error)) | ||
| return | ||
| } | ||
|
|
||
| const token = url.searchParams.get("token") | ||
| const instance = url.searchParams.get("instance") | ||
| const apiUrl = url.searchParams.get("url") || DEFAULT_API_URL | ||
| if (!token || !instance) { | ||
| const msg = "Missing credential in callback" | ||
| entry.reject(new Error(msg)) | ||
| html(400, HTML_ERROR(msg)) | ||
| return | ||
| } | ||
|
|
||
| entry.resolve({ api_url: apiUrl, instance, token }) | ||
| html(200, HTML_SUCCESS) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The browser says "Signed in" before we've actually finished signing in MINOR — NEW-6 — browser shows "Signed in ✓" before the token exchange/persist succeeds. The loopback returns Fix: use neutral "authorization received, return to terminal" copy, or delay success until exchange + persist succeed. Flagged by: GPT, MiniMax
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| }) | ||
|
|
||
| try { | ||
| await new Promise<void>((resolve, reject) => { | ||
| // Bind to loopback only — the credential/abort endpoints must not be reachable | ||
| // from the LAN. | ||
| server!.listen(CALLBACK_PORT, "127.0.0.1", () => resolve()) | ||
| server!.on("error", reject) | ||
| }) | ||
| } catch (err) { | ||
| // Reset so a retry isn't blocked by the `if (server) return` guard, and surface | ||
| // a clear reason (e.g. the port is already taken) instead of hanging. | ||
| server = undefined | ||
| const code = (err as NodeJS.ErrnoException)?.code | ||
| throw new Error( | ||
| code === "EADDRINUSE" | ||
| ? `Port ${CALLBACK_PORT} is already in use — close whatever is using it and try again.` | ||
| : `Could not start the sign-in server: ${err instanceof Error ? err.message : String(err)}`, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| function stopCallbackServer() { | ||
| if (server) { | ||
| server.close() | ||
| server = undefined | ||
| } | ||
| } | ||
|
|
||
| // Register a pending flow keyed by `state` and return its promise. Called | ||
| // synchronously in authorize() before the browser opens; the server handler | ||
| // resolves/rejects it by state, so a fast redirect is never lost. | ||
| function registerPending(state: string, timeoutMs = 5 * 60 * 1000): Promise<CallbackResult> { | ||
| return new Promise<CallbackResult>((resolve, reject) => { | ||
| const timeout = setTimeout(() => { | ||
| if (pending.delete(state)) reject(new Error("Timed out waiting for browser sign-in")) | ||
| }, timeoutMs) | ||
| pending.set(state, { | ||
| resolve: (creds) => { | ||
| clearTimeout(timeout) | ||
| resolve(creds) | ||
| }, | ||
| reject: (err) => { | ||
| clearTimeout(timeout) | ||
| reject(err) | ||
| }, | ||
| }) | ||
| }) | ||
| } | ||
|
|
||
| export async function AltimateAuthPlugin(_input: PluginInput): Promise<Hooks> { | ||
| return { | ||
| auth: { | ||
| provider: "altimate-backend", | ||
| methods: [ | ||
| { | ||
| type: "oauth", | ||
| label: "Altimate LLM Gateway", | ||
| async authorize() { | ||
| const state = randomBytes(16).toString("hex") | ||
| await startCallbackServer() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The browser can hand back the login before we're listening for it MAJOR — Race — Fix: register
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| // Register the pending flow BEFORE opening the browser so an instant | ||
| // redirect can be matched by state rather than dropped as CSRF. | ||
| const result = registerPending(state) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Backing out of sign-in leaves the server and pending flow running for 5 minutes MINOR — NEW-3 (residual of prior #2) — dismissing the dialog doesn't cancel the server-side flow. The Fix: on dismiss, reject/cancel the pending flow by Flagged by: Kimi (MAJOR), MiMo, Claude — consensus
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Partially addressed in |
||
| // If callback() is never awaited (e.g. the dialog is dismissed before it | ||
| // runs), the pending promise still rejects on timeout — swallow that here | ||
| // so it can't surface as an unhandled rejection. callback() awaits the | ||
| // same promise independently. | ||
| void result.catch(() => {}) | ||
|
|
||
| const webUrl = (process.env.ALTIMATE_WEB_URL || DEFAULT_WEB_URL).replace(/\/+$/, "") | ||
| // Use 127.0.0.1 to match the loopback bind — a plain `localhost` redirect | ||
| // can resolve to ::1 first and hit a closed IPv6 port. | ||
| const redirect = `http://127.0.0.1:${CALLBACK_PORT}/callback` | ||
| // Land on the sign-up page and let the user choose how to authenticate | ||
| // (Google today, more providers later) rather than forcing Google. | ||
| const authorizeUrl = | ||
| `${webUrl}/register?client=altimate-code` + | ||
| `&redirect=${encodeURIComponent(redirect)}` + | ||
| `&state=${state}` | ||
|
|
||
| await open(authorizeUrl).catch(() => undefined) | ||
|
|
||
| return { | ||
| url: authorizeUrl, | ||
| instructions: "Complete sign-in in your browser to connect Altimate LLM Gateway.", | ||
| method: "auto", | ||
| async callback() { | ||
| try { | ||
| const creds = await result | ||
| // The instance name comes from the browser callback; validate it | ||
| // before persisting (the manual paste path validates too). | ||
| if (!AltimateApi.isValidInstanceName(creds.instance)) { | ||
| throw new Error(`invalid instance name from callback: ${creds.instance}`) | ||
| } | ||
| // Exchange the short-lived, one-time login_token for the gateway | ||
| // auth_token server-side — the raw api_key never rides in the URL. | ||
| const authToken = await AltimateApi.exchangeSocialToken(creds.api_url, creds.instance, creds.token) | ||
| // Persist to ~/.altimate/altimate.json — the provider loader | ||
| // reads this first (it carries the instance/tenant + api_url | ||
| // the generic auth.json store can't). | ||
| await AltimateApi.saveCredentials({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Browser sign-in skips the tenant-name check that manual entry enforces MINOR — OAuth callback skips the tenant-name validation the manual path enforces. The manual paste path runs Fix: validate
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| altimateUrl: creds.api_url, | ||
| altimateInstanceName: creds.instance, | ||
| altimateApiKey: authToken, | ||
| }) | ||
| return { type: "success", key: authToken, provider: "altimate-backend" } | ||
| } catch (err) { | ||
| // Log the reason (CSRF / timeout / invalid instance / …). Runs in the | ||
| // server process, so this goes to the log, not the TUI display. | ||
| console.error("[altimate] gateway sign-in failed:", err instanceof Error ? err.message : err) | ||
| return { type: "failed" } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When sign-in fails, we throw away the reason MINOR — Failed-auth callback discards the error reason. The catch returns Fix: log the caught error and thread a reason (e.g.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When sign-in fails, the user sees a generic error with no reason MINOR — NEW-4 (UX) — auth/callback failures are opaque to the user. The plugin Fix: thread a reason string through the failed result (or surface it via Flagged by: MiniMax (rated CRITICAL), GPT, Kimi, MiMo — consensus
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| } finally { | ||
| // Keep the shared server up while another flow is still waiting. | ||
| if (pending.size === 0) stopCallbackServer() | ||
| } | ||
| }, | ||
| } | ||
| }, | ||
| }, | ||
| { | ||
| // Fallback: paste an instance-name::api-key manually. | ||
| type: "api", | ||
| label: "Connect to Altimate", | ||
| label: "Paste API key", | ||
| }, | ||
| ], | ||
| }, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error page echoes attacker-controlled text straight into HTML
MAJOR — Reflected, unescaped input in the callback error page (localhost XSS).
HTML_ERROR(msg)interpolatesmsginto HTML with no escaping, and the value ultimately comes fromurl.searchParams(theerrorbranch at L57, which runs before the state check). Any page that drives the browser tohttp://localhost:7317/callback?error=<script>...during the auth window yields reflected XSS on the localhost origin. Impact is limited (transient page, no secrets/cookies there) but it's avoidable.Fix: HTML-escape all reflected values, and validate
statebefore handling theerrorbranch.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in
e46fdd48b. AddedescapeHtml()and applied it to the reflected value inHTML_ERROR(altimate.ts:20-25,37). The related root cause is also closed —stateis now validated before the?error=branch runs (see the abort-path comment), so untrusted input no longer reaches that branch without a valid state.