diff --git a/package-lock.json b/package-lock.json index 3883a12c..bf5e1a50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1096,9 +1096,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.1.tgz", - "integrity": "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz", + "integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==", "license": "MIT" }, "node_modules/@pinojs/redact": { @@ -7469,7 +7469,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.1.1", + "@opencode-ai/sdk": "1.1.11", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index f4e8be1d..60240886 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -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 { + 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) => { diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 1630b875..20134f98 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -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 { 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 + + 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(line) + 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") diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index fa8aa1ea..4feb72e9 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -3,6 +3,6 @@ "version": "0.5.0", "private": true, "dependencies": { - "@opencode-ai/plugin": "1.1.8" + "@opencode-ai/plugin": "1.1.12" } } diff --git a/packages/opencode-config/plugin/lib/background-process.ts b/packages/opencode-config/plugin/lib/background-process.ts index 91da1f45..689ce519 100644 --- a/packages/opencode-config/plugin/lib/background-process.ts +++ b/packages/opencode-config/plugin/lib/background-process.ts @@ -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 request = async (path: string, init?: RequestInit): Promise => { - - 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, - }) + const requester = createCodeNomadRequester(config) - 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 + const request = async (path: string, init?: RequestInit): Promise => { + return requester.requestJson(`/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 { - const output: Record = {} - 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)) } diff --git a/packages/opencode-config/plugin/lib/client.ts b/packages/opencode-config/plugin/lib/client.ts index b6727de4..fddc49a4 100644 --- a/packages/opencode-config/plugin/lib/client.ts +++ b/packages/opencode-config/plugin/lib/client.ts @@ -1,74 +1,41 @@ -export type PluginEvent = { - type: string - properties?: Record -} +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((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, + 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, 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, onEvent: (event: PluginEvent) => void, initialBody: ReadableStream, ) { @@ -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) diff --git a/packages/opencode-config/plugin/lib/request.ts b/packages/opencode-config/plugin/lib/request.ts new file mode 100644 index 00000000..90df50fe --- /dev/null +++ b/packages/opencode-config/plugin/lib/request.ts @@ -0,0 +1,124 @@ +export type PluginEvent = { + type: string + properties?: Record +} + +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 => { + const output: Record = 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 => { + const url = buildUrl(path) + const hasBody = init?.body !== undefined + const headers = buildHeaders(init?.headers, hasBody) + + return fetch(url, { + ...init, + headers, + }) + } + + const requestJson = async (path: string, init?: RequestInit): Promise => { + 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 => { + 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> => { + 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 + } + + 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 { + const output: Record = {} + 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 } +} diff --git a/packages/server/package.json b/packages/server/package.json index eeeb5389..417cbf80 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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": { diff --git a/packages/server/scripts/copy-auth-pages.mjs b/packages/server/scripts/copy-auth-pages.mjs new file mode 100644 index 00000000..387b7fc7 --- /dev/null +++ b/packages/server/scripts/copy-auth-pages.mjs @@ -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}`) diff --git a/packages/server/src/auth/auth-store.ts b/packages/server/src/auth/auth-store.ts new file mode 100644 index 00000000..640f5c80 --- /dev/null +++ b/packages/server/src/auth/auth-store.ts @@ -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 + } + } +} diff --git a/packages/server/src/auth/http-auth.ts b/packages/server/src/auth/http-auth.ts new file mode 100644 index 00000000..5da2bb87 --- /dev/null +++ b/packages/server/src/auth/http-auth.ts @@ -0,0 +1,38 @@ +import type { FastifyReply, FastifyRequest } from "fastify" + +export function parseCookies(header: string | undefined): Record { + const result: Record = {} + 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" }) +} diff --git a/packages/server/src/auth/manager.ts b/packages/server/src/auth/manager.ts new file mode 100644 index 00000000..55014d55 --- /dev/null +++ b/packages/server/src/auth/manager.ts @@ -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("; ") +} diff --git a/packages/server/src/auth/password-hash.ts b/packages/server/src/auth/password-hash.ts new file mode 100644 index 00000000..b238581f --- /dev/null +++ b/packages/server/src/auth/password-hash.ts @@ -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)) +} diff --git a/packages/server/src/auth/session-manager.ts b/packages/server/src/auth/session-manager.ts new file mode 100644 index 00000000..e952ae1b --- /dev/null +++ b/packages/server/src/auth/session-manager.ts @@ -0,0 +1,23 @@ +import crypto from "crypto" + +export interface SessionInfo { + id: string + createdAt: number + username: string +} + +export class SessionManager { + private sessions = new Map() + + 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) + } +} diff --git a/packages/server/src/auth/token-manager.ts b/packages/server/src/auth/token-manager.ts new file mode 100644 index 00000000..17297b33 --- /dev/null +++ b/packages/server/src/auth/token-manager.ts @@ -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 + } +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 6b548402..831e9381 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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 ", "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 for server authentication") + .env("CODENOMAD_SERVER_USERNAME") + .default(DEFAULT_AUTH_USERNAME), + ) + .addOption(new Option("--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, diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 26542790..a92f01e3 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -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) }) } diff --git a/packages/server/src/server/routes/auth-pages/login.html b/packages/server/src/server/routes/auth-pages/login.html new file mode 100644 index 00000000..c3f95ac8 --- /dev/null +++ b/packages/server/src/server/routes/auth-pages/login.html @@ -0,0 +1,134 @@ + + + + + + CodeNomad Login + + + +
+

Sign in

+

This CodeNomad server is protected. Enter your credentials to continue.

+ + + + + + + + + +
+ + + + diff --git a/packages/server/src/server/routes/auth-pages/token.html b/packages/server/src/server/routes/auth-pages/token.html new file mode 100644 index 00000000..9ba3feb0 --- /dev/null +++ b/packages/server/src/server/routes/auth-pages/token.html @@ -0,0 +1,93 @@ + + + + + + CodeNomad + + + +
+

Connecting…

+

Finalizing local authentication.

+ +
+ + + + diff --git a/packages/server/src/server/routes/auth.ts b/packages/server/src/server/routes/auth.ts new file mode 100644 index 00000000..13401f23 --- /dev/null +++ b/packages/server/src/server/routes/auth.ts @@ -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 + } + }) +} diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index 5fff69ae..aeda2fc3 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -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 = { 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, }) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index cc76e9d8..ee5a5aea 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -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() private readonly runtime: WorkspaceRuntime private readonly opencodeConfigDir: string + private readonly opencodeAuth = new Map() 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 = {} + 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 diff --git a/packages/server/src/workspaces/opencode-auth.ts b/packages/server/src/workspaces/opencode-auth.ts new file mode 100644 index 00000000..8713272f --- /dev/null +++ b/packages/server/src/workspaces/opencode-auth.ts @@ -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}` +} diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index ce6794d3..6861f385 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -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): Record { + const redacted: Record = {} + 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", ) diff --git a/packages/tauri-app/scripts/prebuild.js b/packages/tauri-app/scripts/prebuild.js index c3968927..f4daf3b3 100644 --- a/packages/tauri-app/scripts/prebuild.js +++ b/packages/tauri-app/scripts/prebuild.js @@ -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() diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 6b43574d..14475910 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -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 { }) } +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 { + 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> { + 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>, child: Arc>>, ready: Arc, + bootstrap_token: Arc>>, } 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>, child_holder: Arc>>, ready: Arc, + bootstrap_token: Arc>>, 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>, ready: &Arc, + bootstrap_token: &Arc>>, ) { 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::().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::().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::(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>, ready: &Arc, port: u16) { + fn mark_ready( + app: &AppHandle, + status: &Arc>, + ready: &Arc, + bootstrap_token: &Arc>>, + 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()); diff --git a/packages/ui/package.json b/packages/ui/package.json index d18b89e4..fadc82ac 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,7 +12,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.1.1", + "@opencode-ai/sdk": "1.1.11", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/src/components/background-process-output-dialog.tsx b/packages/ui/src/components/background-process-output-dialog.tsx index ee412e53..89052542 100644 --- a/packages/ui/src/components/background-process-output-dialog.tsx +++ b/packages/ui/src/components/background-process-output-dialog.tsx @@ -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 } diff --git a/packages/ui/src/components/expand-button.tsx b/packages/ui/src/components/expand-button.tsx new file mode 100644 index 00000000..0b6e4fb0 --- /dev/null +++ b/packages/ui/src/components/expand-button.tsx @@ -0,0 +1,30 @@ +import { Show } from "solid-js" +import { Maximize2, Minimize2 } from "lucide-solid" + +interface ExpandButtonProps { + expandState: () => "normal" | "expanded" + onToggleExpand: (nextState: "normal" | "expanded") => void +} + +export default function ExpandButton(props: ExpandButtonProps) { + function handleClick() { + const current = props.expandState() + props.onToggleExpand(current === "normal" ? "expanded" : "normal") + } + + return ( + + ) +} diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 74f6362f..a2a83f7e 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -875,7 +875,6 @@ const InstanceShell2: Component = (props) => {
lastAssistantIdx) - const info = messageInfo() - const infoTime = (info?.time ?? {}) as { created?: number; updated?: number; completed?: number } - const infoTimestamp = - typeof infoTime.completed === "number" - ? infoTime.completed - : typeof infoTime.updated === "number" - ? infoTime.updated - : infoTime.created ?? 0 - const infoError = (info as { error?: { name?: string } } | undefined)?.error - const infoErrorName = typeof infoError?.name === "string" ? infoError.name : "" + + // Intentionally untracked: messageInfoVersion updates should not trigger + // a full message block rebuild; record revision is the invalidation key. + const info = untrack(messageInfo) + const cacheSignature = [ current.id, current.revision, @@ -252,8 +247,6 @@ export default function MessageBlock(props: MessageBlockProps) { props.showThinking() ? 1 : 0, props.thinkingDefaultExpanded() ? 1 : 0, props.showUsageMetrics() ? 1 : 0, - infoTimestamp, - infoErrorName, ].join("|") const cachedBlock = sessionCache.messageBlocks.get(current.id) diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index 33970125..66e79be8 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -1,7 +1,15 @@ import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js" import type { PermissionRequestLike } from "../types/permission" import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission" -import { activePermissionId, getPermissionQueue } from "../stores/instances" +import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question" +import { + activeInterruption, + getPermissionQueue, + getQuestionQueue, + getQuestionEnqueuedAtForInstance, + setActivePermissionIdForInstance, + setActiveQuestionIdForInstance, +} from "../stores/instances" import { loadMessages, setActiveSession } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" import ToolCall from "./tool-call" @@ -88,24 +96,72 @@ function resolveToolCallFromPermission( return null } +function resolveToolCallFromQuestion(instanceId: string, request: QuestionRequest): ResolvedToolCall | null { + const sessionId = getQuestionSessionId(request) + const messageId = getQuestionMessageId(request) + if (!sessionId || !messageId) return null + + const store = messageStoreBus.getInstance(instanceId) + if (!store) return null + + const record = store.getMessage(messageId) + if (!record) return null + + const callId = getQuestionCallId(request) + if (!callId) return null + + for (const partId of record.partIds) { + const partRecord = record.parts?.[partId] + const part = partRecord?.data as any + if (!part || part.type !== "tool") continue + const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined + if (partCallId !== callId) continue + + if (typeof part.id !== "string" || part.id.length === 0) continue + return { + messageId, + sessionId, + toolPart: part as ResolvedToolCall["toolPart"], + messageVersion: record.revision, + partVersion: partRecord?.revision ?? 0, + } + } + + return null +} + const PermissionApprovalModal: Component = (props) => { const [loadingSession, setLoadingSession] = createSignal(null) - const queue = createMemo(() => getPermissionQueue(props.instanceId)) - const activePermId = createMemo(() => activePermissionId().get(props.instanceId) ?? null) - - const orderedQueue = createMemo(() => { - const current = queue() - const activeId = activePermId() - if (!activeId) return current - const index = current.findIndex((entry) => entry.id === activeId) - if (index <= 0) return current - const active = current[index] - if (!active) return current - return [active, ...current.slice(0, index), ...current.slice(index + 1)] + const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId)) + const questionQueue = createMemo(() => getQuestionQueue(props.instanceId)) + const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null) + + type InterruptionItem = + | { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequestLike } + | { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest } + + const orderedQueue = createMemo(() => { + const permissions = permissionQueue().map((permission) => ({ + kind: "permission" as const, + id: permission.id, + sessionId: getPermissionSessionId(permission) || "", + createdAt: (permission as any)?.time?.created ?? Date.now(), + payload: permission, + })) + + const questions = questionQueue().map((question) => ({ + kind: "question" as const, + id: question.id, + sessionId: getQuestionSessionId(question) || "", + createdAt: getQuestionEnqueuedAtForInstance(props.instanceId, question.id), + payload: question, + })) + + return [...permissions, ...questions].sort((a, b) => a.createdAt - b.createdAt) }) - const hasPermissions = createMemo(() => queue().length > 0) + const hasRequests = createMemo(() => orderedQueue().length > 0) const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") { @@ -122,7 +178,7 @@ const PermissionApprovalModal: Component = (props) createEffect(() => { if (!props.isOpen) return - if (queue().length === 0) { + if (orderedQueue().length === 0) { props.onClose() } }) @@ -156,10 +212,10 @@ const PermissionApprovalModal: Component = (props)

