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")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { createCodeNomadRequester, type CodeNomadConfig } from "./request"
|
||||
|
||||
type BackgroundProcess = {
|
||||
id: string
|
||||
@@ -12,11 +13,6 @@ type BackgroundProcess = {
|
||||
outputSizeBytes?: number
|
||||
}
|
||||
|
||||
type CodeNomadConfig = {
|
||||
instanceId: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
type BackgroundProcessOptions = {
|
||||
baseDir: string
|
||||
}
|
||||
@@ -27,30 +23,10 @@ type ParsedCommand = {
|
||||
}
|
||||
|
||||
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
|
||||
const requester = createCodeNomadRequester(config)
|
||||
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
|
||||
const base = config.baseUrl.replace(/\/+$/, "")
|
||||
const url = `${base}/workspaces/${config.instanceId}/plugin/background-processes${path}`
|
||||
const headers = normalizeHeaders(init?.headers)
|
||||
if (init?.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json"
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
return requester.requestJson<T>(`/background-processes${path}`, init)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -249,13 +225,7 @@ function tokenize(input: string): string[] {
|
||||
|
||||
if (char === "|" || char === "&" || char === ";") {
|
||||
flush()
|
||||
const next = input[index + 1]
|
||||
if ((char === "|" || char === "&") && next === char) {
|
||||
tokens.push(char + next)
|
||||
index += 1
|
||||
} else {
|
||||
tokens.push(char)
|
||||
}
|
||||
tokens.push(char)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -266,44 +236,18 @@ function tokenize(input: string): string[] {
|
||||
return tokens
|
||||
}
|
||||
|
||||
function isSeparator(token: string) {
|
||||
return token === "|" || token === "||" || token === "&&" || token === ";" || token === "&"
|
||||
function isSeparator(token: string): boolean {
|
||||
return token === "|" || token === "&" || token === ";"
|
||||
}
|
||||
|
||||
function unquote(value: string) {
|
||||
if (value.length >= 2) {
|
||||
const first = value[0]
|
||||
const last = value[value.length - 1]
|
||||
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
|
||||
return value.slice(1, -1)
|
||||
}
|
||||
function unquote(token: string): string {
|
||||
if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
|
||||
return token.slice(1, -1)
|
||||
}
|
||||
return value
|
||||
return token
|
||||
}
|
||||
|
||||
function isWithinBase(baseDir: string, target: string) {
|
||||
const relative = path.relative(baseDir, target)
|
||||
if (!relative) return true
|
||||
return !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
const output: Record<string, string> = {}
|
||||
if (!headers) return output
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
output[key] = value
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
output[key] = value
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
return { ...headers }
|
||||
function isWithinBase(base: string, candidate: string): boolean {
|
||||
const relative = path.relative(base, candidate)
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
}
|
||||
|
||||
@@ -1,74 +1,41 @@
|
||||
export type PluginEvent = {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request"
|
||||
|
||||
export type CodeNomadConfig = {
|
||||
instanceId: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
export function getCodeNomadConfig(): CodeNomadConfig {
|
||||
return {
|
||||
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
|
||||
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
|
||||
}
|
||||
}
|
||||
export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request"
|
||||
|
||||
export function createCodeNomadClient(config: CodeNomadConfig) {
|
||||
return {
|
||||
postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event),
|
||||
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent),
|
||||
}
|
||||
}
|
||||
const requester = createCodeNomadRequester(config)
|
||||
|
||||
function requireEnv(key: string): string {
|
||||
const value = process.env[key]
|
||||
if (!value || !value.trim()) {
|
||||
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
|
||||
return {
|
||||
postEvent: (event: PluginEvent) =>
|
||||
requester.requestVoid("/event", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(event),
|
||||
}),
|
||||
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(requester, onEvent),
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event`
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(event),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`)
|
||||
}
|
||||
}
|
||||
|
||||
async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events`
|
||||
|
||||
async function startPluginEvents(
|
||||
requester: ReturnType<typeof createCodeNomadRequester>,
|
||||
onEvent: (event: PluginEvent) => void,
|
||||
) {
|
||||
// Fail plugin startup if we cannot establish the initial connection.
|
||||
const initialBody = await connectWithRetries(url, 3)
|
||||
const initialBody = await connectWithRetries(requester, 3)
|
||||
|
||||
// After startup, keep reconnecting; throw after 3 consecutive failures.
|
||||
void consumeWithReconnect(url, onEvent, initialBody)
|
||||
void consumeWithReconnect(requester, onEvent, initialBody)
|
||||
}
|
||||
|
||||
async function connectWithRetries(url: string, maxAttempts: number) {
|
||||
async function connectWithRetries(requester: ReturnType<typeof createCodeNomadRequester>, maxAttempts: number) {
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, { headers: { Accept: "text/event-stream" } })
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
|
||||
}
|
||||
return response.body
|
||||
return await requester.requestSseBody("/events")
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
await delay(500 * attempt)
|
||||
@@ -76,11 +43,12 @@ async function connectWithRetries(url: string, maxAttempts: number) {
|
||||
}
|
||||
|
||||
const reason = lastError instanceof Error ? lastError.message : String(lastError)
|
||||
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`)
|
||||
const url = requester.buildUrl("/events")
|
||||
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad at ${url} after ${maxAttempts} retries: ${reason}`)
|
||||
}
|
||||
|
||||
async function consumeWithReconnect(
|
||||
url: string,
|
||||
requester: ReturnType<typeof createCodeNomadRequester>,
|
||||
onEvent: (event: PluginEvent) => void,
|
||||
initialBody: ReadableStream<Uint8Array>,
|
||||
) {
|
||||
@@ -90,7 +58,7 @@ async function consumeWithReconnect(
|
||||
while (true) {
|
||||
try {
|
||||
if (!body) {
|
||||
body = await connectWithRetries(url, 3)
|
||||
body = await connectWithRetries(requester, 3)
|
||||
}
|
||||
|
||||
await consumeSseBody(body, onEvent)
|
||||
|
||||
124
packages/opencode-config/plugin/lib/request.ts
Normal file
124
packages/opencode-config/plugin/lib/request.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
export type PluginEvent = {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type CodeNomadConfig = {
|
||||
instanceId: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
export function getCodeNomadConfig(): CodeNomadConfig {
|
||||
return {
|
||||
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
|
||||
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||
const baseUrl = config.baseUrl.replace(/\/+$/, "")
|
||||
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
|
||||
const authorization = buildInstanceAuthorizationHeader()
|
||||
|
||||
const buildUrl = (path: string) => {
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path
|
||||
}
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`
|
||||
return `${pluginBase}${normalized}`
|
||||
}
|
||||
|
||||
const buildHeaders = (headers: HeadersInit | undefined, hasBody: boolean): Record<string, string> => {
|
||||
const output: Record<string, string> = normalizeHeaders(headers)
|
||||
output.Authorization = authorization
|
||||
if (hasBody) {
|
||||
output["Content-Type"] = output["Content-Type"] ?? "application/json"
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
const fetchWithAuth = async (path: string, init?: RequestInit): Promise<Response> => {
|
||||
const url = buildUrl(path)
|
||||
const hasBody = init?.body !== undefined
|
||||
const headers = buildHeaders(init?.headers, hasBody)
|
||||
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const response = await fetchWithAuth(path, init)
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "")
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
const requestVoid = async (path: string, init?: RequestInit): Promise<void> => {
|
||||
const response = await fetchWithAuth(path, init)
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "")
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
const requestSseBody = async (path: string): Promise<ReadableStream<Uint8Array>> => {
|
||||
const response = await fetchWithAuth(path, { headers: { Accept: "text/event-stream" } })
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`SSE unavailable (${response.status})`)
|
||||
}
|
||||
return response.body as ReadableStream<Uint8Array>
|
||||
}
|
||||
|
||||
return {
|
||||
buildUrl,
|
||||
fetch: fetchWithAuth,
|
||||
requestJson,
|
||||
requestVoid,
|
||||
requestSseBody,
|
||||
}
|
||||
}
|
||||
|
||||
function requireEnv(key: string): string {
|
||||
const value = process.env[key]
|
||||
if (!value || !value.trim()) {
|
||||
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function buildInstanceAuthorizationHeader(): string {
|
||||
const username = requireEnv("OPENCODE_SERVER_USERNAME")
|
||||
const password = requireEnv("OPENCODE_SERVER_PASSWORD")
|
||||
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
|
||||
return `Basic ${token}`
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
const output: Record<string, string> = {}
|
||||
if (!headers) return output
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
output[key] = value
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
output[key] = value
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
return { ...headers }
|
||||
}
|
||||
@@ -16,11 +16,11 @@
|
||||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
|
||||
"build:ui": "npm run build --prefix ../ui",
|
||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||
"dev": "cross-env CODENOMAD_DEV=1 CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
22
packages/server/scripts/copy-auth-pages.mjs
Normal file
22
packages/server/scripts/copy-auth-pages.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliRoot = path.resolve(__dirname, "..")
|
||||
|
||||
const sourceDir = path.resolve(cliRoot, "src/server/routes/auth-pages")
|
||||
const targetDir = path.resolve(cliRoot, "dist/server/routes/auth-pages")
|
||||
|
||||
if (!existsSync(sourceDir)) {
|
||||
console.error(`[copy-auth-pages] Missing auth pages at ${sourceDir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
rmSync(targetDir, { recursive: true, force: true })
|
||||
mkdirSync(targetDir, { recursive: true })
|
||||
cpSync(sourceDir, targetDir, { recursive: true })
|
||||
|
||||
console.log(`[copy-auth-pages] Copied ${sourceDir} -> ${targetDir}`)
|
||||
175
packages/server/src/auth/auth-store.ts
Normal file
175
packages/server/src/auth/auth-store.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import type { Logger } from "../logger"
|
||||
import { hashPassword, type PasswordHashRecord, verifyPassword } from "./password-hash"
|
||||
|
||||
export interface AuthFile {
|
||||
version: 1
|
||||
username: string
|
||||
password: PasswordHashRecord
|
||||
userProvided: boolean
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
username: string
|
||||
passwordUserProvided: boolean
|
||||
}
|
||||
|
||||
export class AuthStore {
|
||||
private cachedFile: AuthFile | null = null
|
||||
private overrideAuth: AuthFile | null = null
|
||||
private bootstrapUsername: string | null = null
|
||||
|
||||
constructor(private readonly authFilePath: string, private readonly logger: Logger) {}
|
||||
|
||||
getAuthFilePath() {
|
||||
return this.authFilePath
|
||||
}
|
||||
|
||||
load(): AuthFile | null {
|
||||
if (this.overrideAuth) {
|
||||
return this.overrideAuth
|
||||
}
|
||||
|
||||
if (this.cachedFile) {
|
||||
return this.cachedFile
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(this.authFilePath)) {
|
||||
return null
|
||||
}
|
||||
const raw = fs.readFileSync(this.authFilePath, "utf-8")
|
||||
const parsed = JSON.parse(raw) as AuthFile
|
||||
if (!parsed || parsed.version !== 1) {
|
||||
this.logger.warn({ authFilePath: this.authFilePath }, "Auth file has unsupported version")
|
||||
return null
|
||||
}
|
||||
this.cachedFile = parsed
|
||||
return parsed
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, authFilePath: this.authFilePath }, "Failed to load auth file")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
ensureInitialized(params: {
|
||||
username: string
|
||||
password?: string
|
||||
allowBootstrapWithoutPassword: boolean
|
||||
}): void {
|
||||
const password = params.password?.trim()
|
||||
if (password) {
|
||||
const now = new Date().toISOString()
|
||||
const runtime: AuthFile = {
|
||||
version: 1,
|
||||
username: params.username,
|
||||
password: hashPassword(password),
|
||||
userProvided: true,
|
||||
updatedAt: now,
|
||||
}
|
||||
this.overrideAuth = runtime
|
||||
this.cachedFile = null
|
||||
this.bootstrapUsername = null
|
||||
this.logger.debug({ authFilePath: this.authFilePath }, "Using runtime auth password override; ignoring auth file")
|
||||
return
|
||||
}
|
||||
|
||||
const existing = this.load()
|
||||
if (existing) {
|
||||
if (existing.username !== params.username) {
|
||||
// Keep existing username unless explicitly overridden later.
|
||||
this.logger.debug({ existing: existing.username, requested: params.username }, "Auth username differs from requested")
|
||||
}
|
||||
this.bootstrapUsername = null
|
||||
return
|
||||
}
|
||||
|
||||
if (params.allowBootstrapWithoutPassword) {
|
||||
this.bootstrapUsername = params.username
|
||||
this.logger.debug({ authFilePath: this.authFilePath }, "No auth file present; bootstrap-only mode enabled")
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No server password configured. Create ${this.authFilePath} or start with --password / CODENOMAD_SERVER_PASSWORD.`,
|
||||
)
|
||||
}
|
||||
|
||||
validateCredentials(username: string, password: string): boolean {
|
||||
const auth = this.load()
|
||||
if (!auth) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (username !== auth.username) {
|
||||
return false
|
||||
}
|
||||
|
||||
return verifyPassword(password, auth.password)
|
||||
}
|
||||
|
||||
setPassword(params: { password: string; markUserProvided: boolean }): AuthStatus {
|
||||
if (this.overrideAuth) {
|
||||
throw new Error(
|
||||
"Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.",
|
||||
)
|
||||
}
|
||||
|
||||
const current = this.load()
|
||||
|
||||
if (!current) {
|
||||
if (!this.bootstrapUsername) {
|
||||
throw new Error("Auth is not initialized")
|
||||
}
|
||||
|
||||
const created: AuthFile = {
|
||||
version: 1,
|
||||
username: this.bootstrapUsername,
|
||||
password: hashPassword(params.password),
|
||||
userProvided: params.markUserProvided,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
this.persist(created)
|
||||
this.bootstrapUsername = null
|
||||
return { username: created.username, passwordUserProvided: created.userProvided }
|
||||
}
|
||||
|
||||
const next: AuthFile = {
|
||||
...current,
|
||||
password: hashPassword(params.password),
|
||||
userProvided: params.markUserProvided,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
this.persist(next)
|
||||
return { username: next.username, passwordUserProvided: next.userProvided }
|
||||
}
|
||||
|
||||
getStatus(): AuthStatus {
|
||||
const current = this.load()
|
||||
if (current) {
|
||||
return { username: current.username, passwordUserProvided: current.userProvided }
|
||||
}
|
||||
|
||||
if (this.bootstrapUsername) {
|
||||
return { username: this.bootstrapUsername, passwordUserProvided: false }
|
||||
}
|
||||
|
||||
throw new Error("Auth is not initialized")
|
||||
}
|
||||
|
||||
private persist(auth: AuthFile) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.authFilePath), { recursive: true })
|
||||
fs.writeFileSync(this.authFilePath, JSON.stringify(auth, null, 2), "utf-8")
|
||||
this.cachedFile = auth
|
||||
this.logger.debug({ authFilePath: this.authFilePath }, "Persisted auth file")
|
||||
} catch (error) {
|
||||
this.logger.error({ err: error, authFilePath: this.authFilePath }, "Failed to persist auth file")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
38
packages/server/src/auth/http-auth.ts
Normal file
38
packages/server/src/auth/http-auth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
|
||||
export function parseCookies(header: string | undefined): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
if (!header) return result
|
||||
|
||||
const parts = header.split(";")
|
||||
for (const part of parts) {
|
||||
const index = part.indexOf("=")
|
||||
if (index < 0) continue
|
||||
const key = part.slice(0, index).trim()
|
||||
const value = part.slice(index + 1).trim()
|
||||
if (!key) continue
|
||||
result[key] = decodeURIComponent(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function isLoopbackAddress(remoteAddress: string | undefined): boolean {
|
||||
if (!remoteAddress) return false
|
||||
if (remoteAddress === "127.0.0.1" || remoteAddress === "::1") return true
|
||||
if (remoteAddress === "::ffff:127.0.0.1") return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function wantsHtml(request: FastifyRequest): boolean {
|
||||
const accept = (request.headers["accept"] ?? "").toString().toLowerCase()
|
||||
return accept.includes("text/html") || accept.includes("application/xhtml")
|
||||
}
|
||||
|
||||
export function sendUnauthorized(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (request.method === "GET" && !request.url.startsWith("/api/") && wantsHtml(request)) {
|
||||
reply.redirect("/login")
|
||||
return
|
||||
}
|
||||
|
||||
reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
113
packages/server/src/auth/manager.ts
Normal file
113
packages/server/src/auth/manager.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
import path from "path"
|
||||
import type { Logger } from "../logger"
|
||||
import { AuthStore } from "./auth-store"
|
||||
import { TokenManager } from "./token-manager"
|
||||
import { SessionManager } from "./session-manager"
|
||||
import { isLoopbackAddress, parseCookies } from "./http-auth"
|
||||
|
||||
export const BOOTSTRAP_TOKEN_STDOUT_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" as const
|
||||
export const DEFAULT_AUTH_USERNAME = "codenomad" as const
|
||||
export const DEFAULT_AUTH_COOKIE_NAME = "codenomad_session" as const
|
||||
|
||||
export interface AuthManagerInit {
|
||||
configPath: string
|
||||
username: string
|
||||
password?: string
|
||||
generateToken: boolean
|
||||
}
|
||||
|
||||
export class AuthManager {
|
||||
private readonly authStore: AuthStore
|
||||
private readonly tokenManager: TokenManager | null
|
||||
private readonly sessionManager = new SessionManager()
|
||||
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
||||
|
||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||
const authFilePath = resolveAuthFilePath(init.configPath)
|
||||
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }))
|
||||
|
||||
// Startup: password comes from CLI/env, auth.json, or bootstrap-only mode.
|
||||
this.authStore.ensureInitialized({
|
||||
username: init.username,
|
||||
password: init.password,
|
||||
allowBootstrapWithoutPassword: init.generateToken,
|
||||
})
|
||||
|
||||
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
|
||||
}
|
||||
|
||||
getCookieName(): string {
|
||||
return this.cookieName
|
||||
}
|
||||
|
||||
isTokenBootstrapEnabled(): boolean {
|
||||
return Boolean(this.tokenManager)
|
||||
}
|
||||
|
||||
issueBootstrapToken(): string | null {
|
||||
if (!this.tokenManager) return null
|
||||
return this.tokenManager.generate()
|
||||
}
|
||||
|
||||
consumeBootstrapToken(token: string): boolean {
|
||||
if (!this.tokenManager) return false
|
||||
return this.tokenManager.consume(token)
|
||||
}
|
||||
|
||||
validateLogin(username: string, password: string): boolean {
|
||||
return this.authStore.validateCredentials(username, password)
|
||||
}
|
||||
|
||||
createSession(username: string) {
|
||||
return this.sessionManager.createSession(username)
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.authStore.getStatus()
|
||||
}
|
||||
|
||||
setPassword(password: string) {
|
||||
return this.authStore.setPassword({ password, markUserProvided: true })
|
||||
}
|
||||
|
||||
isLoopbackRequest(request: FastifyRequest): boolean {
|
||||
return isLoopbackAddress(request.socket.remoteAddress)
|
||||
}
|
||||
|
||||
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
||||
const cookies = parseCookies(request.headers.cookie)
|
||||
const sessionId = cookies[this.cookieName]
|
||||
const session = this.sessionManager.getSession(sessionId)
|
||||
if (!session) return null
|
||||
return { username: session.username, sessionId: session.id }
|
||||
}
|
||||
|
||||
setSessionCookie(reply: FastifyReply, sessionId: string) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
|
||||
}
|
||||
|
||||
clearSessionCookie(reply: FastifyReply) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAuthFilePath(configPath: string) {
|
||||
const resolvedConfigPath = resolvePath(configPath)
|
||||
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||
}
|
||||
|
||||
function resolvePath(filePath: string) {
|
||||
if (filePath.startsWith("~/")) {
|
||||
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||
}
|
||||
return path.resolve(filePath)
|
||||
}
|
||||
|
||||
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) {
|
||||
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
|
||||
if (options?.maxAgeSeconds !== undefined) {
|
||||
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
|
||||
}
|
||||
return parts.join("; ")
|
||||
}
|
||||
49
packages/server/src/auth/password-hash.ts
Normal file
49
packages/server/src/auth/password-hash.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import crypto from "crypto"
|
||||
|
||||
export interface PasswordHashRecord {
|
||||
algorithm: "scrypt"
|
||||
saltBase64: string
|
||||
hashBase64: string
|
||||
keyLength: number
|
||||
params: {
|
||||
N: number
|
||||
r: number
|
||||
p: number
|
||||
maxmem: number
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SCRYPT_PARAMS = {
|
||||
N: 16384,
|
||||
r: 8,
|
||||
p: 1,
|
||||
maxmem: 32 * 1024 * 1024,
|
||||
}
|
||||
|
||||
export function hashPassword(password: string): PasswordHashRecord {
|
||||
const salt = crypto.randomBytes(16)
|
||||
const params = DEFAULT_SCRYPT_PARAMS
|
||||
const keyLength = 64
|
||||
const derived = crypto.scryptSync(password, salt, keyLength, params)
|
||||
return {
|
||||
algorithm: "scrypt",
|
||||
saltBase64: salt.toString("base64"),
|
||||
hashBase64: Buffer.from(derived).toString("base64"),
|
||||
keyLength,
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, record: PasswordHashRecord): boolean {
|
||||
if (record.algorithm !== "scrypt") {
|
||||
return false
|
||||
}
|
||||
|
||||
const salt = Buffer.from(record.saltBase64, "base64")
|
||||
const expected = Buffer.from(record.hashBase64, "base64")
|
||||
const derived = crypto.scryptSync(password, salt, record.keyLength, record.params)
|
||||
if (expected.length !== derived.length) {
|
||||
return false
|
||||
}
|
||||
return crypto.timingSafeEqual(expected, Buffer.from(derived))
|
||||
}
|
||||
23
packages/server/src/auth/session-manager.ts
Normal file
23
packages/server/src/auth/session-manager.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import crypto from "crypto"
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string
|
||||
createdAt: number
|
||||
username: string
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private sessions = new Map<string, SessionInfo>()
|
||||
|
||||
createSession(username: string): SessionInfo {
|
||||
const id = crypto.randomBytes(32).toString("base64url")
|
||||
const info: SessionInfo = { id, createdAt: Date.now(), username }
|
||||
this.sessions.set(id, info)
|
||||
return info
|
||||
}
|
||||
|
||||
getSession(id: string | undefined): SessionInfo | undefined {
|
||||
if (!id) return undefined
|
||||
return this.sessions.get(id)
|
||||
}
|
||||
}
|
||||
32
packages/server/src/auth/token-manager.ts
Normal file
32
packages/server/src/auth/token-manager.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import crypto from "crypto"
|
||||
|
||||
export interface BootstrapToken {
|
||||
token: string
|
||||
createdAt: number
|
||||
consumed: boolean
|
||||
}
|
||||
|
||||
export class TokenManager {
|
||||
private token: BootstrapToken | null = null
|
||||
|
||||
constructor(private readonly ttlMs: number) {}
|
||||
|
||||
generate(): string {
|
||||
const token = crypto.randomBytes(32).toString("base64url")
|
||||
this.token = { token, createdAt: Date.now(), consumed: false }
|
||||
return token
|
||||
}
|
||||
|
||||
consume(token: string): boolean {
|
||||
if (!this.token) return false
|
||||
if (this.token.consumed) return false
|
||||
if (Date.now() - this.token.createdAt > this.ttlMs) return false
|
||||
if (token !== this.token.token) return false
|
||||
this.token.consumed = true
|
||||
return true
|
||||
}
|
||||
|
||||
peek(): string | null {
|
||||
return this.token?.token ?? null
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||
import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
import { startReleaseMonitor } from "./releases/release-monitor"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
@@ -37,6 +38,9 @@ interface CliOptions {
|
||||
uiStaticDir: string
|
||||
uiDevServer?: string
|
||||
launch: boolean
|
||||
authUsername: string
|
||||
authPassword?: string
|
||||
generateToken: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 9898
|
||||
@@ -63,6 +67,17 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
)
|
||||
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
|
||||
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
|
||||
.addOption(
|
||||
new Option("--username <username>", "Username for server authentication")
|
||||
.env("CODENOMAD_SERVER_USERNAME")
|
||||
.default(DEFAULT_AUTH_USERNAME),
|
||||
)
|
||||
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
||||
.addOption(
|
||||
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||
.env("CODENOMAD_GENERATE_TOKEN")
|
||||
.default(false),
|
||||
)
|
||||
|
||||
program.parse(argv, { from: "user" })
|
||||
const parsed = program.opts<{
|
||||
@@ -77,6 +92,9 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
uiDir: string
|
||||
uiDevServer?: string
|
||||
launch?: boolean
|
||||
username: string
|
||||
password?: string
|
||||
generateToken?: boolean
|
||||
}>()
|
||||
|
||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||
@@ -94,6 +112,9 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
uiStaticDir: parsed.uiDir,
|
||||
uiDevServer: parsed.uiDevServer,
|
||||
launch: Boolean(parsed.launch),
|
||||
authUsername: parsed.username,
|
||||
authPassword: parsed.password,
|
||||
generateToken: Boolean(parsed.generateToken),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +140,12 @@ async function main() {
|
||||
const configLogger = logger.child({ component: "config" })
|
||||
const eventLogger = logger.child({ component: "events" })
|
||||
|
||||
logger.info({ options }, "Starting CodeNomad CLI server")
|
||||
const logOptions = {
|
||||
...options,
|
||||
authPassword: options.authPassword ? "[REDACTED]" : undefined,
|
||||
}
|
||||
|
||||
logger.info({ options: logOptions }, "Starting CodeNomad CLI server")
|
||||
|
||||
const eventBus = new EventBus(eventLogger)
|
||||
|
||||
@@ -134,6 +160,23 @@ async function main() {
|
||||
addresses: [],
|
||||
}
|
||||
|
||||
const authManager = new AuthManager(
|
||||
{
|
||||
configPath: options.configPath,
|
||||
username: options.authUsername,
|
||||
password: options.authPassword,
|
||||
generateToken: options.generateToken,
|
||||
},
|
||||
logger.child({ component: "auth" }),
|
||||
)
|
||||
|
||||
if (options.generateToken) {
|
||||
const token = authManager.issueBootstrapToken()
|
||||
if (token) {
|
||||
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
|
||||
}
|
||||
}
|
||||
|
||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||
const workspaceManager = new WorkspaceManager({
|
||||
@@ -175,6 +218,7 @@ async function main() {
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
authManager,
|
||||
uiStaticDir: options.uiStaticDir,
|
||||
uiDevServerUrl: options.uiDevServer,
|
||||
logger,
|
||||
|
||||
@@ -23,6 +23,9 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||
import type { AuthManager } from "../auth/manager"
|
||||
import { registerAuthRoutes } from "./routes/auth"
|
||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||
|
||||
interface HttpServerDeps {
|
||||
host: string
|
||||
@@ -34,6 +37,7 @@ interface HttpServerDeps {
|
||||
eventBus: EventBus
|
||||
serverMeta: ServerMeta
|
||||
instanceStore: InstanceStore
|
||||
authManager: AuthManager
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
logger: Logger
|
||||
@@ -88,8 +92,34 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
done()
|
||||
})
|
||||
|
||||
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
|
||||
|
||||
app.register(cors, {
|
||||
origin: true,
|
||||
origin: (origin, cb) => {
|
||||
if (!origin) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
|
||||
let selfOrigin: string | null = null
|
||||
try {
|
||||
selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin
|
||||
} catch {
|
||||
selfOrigin = null
|
||||
}
|
||||
|
||||
if (selfOrigin && origin === selfOrigin) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
|
||||
if (allowedDevOrigins.has(origin)) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
|
||||
cb(null, false)
|
||||
},
|
||||
credentials: true,
|
||||
})
|
||||
|
||||
@@ -109,6 +139,76 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
logger: deps.logger.child({ component: "background-processes" }),
|
||||
})
|
||||
|
||||
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||
|
||||
app.addHook("preHandler", (request, reply, done) => {
|
||||
const rawUrl = request.raw.url ?? request.url
|
||||
const pathname = (rawUrl.split("?")[0] ?? "").trim()
|
||||
|
||||
const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"])
|
||||
const publicPagePaths = new Set(["/login"])
|
||||
if (deps.authManager.isTokenBootstrapEnabled()) {
|
||||
publicPagePaths.add("/auth/token")
|
||||
}
|
||||
|
||||
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
|
||||
const session = deps.authManager.getSessionFromRequest(request)
|
||||
|
||||
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
|
||||
if (requiresAuthForApi && !session) {
|
||||
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
||||
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
||||
if (pluginMatch) {
|
||||
const workspaceId = pluginMatch[1]
|
||||
const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
const provided = Array.isArray(request.headers.authorization)
|
||||
? request.headers.authorization[0]
|
||||
: request.headers.authorization
|
||||
|
||||
if (expected && provided && provided === expected) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sendUnauthorized(request, reply)
|
||||
return
|
||||
}
|
||||
|
||||
if (!session && wantsHtml(request)) {
|
||||
reply.redirect("/login")
|
||||
return
|
||||
}
|
||||
|
||||
done()
|
||||
})
|
||||
|
||||
app.get("/", async (request, reply) => {
|
||||
const session = deps.authManager.getSessionFromRequest(request)
|
||||
if (!session) {
|
||||
reply.redirect("/login")
|
||||
return
|
||||
}
|
||||
|
||||
if (deps.uiDevServerUrl) {
|
||||
await proxyToDevServer(request, reply, deps.uiDevServerUrl)
|
||||
return
|
||||
}
|
||||
|
||||
const uiDir = deps.uiStaticDir
|
||||
const indexPath = path.join(uiDir, "index.html")
|
||||
if (uiDir && fs.existsSync(indexPath)) {
|
||||
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
||||
return
|
||||
}
|
||||
|
||||
reply.code(404).send({ message: "UI bundle missing" })
|
||||
})
|
||||
|
||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
@@ -125,9 +225,9 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
|
||||
|
||||
if (deps.uiDevServerUrl) {
|
||||
setupDevProxy(app, deps.uiDevServerUrl)
|
||||
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager)
|
||||
} else {
|
||||
setupStaticUi(app, deps.uiStaticDir)
|
||||
setupStaticUi(app, deps.uiStaticDir, deps.authManager)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -260,6 +360,7 @@ async function proxyWorkspaceRequest(args: {
|
||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
|
||||
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
|
||||
if (logger.isLevelEnabled("trace")) {
|
||||
@@ -267,6 +368,12 @@ async function proxyWorkspaceRequest(args: {
|
||||
}
|
||||
|
||||
return reply.from(targetUrl, {
|
||||
rewriteRequestHeaders: (_originalRequest, headers) => {
|
||||
if (instanceAuthHeader) {
|
||||
headers.authorization = instanceAuthHeader
|
||||
}
|
||||
return headers
|
||||
},
|
||||
onError: (proxyReply, { error }) => {
|
||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||
if (!proxyReply.sent) {
|
||||
@@ -284,7 +391,7 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
||||
}
|
||||
|
||||
function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
||||
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
||||
if (!uiDir) {
|
||||
app.log.warn("UI static directory not provided; API endpoints only")
|
||||
return
|
||||
@@ -310,6 +417,12 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const session = authManager.getSessionFromRequest(request)
|
||||
if (!session && wantsHtml(request)) {
|
||||
reply.redirect("/login")
|
||||
return
|
||||
}
|
||||
|
||||
if (fs.existsSync(indexPath)) {
|
||||
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
||||
} else {
|
||||
@@ -318,7 +431,7 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
|
||||
function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) {
|
||||
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
|
||||
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||
const url = request.raw.url ?? ""
|
||||
@@ -326,6 +439,13 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
|
||||
reply.code(404).send({ message: "Not Found" })
|
||||
return
|
||||
}
|
||||
|
||||
const session = authManager.getSessionFromRequest(request)
|
||||
if (!session && wantsHtml(request)) {
|
||||
reply.redirect("/login")
|
||||
return
|
||||
}
|
||||
|
||||
void proxyToDevServer(request, reply, upstreamBase)
|
||||
})
|
||||
}
|
||||
|
||||
134
packages/server/src/server/routes/auth-pages/login.html
Normal file
134
packages/server/src/server/routes/auth-pages/login.html
Normal file
@@ -0,0 +1,134 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CodeNomad Login</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
background: #0b0b0f;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.card {
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
background: #14141c;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 18px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
margin: 10px 0 6px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: #0f0f16;
|
||||
color: #fff;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
background: #4c6fff;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.error {
|
||||
margin-top: 12px;
|
||||
color: #ff6b6b;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Sign in</h1>
|
||||
<p>This CodeNomad server is protected. Enter your credentials to continue.</p>
|
||||
|
||||
<label for="username">Username</label>
|
||||
<input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" />
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" autocomplete="current-password" value="" />
|
||||
|
||||
<button id="submit" type="button">Continue</button>
|
||||
<div id="error" class="error" style="display: none"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id)
|
||||
const errorEl = $("error")
|
||||
const showError = (msg) => {
|
||||
errorEl.textContent = msg
|
||||
errorEl.style.display = "block"
|
||||
}
|
||||
const hideError = () => {
|
||||
errorEl.textContent = ""
|
||||
errorEl.style.display = "none"
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
hideError()
|
||||
const username = $("username").value.trim()
|
||||
const password = $("password").value
|
||||
if (!username || !password) {
|
||||
showError("Username and password are required.")
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = ""
|
||||
try {
|
||||
const json = await res.json()
|
||||
message = json && json.error ? String(json.error) : ""
|
||||
} catch {
|
||||
message = ""
|
||||
}
|
||||
showError(message || `Login failed (${res.status})`)
|
||||
return
|
||||
}
|
||||
window.location.href = "/"
|
||||
} catch (e) {
|
||||
showError(e && e.message ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
$("submit").addEventListener("click", submit)
|
||||
$("password").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") submit()
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
93
packages/server/src/server/routes/auth-pages/token.html
Normal file
93
packages/server/src/server/routes/auth-pages/token.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CodeNomad</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
background: #0b0b0f;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.card {
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
background: #14141c;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.error {
|
||||
margin-top: 12px;
|
||||
color: #ff6b6b;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Connecting…</h1>
|
||||
<p>Finalizing local authentication.</p>
|
||||
<div id="error" class="error" style="display: none"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = (location.hash || "").replace(/^#/, "").trim()
|
||||
const errorEl = document.getElementById("error")
|
||||
const showError = (msg) => {
|
||||
errorEl.textContent = msg
|
||||
errorEl.style.display = "block"
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!token) {
|
||||
showError("Missing bootstrap token.")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
let message = ""
|
||||
try {
|
||||
const json = await res.json()
|
||||
message = json && json.error ? String(json.error) : ""
|
||||
} catch {
|
||||
message = ""
|
||||
}
|
||||
showError(message || `Token exchange failed (${res.status})`)
|
||||
return
|
||||
}
|
||||
|
||||
window.location.replace("/")
|
||||
} catch (e) {
|
||||
showError(e && e.message ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
157
packages/server/src/server/routes/auth.ts
Normal file
157
packages/server/src/server/routes/auth.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import fs from "fs"
|
||||
import { z } from "zod"
|
||||
import type { AuthManager } from "../../auth/manager"
|
||||
import { isLoopbackAddress } from "../../auth/http-auth"
|
||||
|
||||
interface RouteDeps {
|
||||
authManager: AuthManager
|
||||
}
|
||||
|
||||
const LoginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
|
||||
const TokenSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
})
|
||||
|
||||
const PasswordSchema = z.object({
|
||||
password: z.string().min(8),
|
||||
})
|
||||
|
||||
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
|
||||
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
|
||||
|
||||
let cachedLoginTemplate: string | null = null
|
||||
let cachedTokenTemplate: string | null = null
|
||||
|
||||
function readTemplate(url: URL, cache: string | null): string {
|
||||
if (cache) return cache
|
||||
const content = fs.readFileSync(url, "utf-8")
|
||||
return content
|
||||
}
|
||||
|
||||
function getLoginHtml(defaultUsername: string): string {
|
||||
if (!cachedLoginTemplate) {
|
||||
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
|
||||
}
|
||||
|
||||
const escapedUsername = escapeHtml(defaultUsername)
|
||||
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername)
|
||||
}
|
||||
|
||||
function getTokenHtml(): string {
|
||||
if (!cachedTokenTemplate) {
|
||||
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
|
||||
}
|
||||
|
||||
return cachedTokenTemplate
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/login", async (_request, reply) => {
|
||||
const status = deps.authManager.getStatus()
|
||||
reply.type("text/html").send(getLoginHtml(status.username))
|
||||
})
|
||||
|
||||
app.get("/auth/token", async (request, reply) => {
|
||||
if (!deps.authManager.isTokenBootstrapEnabled()) {
|
||||
reply.code(404).send({ error: "Not found" })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
||||
reply.code(404).send({ error: "Not found" })
|
||||
return
|
||||
}
|
||||
|
||||
reply.type("text/html").send(getTokenHtml())
|
||||
})
|
||||
|
||||
app.get("/api/auth/status", async (request, reply) => {
|
||||
const session = deps.authManager.getSessionFromRequest(request)
|
||||
if (!session) {
|
||||
reply.send({ authenticated: false })
|
||||
return
|
||||
}
|
||||
reply.send({ authenticated: true, ...deps.authManager.getStatus() })
|
||||
})
|
||||
|
||||
app.post("/api/auth/login", async (request, reply) => {
|
||||
const body = LoginSchema.parse(request.body ?? {})
|
||||
const ok = deps.authManager.validateLogin(body.username, body.password)
|
||||
if (!ok) {
|
||||
reply.code(401).send({ error: "Invalid credentials" })
|
||||
return
|
||||
}
|
||||
|
||||
const session = deps.authManager.createSession(body.username)
|
||||
deps.authManager.setSessionCookie(reply, session.id)
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.post("/api/auth/token", async (request, reply) => {
|
||||
if (!deps.authManager.isTokenBootstrapEnabled()) {
|
||||
reply.code(404).send({ error: "Not found" })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
||||
reply.code(404).send({ error: "Not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const body = TokenSchema.parse(request.body ?? {})
|
||||
const ok = deps.authManager.consumeBootstrapToken(body.token)
|
||||
if (!ok) {
|
||||
reply.code(401).send({ error: "Invalid token" })
|
||||
return
|
||||
}
|
||||
|
||||
const username = deps.authManager.getStatus().username
|
||||
const session = deps.authManager.createSession(username)
|
||||
deps.authManager.setSessionCookie(reply, session.id)
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.post("/api/auth/logout", async (_request, reply) => {
|
||||
deps.authManager.clearSessionCookie(reply)
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.post("/api/auth/password", async (request, reply) => {
|
||||
const session = deps.authManager.getSessionFromRequest(request)
|
||||
if (!session) {
|
||||
reply.code(401).send({ error: "Unauthorized" })
|
||||
return
|
||||
}
|
||||
|
||||
const body = PasswordSchema.parse(request.body ?? {})
|
||||
try {
|
||||
const status = deps.authManager.setPassword(body.password)
|
||||
reply.send({ ok: true, ...status })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
reply.code(409).type("text/plain").send(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value.replace(/[&<>"]/g, (char) => {
|
||||
switch (char) {
|
||||
case "&":
|
||||
return "&"
|
||||
case "<":
|
||||
return "<"
|
||||
case ">":
|
||||
return ">"
|
||||
case '"':
|
||||
return """
|
||||
default:
|
||||
return char
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -96,8 +96,15 @@ export class InstanceEventBridge {
|
||||
|
||||
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
||||
const url = `http://${INSTANCE_HOST}:${port}/event`
|
||||
|
||||
const headers: Record<string, string> = { Accept: "text/event-stream" }
|
||||
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
if (authHeader) {
|
||||
headers["Authorization"] = authHeader
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
headers,
|
||||
signal,
|
||||
dispatcher: STREAM_AGENT,
|
||||
})
|
||||
|
||||
@@ -11,6 +11,13 @@ import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../
|
||||
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
|
||||
import { Logger } from "../logger"
|
||||
import { getOpencodeConfigDir } from "../opencode-config.js"
|
||||
import {
|
||||
buildOpencodeBasicAuthHeader,
|
||||
DEFAULT_OPENCODE_USERNAME,
|
||||
generateOpencodeServerPassword,
|
||||
OPENCODE_SERVER_PASSWORD_ENV,
|
||||
OPENCODE_SERVER_USERNAME_ENV,
|
||||
} from "./opencode-auth"
|
||||
|
||||
const STARTUP_STABILITY_DELAY_MS = 1500
|
||||
|
||||
@@ -29,6 +36,7 @@ export class WorkspaceManager {
|
||||
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
||||
private readonly runtime: WorkspaceRuntime
|
||||
private readonly opencodeConfigDir: string
|
||||
private readonly opencodeAuth = new Map<string, { username: string; password: string; authorization: string }>()
|
||||
|
||||
constructor(private readonly options: WorkspaceManagerOptions) {
|
||||
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
||||
@@ -47,6 +55,10 @@ export class WorkspaceManager {
|
||||
return this.workspaces.get(id)?.port
|
||||
}
|
||||
|
||||
getInstanceAuthorizationHeader(id: string): string | undefined {
|
||||
return this.opencodeAuth.get(id)?.authorization
|
||||
}
|
||||
|
||||
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
@@ -106,11 +118,22 @@ export class WorkspaceManager {
|
||||
|
||||
const preferences = this.options.configStore.get().preferences ?? {}
|
||||
const userEnvironment = preferences.environmentVariables ?? {}
|
||||
|
||||
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
||||
const opencodePassword = generateOpencodeServerPassword()
|
||||
const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword })
|
||||
if (!authorization) {
|
||||
throw new Error("Failed to build OpenCode auth header")
|
||||
}
|
||||
this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization })
|
||||
|
||||
const environment = {
|
||||
...userEnvironment,
|
||||
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
||||
CODENOMAD_INSTANCE_ID: id,
|
||||
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
||||
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
|
||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -154,6 +177,7 @@ export class WorkspaceManager {
|
||||
}
|
||||
|
||||
this.workspaces.delete(id)
|
||||
this.opencodeAuth.delete(id)
|
||||
clearWorkspaceSearchCache(workspace.path)
|
||||
if (!wasRunning) {
|
||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
||||
@@ -174,6 +198,7 @@ export class WorkspaceManager {
|
||||
}
|
||||
}
|
||||
this.workspaces.clear()
|
||||
this.opencodeAuth.clear()
|
||||
this.options.logger.info("All workspaces cleared")
|
||||
}
|
||||
|
||||
@@ -317,7 +342,13 @@ export class WorkspaceManager {
|
||||
const url = `http://127.0.0.1:${port}/project/current`
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
const headers: Record<string, string> = {}
|
||||
const authHeader = this.opencodeAuth.get(workspaceId)?.authorization
|
||||
if (authHeader) {
|
||||
headers["Authorization"] = authHeader
|
||||
}
|
||||
|
||||
const response = await fetch(url, { headers })
|
||||
if (!response.ok) {
|
||||
const reason = `health probe returned HTTP ${response.status}`
|
||||
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||
@@ -408,6 +439,8 @@ export class WorkspaceManager {
|
||||
const workspace = this.workspaces.get(workspaceId)
|
||||
if (!workspace) return
|
||||
|
||||
this.opencodeAuth.delete(workspaceId)
|
||||
|
||||
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
|
||||
|
||||
workspace.pid = undefined
|
||||
|
||||
22
packages/server/src/workspaces/opencode-auth.ts
Normal file
22
packages/server/src/workspaces/opencode-auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import crypto from "node:crypto"
|
||||
|
||||
export const OPENCODE_SERVER_USERNAME_ENV = "OPENCODE_SERVER_USERNAME" as const
|
||||
export const OPENCODE_SERVER_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD" as const
|
||||
|
||||
export const DEFAULT_OPENCODE_USERNAME = "codenomad" as const
|
||||
|
||||
export function generateOpencodeServerPassword(): string {
|
||||
return crypto.randomBytes(32).toString("base64url")
|
||||
}
|
||||
|
||||
export function buildOpencodeBasicAuthHeader(params: { username?: string; password?: string }): string | undefined {
|
||||
const username = params.username
|
||||
const password = params.password
|
||||
|
||||
if (!username || !password) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
|
||||
return `Basic ${token}`
|
||||
}
|
||||
@@ -5,6 +5,20 @@ import { EventBus } from "../events/bus"
|
||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||
import { Logger } from "../logger"
|
||||
|
||||
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
||||
|
||||
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||
const redacted: Record<string, string | undefined> = {}
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value === undefined) {
|
||||
redacted[key] = value
|
||||
continue
|
||||
}
|
||||
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "[REDACTED]" : value
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
interface LaunchOptions {
|
||||
workspaceId: string
|
||||
folder: string
|
||||
@@ -67,7 +81,7 @@ export class WorkspaceRuntime {
|
||||
binary: options.binaryPath,
|
||||
args,
|
||||
commandLine,
|
||||
env,
|
||||
env: redactEnvironment(env),
|
||||
},
|
||||
"Launching OpenCode process",
|
||||
)
|
||||
|
||||
@@ -166,6 +166,44 @@ function copyServerArtifacts() {
|
||||
}
|
||||
}
|
||||
|
||||
function stripNodeModuleBins() {
|
||||
const root = path.join(serverDest, "node_modules")
|
||||
if (!fs.existsSync(root)) {
|
||||
return
|
||||
}
|
||||
|
||||
const stack = [root]
|
||||
let removed = 0
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()
|
||||
if (!current) break
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name)
|
||||
if (entry.name === ".bin") {
|
||||
fs.rmSync(full, { recursive: true, force: true })
|
||||
removed += 1
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
console.log(`[prebuild] removed ${removed} node_modules/.bin directories`)
|
||||
}
|
||||
}
|
||||
|
||||
function copyUiLoadingAssets() {
|
||||
const loadingSource = path.join(uiDist, "loading.html")
|
||||
const assetsSource = path.join(uiDist, "assets")
|
||||
@@ -192,4 +230,5 @@ ensureServerDependencies()
|
||||
ensureServerBuild()
|
||||
ensureUiBuild()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
copyUiLoadingAssets()
|
||||
|
||||
@@ -7,14 +7,15 @@ use std::collections::VecDeque;
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter, Manager, Url};
|
||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||
|
||||
fn log_line(message: &str) {
|
||||
println!("[tauri-cli] {message}");
|
||||
@@ -31,9 +32,15 @@ fn workspace_root() -> Option<PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||
|
||||
fn navigate_main(app: &AppHandle, url: &str) {
|
||||
if let Some(win) = app.webview_windows().get("main") {
|
||||
log_line(&format!("navigating main to {url}"));
|
||||
let mut display = url.to_string();
|
||||
if let Some(hash_index) = display.find('#') {
|
||||
display.replace_range(hash_index + 1.., "[REDACTED]");
|
||||
}
|
||||
log_line(&format!("navigating main to {display}"));
|
||||
if let Ok(parsed) = Url::parse(url) {
|
||||
let _ = win.navigate(parsed);
|
||||
} else {
|
||||
@@ -44,6 +51,85 @@ fn navigate_main(app: &AppHandle, url: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
||||
let prefix = format!("{name}=");
|
||||
let cookie_kv = set_cookie.split(';').next()?.trim();
|
||||
if !cookie_kv.starts_with(&prefix) {
|
||||
return None;
|
||||
}
|
||||
let value = cookie_kv.trim_start_matches(&prefix).trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(value.to_string())
|
||||
}
|
||||
|
||||
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||
let port = parsed.port_or_known_default().unwrap_or(80);
|
||||
|
||||
// This is only used for local bootstrap; we assume plain HTTP.
|
||||
let mut stream = TcpStream::connect((host, port))?;
|
||||
|
||||
let body = format!("{{\"token\":\"{}\"}}", token);
|
||||
let request = format!(
|
||||
"POST /api/auth/token HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.as_bytes().len(),
|
||||
body
|
||||
);
|
||||
|
||||
stream.write_all(request.as_bytes())?;
|
||||
stream.flush()?;
|
||||
|
||||
let mut response = String::new();
|
||||
stream.read_to_string(&mut response)?;
|
||||
|
||||
let (raw_headers, _rest) = response
|
||||
.split_once("\r\n\r\n")
|
||||
.or_else(|| response.split_once("\n\n"))
|
||||
.unwrap_or((response.as_str(), ""));
|
||||
|
||||
let mut lines = raw_headers.lines();
|
||||
let status_line = lines.next().unwrap_or("");
|
||||
if !status_line.contains(" 200 ") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
for line in lines {
|
||||
// handle case-insensitive header name
|
||||
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
||||
|
||||
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
|
||||
.domain(domain)
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(tauri::webview::cookie::SameSite::Lax)
|
||||
.build();
|
||||
|
||||
if let Some(win) = app.webview_windows().get("main") {
|
||||
win.set_cookie(cookie)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -139,6 +225,7 @@ pub struct CliProcessManager {
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl CliProcessManager {
|
||||
@@ -147,6 +234,7 @@ impl CliProcessManager {
|
||||
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||
child: Arc::new(Mutex::new(None)),
|
||||
ready: Arc::new(AtomicBool::new(false)),
|
||||
bootstrap_token: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +242,7 @@ impl CliProcessManager {
|
||||
log_line(&format!("start requested (dev={dev})"));
|
||||
self.stop()?;
|
||||
self.ready.store(false, Ordering::SeqCst);
|
||||
*self.bootstrap_token.lock() = None;
|
||||
{
|
||||
let mut status = self.status.lock();
|
||||
status.state = CliState::Starting;
|
||||
@@ -167,8 +256,9 @@ impl CliProcessManager {
|
||||
let status_arc = self.status.clone();
|
||||
let child_arc = self.child.clone();
|
||||
let ready_flag = self.ready.clone();
|
||||
let token_arc = self.bootstrap_token.clone();
|
||||
thread::spawn(move || {
|
||||
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
|
||||
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev) {
|
||||
log_line(&format!("cli spawn failed: {err}"));
|
||||
let mut locked = status_arc.lock();
|
||||
locked.state = CliState::Error;
|
||||
@@ -237,6 +327,7 @@ impl CliProcessManager {
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child_holder: Arc<Mutex<Option<Child>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||
dev: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
log_line("resolving CLI entry");
|
||||
@@ -318,8 +409,10 @@ impl CliProcessManager {
|
||||
let status_clone = status.clone();
|
||||
let app_clone = app.clone();
|
||||
let ready_clone = ready.clone();
|
||||
let token_clone = bootstrap_token.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
|
||||
let stdout = child_clone
|
||||
.lock()
|
||||
.as_mut()
|
||||
@@ -332,10 +425,10 @@ impl CliProcessManager {
|
||||
.map(BufReader::new);
|
||||
|
||||
if let Some(reader) = stdout {
|
||||
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
|
||||
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone);
|
||||
}
|
||||
if let Some(reader) = stderr {
|
||||
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
|
||||
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -407,10 +500,12 @@ impl CliProcessManager {
|
||||
app: &AppHandle,
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
) {
|
||||
let mut buffer = String::new();
|
||||
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
|
||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||
|
||||
loop {
|
||||
buffer.clear();
|
||||
@@ -419,6 +514,17 @@ impl CliProcessManager {
|
||||
Ok(_) => {
|
||||
let line = buffer.trim_end();
|
||||
if !line.is_empty() {
|
||||
if line.starts_with(token_prefix) {
|
||||
let token = line.trim_start_matches(token_prefix).trim();
|
||||
if !token.is_empty() {
|
||||
let mut guard = bootstrap_token.lock();
|
||||
if guard.is_none() {
|
||||
*guard = Some(token.to_string());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
log_line(&format!("[cli][{}] {}", stream, line));
|
||||
|
||||
if ready.load(Ordering::SeqCst) {
|
||||
@@ -430,7 +536,7 @@ impl CliProcessManager {
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, port);
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, port);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -440,13 +546,13 @@ impl CliProcessManager {
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, port);
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, port);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||
Self::mark_ready(app, status, ready, port as u16);
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, port as u16);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -458,16 +564,46 @@ impl CliProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
|
||||
fn mark_ready(
|
||||
app: &AppHandle,
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
port: u16,
|
||||
) {
|
||||
ready.store(true, Ordering::SeqCst);
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let mut locked = status.lock();
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
locked.port = Some(port);
|
||||
locked.url = Some(url.clone());
|
||||
locked.url = Some(base_url.clone());
|
||||
locked.state = CliState::Ready;
|
||||
locked.error = None;
|
||||
log_line(&format!("cli ready on {url}"));
|
||||
navigate_main(app, &url);
|
||||
log_line(&format!("cli ready on {base_url}"));
|
||||
|
||||
let token = bootstrap_token.lock().take();
|
||||
|
||||
if let Some(token) = token {
|
||||
match exchange_bootstrap_token(&base_url, &token) {
|
||||
Ok(Some(session_id)) => {
|
||||
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||
log_line(&format!("failed to set session cookie: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
} else {
|
||||
navigate_main(app, &base_url);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
log_line("bootstrap token exchange failed (invalid token)");
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
}
|
||||
Err(err) => {
|
||||
log_line(&format!("bootstrap token exchange failed: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
navigate_main(app, &base_url);
|
||||
}
|
||||
let _ = app.emit("cli:ready", locked.clone());
|
||||
Self::emit_status(app, &locked);
|
||||
}
|
||||
@@ -551,6 +687,7 @@ impl CliEntry {
|
||||
host.to_string(),
|
||||
"--port".to_string(),
|
||||
"0".to_string(),
|
||||
"--generate-token".to_string(),
|
||||
];
|
||||
if dev {
|
||||
args.push("--ui-dev-server".to_string());
|
||||
|
||||
@@ -76,7 +76,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id))
|
||||
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id), { withCredentials: true } as any)
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as { type?: string; content?: string }
|
||||
|
||||
@@ -19,10 +19,16 @@ interface RemoteAccessOverlayProps {
|
||||
|
||||
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
||||
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [passwordFormOpen, setPasswordFormOpen] = createSignal(false)
|
||||
const [passwordValue, setPasswordValue] = createSignal("")
|
||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||
|
||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
|
||||
@@ -38,9 +44,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const refreshMeta = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setPasswordError(null)
|
||||
try {
|
||||
const result = await serverApi.fetchServerMeta()
|
||||
setMeta(result)
|
||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||
setMeta(metaResult)
|
||||
setAuthStatus(authResult)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
@@ -108,6 +116,36 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitPassword = async () => {
|
||||
setPasswordError(null)
|
||||
|
||||
const next = passwordValue()
|
||||
const confirm = passwordConfirm()
|
||||
|
||||
if (next.trim().length < 8) {
|
||||
setPasswordError("Password must be at least 8 characters.")
|
||||
return
|
||||
}
|
||||
|
||||
if (next !== confirm) {
|
||||
setPasswordError("Passwords do not match.")
|
||||
return
|
||||
}
|
||||
|
||||
setSavingPassword(true)
|
||||
try {
|
||||
const result = await serverApi.setServerPassword(next)
|
||||
setAuthStatus({ authenticated: true, username: result.username, passwordUserProvided: result.passwordUserProvided })
|
||||
setPasswordValue("")
|
||||
setPasswordConfirm("")
|
||||
setPasswordFormOpen(false)
|
||||
} catch (err) {
|
||||
setPasswordError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSavingPassword(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
@@ -175,6 +213,87 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
</section>
|
||||
|
||||
<section class="remote-section">
|
||||
<div class="remote-section-heading">
|
||||
<div class="remote-section-title">
|
||||
<Shield class="remote-icon" />
|
||||
<div>
|
||||
<p class="remote-label">Server password</p>
|
||||
<p class="remote-help">Remote handovers require a password. Set a memorable one to enable logins from other devices.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={authStatus() && authStatus()!.authenticated}
|
||||
fallback={<div class="remote-card">Authentication status unavailable.</div>}
|
||||
>
|
||||
<div class="remote-card">
|
||||
<p class="remote-help">Username: {authStatus()!.username ?? "codenomad"}</p>
|
||||
<p class="remote-help">
|
||||
{authStatus()!.passwordUserProvided
|
||||
? "A password is set for remote access."
|
||||
: "No memorable password is set yet. Set one to allow remote handover logins."}
|
||||
</p>
|
||||
|
||||
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPasswordFormOpen(!passwordFormOpen())
|
||||
setPasswordError(null)
|
||||
}}
|
||||
>
|
||||
{passwordFormOpen()
|
||||
? "Cancel"
|
||||
: authStatus()!.passwordUserProvided
|
||||
? "Change password"
|
||||
: "Set password"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={passwordFormOpen()}>
|
||||
<div class="selector-input-group" style={{ "margin-top": "12px" }}>
|
||||
<label class="text-sm font-medium text-secondary">New password</label>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
type="password"
|
||||
value={passwordValue()}
|
||||
onInput={(event) => setPasswordValue(event.currentTarget.value)}
|
||||
placeholder="At least 8 characters"
|
||||
/>
|
||||
</div>
|
||||
<div class="selector-input-group" style={{ "margin-top": "10px" }}>
|
||||
<label class="text-sm font-medium text-secondary">Confirm password</label>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
type="password"
|
||||
value={passwordConfirm()}
|
||||
onInput={(event) => setPasswordConfirm(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={passwordError()}>
|
||||
{(message) => <div class="remote-error" style={{ "margin-top": "10px" }}>{message()}</div>}
|
||||
</Show>
|
||||
|
||||
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
disabled={savingPassword()}
|
||||
onClick={() => void handleSubmitPassword()}
|
||||
>
|
||||
{savingPassword() ? "Saving…" : "Save password"}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<section class="remote-section">
|
||||
|
||||
<div class="remote-section-heading">
|
||||
<div class="remote-section-title">
|
||||
<Wifi class="remote-icon" />
|
||||
|
||||
@@ -103,7 +103,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
logHttp(`${method} ${path}`)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { ...init, headers })
|
||||
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
||||
@@ -135,6 +135,15 @@ export const serverApi = {
|
||||
fetchServerMeta(): Promise<ServerMeta> {
|
||||
return request<ServerMeta>("/api/meta")
|
||||
},
|
||||
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
||||
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
||||
},
|
||||
setServerPassword(password: string): Promise<{ ok: boolean; username: string; passwordUserProvided: boolean }> {
|
||||
return request<{ ok: boolean; username: string; passwordUserProvided: boolean }>("/api/auth/password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
},
|
||||
deleteWorkspace(id: string): Promise<void> {
|
||||
return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||
},
|
||||
@@ -270,7 +279,7 @@ export const serverApi = {
|
||||
},
|
||||
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
|
||||
sseLogger.info(`Connecting to ${EVENTS_URL}`)
|
||||
const source = new EventSource(EVENTS_URL)
|
||||
const source = new EventSource(EVENTS_URL, { withCredentials: true } as any)
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as WorkspaceEventPayload
|
||||
|
||||
Reference in New Issue
Block a user