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:
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user