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() {
|
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;
|
let cleaning = false;
|
||||||
|
|
||||||
|
|||||||
Generated
+2
@@ -1187,6 +1187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3411,6 +3412,7 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ devtools = ["tauri/devtools"]
|
|||||||
anyhow = "1.0.86"
|
anyhow = "1.0.86"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
log = "0.4.28"
|
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 = { version = "1.0.210", features = ["derive"] }
|
||||||
serde_json = "1.0.143"
|
serde_json = "1.0.143"
|
||||||
tauri = { version = "2.9.4", features = ["macos-private-api"] }
|
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;
|
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");
|
let mut guard = state.child.lock().expect("sidecar mutex");
|
||||||
if let Some(child) = guard.take() {
|
if let Some(child) = guard.take() {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
}
|
}
|
||||||
|
*state.url.lock().expect("sidecar url mutex") = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_local_url(port: u16) -> String {
|
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()])
|
.args(["--port", &port.to_string()])
|
||||||
.env("OPENCHAMBER_HOST", "127.0.0.1")
|
.env("OPENCHAMBER_HOST", "127.0.0.1")
|
||||||
.env("OPENCHAMBER_DIST_DIR", dist_dir.clone())
|
.env("OPENCHAMBER_DIST_DIR", dist_dir.clone())
|
||||||
|
.env("OPENCHAMBER_RUNTIME", "desktop")
|
||||||
.env("OPENCHAMBER_DESKTOP_NOTIFY", "true")
|
.env("OPENCHAMBER_DESKTOP_NOTIFY", "true")
|
||||||
.env("PATH", augmented_path.clone())
|
.env("PATH", augmented_path.clone())
|
||||||
.env("NO_PROXY", no_proxy)
|
.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() {
|
let (rx, child) = match cmd.spawn() {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(err) => {
|
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';
|
return sessions.find((session) => session.id === currentSessionId)?.title || 'Session';
|
||||||
}, [currentSessionId, sessions]);
|
}, [currentSessionId, sessions]);
|
||||||
const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
|
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 openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
|
||||||
const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
|
const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
|
||||||
() => (typeof window !== 'undefined'
|
() => (typeof window !== 'undefined'
|
||||||
@@ -129,10 +142,37 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentSessionId && !newSessionDraftOpen && currentView === 'chat') {
|
if (currentView !== 'chat') {
|
||||||
setCurrentView('sessions');
|
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(() => {
|
const handleBackToSessions = React.useCallback(() => {
|
||||||
setCurrentView('sessions');
|
setCurrentView('sessions');
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ export const useEventStream = () => {
|
|||||||
// Note: needs_attention logic is now handled by the server
|
// Note: needs_attention logic is now handled by the server
|
||||||
// Server maintains authoritative state based on view tracking and message events
|
// Server maintains authoritative state based on view tracking and message events
|
||||||
|
|
||||||
if (prevType !== nextType) {
|
if (process.env.NODE_ENV === 'development' && prevType !== nextType) {
|
||||||
try {
|
try {
|
||||||
console.info('[SESSION-STATUS]', {
|
console.info('[SESSION-STATUS]', {
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -264,11 +264,29 @@ export const debugUtils = {
|
|||||||
const resp = await fetch('/api/health');
|
const resp = await fetch('/api/health');
|
||||||
const contentType = resp.headers.get('content-type') || '';
|
const contentType = resp.headers.get('content-type') || '';
|
||||||
const body = await safeText(resp);
|
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 = {
|
opencodeHealth = {
|
||||||
status: resp.status,
|
status: resp.status,
|
||||||
ok: resp.ok,
|
ok: resp.ok,
|
||||||
contentType,
|
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,
|
preview: body ? body.slice(0, 120) : null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ type ProbeResult = {
|
|||||||
type OpenChamberHealthSnapshot = {
|
type OpenChamberHealthSnapshot = {
|
||||||
openCodePort?: unknown;
|
openCodePort?: unknown;
|
||||||
openCodeRunning?: unknown;
|
openCodeRunning?: unknown;
|
||||||
|
openCodeSecureConnection?: unknown;
|
||||||
|
openCodeAuthSource?: unknown;
|
||||||
isOpenCodeReady?: unknown;
|
isOpenCodeReady?: unknown;
|
||||||
lastOpenCodeError?: unknown;
|
lastOpenCodeError?: unknown;
|
||||||
opencodeBinaryResolved?: 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> => {
|
export const buildOpenCodeStatusReport = async (): Promise<string> => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '(unknown)';
|
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(`Directory: ${directory || '(none)'}`);
|
||||||
lines.push(`Platform: ${platform}`);
|
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') {
|
if (typeof window !== 'undefined') {
|
||||||
const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__;
|
const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__;
|
||||||
if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) {
|
if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) {
|
||||||
|
|||||||
@@ -1707,12 +1707,14 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (messageIndex === -1) {
|
if (messageIndex === -1) {
|
||||||
console.info("[MESSAGE-DEBUG] updateMessageInfo: messageIndex === -1", {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
sessionId,
|
console.info("[MESSAGE-DEBUG] updateMessageInfo: messageIndex === -1", {
|
||||||
messageId,
|
sessionId,
|
||||||
messageInfo,
|
messageId,
|
||||||
existingCount: normalizedSessionMessages.length,
|
messageInfo,
|
||||||
});
|
existingCount: normalizedSessionMessages.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (normalizedSessionMessages.length > 0) {
|
if (normalizedSessionMessages.length > 0) {
|
||||||
const firstMessage = normalizedSessionMessages[0];
|
const firstMessage = normalizedSessionMessages[0];
|
||||||
|
|||||||
@@ -176,7 +176,10 @@ export class AgentManagerPanelProvider {
|
|||||||
let response: Response;
|
let response: Response;
|
||||||
let wrapAsGlobal = false;
|
let wrapAsGlobal = false;
|
||||||
|
|
||||||
const requestHeaders = this._buildSseHeaders(headers || {});
|
const requestHeaders = this._buildSseHeaders({
|
||||||
|
...(headers || {}),
|
||||||
|
...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}),
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(targetUrl, {
|
response = await fetch(targetUrl, {
|
||||||
|
|||||||
@@ -204,7 +204,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
let response: Response;
|
let response: Response;
|
||||||
let wrapAsGlobal = false;
|
let wrapAsGlobal = false;
|
||||||
|
|
||||||
const requestHeaders = this._buildSseHeaders(headers || {});
|
const requestHeaders = this._buildSseHeaders({
|
||||||
|
...(headers || {}),
|
||||||
|
...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}),
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(targetUrl, {
|
response = await fetch(targetUrl, {
|
||||||
|
|||||||
@@ -199,7 +199,10 @@ export class SessionEditorPanelProvider {
|
|||||||
let response: Response;
|
let response: Response;
|
||||||
let wrapAsGlobal = false;
|
let wrapAsGlobal = false;
|
||||||
|
|
||||||
const requestHeaders = this._buildSseHeaders(headers || {});
|
const requestHeaders = this._buildSseHeaders({
|
||||||
|
...(headers || {}),
|
||||||
|
...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}),
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(targetUrl, {
|
response = await fetch(targetUrl, {
|
||||||
|
|||||||
@@ -806,7 +806,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
|
|
||||||
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
||||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
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.
|
// Ensure SSE requests are negotiated correctly.
|
||||||
if (normalizedPath === '/event' || normalizedPath === '/global/event') {
|
if (normalizedPath === '/event' || normalizedPath === '/global/event') {
|
||||||
@@ -875,7 +878,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
|
|
||||||
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
||||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
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 {
|
try {
|
||||||
const response = await fetch(targetUrl, {
|
const response = await fetch(targetUrl, {
|
||||||
|
|||||||
@@ -362,10 +362,11 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
const openCodeAuthHeaders = openCodeManager?.getOpenCodeAuthHeaders() || {};
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(input, {
|
const resp = await fetch(input, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json', ...openCodeAuthHeaders },
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
const elapsedMs = Date.now() - startedAt;
|
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: ${debug.mode} (starts=${debug.startCount}, restarts=${debug.restartCount})`
|
||||||
: `OpenCode mode: (unknown)`,
|
: `OpenCode mode: (unknown)`,
|
||||||
debug
|
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)`,
|
: `OpenCode CLI path: (unknown)`,
|
||||||
debug
|
debug
|
||||||
? `OpenCode detected port: ${debug.detectedPort ?? '(none)'}`
|
? `OpenCode detected port: ${debug.detectedPort ?? '(none)'}`
|
||||||
|
|||||||
+236
-46
@@ -2,12 +2,13 @@ import * as vscode from 'vscode';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as net from 'net';
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { spawnSync } 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;
|
const READY_CHECK_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||||
|
|
||||||
export type OpenCodeDebugInfo = {
|
export type OpenCodeDebugInfo = {
|
||||||
@@ -32,6 +33,8 @@ export type OpenCodeDebugInfo = {
|
|||||||
lastReadyAttempts: number | null;
|
lastReadyAttempts: number | null;
|
||||||
lastStartAttempts: number | null;
|
lastStartAttempts: number | null;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
|
secureConnection: boolean;
|
||||||
|
authSource: 'user-env' | 'generated' | 'rotated' | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface OpenCodeManager {
|
export interface OpenCodeManager {
|
||||||
@@ -41,12 +44,43 @@ export interface OpenCodeManager {
|
|||||||
setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>;
|
setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>;
|
||||||
getStatus(): ConnectionStatus;
|
getStatus(): ConnectionStatus;
|
||||||
getApiUrl(): string | null;
|
getApiUrl(): string | null;
|
||||||
|
getOpenCodeAuthHeaders(): Record<string, string>;
|
||||||
getWorkingDirectory(): string;
|
getWorkingDirectory(): string;
|
||||||
isCliAvailable(): boolean;
|
isCliAvailable(): boolean;
|
||||||
getDebugInfo(): OpenCodeDebugInfo;
|
getDebugInfo(): OpenCodeDebugInfo;
|
||||||
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
|
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 {
|
function resolvePortFromUrl(url: string): number | null {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
@@ -110,13 +144,8 @@ function resolveOpencodeCliPath(): string | null {
|
|||||||
|
|
||||||
const sharedFromOpenChamber = (() => {
|
const sharedFromOpenChamber = (() => {
|
||||||
try {
|
try {
|
||||||
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
|
const settings = readOpenChamberSettings();
|
||||||
const raw = fs.readFileSync(settingsPath, 'utf8');
|
const candidate = settings.opencodeBinary;
|
||||||
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;
|
|
||||||
if (typeof candidate !== 'string') {
|
if (typeof candidate !== 'string') {
|
||||||
return null;
|
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 outputChannel = vscode.window.createOutputChannel('OpenChamberManager');
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const candidates = getCandidateBaseUrls(serverUrl);
|
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 url = new URL(`${baseUrl}/global/health`);
|
||||||
const res = await fetch(url.toString(), {
|
const res = await fetch(url.toString(), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json', ...authHeaders },
|
||||||
signal: controller.signal,
|
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 };
|
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 {
|
export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager {
|
||||||
// Discard unused parameter - reserved for future use (state persistence, subscriptions)
|
|
||||||
void _context;
|
void _context;
|
||||||
let server: { url: string; close: () => void } | null = null;
|
let server: { url: string; close: () => void } | null = null;
|
||||||
let managedApiUrlOverride: string | 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 status: ConnectionStatus = 'disconnected';
|
||||||
let lastError: string | undefined;
|
let lastError: string | undefined;
|
||||||
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
||||||
@@ -375,7 +518,48 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
return null;
|
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;
|
startCount += 1;
|
||||||
setStatus('connecting');
|
setStatus('connecting');
|
||||||
lastStartAt = Date.now();
|
lastStartAt = Date.now();
|
||||||
@@ -418,18 +602,19 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
process.env.OPENCODE_BINARY = resolvedCli;
|
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.
|
// SDK spawns `opencode serve` in current process cwd.
|
||||||
// Some OpenCode endpoints behave differently based on server process cwd,
|
// Some OpenCode endpoints behave differently based on server process cwd,
|
||||||
// so ensure we start it from the workspace directory.
|
// so ensure we start it from the workspace directory.
|
||||||
const originalCwd = process.cwd();
|
const originalCwd = process.cwd();
|
||||||
try {
|
try {
|
||||||
process.chdir(workingDirectory);
|
process.chdir(workingDirectory);
|
||||||
server = await createOpencodeServer({
|
const port = await allocateManagedOpenCodePort();
|
||||||
hostname: '127.0.0.1',
|
server = await spawnManagedOpenCodeServer(workingDirectory, port, READY_CHECK_TIMEOUT_MS);
|
||||||
port: 0,
|
|
||||||
timeout: READY_CHECK_TIMEOUT_MS,
|
|
||||||
signal: undefined,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
process.chdir(originalCwd);
|
process.chdir(originalCwd);
|
||||||
@@ -440,7 +625,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
|
|
||||||
if (server && server.url) {
|
if (server && server.url) {
|
||||||
// Validate readiness for the current workspace context.
|
// 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;
|
lastReadyElapsedMs = ready.elapsedMs;
|
||||||
lastReadyAttempts = ready.attempts;
|
lastReadyAttempts = ready.attempts;
|
||||||
if (ready.ok) {
|
if (ready.ok) {
|
||||||
@@ -496,7 +681,6 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
server = null;
|
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.
|
// Kill any process listening on our port to clean up orphaned children.
|
||||||
if (portToKill) {
|
if (portToKill) {
|
||||||
try {
|
try {
|
||||||
@@ -530,7 +714,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
restartCount += 1;
|
restartCount += 1;
|
||||||
await stopInternal();
|
await stopInternal();
|
||||||
await new Promise(r => setTimeout(r, 250));
|
await new Promise(r => setTimeout(r, 250));
|
||||||
await startInternal();
|
await startInternal(undefined, { rotateManaged: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function start(workdir?: string): Promise<void> {
|
async function start(workdir?: string): Promise<void> {
|
||||||
@@ -541,7 +725,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
lastStartAttempts = 1;
|
lastStartAttempts = 1;
|
||||||
pendingOperation = startInternal(workdir);
|
pendingOperation = startInternal(workdir, { rotateManaged: true });
|
||||||
try {
|
try {
|
||||||
await pendingOperation;
|
await pendingOperation;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -603,31 +787,37 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
setWorkingDirectory,
|
setWorkingDirectory,
|
||||||
getStatus: () => status,
|
getStatus: () => status,
|
||||||
getApiUrl,
|
getApiUrl,
|
||||||
|
getOpenCodeAuthHeaders,
|
||||||
getWorkingDirectory: () => workingDirectory,
|
getWorkingDirectory: () => workingDirectory,
|
||||||
isCliAvailable: () => !cliMissing,
|
isCliAvailable: () => !cliMissing,
|
||||||
getDebugInfo: () => ({
|
getDebugInfo: () => {
|
||||||
mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed',
|
const secureConnection = Boolean(getOpenCodeAuthHeaders().Authorization);
|
||||||
status,
|
return {
|
||||||
lastError,
|
mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed',
|
||||||
workingDirectory,
|
status,
|
||||||
cliAvailable: !cliMissing,
|
lastError,
|
||||||
cliPath,
|
workingDirectory,
|
||||||
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
|
cliAvailable: !cliMissing,
|
||||||
configuredPort,
|
cliPath,
|
||||||
detectedPort,
|
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
|
||||||
apiPrefix: '',
|
configuredPort,
|
||||||
apiPrefixDetected: true,
|
detectedPort,
|
||||||
startCount,
|
apiPrefix: '',
|
||||||
restartCount,
|
apiPrefixDetected: true,
|
||||||
lastStartAt,
|
startCount,
|
||||||
lastConnectedAt,
|
restartCount,
|
||||||
lastExitCode,
|
lastStartAt,
|
||||||
serverUrl: getApiUrl(),
|
lastConnectedAt,
|
||||||
lastReadyElapsedMs,
|
lastExitCode,
|
||||||
lastReadyAttempts,
|
serverUrl: getApiUrl(),
|
||||||
lastStartAttempts,
|
lastReadyElapsedMs,
|
||||||
version,
|
lastReadyAttempts,
|
||||||
}),
|
lastStartAttempts,
|
||||||
|
version,
|
||||||
|
secureConnection,
|
||||||
|
authSource: managedPasswordSource || (userProvidedEnvPassword ? 'user-env' : null),
|
||||||
|
};
|
||||||
|
},
|
||||||
onStatusChange(callback) {
|
onStatusChange(callback) {
|
||||||
listeners.add(callback);
|
listeners.add(callback);
|
||||||
callback(status, lastError);
|
callback(status, lastError);
|
||||||
|
|||||||
@@ -191,11 +191,13 @@ export const startGlobalEventWatcher = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = buildOpenCodeUrl('/global/event', baseUrl);
|
const url = buildOpenCodeUrl('/global/event', baseUrl);
|
||||||
|
const authHeaders = manager.getOpenCodeAuthHeaders();
|
||||||
upstream = await fetch(url, {
|
upstream = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'text/event-stream',
|
Accept: 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Connection: 'keep-alive',
|
Connection: 'keep-alive',
|
||||||
|
...authHeaders,
|
||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -303,6 +303,19 @@ const decodeBase64 = (value: string): Uint8Array => {
|
|||||||
return bytes;
|
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 encodeBase64 = (bytes: Uint8Array): string => {
|
||||||
const CHUNK = 0x8000;
|
const CHUNK = 0x8000;
|
||||||
let binary = '';
|
let binary = '';
|
||||||
@@ -375,6 +388,47 @@ const isSessionMessageApiPath = (pathname: string) => /^\/api\/session\/[^/]+\/m
|
|||||||
|
|
||||||
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
||||||
const pathname = url.pathname;
|
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
|
// Health endpoints: reflect actual connection status
|
||||||
if (pathname === '/health' || pathname === '/api/health') {
|
if (pathname === '/health' || pathname === '/api/health') {
|
||||||
@@ -792,8 +846,7 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|||||||
if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) {
|
if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) {
|
||||||
const bodyText = await extractBodyText(input, init, method);
|
const bodyText = await extractBodyText(input, init, method);
|
||||||
const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText });
|
const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText });
|
||||||
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
|
const response = buildProxiedResponse(proxied);
|
||||||
const response = new Response(body, { status: proxied.status, headers: proxied.headers });
|
|
||||||
recordBootstrapFetch(targetUrl.pathname, response.ok);
|
recordBootstrapFetch(targetUrl.pathname, response.ok);
|
||||||
maybeHideLoadingOverlay();
|
maybeHideLoadingOverlay();
|
||||||
return response;
|
return response;
|
||||||
@@ -801,8 +854,7 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|||||||
|
|
||||||
const bodyBase64 = await extractBodyBase64(input, init, method);
|
const bodyBase64 = await extractBodyBase64(input, init, method);
|
||||||
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 });
|
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 });
|
||||||
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
|
const response = buildProxiedResponse(proxied);
|
||||||
const response = new Response(body, { status: proxied.status, headers: proxied.headers });
|
|
||||||
recordBootstrapFetch(targetUrl.pathname, response.ok);
|
recordBootstrapFetch(targetUrl.pathname, response.ok);
|
||||||
maybeHideLoadingOverlay();
|
maybeHideLoadingOverlay();
|
||||||
return response;
|
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 = {
|
const commands = {
|
||||||
async serve(options) {
|
async serve(options) {
|
||||||
options.port = await resolveAvailablePort(options.port);
|
options.port = await resolveAvailablePort(options.port);
|
||||||
@@ -675,6 +692,7 @@ const commands = {
|
|||||||
console.log(`Stopping OpenChamber (PID: ${targetInstance.pid}, Port: ${targetInstance.port})...`);
|
console.log(`Stopping OpenChamber (PID: ${targetInstance.pid}, Port: ${targetInstance.port})...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await requestServerShutdown(targetInstance.port);
|
||||||
process.kill(targetInstance.pid, 'SIGTERM');
|
process.kill(targetInstance.pid, 'SIGTERM');
|
||||||
|
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
@@ -709,6 +727,7 @@ const commands = {
|
|||||||
console.log(` Stopping instance on port ${instance.port} (PID: ${instance.pid})...`);
|
console.log(` Stopping instance on port ${instance.port} (PID: ${instance.pid})...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await requestServerShutdown(instance.port);
|
||||||
process.kill(instance.pid, 'SIGTERM');
|
process.kill(instance.pid, 'SIGTERM');
|
||||||
|
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
@@ -814,6 +833,7 @@ const commands = {
|
|||||||
|
|
||||||
// Stop the instance
|
// Stop the instance
|
||||||
try {
|
try {
|
||||||
|
await requestServerShutdown(instance.port);
|
||||||
process.kill(instance.pid, 'SIGTERM');
|
process.kill(instance.pid, 'SIGTERM');
|
||||||
// Wait for it to stop
|
// Wait for it to stop
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
@@ -970,6 +990,7 @@ const commands = {
|
|||||||
console.log(`\nStopping ${runningInstances.length} running instance(s) before update...`);
|
console.log(`\nStopping ${runningInstances.length} running instance(s) before update...`);
|
||||||
for (const instance of runningInstances) {
|
for (const instance of runningInstances) {
|
||||||
try {
|
try {
|
||||||
|
await requestServerShutdown(instance.port);
|
||||||
process.kill(instance.pid, 'SIGTERM');
|
process.kill(instance.pid, 'SIGTERM');
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
while (isProcessRunning(instance.pid) && attempts < 20) {
|
while (isProcessRunning(instance.pid) && attempts < 20) {
|
||||||
|
|||||||
+228
-18
@@ -3,6 +3,7 @@ import path from 'path';
|
|||||||
import { spawn, spawnSync } from 'child_process';
|
import { spawn, spawnSync } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
import net from 'net';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
@@ -20,7 +21,6 @@ import {
|
|||||||
pruneRebindTimestamps,
|
pruneRebindTimestamps,
|
||||||
readTerminalInputWsControlFrame,
|
readTerminalInputWsControlFrame,
|
||||||
} from './lib/terminal-input-ws-protocol.js';
|
} from './lib/terminal-input-ws-protocol.js';
|
||||||
import { createOpencodeServer } from '@opencode-ai/sdk/server';
|
|
||||||
import webPush from 'web-push';
|
import webPush from 'web-push';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -2734,15 +2734,30 @@ const getHmrState = () => {
|
|||||||
globalThis[HMR_STATE_KEY] = {
|
globalThis[HMR_STATE_KEY] = {
|
||||||
openCodeProcess: null,
|
openCodeProcess: null,
|
||||||
openCodePort: null,
|
openCodePort: null,
|
||||||
openCodeWorkingDirectory: os.homedir(),
|
openCodeWorkingDirectory: os.homedir(),
|
||||||
isShuttingDown: false,
|
isShuttingDown: false,
|
||||||
signalsAttached: false,
|
signalsAttached: false,
|
||||||
};
|
userProvidedOpenCodePassword: undefined,
|
||||||
|
openCodeAuthPassword: null,
|
||||||
|
openCodeAuthSource: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return globalThis[HMR_STATE_KEY];
|
return globalThis[HMR_STATE_KEY];
|
||||||
};
|
};
|
||||||
const hmrState = getHmrState();
|
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)
|
// Non-HMR state (safe to reset on reload)
|
||||||
let healthCheckInterval = null;
|
let healthCheckInterval = null;
|
||||||
let server = null;
|
let server = null;
|
||||||
@@ -2762,6 +2777,18 @@ let exitOnShutdown = true;
|
|||||||
let uiAuthController = null;
|
let uiAuthController = null;
|
||||||
let cloudflareTunnelController = null;
|
let cloudflareTunnelController = null;
|
||||||
let terminalInputWsServer = 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
|
// Sync helper - call after modifying any HMR state variable
|
||||||
const syncToHmrState = () => {
|
const syncToHmrState = () => {
|
||||||
@@ -2770,6 +2797,8 @@ const syncToHmrState = () => {
|
|||||||
hmrState.isShuttingDown = isShuttingDown;
|
hmrState.isShuttingDown = isShuttingDown;
|
||||||
hmrState.signalsAttached = signalsAttached;
|
hmrState.signalsAttached = signalsAttached;
|
||||||
hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory;
|
hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory;
|
||||||
|
hmrState.openCodeAuthPassword = openCodeAuthPassword;
|
||||||
|
hmrState.openCodeAuthSource = openCodeAuthSource;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync helper - call to restore state from HMR (e.g., on module reload)
|
// Sync helper - call to restore state from HMR (e.g., on module reload)
|
||||||
@@ -2779,6 +2808,14 @@ const syncFromHmrState = () => {
|
|||||||
isShuttingDown = hmrState.isShuttingDown;
|
isShuttingDown = hmrState.isShuttingDown;
|
||||||
signalsAttached = hmrState.signalsAttached;
|
signalsAttached = hmrState.signalsAttached;
|
||||||
openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory;
|
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
|
// 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';
|
const ENV_DESKTOP_NOTIFY = process.env.OPENCHAMBER_DESKTOP_NOTIFY === 'true';
|
||||||
|
|
||||||
// OpenCode server authentication (Basic Auth with username "opencode")
|
// 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.
|
* 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.
|
* Uses Basic Auth with username "opencode" and the password from the env variable.
|
||||||
*/
|
*/
|
||||||
function getOpenCodeAuthHeaders() {
|
function getOpenCodeAuthHeaders() {
|
||||||
// Re-read from env each time in case it wasn't set at module load (HMR issue)
|
const password = normalizeOpenCodePassword(openCodeAuthPassword || process.env.OPENCODE_SERVER_PASSWORD || '');
|
||||||
const password = ENV_OPENCODE_SERVER_PASSWORD || process.env.OPENCODE_SERVER_PASSWORD;
|
|
||||||
|
|
||||||
if (!password) {
|
if (!password) {
|
||||||
return {};
|
return {};
|
||||||
@@ -2879,6 +2911,60 @@ function getOpenCodeAuthHeaders() {
|
|||||||
return { Authorization: `Basic ${credentials}` };
|
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(
|
const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix(
|
||||||
process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || ''
|
process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || ''
|
||||||
);
|
);
|
||||||
@@ -4422,7 +4508,6 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|||||||
function killProcessOnPort(port) {
|
function killProcessOnPort(port) {
|
||||||
if (!port) return;
|
if (!port) return;
|
||||||
try {
|
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.
|
// Kill any process listening on our port to clean up orphaned children.
|
||||||
const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000 });
|
const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000 });
|
||||||
const output = result.stdout || '';
|
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() {
|
async function startOpenCode() {
|
||||||
const desiredPort = ENV_CONFIGURED_OPENCODE_PORT ?? 0;
|
const desiredPort = ENV_CONFIGURED_OPENCODE_PORT ?? 0;
|
||||||
|
const spawnPort = await resolveManagedOpenCodePort(desiredPort);
|
||||||
console.log(
|
console.log(
|
||||||
desiredPort > 0
|
desiredPort > 0
|
||||||
? `Starting OpenCode on requested port ${desiredPort}...`
|
? `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();
|
await applyOpencodeBinaryFromSettings();
|
||||||
ensureOpencodeCliEnv();
|
ensureOpencodeCliEnv();
|
||||||
|
const openCodePassword = await ensureLocalOpenCodeServerPassword({
|
||||||
|
rotateManaged: true,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const serverInstance = await createOpencodeServer({
|
const serverInstance = await createManagedOpenCodeServerProcess({
|
||||||
hostname: '127.0.0.1',
|
hostname: '127.0.0.1',
|
||||||
port: desiredPort,
|
port: spawnPort,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
|
cwd: openCodeWorkingDirectory,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
// Pass minimal config to avoid pollution, but inherit PATH etc
|
OPENCODE_SERVER_PASSWORD: openCodePassword,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!serverInstance || !serverInstance.url) {
|
if (!serverInstance || !serverInstance.url) {
|
||||||
@@ -5350,6 +5551,8 @@ async function main(options = {}) {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
openCodePort: openCodePort,
|
openCodePort: openCodePort,
|
||||||
openCodeRunning: Boolean(openCodePort && isOpenCodeReady && !isRestartingOpenCode),
|
openCodeRunning: Boolean(openCodePort && isOpenCodeReady && !isRestartingOpenCode),
|
||||||
|
openCodeSecureConnection: isOpenCodeConnectionSecure(),
|
||||||
|
openCodeAuthSource: openCodeAuthSource || null,
|
||||||
openCodeApiPrefix: '',
|
openCodeApiPrefix: '',
|
||||||
openCodeApiPrefixDetected: true,
|
openCodeApiPrefixDetected: true,
|
||||||
isOpenCodeReady,
|
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) => {
|
app.use((req, res, next) => {
|
||||||
if (
|
if (
|
||||||
req.path.startsWith('/api/config/agents') ||
|
req.path.startsWith('/api/config/agents') ||
|
||||||
|
|||||||
Reference in New Issue
Block a user