- Permissions + Requests

- 0}> - {queue().length} + 0}> + {orderedQueue().length}
- No pending permissions.
}> + No pending requests.
}>
- {(permission) => { - const sessionId = getPermissionSessionId(permission) || "" - const isActive = () => permission.id === activePermId() - const resolved = createMemo(() => resolveToolCallFromPermission(props.instanceId, permission)) + {(item) => { + const isActive = () => active()?.kind === item.kind && active()?.id === item.id + const sessionId = () => item.sessionId + + const resolved = createMemo(() => { + if (item.kind === "permission") { + return resolveToolCallFromPermission(props.instanceId, item.payload) + } + return resolveToolCallFromQuestion(props.instanceId, item.payload) + }) const showFallback = () => !resolved() + const kindLabel = () => (item.kind === "permission" ? "Permission" : "Question") + + const primaryTitle = () => { + if (item.kind === "permission") { + return getPermissionDisplayTitle(item.payload) + } + const first = item.payload.questions?.[0]?.question + return typeof first === "string" && first.trim().length > 0 ? first : "Question" + } + + const secondaryTitle = () => { + if (item.kind === "permission") { + return getPermissionKind(item.payload) + } + const count = item.payload.questions?.length ?? 0 + return count === 1 ? "1 question" : `${count} questions` + } + + const handleActivate = () => { + if (item.kind === "permission") { + setActivePermissionIdForInstance(props.instanceId, item.id) + } else { + setActiveQuestionIdForInstance(props.instanceId, item.id) + } + } + return (
- {getPermissionKind(permission)} + {kindLabel()} + {secondaryTitle()} Active @@ -195,7 +285,10 @@ const PermissionApprovalModal: Component = (props) @@ -203,10 +296,13 @@ const PermissionApprovalModal: Component = (props)
@@ -217,7 +313,7 @@ const PermissionApprovalModal: Component = (props) fallback={
- {getPermissionDisplayTitle(permission)} + {primaryTitle()}
Load session for more information.
diff --git a/packages/ui/src/components/permission-notification-banner.tsx b/packages/ui/src/components/permission-notification-banner.tsx index 17e04907..8c6f97ca 100644 --- a/packages/ui/src/components/permission-notification-banner.tsx +++ b/packages/ui/src/components/permission-notification-banner.tsx @@ -1,6 +1,6 @@ import { Show, createMemo, type Component } from "solid-js" import { ShieldAlert } from "lucide-solid" -import { getPermissionQueueLength } from "../stores/instances" +import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances" interface PermissionNotificationBannerProps { instanceId: string @@ -8,15 +8,21 @@ interface PermissionNotificationBannerProps { } const PermissionNotificationBanner: Component = (props) => { - const queueLength = createMemo(() => getPermissionQueueLength(props.instanceId)) - const hasPermissions = createMemo(() => queueLength() > 0) + const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId)) + const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId)) + const queueLength = createMemo(() => permissionCount() + questionCount()) + const hasRequests = createMemo(() => queueLength() > 0) const label = createMemo(() => { - const count = queueLength() - return `${count} permission${count === 1 ? "" : "s"} pending approval` + const total = queueLength() + const parts: string[] = [] + if (permissionCount() > 0) parts.push(`${permissionCount()} permission${permissionCount() === 1 ? "" : "s"}`) + if (questionCount() > 0) parts.push(`${questionCount()} question${questionCount() === 1 ? "" : "s"}`) + const detail = parts.length ? ` (${parts.join(", ")})` : "" + return `${total} pending request${total === 1 ? "" : "s"}${detail}` }) return ( - + - - - -
- {attachment.filename} -
-
-
- ) - }} - -
- -
-
+
+ +