Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

132 changes: 131 additions & 1 deletion packages/electron-app/electron/main/main.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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) => {
Expand Down
25 changes: 20 additions & 5 deletions packages/electron-app/electron/main/process-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
"version": "0.5.0",
"private": true,
"dependencies": {
"@opencode-ai/plugin": "1.1.8"
"@opencode-ai/plugin": "1.1.12"
}
}
84 changes: 14 additions & 70 deletions packages/opencode-config/plugin/lib/background-process.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -12,11 +13,6 @@ type BackgroundProcess = {
outputSizeBytes?: number
}

type CodeNomadConfig = {
instanceId: string
baseUrl: string
}

type BackgroundProcessOptions = {
baseDir: string
}
Expand All @@ -27,30 +23,10 @@ type ParsedCommand = {
}

export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
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,
})
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 <T>(path: string, init?: RequestInit): Promise<T> => {
return requester.requestJson<T>(`/background-processes${path}`, init)
}

return {
Expand Down Expand Up @@ -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
}

Expand All @@ -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))
}
Loading