-
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
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,184 @@ | ||
| 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 Google 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" | ||
|
|
||
| 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>Signed in ✓</h2><p>You can return to your terminal.</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>${msg}</p><p>Please return to your terminal and try again.</p></body>` | ||
|
|
||
| interface CallbackResult { | ||
| api_url: string | ||
| instance: string | ||
| api_key: string | ||
| } | ||
|
|
||
| interface Pending { | ||
| state: string | ||
| resolve: (creds: CallbackResult) => void | ||
| reject: (err: Error) => void | ||
| } | ||
|
|
||
| let server: ReturnType<typeof createServer> | undefined | ||
| let pending: Pending | undefined | ||
|
|
||
| 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 |
||
| 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) | ||
| } | ||
|
|
||
| const error = url.searchParams.get("error") | ||
|
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. Anyone on the network can cancel your sign-in without any secret MAJOR — Unauthenticated Fix: validate |
||
| if (error) { | ||
| pending?.reject(new Error(error)) | ||
| pending = undefined | ||
| html(200, HTML_ERROR(error)) | ||
| return | ||
| } | ||
|
|
||
| const state = url.searchParams.get("state") | ||
| // Bind the callback to the unguessable state the CLI generated — rejects a | ||
| // stray/malicious local request that didn't originate from our browser tab. | ||
| if (!pending || !state || state !== pending.state) { | ||
| const msg = "Invalid state — possible CSRF" | ||
| pending?.reject(new Error(msg)) | ||
| pending = undefined | ||
| html(400, HTML_ERROR(msg)) | ||
| return | ||
| } | ||
|
|
||
| const apiKey = url.searchParams.get("key") | ||
|
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 API key travels in the URL, where it can end up in history and logs MAJOR — API key delivered in the callback URL query string. The gateway credential arrives as Fix: deliver a short-lived one-time code in the URL and exchange it server-side for the key, or have the web page POST the credential to the loopback endpoint instead of putting it in the query string. |
||
| const instance = url.searchParams.get("instance") | ||
| const apiUrl = url.searchParams.get("url") || DEFAULT_API_URL | ||
| if (!apiKey || !instance) { | ||
| const msg = "Missing credential in callback" | ||
| pending.reject(new Error(msg)) | ||
| pending = undefined | ||
| html(400, HTML_ERROR(msg)) | ||
| return | ||
| } | ||
|
|
||
| const current = pending | ||
| pending = undefined | ||
| current.resolve({ api_url: apiUrl, instance, api_key: apiKey }) | ||
| html(200, HTML_SUCCESS) | ||
| }) | ||
|
|
||
| await new Promise<void>((resolve, reject) => { | ||
| server!.listen(CALLBACK_PORT, () => resolve()) | ||
|
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 sign-in callback server is reachable from the whole network, not just this machine MAJOR — Callback server listens on all interfaces, not loopback. Fix: |
||
| server!.on("error", reject) | ||
| }) | ||
| } | ||
|
|
||
| function stopCallbackServer() { | ||
| if (server) { | ||
| server.close() | ||
| server = undefined | ||
| } | ||
| } | ||
|
|
||
| function waitForCallback(state: string, timeoutMs = 5 * 60 * 1000): Promise<CallbackResult> { | ||
| return new Promise<CallbackResult>((resolve, reject) => { | ||
| const timeout = setTimeout(() => { | ||
| if (pending) { | ||
| pending = undefined | ||
| reject(new Error("Timed out waiting for browser sign-in")) | ||
| } | ||
| }, timeoutMs) | ||
| pending = { | ||
| 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() { | ||
| // Bind the port BEFORE opening the browser so the credential can | ||
| // only be delivered to this process. | ||
| 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 |
||
|
|
||
| const webUrl = (process.env.ALTIMATE_WEB_URL || DEFAULT_WEB_URL).replace(/\/+$/, "") | ||
| const redirect = `http://localhost:${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 waitForCallback(state) | ||
| // 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 |
||
| altimateUrl: creds.api_url, | ||
| altimateInstanceName: creds.instance, | ||
| altimateApiKey: creds.api_key, | ||
| }) | ||
| return { type: "success", key: creds.api_key, provider: "altimate-backend" } | ||
| } catch { | ||
| 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. |
||
| } finally { | ||
| stopCallbackServer() | ||
| } | ||
| }, | ||
| } | ||
| }, | ||
| }, | ||
| { | ||
| // Fallback: paste an instance-name::api-key manually. | ||
| type: "api", | ||
| label: "Connect to Altimate", | ||
| label: "Paste API key", | ||
| }, | ||
| ], | ||
| }, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,18 +3,21 @@ import { Clipboard } from "@tui/util/clipboard" | |
| import { Selection } from "@tui/util/selection" | ||
| import { MouseButton, TextAttributes } from "@opentui/core" | ||
| import { RouteProvider, useRoute } from "@tui/context/route" | ||
| import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" | ||
| import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show } from "solid-js" | ||
| import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" | ||
| import { Installation } from "@/installation" | ||
| import { UPGRADE_KV_KEY } from "./component/upgrade-indicator-utils" | ||
| import { Flag } from "@/flag/flag" | ||
| import { Log } from "@/util/log" | ||
| import { DialogProvider, useDialog } from "@tui/ui/dialog" | ||
| import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" | ||
| import { SDKProvider, useSDK } from "@tui/context/sdk" | ||
| import { SyncProvider, useSync } from "@tui/context/sync" | ||
| import { LocalProvider, useLocal } from "@tui/context/local" | ||
| import { DialogModel, useConnected } from "@tui/component/dialog-model" | ||
| import { DialogModelWelcome, useReady } from "@tui/component/altimate-onboarding" | ||
| // altimate_change — /auth (gateway sign-in) + /logout commands | ||
| import { DialogAltimateAuth } from "@tui/component/dialog-provider" | ||
| import { AltimateApi } from "../../../altimate/api/client" | ||
| import { DialogMcp } from "@tui/component/dialog-mcp" | ||
| import { DialogStatus } from "@tui/component/dialog-status" | ||
| import { DialogThemeList } from "@tui/component/dialog-theme-list" | ||
|
|
@@ -470,18 +473,12 @@ function App() { | |
| }) | ||
| }) | ||
|
|
||
| createEffect( | ||
| on( | ||
| () => sync.status === "complete" && sync.data.provider.length === 0, | ||
| (isEmpty, wasEmpty) => { | ||
| // only trigger when we transition into an empty-provider state | ||
| if (!isEmpty || wasEmpty) return | ||
| dialog.replace(() => <DialogProviderList />) | ||
| }, | ||
| ), | ||
| ) | ||
| // altimate_change — first run is now a welcoming home-screen panel (see routes/home.tsx), | ||
| // not an auto-opened modal. The welcome panel's readiness-aware tips guide the user to | ||
| // run /connect, which opens the curated DialogModelWelcome picker. | ||
|
|
||
| const connected = useConnected() | ||
| const ready = useReady() | ||
| command.register(() => [ | ||
| { | ||
| title: "Switch session", | ||
|
|
@@ -659,17 +656,47 @@ function App() { | |
| }, | ||
| }, | ||
| { | ||
| title: "Connect provider", | ||
| title: "Connect to your AI model provider", | ||
| value: "provider.connect", | ||
| suggested: !connected(), | ||
| slash: { | ||
| name: "connect", | ||
| }, | ||
| onSelect: () => { | ||
| dialog.replace(() => <DialogProviderList />) | ||
| dialog.replace(() => <DialogModelWelcome />) | ||
| }, | ||
| category: "Provider", | ||
| }, | ||
| // altimate_change start — /auth: sign in to the Altimate LLM Gateway directly; | ||
| // /logout: clear the stored gateway credential and disconnect. | ||
| { | ||
| title: "Sign in to Altimate LLM Gateway", | ||
| value: "altimate.auth", | ||
| suggested: !connected(), | ||
| slash: { | ||
| name: "auth", | ||
| }, | ||
| onSelect: () => { | ||
| dialog.replace(() => <DialogAltimateAuth />) | ||
| }, | ||
| category: "Provider", | ||
| }, | ||
| { | ||
| title: "Sign out of Altimate LLM Gateway", | ||
| value: "altimate.logout", | ||
| slash: { | ||
| name: "logout", | ||
| }, | ||
| onSelect: async () => { | ||
| await AltimateApi.clearCredentials() | ||
|
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. A failed sign-out leaves the app in a half-logged-out state with no feedback MINOR — Fix: wrap in |
||
| await sdk.client.instance.dispose() | ||
| await sync.bootstrap() | ||
| toast.show({ message: "Signed out of Altimate LLM Gateway", variant: "success" }) | ||
| dialog.clear() | ||
| }, | ||
| category: "Provider", | ||
| }, | ||
| // altimate_change end | ||
| { | ||
| title: "View status", | ||
| keybind: "status_view", | ||
|
|
@@ -891,6 +918,9 @@ function App() { | |
| // altimate_change start — branding: altimate upgrade | ||
| sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => { | ||
| kv.set(UPGRADE_KV_KEY, evt.properties.version) | ||
| // altimate_change — don't cover the first-run welcome panel with the update toast; | ||
| // the upgrade indicator in the footer still surfaces it. Show once a model is ready. | ||
| if (!ready()) return | ||
| toast.show({ | ||
| variant: "info", | ||
| title: "Update Available", | ||
|
|
||
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.