feat(server): add authenticated remote access and desktop bootstrap
Adds cookie-based login with a bootstrap token flow for desktop apps, secures OpenCode instance traffic with per-instance Basic auth, and updates UI/plugin clients to use credentials.
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||
import http from "node:http"
|
||||
import https from "node:https"
|
||||
import { existsSync } from "fs"
|
||||
import { dirname, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
@@ -15,6 +17,7 @@ const cliManager = new CliProcessManager()
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let currentCliUrl: string | null = null
|
||||
let pendingCliUrl: string | null = null
|
||||
let pendingBootstrapToken: string | null = null
|
||||
let showingLoadingScreen = false
|
||||
let preloadingView: BrowserView | null = null
|
||||
|
||||
@@ -251,6 +254,15 @@ function showLoadingScreen(force = false) {
|
||||
loadLoadingScreen(mainWindow)
|
||||
}
|
||||
|
||||
function isBootstrapTokenUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.pathname === "/auth/token" && parsed.hash.length > 1
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function startCliPreload(url: string) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
pendingCliUrl = url
|
||||
@@ -268,6 +280,13 @@ function startCliPreload(url: string) {
|
||||
showLoadingScreen(true)
|
||||
}
|
||||
|
||||
// Important: /auth/token#... is one-time. Preloading + swapping would load it twice,
|
||||
// consuming the token in the hidden view and then failing in the main window.
|
||||
if (isBootstrapTokenUrl(url)) {
|
||||
finalizeCliSwap(url)
|
||||
return
|
||||
}
|
||||
|
||||
const view = new BrowserView({
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
@@ -308,6 +327,75 @@ function finalizeCliSwap(url: string) {
|
||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME = "codenomad_session"
|
||||
let bootstrapExchangeInFlight = false
|
||||
|
||||
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader
|
||||
if (!raw) return null
|
||||
|
||||
const first = raw.split(";")[0] ?? ""
|
||||
const index = first.indexOf("=")
|
||||
if (index < 0) return null
|
||||
|
||||
const key = first.slice(0, index).trim()
|
||||
const value = first.slice(index + 1).trim()
|
||||
if (key !== name || !value) return null
|
||||
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||
const target = new URL("/api/auth/token", baseUrl)
|
||||
const body = JSON.stringify({ token })
|
||||
|
||||
const transport = target.protocol === "https:" ? https : http
|
||||
|
||||
const result = await new Promise<{ statusCode: number; setCookie: string | string[] | undefined }>((resolve, reject) => {
|
||||
const req = transport.request(
|
||||
target,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
res.resume()
|
||||
resolve({ statusCode: res.statusCode ?? 0, setCookie: res.headers["set-cookie"] })
|
||||
},
|
||||
)
|
||||
|
||||
req.on("error", reject)
|
||||
req.write(body)
|
||||
req.end()
|
||||
})
|
||||
|
||||
if (result.statusCode !== 200) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
||||
if (!sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
await session.defaultSession.cookies.set({
|
||||
url: baseUrl,
|
||||
name: SESSION_COOKIE_NAME,
|
||||
value: sessionId,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function startCli() {
|
||||
try {
|
||||
@@ -323,11 +411,53 @@ async function startCli() {
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeExchangeAndNavigate(baseUrl: string) {
|
||||
if (bootstrapExchangeInFlight) {
|
||||
return
|
||||
}
|
||||
|
||||
const token = pendingBootstrapToken
|
||||
if (!token) {
|
||||
startCliPreload(baseUrl)
|
||||
return
|
||||
}
|
||||
|
||||
bootstrapExchangeInFlight = true
|
||||
|
||||
try {
|
||||
const ok = await exchangeBootstrapToken(baseUrl, token)
|
||||
pendingBootstrapToken = null
|
||||
|
||||
if (!ok) {
|
||||
startCliPreload(`${baseUrl}/login`)
|
||||
return
|
||||
}
|
||||
|
||||
startCliPreload(baseUrl)
|
||||
} catch (error) {
|
||||
console.error("[cli] bootstrap token exchange failed:", error)
|
||||
pendingBootstrapToken = null
|
||||
startCliPreload(`${baseUrl}/login`)
|
||||
} finally {
|
||||
bootstrapExchangeInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
cliManager.on("bootstrapToken", (token) => {
|
||||
pendingBootstrapToken = token
|
||||
|
||||
const status = cliManager.getStatus()
|
||||
if (status.url) {
|
||||
void maybeExchangeAndNavigate(status.url)
|
||||
}
|
||||
})
|
||||
|
||||
cliManager.on("ready", (status) => {
|
||||
if (!status.url) {
|
||||
return
|
||||
}
|
||||
startCliPreload(status.url)
|
||||
|
||||
void maybeExchangeAndNavigate(status.url)
|
||||
})
|
||||
|
||||
cliManager.on("status", (status) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./use
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||
|
||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||
type ListeningMode = "local" | "all"
|
||||
@@ -69,6 +70,7 @@ function readListeningModeFromConfig(): ListeningMode {
|
||||
export declare interface CliProcessManager {
|
||||
on(event: "status", listener: (status: CliStatus) => void): this
|
||||
on(event: "ready", listener: (status: CliStatus) => void): this
|
||||
on(event: "bootstrapToken", listener: (token: string) => void): this
|
||||
on(event: "log", listener: (entry: CliLogEntry) => void): this
|
||||
on(event: "exit", listener: (status: CliStatus) => void): this
|
||||
on(event: "error", listener: (error: Error) => void): this
|
||||
@@ -79,6 +81,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
private status: CliStatus = { state: "stopped" }
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
private bootstrapToken: string | null = null
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
if (this.child) {
|
||||
@@ -87,6 +90,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.bootstrapToken = null
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
@@ -227,11 +231,22 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
console.info(`[cli][${stream}] ${line}`)
|
||||
this.emit("log", { stream, message: line })
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
const port = this.extractPort(line)
|
||||
if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
|
||||
const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim()
|
||||
if (token && !this.bootstrapToken) {
|
||||
this.bootstrapToken = token
|
||||
this.emit("bootstrapToken", token)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
console.info(`[cli][${stream}] ${trimmed}`)
|
||||
this.emit("log", { stream, message: trimmed })
|
||||
|
||||
const port = this.extractPort(trimmed)
|
||||
if (port && this.status.state === "starting") {
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
console.info(`[cli] ready on ${url}`)
|
||||
@@ -271,7 +286,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||
const args = ["serve", "--host", host, "--port", "0"]
|
||||
const args = ["serve", "--host", host, "--port", "0", "--generate-token"]
|
||||
|
||||
if (options.dev) {
|
||||
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
||||
|
||||
Reference in New Issue
Block a user