Add Command Code browser login fallback
This commit is contained in:
+85
-20
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* Local HTTP callback server for the Command Code browser auth flow.
|
||||
*
|
||||
* Starts a one-shot server on a random port. The Command Code Studio
|
||||
* website POSTs the user's API key to /callback after they authenticate.
|
||||
* Starts a one-shot server on a CLI-compatible localhost port. The Command Code
|
||||
* Studio website POSTs the user's API key to /callback after they authenticate.
|
||||
*/
|
||||
|
||||
import { createServer, type Server } from "node:http"
|
||||
import type { AddressInfo } from "node:net"
|
||||
|
||||
const DEFAULT_PORT = 5959
|
||||
const DEFAULT_PORT_RANGE = 10
|
||||
|
||||
export interface AuthCallback {
|
||||
apiKey: string
|
||||
state: string
|
||||
@@ -22,15 +25,65 @@ export interface AuthServer {
|
||||
waitForCallback: Promise<AuthCallback>
|
||||
}
|
||||
|
||||
export interface AuthServerOptions {
|
||||
startPort?: number
|
||||
portRange?: number
|
||||
}
|
||||
|
||||
function listenOnAvailablePort(
|
||||
server: Server,
|
||||
startPort = DEFAULT_PORT,
|
||||
range = DEFAULT_PORT_RANGE,
|
||||
): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let offset = 0
|
||||
|
||||
const tryListen = () => {
|
||||
const useFallbackPort = startPort === 0 || offset >= range
|
||||
const port = useFallbackPort ? 0 : startPort + offset
|
||||
|
||||
const onError = (err: NodeJS.ErrnoException) => {
|
||||
server.off("listening", onListening)
|
||||
if (err.code === "EADDRINUSE" && !useFallbackPort) {
|
||||
offset += 1
|
||||
tryListen()
|
||||
return
|
||||
}
|
||||
reject(err)
|
||||
}
|
||||
|
||||
const onListening = () => {
|
||||
server.off("error", onError)
|
||||
const address = server.address() as AddressInfo
|
||||
resolve(address.port)
|
||||
}
|
||||
|
||||
server.once("error", onError)
|
||||
server.once("listening", onListening)
|
||||
server.listen(port, "127.0.0.1")
|
||||
}
|
||||
|
||||
tryListen()
|
||||
})
|
||||
}
|
||||
|
||||
function closeServer(server: Server) {
|
||||
server.close((err: NodeJS.ErrnoException | undefined) => {
|
||||
if (err && err.code !== "ERR_SERVER_NOT_RUNNING") {
|
||||
// There is nowhere useful to report this during auth cleanup.
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a local HTTP server that listens for the Command Code Studio
|
||||
* to POST the API key after the user authenticates in their browser.
|
||||
*
|
||||
* The server accepts exactly one valid POST to /callback and then closes.
|
||||
*/
|
||||
export function startAuthServer(): Promise<AuthServer> {
|
||||
let resolveCallback: (value: AuthCallback) => void
|
||||
let rejectCallback: (error: Error) => void
|
||||
export async function startAuthServer(options: AuthServerOptions = {}): Promise<AuthServer> {
|
||||
let resolveCallback!: (value: AuthCallback) => void
|
||||
let rejectCallback!: (error: Error) => void
|
||||
|
||||
const waitForCallback = new Promise<AuthCallback>((resolve, reject) => {
|
||||
resolveCallback = resolve
|
||||
@@ -38,7 +91,7 @@ export function startAuthServer(): Promise<AuthServer> {
|
||||
})
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
// CORS: allow requests from Command Code domains and localhost for dev
|
||||
// CORS: allow requests from Command Code domains and localhost for dev.
|
||||
const origin = req.headers.origin || ""
|
||||
const allowedOrigins = [
|
||||
"http://localhost:3000",
|
||||
@@ -46,13 +99,22 @@ export function startAuthServer(): Promise<AuthServer> {
|
||||
"https://commandcode.ai",
|
||||
]
|
||||
const responseOrigin = allowedOrigins.includes(origin) ? origin : allowedOrigins[0]
|
||||
const requestedHeaders = req.headers["access-control-request-headers"]
|
||||
|
||||
res.setHeader("Access-Control-Allow-Origin", responseOrigin)
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
typeof requestedHeaders === "string" && requestedHeaders.length > 0
|
||||
? requestedHeaders
|
||||
: "Content-Type",
|
||||
)
|
||||
// Chrome's Private Network Access preflight may require this for an HTTPS
|
||||
// page posting to a localhost HTTP callback.
|
||||
res.setHeader("Access-Control-Allow-Private-Network", "true")
|
||||
res.setHeader("Content-Type", "application/json")
|
||||
|
||||
// Handle CORS preflight
|
||||
// Handle CORS preflight.
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
@@ -98,7 +160,7 @@ export function startAuthServer(): Promise<AuthServer> {
|
||||
} else {
|
||||
rejectCallback(new Error(description || String(parsed.error)))
|
||||
}
|
||||
server.close()
|
||||
closeServer(server)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -123,7 +185,7 @@ export function startAuthServer(): Promise<AuthServer> {
|
||||
res.end(JSON.stringify({ success: true }))
|
||||
|
||||
resolveCallback({ apiKey, state, userId, userName, keyName })
|
||||
server.close()
|
||||
closeServer(server)
|
||||
} catch {
|
||||
res.writeHead(400)
|
||||
res.end(JSON.stringify({ success: false, error: "Invalid JSON" }))
|
||||
@@ -136,14 +198,17 @@ export function startAuthServer(): Promise<AuthServer> {
|
||||
})
|
||||
})
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
rejectCallback(new Error(`Failed to start auth server: ${err.message}`))
|
||||
})
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address() as AddressInfo
|
||||
resolve({ server, port: address.port, waitForCallback })
|
||||
})
|
||||
})
|
||||
try {
|
||||
const port = await listenOnAvailablePort(
|
||||
server,
|
||||
options.startPort ?? DEFAULT_PORT,
|
||||
options.portRange ?? DEFAULT_PORT_RANGE,
|
||||
)
|
||||
return { server, port, waitForCallback }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const error = new Error(`Failed to start auth server: ${message}`)
|
||||
rejectCallback(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
usage: defaultUsage(),
|
||||
stopReason: "error",
|
||||
errorMessage:
|
||||
"No Command Code API key. Run /login commandcode, set COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json or ~/.pi/agent/auth.json.",
|
||||
"No Command Code API key. Run /login and select Command Code, set COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json or ~/.pi/agent/auth.json.",
|
||||
timestamp: now(),
|
||||
}
|
||||
stream.push({ type: "error", reason: "error", error: msg })
|
||||
|
||||
+96
-22
@@ -2,11 +2,12 @@
|
||||
* Command Code OAuth provider for pi's /login flow.
|
||||
*
|
||||
* Implements a browser-assisted API key retrieval flow:
|
||||
* 1. Starts a local HTTP server on a random port
|
||||
* 1. Starts a local HTTP server on a Command Code CLI-compatible port
|
||||
* 2. Opens the Command Code Studio auth page in the browser
|
||||
* 3. The user authenticates on the Command Code website
|
||||
* 4. The website POSTs the API key back to the local server
|
||||
* 5. The API key is stored in pi's auth.json as OAuth credentials
|
||||
* 5. If browser transfer fails, the user can paste the API key manually
|
||||
* 6. The API key is stored in pi's auth.json as OAuth credentials
|
||||
*
|
||||
* Since Command Code API keys don't expire, we store them as
|
||||
* OAuth credentials with a far-future expiry.
|
||||
@@ -17,6 +18,7 @@ import { startAuthServer } from "./auth-server.ts"
|
||||
|
||||
const STUDIO_BASE_URL = "https://commandcode.ai"
|
||||
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000 // API keys don't expire
|
||||
const DEFAULT_AUTH_TIMEOUT_MS = 15_000
|
||||
|
||||
export interface OAuthLoginCallbacks {
|
||||
onAuth(params: { url: string }): void
|
||||
@@ -29,10 +31,76 @@ export interface OAuthCredentials {
|
||||
expires: number
|
||||
}
|
||||
|
||||
class AuthTimeoutError extends Error {
|
||||
constructor() {
|
||||
super("Browser authentication timed out")
|
||||
this.name = "AuthTimeoutError"
|
||||
}
|
||||
}
|
||||
|
||||
function generateStateToken(): string {
|
||||
return randomBytes(32).toString("base64url")
|
||||
}
|
||||
|
||||
function getAuthTimeoutMs(): number {
|
||||
const raw = process.env.COMMANDCODE_AUTH_TIMEOUT_MS
|
||||
if (!raw) return DEFAULT_AUTH_TIMEOUT_MS
|
||||
|
||||
const parsed = Number(raw)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_AUTH_TIMEOUT_MS
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new AuthTimeoutError()), timeoutMs)
|
||||
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer)
|
||||
resolve(value)
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function credentialsFromApiKey(apiKey: string): OAuthCredentials {
|
||||
return {
|
||||
refresh: apiKey,
|
||||
access: apiKey,
|
||||
expires: Date.now() + TEN_YEARS_MS,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove common terminal paste wrappers/control chars and surrounding whitespace.
|
||||
*/
|
||||
export function sanitizeApiKey(input: string): string {
|
||||
const esc = String.fromCharCode(27)
|
||||
return Array.from(
|
||||
input
|
||||
.replaceAll(`${esc}[200~`, "")
|
||||
.replaceAll(`${esc}[201~`, "")
|
||||
.replaceAll("[200~", "")
|
||||
.replaceAll("[201~", ""),
|
||||
)
|
||||
.filter((char) => {
|
||||
const code = char.charCodeAt(0)
|
||||
return code > 31 && code !== 127
|
||||
})
|
||||
.join("")
|
||||
.trim()
|
||||
}
|
||||
|
||||
async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string) {
|
||||
const apiKey = sanitizeApiKey(await callbacks.onPrompt({ message }))
|
||||
if (!apiKey) throw new Error("No Command Code API key provided")
|
||||
return credentialsFromApiKey(apiKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the browser-based login flow for Command Code.
|
||||
*
|
||||
@@ -40,37 +108,47 @@ function generateStateToken(): string {
|
||||
* The keys don't expire, so we set a far-future expiry.
|
||||
*/
|
||||
export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||
const authServer = await startAuthServer()
|
||||
let authServer
|
||||
try {
|
||||
authServer = await startAuthServer()
|
||||
} catch {
|
||||
return promptForApiKey(
|
||||
callbacks,
|
||||
"Could not start browser auth. Paste your Command Code API key:",
|
||||
)
|
||||
}
|
||||
|
||||
const stateToken = generateStateToken()
|
||||
const callbackUrl = `http://localhost:${authServer.port}/callback`
|
||||
const authUrl = `${STUDIO_BASE_URL}/studio/auth/cli?callback=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(stateToken)}`
|
||||
|
||||
const authUrl = `${STUDIO_BASE_URL}/studio/auth/cli?callback=${encodeURIComponent(`http://localhost:${authServer.port}/callback`)}&state=${encodeURIComponent(stateToken)}`
|
||||
|
||||
// Tell pi to open the browser
|
||||
// Tell pi to open the browser.
|
||||
callbacks.onAuth({ url: authUrl })
|
||||
|
||||
// Wait for the Command Code Studio to POST the API key back
|
||||
// Wait for the Command Code Studio to POST the API key back. If the browser
|
||||
// cannot reach localhost (Command Code shows "Copy your API key"), fall back
|
||||
// to pi's prompt so the user can paste the key from the browser.
|
||||
let callback: { apiKey: string; state: string }
|
||||
try {
|
||||
callback = await authServer.waitForCallback
|
||||
callback = await withTimeout(authServer.waitForCallback, getAuthTimeoutMs())
|
||||
} catch (error) {
|
||||
// Clean up server on error
|
||||
authServer.server.close()
|
||||
if (error instanceof AuthTimeoutError) {
|
||||
return promptForApiKey(
|
||||
callbacks,
|
||||
"Automatic transfer failed or timed out. Paste your Command Code API key:",
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Validate state token to prevent CSRF
|
||||
// Validate state token to prevent CSRF.
|
||||
if (callback.state !== stateToken) {
|
||||
authServer.server.close()
|
||||
throw new Error("State token mismatch. Authentication may have been tampered with.")
|
||||
}
|
||||
|
||||
// Return as OAuth credentials. Since CC API keys don't expire,
|
||||
// we set a far-future expiry and use the API key as both access and refresh.
|
||||
return {
|
||||
refresh: callback.apiKey,
|
||||
access: callback.apiKey,
|
||||
expires: Date.now() + TEN_YEARS_MS,
|
||||
}
|
||||
return credentialsFromApiKey(callback.apiKey)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,11 +156,7 @@ export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCreden
|
||||
* Returns the same credentials with an updated far-future expiry.
|
||||
*/
|
||||
export async function refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
||||
return {
|
||||
refresh: credentials.refresh,
|
||||
access: credentials.access,
|
||||
expires: Date.now() + TEN_YEARS_MS,
|
||||
}
|
||||
return credentialsFromApiKey(credentials.refresh)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user