Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/opencode/src/altimate/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import z from "zod"
import path from "path"
import { rm } from "node:fs/promises"
import { Global } from "../../global"
import { Filesystem } from "../../util/filesystem"

Expand Down Expand Up @@ -134,6 +135,11 @@ export namespace AltimateApi {
)
}

/** Remove the stored gateway credential file (sign out). No-op if absent. */
export async function clearCredentials(): Promise<void> {
await rm(credentialsPath(), { force: true })
}

const VALID_TENANT_REGEX = /^[a-z_][a-z0-9_-]*$/

/** Validates credentials against the Altimate API.
Expand Down
173 changes: 172 additions & 1 deletion packages/opencode/src/altimate/plugin/altimate.ts
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>

Copy link
Copy Markdown
Contributor

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

MAJORReflected, unescaped input in the callback error page (localhost XSS). HTML_ERROR(msg) interpolates msg into HTML with no escaping, and the value ultimately comes from url.searchParams (the error branch at L57, which runs before the state check). Any page that drives the browser to http://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 state before handling the error branch.

<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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

MAJORPort collision leaves a broken, silently-failing server. server is assigned by createServer() before listen(). On EADDRINUSE the listen promise rejects (L94-96) but server is never reset to undefined, so this if (server) return early-returns on the next attempt and the flow proceeds as if listening — the callback never arrives and the user waits out the 5-minute timeout with no message.

Fix: in the error handler set server = undefined (and close it), and surface a clear "port 7317 in use" error to the TUI. Consider an ephemeral port if the redirect contract allows.

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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anyone on the network can cancel your sign-in without any secret

MAJORUnauthenticated ?error= can abort an in-progress sign-in. This error branch runs before state validation and rejects/clears the current pending flow. Combined with the all-interface bind above, any local or LAN requester can cancel a user's sign-in without knowing state — there's no CSRF protection on the abort path.

Fix: validate state before honoring the error branch (this also closes the reflected-XSS issue on the same branch).

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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

MAJORAPI key delivered in the callback URL query string. The gateway credential arrives as ?key=... and is read here, exposing the token to browser history, the address bar, extensions, and any local request logging. Even on loopback this is avoidable secret-in-URL exposure.

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())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

MAJORCallback server listens on all interfaces, not loopback. listen(CALLBACK_PORT) with no host binds to ::/0.0.0.0, so the auth callback server is reachable from the LAN — despite the comment claiming "the credential can only be delivered to this process." The 128-bit state still protects credential delivery, but the listener still processes remote requests (probing, plus the pre-state-check error abort below).

Fix: server!.listen(CALLBACK_PORT, "127.0.0.1", () => resolve()).

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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

MAJORRace — pending is registered too late. startCallbackServer() binds the port and starts accepting requests here inside authorize(), but the module-level pending (state + resolve/reject) is only set later when the framework calls the returned callback() (waitForCallback, ~L114). If the browser redirects before callback() runs (an already-signed-in user gets an instant redirect, or any scheduling gap), /callback sees pending === undefined and rejects as "Invalid state — possible CSRF", silently dropping the credential. The singleton also can't support two concurrent /auth flows — the second overwrites the first.

Fix: register pending keyed by state (e.g. a Map) synchronously in authorize() before opening the browser; have callback() await that promise, and buffer an early callback by state until it's awaited.


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({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Browser sign-in skips the tenant-name check that manual entry enforces

MINOROAuth callback skips the tenant-name validation the manual path enforces. The manual paste path runs validateCredentials() (tenant regex + API check) before saving; this OAuth path calls saveCredentials() directly on the callback instance. The source is the trusted web app, so risk is low, but the invariant differs between the two entry points.

Fix: validate instance against VALID_TENANT_REGEX before persisting, or document why the OAuth source is trusted.

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" }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When sign-in fails, we throw away the reason

MINORFailed-auth callback discards the error reason. The catch returns { type: "failed" } with no log and no reason, so CSRF/timeout/unknown failures are indistinguishable and undebuggable in the field.

Fix: log the caught error and thread a reason (e.g. csrf / timeout / unknown) through the failure result.

} finally {
stopCallbackServer()
}
},
}
},
},
{
// Fallback: paste an instance-name::api-key manually.
type: "api",
label: "Connect to Altimate",
label: "Paste API key",
},
],
},
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/cli/cmd/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,9 @@ export const ProvidersLoginCommand = cmd({
})

const priority: Record<string, number> = {
// altimate_change start — surface the Altimate LLM Gateway first
"altimate-backend": -1,
// altimate_change end
opencode: 0,
openai: 1,
"github-copilot": 2,
Expand Down
58 changes: 44 additions & 14 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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/logout has no error handling. clearCredentials()dispose()bootstrap() run unguarded; a throw in dispose/bootstrap leaves a partial logout with no toast and an unhandled rejection.

Fix: wrap in try/catch with an error toast.

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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading