fix(managed-runtime): secure auth and lifecycle control across runtimes (#437)
* feat: add OpenCode server authentication with auto-generated passwords * fix(auth): separate user env and managed OpenCode password state * fix(auth): enforce env precedence and managed password rotation across runtimes * fix(vscode): rotate managed auth on startup and harden webview proxy * build: add dev icons and config for Tauri desktop development * fix(runtime): start managed OpenCode via CLI and expose active API port * fix(managed-runtime): control OpenCode lifecycle and surface secure diagnostics * docs: remove VS Code plugin test runbook
This commit is contained in:
@@ -18,7 +18,16 @@ function spawnProcess(command, args, opts = {}) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tauriProcess = spawnProcess('bun', ['--cwd', desktopDir, 'tauri', 'dev', '--features', 'devtools']);
|
||||
const tauriProcess = spawnProcess('bun', [
|
||||
'--cwd',
|
||||
desktopDir,
|
||||
'tauri',
|
||||
'dev',
|
||||
'--features',
|
||||
'devtools',
|
||||
'--config',
|
||||
'./src-tauri/tauri.dev.conf.json',
|
||||
]);
|
||||
|
||||
let cleaning = false;
|
||||
|
||||
|
||||
Generated
+2
@@ -1187,6 +1187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3411,6 +3412,7 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
|
||||
@@ -16,7 +16,7 @@ devtools = ["tauri/devtools"]
|
||||
anyhow = "1.0.86"
|
||||
base64 = "0.22.1"
|
||||
log = "0.4.28"
|
||||
reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls"] }
|
||||
reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls", "blocking"] }
|
||||
serde = { version = "1.0.210", features = ["derive"] }
|
||||
serde_json = "1.0.143"
|
||||
tauri = { version = "2.9.4", features = ["macos-private-api"] }
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
@@ -1476,10 +1476,27 @@ fn kill_sidecar(app: tauri::AppHandle) {
|
||||
return;
|
||||
};
|
||||
|
||||
let sidecar_url = state.url.lock().expect("sidecar url mutex").clone();
|
||||
if let Some(url) = sidecar_url {
|
||||
let shutdown_url = format!("{}/api/system/shutdown", url.trim_end_matches('/'));
|
||||
if let Ok(client) = reqwest::blocking::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_millis(1500))
|
||||
.build()
|
||||
{
|
||||
if let Ok(resp) = client.post(shutdown_url).send() {
|
||||
if resp.status().is_success() {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut guard = state.child.lock().expect("sidecar mutex");
|
||||
if let Some(child) = guard.take() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
*state.url.lock().expect("sidecar url mutex") = None;
|
||||
}
|
||||
|
||||
fn build_local_url(port: u16) -> String {
|
||||
@@ -1627,6 +1644,7 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result<String> {
|
||||
.args(["--port", &port.to_string()])
|
||||
.env("OPENCHAMBER_HOST", "127.0.0.1")
|
||||
.env("OPENCHAMBER_DIST_DIR", dist_dir.clone())
|
||||
.env("OPENCHAMBER_RUNTIME", "desktop")
|
||||
.env("OPENCHAMBER_DESKTOP_NOTIFY", "true")
|
||||
.env("PATH", augmented_path.clone())
|
||||
.env("NO_PROXY", no_proxy)
|
||||
@@ -1643,6 +1661,13 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(password) = env::var("OPENCODE_SERVER_PASSWORD") {
|
||||
let trimmed = password.trim();
|
||||
if !trimmed.is_empty() {
|
||||
cmd = cmd.env("OPENCODE_SERVER_PASSWORD", trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
let (rx, child) = match cmd.spawn() {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"bundle": {
|
||||
"icon": [
|
||||
"icons/dev-icon.icns",
|
||||
"icons/dev-icon.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,19 @@ export const VSCodeLayout: React.FC = () => {
|
||||
return sessions.find((session) => session.id === currentSessionId)?.title || 'Session';
|
||||
}, [currentSessionId, sessions]);
|
||||
const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const isSyncingMessages = useSessionStore((state) => state.isSyncing);
|
||||
const hasActiveSessionWork = useSessionStore((state) => {
|
||||
const statuses = state.sessionStatus;
|
||||
if (!statuses || statuses.size === 0) {
|
||||
return false;
|
||||
}
|
||||
for (const status of statuses.values()) {
|
||||
if (status?.type === 'busy' || status?.type === 'retry') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
|
||||
const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
|
||||
() => (typeof window !== 'undefined'
|
||||
@@ -129,10 +142,37 @@ export const VSCodeLayout: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSessionId && !newSessionDraftOpen && currentView === 'chat') {
|
||||
setCurrentView('sessions');
|
||||
if (currentView !== 'chat') {
|
||||
return;
|
||||
}
|
||||
}, [currentSessionId, newSessionDraftOpen, currentView, viewMode]);
|
||||
|
||||
if (currentSessionId || newSessionDraftOpen || isSyncingMessages || hasActiveSessionWork) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
const state = useSessionStore.getState();
|
||||
const stillNoSession = !state.currentSessionId;
|
||||
const draftStillClosed = !state.newSessionDraft?.open;
|
||||
const stillSyncing = state.isSyncing;
|
||||
const stillActiveWork = (() => {
|
||||
const statuses = state.sessionStatus;
|
||||
if (!statuses || statuses.size === 0) return false;
|
||||
for (const status of statuses.values()) {
|
||||
if (status?.type === 'busy' || status?.type === 'retry') return true;
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
if (stillNoSession && draftStillClosed && !stillSyncing && !stillActiveWork) {
|
||||
setCurrentView('sessions');
|
||||
}
|
||||
}, 900);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [currentSessionId, newSessionDraftOpen, currentView, viewMode, isSyncingMessages, hasActiveSessionWork]);
|
||||
|
||||
const handleBackToSessions = React.useCallback(() => {
|
||||
setCurrentView('sessions');
|
||||
|
||||
@@ -486,7 +486,7 @@ export const useEventStream = () => {
|
||||
// Note: needs_attention logic is now handled by the server
|
||||
// Server maintains authoritative state based on view tracking and message events
|
||||
|
||||
if (prevType !== nextType) {
|
||||
if (process.env.NODE_ENV === 'development' && prevType !== nextType) {
|
||||
try {
|
||||
console.info('[SESSION-STATUS]', {
|
||||
sessionId,
|
||||
|
||||
@@ -264,11 +264,29 @@ export const debugUtils = {
|
||||
const resp = await fetch('/api/health');
|
||||
const contentType = resp.headers.get('content-type') || '';
|
||||
const body = await safeText(resp);
|
||||
const isJson = contentType.toLowerCase().includes('application/json');
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
if (isJson && body) {
|
||||
try {
|
||||
const candidate = JSON.parse(body) as unknown;
|
||||
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
||||
parsed = candidate as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
}
|
||||
opencodeHealth = {
|
||||
status: resp.status,
|
||||
ok: resp.ok,
|
||||
contentType,
|
||||
type: contentType.includes('application/json') ? 'json' : 'html',
|
||||
type: isJson ? 'json' : 'html',
|
||||
openCodePort: parsed?.openCodePort ?? null,
|
||||
openCodeRunning: parsed?.openCodeRunning ?? null,
|
||||
openCodeSecureConnection: parsed?.openCodeSecureConnection ?? null,
|
||||
openCodeAuthSource: parsed?.openCodeAuthSource ?? null,
|
||||
isOpenCodeReady: parsed?.isOpenCodeReady ?? null,
|
||||
lastOpenCodeError: parsed?.lastOpenCodeError ?? null,
|
||||
preview: body ? body.slice(0, 120) : null,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -13,6 +13,8 @@ type ProbeResult = {
|
||||
type OpenChamberHealthSnapshot = {
|
||||
openCodePort?: unknown;
|
||||
openCodeRunning?: unknown;
|
||||
openCodeSecureConnection?: unknown;
|
||||
openCodeAuthSource?: unknown;
|
||||
isOpenCodeReady?: unknown;
|
||||
lastOpenCodeError?: unknown;
|
||||
opencodeBinaryResolved?: unknown;
|
||||
@@ -100,6 +102,19 @@ const formatIso = (timestamp: number | null | undefined): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const normalizePort = (value: unknown): number | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const buildOpenCodeStatusReport = async (): Promise<string> => {
|
||||
const now = new Date();
|
||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '(unknown)';
|
||||
@@ -215,6 +230,18 @@ export const buildOpenCodeStatusReport = async (): Promise<string> => {
|
||||
lines.push(`Directory: ${directory || '(none)'}`);
|
||||
lines.push(`Platform: ${platform}`);
|
||||
|
||||
const runtimeOpenCodePort = normalizePort(openChamberHealth?.openCodePort);
|
||||
lines.push(`OpenCode runtime port: ${runtimeOpenCodePort ?? '(unknown)'}`);
|
||||
if (typeof openChamberHealth?.openCodeRunning === 'boolean') {
|
||||
lines.push(`OpenCode runtime running: ${openChamberHealth.openCodeRunning ? 'yes' : 'no'}`);
|
||||
}
|
||||
if (typeof openChamberHealth?.openCodeSecureConnection === 'boolean') {
|
||||
lines.push(`Secure OpenCode connection: ${openChamberHealth.openCodeSecureConnection ? 'true' : 'false'}`);
|
||||
}
|
||||
if (typeof openChamberHealth?.openCodeAuthSource === 'string' && openChamberHealth.openCodeAuthSource.trim()) {
|
||||
lines.push(`OpenCode auth source: ${openChamberHealth.openCodeAuthSource}`);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__;
|
||||
if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) {
|
||||
|
||||
@@ -1707,12 +1707,14 @@ export const useMessageStore = create<MessageStore>()(
|
||||
};
|
||||
|
||||
if (messageIndex === -1) {
|
||||
console.info("[MESSAGE-DEBUG] updateMessageInfo: messageIndex === -1", {
|
||||
sessionId,
|
||||
messageId,
|
||||
messageInfo,
|
||||
existingCount: normalizedSessionMessages.length,
|
||||
});
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.info("[MESSAGE-DEBUG] updateMessageInfo: messageIndex === -1", {
|
||||
sessionId,
|
||||
messageId,
|
||||
messageInfo,
|
||||
existingCount: normalizedSessionMessages.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedSessionMessages.length > 0) {
|
||||
const firstMessage = normalizedSessionMessages[0];
|
||||
|
||||
@@ -176,7 +176,10 @@ export class AgentManagerPanelProvider {
|
||||
let response: Response;
|
||||
let wrapAsGlobal = false;
|
||||
|
||||
const requestHeaders = this._buildSseHeaders(headers || {});
|
||||
const requestHeaders = this._buildSseHeaders({
|
||||
...(headers || {}),
|
||||
...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}),
|
||||
});
|
||||
|
||||
try {
|
||||
response = await fetch(targetUrl, {
|
||||
|
||||
@@ -204,7 +204,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
let response: Response;
|
||||
let wrapAsGlobal = false;
|
||||
|
||||
const requestHeaders = this._buildSseHeaders(headers || {});
|
||||
const requestHeaders = this._buildSseHeaders({
|
||||
...(headers || {}),
|
||||
...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}),
|
||||
});
|
||||
|
||||
try {
|
||||
response = await fetch(targetUrl, {
|
||||
|
||||
@@ -199,7 +199,10 @@ export class SessionEditorPanelProvider {
|
||||
let response: Response;
|
||||
let wrapAsGlobal = false;
|
||||
|
||||
const requestHeaders = this._buildSseHeaders(headers || {});
|
||||
const requestHeaders = this._buildSseHeaders({
|
||||
...(headers || {}),
|
||||
...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}),
|
||||
});
|
||||
|
||||
try {
|
||||
response = await fetch(targetUrl, {
|
||||
|
||||
@@ -806,7 +806,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
|
||||
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||
const requestHeaders: Record<string, string> = sanitizeForwardHeaders(headers);
|
||||
const requestHeaders: Record<string, string> = {
|
||||
...sanitizeForwardHeaders(headers),
|
||||
...ctx?.manager?.getOpenCodeAuthHeaders(),
|
||||
};
|
||||
|
||||
// Ensure SSE requests are negotiated correctly.
|
||||
if (normalizedPath === '/event' || normalizedPath === '/global/event') {
|
||||
@@ -875,7 +878,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
|
||||
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||
const requestHeaders: Record<string, string> = sanitizeForwardHeaders(headers);
|
||||
const requestHeaders: Record<string, string> = {
|
||||
...sanitizeForwardHeaders(headers),
|
||||
...ctx?.manager?.getOpenCodeAuthHeaders(),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
|
||||
@@ -362,10 +362,11 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const startedAt = Date.now();
|
||||
const openCodeAuthHeaders = openCodeManager?.getOpenCodeAuthHeaders() || {};
|
||||
try {
|
||||
const resp = await fetch(input, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
headers: { Accept: 'application/json', ...openCodeAuthHeaders },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
@@ -465,7 +466,13 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
? `OpenCode mode: ${debug.mode} (starts=${debug.startCount}, restarts=${debug.restartCount})`
|
||||
: `OpenCode mode: (unknown)`,
|
||||
debug
|
||||
? `OpenCode CLI path: ${debug.cliPath || '(not found - SDK manages process)'}`
|
||||
? `Secure OpenCode connection: ${debug.secureConnection ? 'true' : 'false'}`
|
||||
: `Secure OpenCode connection: (unknown)`,
|
||||
debug
|
||||
? `OpenCode auth source: ${debug.authSource ?? '(none)'}`
|
||||
: `OpenCode auth source: (unknown)`,
|
||||
debug
|
||||
? `OpenCode CLI path: ${debug.cliPath || '(not found)'}`
|
||||
: `OpenCode CLI path: (unknown)`,
|
||||
debug
|
||||
? `OpenCode detected port: ${debug.detectedPort ?? '(none)'}`
|
||||
|
||||
+236
-46
@@ -2,12 +2,13 @@ import * as vscode from 'vscode';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as net from 'net';
|
||||
import { execSync } from 'child_process';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { createOpencodeServer } from '@opencode-ai/sdk/v2/server';
|
||||
import { spawn } from 'child_process';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const READY_CHECK_TIMEOUT_MS = 30000;
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
export type OpenCodeDebugInfo = {
|
||||
@@ -32,6 +33,8 @@ export type OpenCodeDebugInfo = {
|
||||
lastReadyAttempts: number | null;
|
||||
lastStartAttempts: number | null;
|
||||
version: string | null;
|
||||
secureConnection: boolean;
|
||||
authSource: 'user-env' | 'generated' | 'rotated' | null;
|
||||
};
|
||||
|
||||
export interface OpenCodeManager {
|
||||
@@ -41,12 +44,43 @@ export interface OpenCodeManager {
|
||||
setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>;
|
||||
getStatus(): ConnectionStatus;
|
||||
getApiUrl(): string | null;
|
||||
getOpenCodeAuthHeaders(): Record<string, string>;
|
||||
getWorkingDirectory(): string;
|
||||
isCliAvailable(): boolean;
|
||||
getDebugInfo(): OpenCodeDebugInfo;
|
||||
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
|
||||
}
|
||||
|
||||
function generateSecureOpenCodePassword(): string {
|
||||
return randomBytes(32)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function buildOpenCodeAuthHeader(password: string): string {
|
||||
return `Basic ${Buffer.from(`opencode:${password}`, 'utf8').toString('base64')}`;
|
||||
}
|
||||
|
||||
function isValidOpenCodePassword(password: string): boolean {
|
||||
return typeof password === 'string' && password.trim().length > 0;
|
||||
}
|
||||
|
||||
function readOpenChamberSettings(): Record<string, unknown> {
|
||||
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
|
||||
try {
|
||||
const raw = fs.readFileSync(settingsPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePortFromUrl(url: string): number | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
@@ -110,13 +144,8 @@ function resolveOpencodeCliPath(): string | null {
|
||||
|
||||
const sharedFromOpenChamber = (() => {
|
||||
try {
|
||||
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
|
||||
const raw = fs.readFileSync(settingsPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const candidate = (parsed as Record<string, unknown>).opencodeBinary;
|
||||
const settings = readOpenChamberSettings();
|
||||
const candidate = settings.opencodeBinary;
|
||||
if (typeof candidate !== 'string') {
|
||||
return null;
|
||||
}
|
||||
@@ -258,7 +287,11 @@ function getCandidateBaseUrls(serverUrl: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForReady(serverUrl: string, timeoutMs = 15000): Promise<ReadyResult> {
|
||||
async function waitForReady(
|
||||
serverUrl: string,
|
||||
timeoutMs = 15000,
|
||||
authHeaders: Record<string, string> = {}
|
||||
): Promise<ReadyResult> {
|
||||
const outputChannel = vscode.window.createOutputChannel('OpenChamberManager');
|
||||
const start = Date.now();
|
||||
const candidates = getCandidateBaseUrls(serverUrl);
|
||||
@@ -275,7 +308,7 @@ async function waitForReady(serverUrl: string, timeoutMs = 15000): Promise<Ready
|
||||
const url = new URL(`${baseUrl}/global/health`);
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
headers: { Accept: 'application/json', ...authHeaders },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -305,11 +338,121 @@ async function waitForReady(serverUrl: string, timeoutMs = 15000): Promise<Ready
|
||||
return { ok: false, elapsedMs: Date.now() - start, attempts, version: null };
|
||||
}
|
||||
|
||||
async function spawnManagedOpenCodeServer(
|
||||
workingDirectory: string,
|
||||
port: number,
|
||||
timeoutMs: number
|
||||
): Promise<{ url: string; close: () => void }> {
|
||||
const binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
|
||||
const args = ['serve', '--hostname', '127.0.0.1', '--port', String(port)];
|
||||
const child = spawn(binary, args, {
|
||||
cwd: workingDirectory,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const url = await new Promise<string>((resolve, reject) => {
|
||||
let output = '';
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.stdout?.off('data', onStdout);
|
||||
child.stderr?.off('data', onStderr);
|
||||
child.off('exit', onExit);
|
||||
child.off('error', onError);
|
||||
};
|
||||
|
||||
const onStdout = (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('opencode server listening')) continue;
|
||||
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
|
||||
if (!match) {
|
||||
cleanup();
|
||||
reject(new Error(`Failed to parse server url from output: ${line}`));
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
resolve(match[1]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onStderr = (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
};
|
||||
|
||||
const onExit = (code: number | null) => {
|
||||
cleanup();
|
||||
reject(new Error(`OpenCode exited with code ${code}. Output: ${output}`));
|
||||
};
|
||||
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`Timeout waiting for server to start after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout?.on('data', onStdout);
|
||||
child.stderr?.on('data', onStderr);
|
||||
child.on('exit', onExit);
|
||||
child.on('error', onError);
|
||||
});
|
||||
|
||||
return {
|
||||
url,
|
||||
close: () => {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function allocateManagedOpenCodePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
|
||||
server.once('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
server.once('listening', () => {
|
||||
const address = server.address();
|
||||
const port = address && typeof address === 'object' ? address.port : 0;
|
||||
server.close(() => {
|
||||
if (port > 0) {
|
||||
resolve(port);
|
||||
return;
|
||||
}
|
||||
reject(new Error('Failed to allocate OpenCode port'));
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(0, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager {
|
||||
// Discard unused parameter - reserved for future use (state persistence, subscriptions)
|
||||
void _context;
|
||||
let server: { url: string; close: () => void } | null = null;
|
||||
let managedApiUrlOverride: string | null = null;
|
||||
let managedPassword: string | null = null;
|
||||
let managedPasswordSource: 'user-env' | 'generated' | 'rotated' | null = null;
|
||||
const userProvidedEnvPassword = (() => {
|
||||
const normalized = (process.env.OPENCODE_SERVER_PASSWORD || '').trim();
|
||||
return isValidOpenCodePassword(normalized) ? normalized : null;
|
||||
})();
|
||||
let status: ConnectionStatus = 'disconnected';
|
||||
let lastError: string | undefined;
|
||||
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
||||
@@ -375,7 +518,48 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
return null;
|
||||
};
|
||||
|
||||
async function startInternal(workdir?: string): Promise<void> {
|
||||
const getOpenCodeAuthHeaders = (): Record<string, string> => {
|
||||
const password = (managedPassword || userProvidedEnvPassword || process.env.OPENCODE_SERVER_PASSWORD || '').trim();
|
||||
if (!password) {
|
||||
return {};
|
||||
}
|
||||
return { Authorization: buildOpenCodeAuthHeader(password) };
|
||||
};
|
||||
|
||||
const setManagedPasswordState = (
|
||||
password: string,
|
||||
source: 'user-env' | 'generated' | 'rotated'
|
||||
): string => {
|
||||
const normalized = password.trim();
|
||||
managedPassword = normalized;
|
||||
managedPasswordSource = source;
|
||||
process.env.OPENCODE_SERVER_PASSWORD = normalized;
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const ensureManagedOpenCodeServerPassword = async ({ rotateManaged = false }: { rotateManaged?: boolean } = {}): Promise<string> => {
|
||||
if (userProvidedEnvPassword) {
|
||||
return setManagedPasswordState(userProvidedEnvPassword, 'user-env');
|
||||
}
|
||||
|
||||
if (rotateManaged) {
|
||||
return setManagedPasswordState(generateSecureOpenCodePassword(), 'rotated');
|
||||
}
|
||||
|
||||
if (managedPassword && isValidOpenCodePassword(managedPassword)) {
|
||||
return setManagedPasswordState(
|
||||
managedPassword,
|
||||
managedPasswordSource || 'generated'
|
||||
);
|
||||
}
|
||||
|
||||
return setManagedPasswordState(generateSecureOpenCodePassword(), 'generated');
|
||||
};
|
||||
|
||||
async function startInternal(
|
||||
workdir?: string,
|
||||
options: { rotateManaged?: boolean } = {}
|
||||
): Promise<void> {
|
||||
startCount += 1;
|
||||
setStatus('connecting');
|
||||
lastStartAt = Date.now();
|
||||
@@ -418,18 +602,19 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
process.env.OPENCODE_BINARY = resolvedCli;
|
||||
}
|
||||
|
||||
const password = await ensureManagedOpenCodeServerPassword({
|
||||
rotateManaged: options.rotateManaged === true,
|
||||
});
|
||||
process.env.OPENCODE_SERVER_PASSWORD = password;
|
||||
|
||||
// SDK spawns `opencode serve` in current process cwd.
|
||||
// Some OpenCode endpoints behave differently based on server process cwd,
|
||||
// so ensure we start it from the workspace directory.
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(workingDirectory);
|
||||
server = await createOpencodeServer({
|
||||
hostname: '127.0.0.1',
|
||||
port: 0,
|
||||
timeout: READY_CHECK_TIMEOUT_MS,
|
||||
signal: undefined,
|
||||
});
|
||||
const port = await allocateManagedOpenCodePort();
|
||||
server = await spawnManagedOpenCodeServer(workingDirectory, port, READY_CHECK_TIMEOUT_MS);
|
||||
} finally {
|
||||
try {
|
||||
process.chdir(originalCwd);
|
||||
@@ -440,7 +625,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
|
||||
if (server && server.url) {
|
||||
// Validate readiness for the current workspace context.
|
||||
const ready = await waitForReady(server.url, READY_CHECK_TIMEOUT_MS);
|
||||
const ready = await waitForReady(server.url, READY_CHECK_TIMEOUT_MS, getOpenCodeAuthHeaders());
|
||||
lastReadyElapsedMs = ready.elapsedMs;
|
||||
lastReadyAttempts = ready.attempts;
|
||||
if (ready.ok) {
|
||||
@@ -496,7 +681,6 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
server = null;
|
||||
}
|
||||
|
||||
// SDK's proc.kill() only kills the Node wrapper, not the actual opencode binary.
|
||||
// Kill any process listening on our port to clean up orphaned children.
|
||||
if (portToKill) {
|
||||
try {
|
||||
@@ -530,7 +714,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
restartCount += 1;
|
||||
await stopInternal();
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
await startInternal();
|
||||
await startInternal(undefined, { rotateManaged: true });
|
||||
}
|
||||
|
||||
async function start(workdir?: string): Promise<void> {
|
||||
@@ -541,7 +725,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
}
|
||||
}
|
||||
lastStartAttempts = 1;
|
||||
pendingOperation = startInternal(workdir);
|
||||
pendingOperation = startInternal(workdir, { rotateManaged: true });
|
||||
try {
|
||||
await pendingOperation;
|
||||
} finally {
|
||||
@@ -603,31 +787,37 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
setWorkingDirectory,
|
||||
getStatus: () => status,
|
||||
getApiUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
getWorkingDirectory: () => workingDirectory,
|
||||
isCliAvailable: () => !cliMissing,
|
||||
getDebugInfo: () => ({
|
||||
mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed',
|
||||
status,
|
||||
lastError,
|
||||
workingDirectory,
|
||||
cliAvailable: !cliMissing,
|
||||
cliPath,
|
||||
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
|
||||
configuredPort,
|
||||
detectedPort,
|
||||
apiPrefix: '',
|
||||
apiPrefixDetected: true,
|
||||
startCount,
|
||||
restartCount,
|
||||
lastStartAt,
|
||||
lastConnectedAt,
|
||||
lastExitCode,
|
||||
serverUrl: getApiUrl(),
|
||||
lastReadyElapsedMs,
|
||||
lastReadyAttempts,
|
||||
lastStartAttempts,
|
||||
version,
|
||||
}),
|
||||
getDebugInfo: () => {
|
||||
const secureConnection = Boolean(getOpenCodeAuthHeaders().Authorization);
|
||||
return {
|
||||
mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed',
|
||||
status,
|
||||
lastError,
|
||||
workingDirectory,
|
||||
cliAvailable: !cliMissing,
|
||||
cliPath,
|
||||
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
|
||||
configuredPort,
|
||||
detectedPort,
|
||||
apiPrefix: '',
|
||||
apiPrefixDetected: true,
|
||||
startCount,
|
||||
restartCount,
|
||||
lastStartAt,
|
||||
lastConnectedAt,
|
||||
lastExitCode,
|
||||
serverUrl: getApiUrl(),
|
||||
lastReadyElapsedMs,
|
||||
lastReadyAttempts,
|
||||
lastStartAttempts,
|
||||
version,
|
||||
secureConnection,
|
||||
authSource: managedPasswordSource || (userProvidedEnvPassword ? 'user-env' : null),
|
||||
};
|
||||
},
|
||||
onStatusChange(callback) {
|
||||
listeners.add(callback);
|
||||
callback(status, lastError);
|
||||
|
||||
@@ -191,11 +191,13 @@ export const startGlobalEventWatcher = async (
|
||||
}
|
||||
|
||||
const url = buildOpenCodeUrl('/global/event', baseUrl);
|
||||
const authHeaders = manager.getOpenCodeAuthHeaders();
|
||||
upstream = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
...authHeaders,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -303,6 +303,19 @@ const decodeBase64 = (value: string): Uint8Array => {
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const isNullBodyStatus = (status: number): boolean => status === 204 || status === 205 || status === 304;
|
||||
|
||||
const buildProxiedResponse = (
|
||||
proxied: { status: number; headers: Record<string, string>; bodyBase64?: string }
|
||||
): Response => {
|
||||
if (isNullBodyStatus(proxied.status)) {
|
||||
return new Response(null, { status: proxied.status, headers: proxied.headers });
|
||||
}
|
||||
|
||||
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
|
||||
return new Response(body, { status: proxied.status, headers: proxied.headers });
|
||||
};
|
||||
|
||||
const encodeBase64 = (bytes: Uint8Array): string => {
|
||||
const CHUNK = 0x8000;
|
||||
let binary = '';
|
||||
@@ -375,6 +388,47 @@ const isSessionMessageApiPath = (pathname: string) => /^\/api\/session\/[^/]+\/m
|
||||
|
||||
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
||||
const pathname = url.pathname;
|
||||
const normalizedPathname = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname;
|
||||
const method = ((init?.method || 'GET') as string).toUpperCase();
|
||||
|
||||
if (normalizedPathname === '/api/sessions/snapshot' && method === 'GET') {
|
||||
return new Response(JSON.stringify({
|
||||
statusSessions: {},
|
||||
attentionSessions: {},
|
||||
serverTime: Date.now(),
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (/^\/api\/sessions\/[^/]+\/(view|unview)$/.test(normalizedPathname) && method === 'POST') {
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedPathname === '/api/tts/status' && method === 'GET') {
|
||||
return new Response(JSON.stringify({ available: false }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedPathname === '/api/tts/say/status' && method === 'GET') {
|
||||
return new Response(JSON.stringify({ available: false, voices: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if ((pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak' || pathname === '/api/tts/summarize') && method === 'POST') {
|
||||
return new Response(JSON.stringify({ error: 'TTS endpoints are not available in VS Code runtime' }), {
|
||||
status: 501,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Health endpoints: reflect actual connection status
|
||||
if (pathname === '/health' || pathname === '/api/health') {
|
||||
@@ -792,8 +846,7 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) {
|
||||
const bodyText = await extractBodyText(input, init, method);
|
||||
const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText });
|
||||
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
|
||||
const response = new Response(body, { status: proxied.status, headers: proxied.headers });
|
||||
const response = buildProxiedResponse(proxied);
|
||||
recordBootstrapFetch(targetUrl.pathname, response.ok);
|
||||
maybeHideLoadingOverlay();
|
||||
return response;
|
||||
@@ -801,8 +854,7 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
|
||||
const bodyBase64 = await extractBodyBase64(input, init, method);
|
||||
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 });
|
||||
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
|
||||
const response = new Response(body, { status: proxied.status, headers: proxied.headers });
|
||||
const response = buildProxiedResponse(proxied);
|
||||
recordBootstrapFetch(targetUrl.pathname, response.ok);
|
||||
maybeHideLoadingOverlay();
|
||||
return response;
|
||||
|
||||
@@ -450,6 +450,23 @@ function isProcessRunning(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestServerShutdown(port) {
|
||||
if (!Number.isFinite(port) || port <= 0) return false;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 1500);
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${port}/api/system/shutdown`, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
});
|
||||
return resp.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
const commands = {
|
||||
async serve(options) {
|
||||
options.port = await resolveAvailablePort(options.port);
|
||||
@@ -675,6 +692,7 @@ const commands = {
|
||||
console.log(`Stopping OpenChamber (PID: ${targetInstance.pid}, Port: ${targetInstance.port})...`);
|
||||
|
||||
try {
|
||||
await requestServerShutdown(targetInstance.port);
|
||||
process.kill(targetInstance.pid, 'SIGTERM');
|
||||
|
||||
let attempts = 0;
|
||||
@@ -709,6 +727,7 @@ const commands = {
|
||||
console.log(` Stopping instance on port ${instance.port} (PID: ${instance.pid})...`);
|
||||
|
||||
try {
|
||||
await requestServerShutdown(instance.port);
|
||||
process.kill(instance.pid, 'SIGTERM');
|
||||
|
||||
let attempts = 0;
|
||||
@@ -814,6 +833,7 @@ const commands = {
|
||||
|
||||
// Stop the instance
|
||||
try {
|
||||
await requestServerShutdown(instance.port);
|
||||
process.kill(instance.pid, 'SIGTERM');
|
||||
// Wait for it to stop
|
||||
let attempts = 0;
|
||||
@@ -970,6 +990,7 @@ const commands = {
|
||||
console.log(`\nStopping ${runningInstances.length} running instance(s) before update...`);
|
||||
for (const instance of runningInstances) {
|
||||
try {
|
||||
await requestServerShutdown(instance.port);
|
||||
process.kill(instance.pid, 'SIGTERM');
|
||||
let attempts = 0;
|
||||
while (isProcessRunning(instance.pid) && attempts < 20) {
|
||||
|
||||
+228
-18
@@ -3,6 +3,7 @@ import path from 'path';
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { fileURLToPath } from 'url';
|
||||
import os from 'os';
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
pruneRebindTimestamps,
|
||||
readTerminalInputWsControlFrame,
|
||||
} from './lib/terminal-input-ws-protocol.js';
|
||||
import { createOpencodeServer } from '@opencode-ai/sdk/server';
|
||||
import webPush from 'web-push';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -2734,15 +2734,30 @@ const getHmrState = () => {
|
||||
globalThis[HMR_STATE_KEY] = {
|
||||
openCodeProcess: null,
|
||||
openCodePort: null,
|
||||
openCodeWorkingDirectory: os.homedir(),
|
||||
isShuttingDown: false,
|
||||
signalsAttached: false,
|
||||
};
|
||||
openCodeWorkingDirectory: os.homedir(),
|
||||
isShuttingDown: false,
|
||||
signalsAttached: false,
|
||||
userProvidedOpenCodePassword: undefined,
|
||||
openCodeAuthPassword: null,
|
||||
openCodeAuthSource: null,
|
||||
};
|
||||
}
|
||||
return globalThis[HMR_STATE_KEY];
|
||||
};
|
||||
const hmrState = getHmrState();
|
||||
|
||||
const normalizeOpenCodePassword = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
if (typeof hmrState.userProvidedOpenCodePassword === 'undefined') {
|
||||
const initialPassword = normalizeOpenCodePassword(process.env.OPENCODE_SERVER_PASSWORD);
|
||||
hmrState.userProvidedOpenCodePassword = initialPassword || null;
|
||||
}
|
||||
|
||||
// Non-HMR state (safe to reset on reload)
|
||||
let healthCheckInterval = null;
|
||||
let server = null;
|
||||
@@ -2762,6 +2777,18 @@ let exitOnShutdown = true;
|
||||
let uiAuthController = null;
|
||||
let cloudflareTunnelController = null;
|
||||
let terminalInputWsServer = null;
|
||||
const userProvidedOpenCodePassword =
|
||||
typeof hmrState.userProvidedOpenCodePassword === 'string' && hmrState.userProvidedOpenCodePassword.length > 0
|
||||
? hmrState.userProvidedOpenCodePassword
|
||||
: null;
|
||||
let openCodeAuthPassword =
|
||||
typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0
|
||||
? hmrState.openCodeAuthPassword
|
||||
: userProvidedOpenCodePassword;
|
||||
let openCodeAuthSource =
|
||||
typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0
|
||||
? hmrState.openCodeAuthSource
|
||||
: (userProvidedOpenCodePassword ? 'user-env' : null);
|
||||
|
||||
// Sync helper - call after modifying any HMR state variable
|
||||
const syncToHmrState = () => {
|
||||
@@ -2770,6 +2797,8 @@ const syncToHmrState = () => {
|
||||
hmrState.isShuttingDown = isShuttingDown;
|
||||
hmrState.signalsAttached = signalsAttached;
|
||||
hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory;
|
||||
hmrState.openCodeAuthPassword = openCodeAuthPassword;
|
||||
hmrState.openCodeAuthSource = openCodeAuthSource;
|
||||
};
|
||||
|
||||
// Sync helper - call to restore state from HMR (e.g., on module reload)
|
||||
@@ -2779,6 +2808,14 @@ const syncFromHmrState = () => {
|
||||
isShuttingDown = hmrState.isShuttingDown;
|
||||
signalsAttached = hmrState.signalsAttached;
|
||||
openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory;
|
||||
openCodeAuthPassword =
|
||||
typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0
|
||||
? hmrState.openCodeAuthPassword
|
||||
: userProvidedOpenCodePassword;
|
||||
openCodeAuthSource =
|
||||
typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0
|
||||
? hmrState.openCodeAuthSource
|
||||
: (userProvidedOpenCodePassword ? 'user-env' : null);
|
||||
};
|
||||
|
||||
// Module-level variables that shadow HMR state
|
||||
@@ -2858,18 +2895,13 @@ const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' ||
|
||||
const ENV_DESKTOP_NOTIFY = process.env.OPENCHAMBER_DESKTOP_NOTIFY === 'true';
|
||||
|
||||
// OpenCode server authentication (Basic Auth with username "opencode")
|
||||
const ENV_OPENCODE_SERVER_PASSWORD = (() => {
|
||||
const pwd = process.env.OPENCODE_SERVER_PASSWORD;
|
||||
return typeof pwd === 'string' && pwd.length > 0 ? pwd : null;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Returns auth headers for OpenCode server requests if OPENCODE_SERVER_PASSWORD is set.
|
||||
* Uses Basic Auth with username "opencode" and the password from the env variable.
|
||||
*/
|
||||
function getOpenCodeAuthHeaders() {
|
||||
// Re-read from env each time in case it wasn't set at module load (HMR issue)
|
||||
const password = ENV_OPENCODE_SERVER_PASSWORD || process.env.OPENCODE_SERVER_PASSWORD;
|
||||
const password = normalizeOpenCodePassword(openCodeAuthPassword || process.env.OPENCODE_SERVER_PASSWORD || '');
|
||||
|
||||
if (!password) {
|
||||
return {};
|
||||
@@ -2879,6 +2911,60 @@ function getOpenCodeAuthHeaders() {
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
}
|
||||
|
||||
function isOpenCodeConnectionSecure() {
|
||||
return Object.prototype.hasOwnProperty.call(getOpenCodeAuthHeaders(), 'Authorization');
|
||||
}
|
||||
|
||||
function generateSecureOpenCodePassword() {
|
||||
return crypto
|
||||
.randomBytes(32)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function isValidOpenCodePassword(password) {
|
||||
return typeof password === 'string' && password.trim().length > 0;
|
||||
}
|
||||
|
||||
function setOpenCodeAuthState(password, source) {
|
||||
const normalized = normalizeOpenCodePassword(password);
|
||||
if (!isValidOpenCodePassword(normalized)) {
|
||||
openCodeAuthPassword = null;
|
||||
openCodeAuthSource = null;
|
||||
delete process.env.OPENCODE_SERVER_PASSWORD;
|
||||
syncToHmrState();
|
||||
return null;
|
||||
}
|
||||
|
||||
openCodeAuthPassword = normalized;
|
||||
openCodeAuthSource = source;
|
||||
process.env.OPENCODE_SERVER_PASSWORD = normalized;
|
||||
syncToHmrState();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function ensureLocalOpenCodeServerPassword({ rotateManaged = false } = {}) {
|
||||
if (isValidOpenCodePassword(userProvidedOpenCodePassword)) {
|
||||
return setOpenCodeAuthState(userProvidedOpenCodePassword, 'user-env');
|
||||
}
|
||||
|
||||
if (rotateManaged) {
|
||||
const rotatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'rotated');
|
||||
console.log('Rotated secure password for managed local OpenCode instance');
|
||||
return rotatedPassword;
|
||||
}
|
||||
|
||||
if (isValidOpenCodePassword(openCodeAuthPassword)) {
|
||||
return setOpenCodeAuthState(openCodeAuthPassword, openCodeAuthSource || 'generated');
|
||||
}
|
||||
|
||||
const generatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'generated');
|
||||
console.log('Generated secure password for managed local OpenCode instance');
|
||||
return generatedPassword;
|
||||
}
|
||||
|
||||
const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix(
|
||||
process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || ''
|
||||
);
|
||||
@@ -4422,7 +4508,6 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
function killProcessOnPort(port) {
|
||||
if (!port) return;
|
||||
try {
|
||||
// SDK's proc.kill() only kills the Node wrapper, not the actual opencode binary.
|
||||
// Kill any process listening on our port to clean up orphaned children.
|
||||
const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000 });
|
||||
const output = result.stdout || '';
|
||||
@@ -4442,27 +4527,143 @@ function killProcessOnPort(port) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createManagedOpenCodeServerProcess({
|
||||
hostname,
|
||||
port,
|
||||
timeout,
|
||||
cwd,
|
||||
env,
|
||||
}) {
|
||||
const binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
|
||||
const args = ['serve', '--hostname', hostname, '--port', String(port)];
|
||||
const child = spawn(binary, args, {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const url = await new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
let done = false;
|
||||
const finish = (handler, value) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
child.stdout?.off('data', onStdout);
|
||||
child.stderr?.off('data', onStderr);
|
||||
child.off('exit', onExit);
|
||||
child.off('error', onError);
|
||||
handler(value);
|
||||
};
|
||||
|
||||
const onStdout = (chunk) => {
|
||||
output += chunk.toString();
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('opencode server listening')) continue;
|
||||
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
|
||||
if (!match) {
|
||||
finish(reject, new Error(`Failed to parse server url from output: ${line}`));
|
||||
return;
|
||||
}
|
||||
finish(resolve, match[1]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onStderr = (chunk) => {
|
||||
output += chunk.toString();
|
||||
};
|
||||
|
||||
const onExit = (code) => {
|
||||
finish(reject, new Error(`OpenCode exited with code ${code}. Output: ${output}`));
|
||||
};
|
||||
|
||||
const onError = (error) => {
|
||||
finish(reject, error);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish(reject, new Error(`Timeout waiting for OpenCode to start after ${timeout}ms`));
|
||||
}, timeout);
|
||||
|
||||
child.stdout?.on('data', onStdout);
|
||||
child.stderr?.on('data', onStderr);
|
||||
child.on('exit', onExit);
|
||||
child.on('error', onError);
|
||||
});
|
||||
|
||||
return {
|
||||
url,
|
||||
close() {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveManagedOpenCodePort(requestedPort) {
|
||||
if (typeof requestedPort === 'number' && Number.isFinite(requestedPort) && requestedPort > 0) {
|
||||
return requestedPort;
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
const cleanup = () => {
|
||||
server.removeAllListeners('error');
|
||||
server.removeAllListeners('listening');
|
||||
};
|
||||
|
||||
server.once('error', (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
server.once('listening', () => {
|
||||
const address = server.address();
|
||||
const port = address && typeof address === 'object' ? address.port : 0;
|
||||
server.close(() => {
|
||||
cleanup();
|
||||
if (port > 0) {
|
||||
resolve(port);
|
||||
return;
|
||||
}
|
||||
reject(new Error('Failed to allocate OpenCode port'));
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(0, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
async function startOpenCode() {
|
||||
const desiredPort = ENV_CONFIGURED_OPENCODE_PORT ?? 0;
|
||||
const spawnPort = await resolveManagedOpenCodePort(desiredPort);
|
||||
console.log(
|
||||
desiredPort > 0
|
||||
? `Starting OpenCode on requested port ${desiredPort}...`
|
||||
: 'Starting OpenCode with dynamic port assignment...'
|
||||
: `Starting OpenCode on allocated port ${spawnPort}...`
|
||||
);
|
||||
// Note: SDK starts in current process CWD. openCodeWorkingDirectory is tracked but not used for spawn in SDK.
|
||||
|
||||
await applyOpencodeBinaryFromSettings();
|
||||
ensureOpencodeCliEnv();
|
||||
const openCodePassword = await ensureLocalOpenCodeServerPassword({
|
||||
rotateManaged: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const serverInstance = await createOpencodeServer({
|
||||
const serverInstance = await createManagedOpenCodeServerProcess({
|
||||
hostname: '127.0.0.1',
|
||||
port: desiredPort,
|
||||
port: spawnPort,
|
||||
timeout: 30000,
|
||||
cwd: openCodeWorkingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
// Pass minimal config to avoid pollution, but inherit PATH etc
|
||||
}
|
||||
OPENCODE_SERVER_PASSWORD: openCodePassword,
|
||||
},
|
||||
});
|
||||
|
||||
if (!serverInstance || !serverInstance.url) {
|
||||
@@ -5350,6 +5551,8 @@ async function main(options = {}) {
|
||||
timestamp: new Date().toISOString(),
|
||||
openCodePort: openCodePort,
|
||||
openCodeRunning: Boolean(openCodePort && isOpenCodeReady && !isRestartingOpenCode),
|
||||
openCodeSecureConnection: isOpenCodeConnectionSecure(),
|
||||
openCodeAuthSource: openCodeAuthSource || null,
|
||||
openCodeApiPrefix: '',
|
||||
openCodeApiPrefixDetected: true,
|
||||
isOpenCodeReady,
|
||||
@@ -5362,6 +5565,13 @@ async function main(options = {}) {
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/system/shutdown', (req, res) => {
|
||||
res.json({ ok: true });
|
||||
gracefulShutdown({ exitProcess: false }).catch((error) => {
|
||||
console.error('Shutdown request failed:', error?.message || error);
|
||||
});
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (
|
||||
req.path.startsWith('/api/config/agents') ||
|
||||
|
||||
Reference in New Issue
Block a user