diff --git a/.gitignore b/.gitignore index c4a043c0..5aab6019 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,8 @@ token.json # Test results test-results/ playwright-report/ +test-results-web-shell/ +playwright-report-web-shell/ artifacts/ tmp/ diff --git a/docs/CONTROL_SURFACE.md b/docs/CONTROL_SURFACE.md index 1858f2cd..05abbb41 100644 --- a/docs/CONTROL_SURFACE.md +++ b/docs/CONTROL_SURFACE.md @@ -69,3 +69,41 @@ Control Surface 与 transport 解耦: 相关架构总规范见 `docs/ARCHITECTURE.md` 与 `docs/LANDING_ARCHITECTURE.md`。 +## 7. Remote Worker(SSH-first,Phase 4 v0) + +> 目标:不改变 contracts 的前提下,把 Control Surface 通过 SSH tunnel 带到远端。 + +最小工作流(示例): + +1. 先构建一次(保证 `out/main/worker.js` 可用): + +```bash +pnpm build +``` + +2. 在远端机器启动 worker(默认只监听 `127.0.0.1`,需要 token): + +```bash +node out/main/worker.js --port 16661 --token --approve-root +``` + +3. 本地通过 SSH 建立 tunnel: + +```bash +ssh -L 16661:127.0.0.1:16661 +``` + +4. 本地 CLI 通过 tunnel 调用同一套 contracts: + +```bash +opencove ping --endpoint http://127.0.0.1:16661 --token +``` + +可选(用于验证/调试): + +- Worker 启动后可访问 `http://127.0.0.1:/` 打开最小 web shell,通过 `Authorization: Bearer ` 调用 `/invoke`。 + +原则: + +- 远端 worker 默认不暴露公网端口;SSH/tunnel 是保守默认路径。 +- CLI/Web/Desktop 只作为 client;durable truth 与副作用由 worker owner。 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 4769cb33..e27306f1 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -68,6 +68,7 @@ export default defineConfig({ rollupOptions: { input: { index: resolve(__dirname, 'src/app/main/index.ts'), + worker: resolve(__dirname, 'src/app/worker/index.ts'), ptyHost: resolve(__dirname, 'src/platform/process/ptyHost/entry.ts'), }, }, diff --git a/package.json b/package.json index dcd95669..0cc69426 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "test": "vitest", "test:coverage": "vitest run --coverage", "test:e2e": "node scripts/test-e2e-with-window-fallback.mjs", + "test:e2e:web-shell": "node scripts/test-e2e-web-shell.mjs", "lint": "oxlint .", "lint:fix": "oxlint --fix .", "opencove": "node src/app/cli/opencove.mjs", diff --git a/playwright.web-shell.config.ts b/playwright.web-shell.config.ts new file mode 100644 index 00000000..6525cf3e --- /dev/null +++ b/playwright.web-shell.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from '@playwright/test' + +const baseURL = process.env['OPENCOVE_WEB_SHELL_BASE_URL'] + +export default defineConfig({ + testDir: './tests/e2e-web-shell', + testMatch: '**/*.spec.ts', + timeout: 60_000, + expect: { + timeout: 15_000, + }, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report-web-shell' }]], + outputDir: './test-results-web-shell', + projects: [ + { + name: 'chromium', + use: { + baseURL, + screenshot: 'only-on-failure', + video: 'retain-on-failure', + trace: 'retain-on-failure', + }, + }, + ], +}) diff --git a/scripts/test-e2e-web-shell.mjs b/scripts/test-e2e-web-shell.mjs new file mode 100644 index 00000000..f8e2f601 --- /dev/null +++ b/scripts/test-e2e-web-shell.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { createInterface } from 'node:readline' +import { pathToFileURL } from 'node:url' + +const PNPM_COMMAND = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' + +function isTruthyEnv(rawValue) { + if (!rawValue) { + return false + } + + return rawValue === '1' || rawValue.toLowerCase() === 'true' +} + +function runCommand(args, env = process.env) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(PNPM_COMMAND, args, { + cwd: process.cwd(), + env, + shell: process.platform === 'win32', + stdio: 'inherit', + windowsHide: true, + }) + + child.on('error', rejectPromise) + child.on('close', code => { + resolvePromise(typeof code === 'number' ? code : 1) + }) + }) +} + +async function startWorker(options) { + const child = spawn( + process.execPath, + [ + options.workerPath, + '--hostname', + '127.0.0.1', + '--port', + '0', + '--user-data', + options.userDataPath, + '--token', + options.token, + '--approve-root', + options.approvedRoot, + ], + { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }, + ) + + child.stderr?.on('data', chunk => { + process.stderr.write(chunk) + }) + + const ready = new Promise((resolvePromise, rejectPromise) => { + if (!child.stdout) { + rejectPromise(new Error('[web-shell-e2e] Worker stdout not available')) + return + } + + const rl = createInterface({ input: child.stdout }) + + rl.once('line', line => { + rl.close() + + try { + const info = JSON.parse(line) + const hostname = info && typeof info.hostname === 'string' ? info.hostname : null + const port = info && typeof info.port === 'number' ? info.port : null + if (!hostname || !port) { + rejectPromise(new Error('[web-shell-e2e] Worker ready payload is invalid')) + return + } + + resolvePromise({ hostname, port }) + } catch (error) { + rejectPromise(error) + } + }) + + child.once('exit', code => { + rl.close() + rejectPromise(new Error(`[web-shell-e2e] Worker exited before ready (code=${code ?? 1})`)) + }) + }) + + const info = await ready + return { child, info } +} + +async function stopWorker(child) { + if (!child || child.killed) { + return + } + + await new Promise(resolvePromise => { + const timeout = setTimeout(() => { + try { + child.kill('SIGKILL') + } catch { + child.kill() + } + }, 3_000) + + child.once('exit', () => { + clearTimeout(timeout) + resolvePromise() + }) + + try { + child.kill('SIGTERM') + } catch { + child.kill() + } + }) +} + +async function main() { + const forwardedArgs = process.argv.slice(2) + + if (!isTruthyEnv(process.env['OPENCOVE_E2E_SKIP_BUILD'])) { + const buildCode = await runCommand(['build']) + if (buildCode !== 0) { + process.exit(buildCode) + } + } + + const workerPath = resolve(process.cwd(), 'out', 'main', 'worker.js') + const userDataPath = await mkdtemp(resolve(tmpdir(), 'opencove-web-shell-userdata-')) + const workspaceRoot = await mkdtemp(resolve(tmpdir(), 'opencove-web-shell-workspace-')) + const testFilePath = resolve(workspaceRoot, 'hello.txt') + await writeFile(testFilePath, 'hello from opencove web shell e2e\n', 'utf8') + + const token = randomBytes(16).toString('hex') + const testFileUri = pathToFileURL(testFilePath).toString() + + let worker = null + + let exitCode = 1 + try { + const started = await startWorker({ + workerPath, + userDataPath, + approvedRoot: workspaceRoot, + token, + }) + + worker = started.child + const baseUrl = `http://${started.info.hostname}:${started.info.port}` + + const testEnv = { + ...process.env, + OPENCOVE_WEB_SHELL_BASE_URL: baseUrl, + OPENCOVE_WEB_SHELL_TOKEN: token, + OPENCOVE_WEB_SHELL_TEST_FILE_URI: testFileUri, + } + + const code = await runCommand( + [ + 'exec', + 'playwright', + 'test', + '--config', + 'playwright.web-shell.config.ts', + ...forwardedArgs, + ], + testEnv, + ) + + exitCode = code + } finally { + await stopWorker(worker) + await rm(userDataPath, { recursive: true, force: true }) + await rm(workspaceRoot, { recursive: true, force: true }) + } + + process.exit(exitCode) +} + +void main().catch(error => { + const message = error instanceof Error ? (error.stack ?? error.message) : String(error) + process.stderr.write(`${message}\n`) + process.exit(1) +}) diff --git a/src/app/cli/args.mjs b/src/app/cli/args.mjs index 8301c2d0..e2cf2a5e 100644 --- a/src/app/cli/args.mjs +++ b/src/app/cli/args.mjs @@ -43,6 +43,11 @@ export function stripGlobalOptions(argv) { continue } + if (arg === '--endpoint' || arg === '--token') { + index += 1 + continue + } + args.push(arg) } diff --git a/src/app/cli/opencove.mjs b/src/app/cli/opencove.mjs index 5ade7676..a435ad19 100755 --- a/src/app/cli/opencove.mjs +++ b/src/app/cli/opencove.mjs @@ -1,5 +1,10 @@ #!/usr/bin/env node +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + import { readFlagValue, requireFlagValue, resolveTimeoutMs, stripGlobalOptions } from './args.mjs' import { resolveConnectionInfo } from './connection.mjs' import { invokeAndPrint } from './invoke.mjs' @@ -21,6 +26,8 @@ async function main() { const argv = process.argv.slice(2) const wantsHelp = argv.includes('--help') || argv.includes('-h') const pretty = argv.includes('--pretty') + const endpoint = readFlagValue(argv, '--endpoint') + const token = readFlagValue(argv, '--token') const timeoutMs = resolveTimeoutMs(argv) const args = stripGlobalOptions(argv) @@ -31,7 +38,91 @@ async function main() { process.exit(command.length === 0 ? 2 : 0) } - const connection = await resolveConnectionInfo() + if (command === 'worker' && args[1] === 'start') { + const cliDir = resolve(fileURLToPath(new URL('.', import.meta.url))) + const repoRoot = resolve(cliDir, '../../..') + const workerPath = resolve(repoRoot, 'out', 'main', 'worker.js') + + if (!existsSync(workerPath)) { + process.stderr.write('[opencove] worker is not built. Run `pnpm build` first.\n') + process.exit(2) + } + + const workerArgs = [] + const hostname = readFlagValue(argv, '--hostname') + const port = readFlagValue(argv, '--port') + const userData = readFlagValue(argv, '--user-data') + const approvedRoots = [] + + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] !== '--approve-root') { + continue + } + + const next = argv[index + 1] + if (!next || next.startsWith('-')) { + continue + } + + const normalized = next.trim() + if (normalized.length > 0) { + approvedRoots.push(normalized) + } + } + + if (hostname) { + workerArgs.push('--hostname', hostname) + } + + if (port) { + workerArgs.push('--port', port) + } + + if (userData) { + workerArgs.push('--user-data', userData) + } + + if (token) { + workerArgs.push('--token', token) + } + + for (const root of approvedRoots) { + workerArgs.push('--approve-root', root) + } + + const child = spawn(process.execPath, [workerPath, ...workerArgs], { stdio: 'inherit' }) + child.on('exit', code => { + process.exit(code ?? 1) + }) + + return + } + + const connection = endpoint + ? (() => { + if (!token) { + process.stderr.write('[opencove] missing required flag: --token \n') + process.exit(2) + } + + let parsed + try { + parsed = new URL(endpoint.includes('://') ? endpoint : `http://${endpoint}`) + } catch { + process.stderr.write(`[opencove] invalid endpoint: ${endpoint}\n`) + process.exit(2) + } + + const port = Number(parsed.port) + if (!Number.isFinite(port) || port <= 0) { + process.stderr.write(`[opencove] endpoint must include port: ${endpoint}\n`) + process.exit(2) + } + + return { hostname: parsed.hostname, port, token } + })() + : await resolveConnectionInfo() + if (!connection) { process.stderr.write( '[opencove] control surface is not running (no valid connection info found).\n', @@ -49,6 +140,16 @@ async function main() { return } + if (command === 'worker' && args[1] === 'status') { + await invokeAndPrint( + connection, + { kind: 'query', id: 'system.ping', payload: null }, + { pretty, timeoutMs }, + ) + + return + } + if (command === 'project' && args[1] === 'list') { await invokeAndPrint( connection, diff --git a/src/app/cli/usage.mjs b/src/app/cli/usage.mjs index b24c620a..54f41b30 100644 --- a/src/app/cli/usage.mjs +++ b/src/app/cli/usage.mjs @@ -3,7 +3,7 @@ import { DEFAULT_TIMEOUT_MS } from './constants.mjs' export function printUsage() { process.stdout.write(`OpenCove CLI (dev)\n\n`) process.stdout.write(`Usage:\n`) - process.stdout.write(` opencove ping [--pretty]\n`) + process.stdout.write(` opencove ping [--pretty] [--endpoint ] [--token ]\n`) process.stdout.write(` opencove project list [--pretty]\n`) process.stdout.write(` opencove space list [--project ] [--pretty]\n\n`) process.stdout.write(` opencove space get --space [--pretty]\n\n`) @@ -22,8 +22,20 @@ export function printUsage() { process.stdout.write(` opencove session get --session [--pretty]\n`) process.stdout.write(` opencove session final --session [--pretty]\n`) process.stdout.write(` opencove session kill --session [--pretty]\n\n`) + process.stdout.write( + ` opencove worker start [--hostname ] [--port ] [--user-data ] [--token ] [--approve-root ]\n`, + ) + process.stdout.write( + ` opencove worker status [--endpoint ] [--token ] [--pretty]\n\n`, + ) process.stdout.write(`Global Options:\n`) process.stdout.write(` --pretty Pretty-print JSON output\n`) + process.stdout.write( + ` --endpoint Override control surface base URL (for tunnels/remote)\n`, + ) + process.stdout.write( + ` --token Override control surface bearer token (required with --endpoint)\n`, + ) process.stdout.write( ` --timeout Override control surface request timeout (default ${DEFAULT_TIMEOUT_MS}ms)\n\n`, ) diff --git a/src/app/main/controlSurface/controlSurfaceHttpServer.ts b/src/app/main/controlSurface/controlSurfaceHttpServer.ts new file mode 100644 index 00000000..47de6984 --- /dev/null +++ b/src/app/main/controlSurface/controlSurfaceHttpServer.ts @@ -0,0 +1,459 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { randomBytes, timingSafeEqual } from 'node:crypto' +import { createAppErrorDescriptor } from '../../../shared/errors/appError' +import type { ControlSurfaceInvokeResult } from '../../../shared/contracts/controlSurface' +import type { PersistenceStore } from '../../../platform/persistence/sqlite/PersistenceStore' +import { createPersistenceStore } from '../../../platform/persistence/sqlite/PersistenceStore' +import { createControlSurface } from './controlSurface' +import { normalizeInvokeRequest } from './validate' +import type { ControlSurfaceContext } from './types' +import { registerSystemHandlers } from './handlers/systemHandlers' +import { registerProjectHandlers } from './handlers/projectHandlers' +import { registerSpaceHandlers } from './handlers/spaceHandlers' +import { registerFilesystemHandlers } from './handlers/filesystemHandlers' +import type { ApprovedWorkspaceStore } from '../../../contexts/workspace/infrastructure/approval/ApprovedWorkspaceStoreCore' +import { registerWorktreeHandlers } from './handlers/worktreeHandlers' +import { registerSessionHandlers } from './handlers/sessionHandlers' +import type { ControlSurfacePtyRuntime } from './handlers/sessionPtyRuntime' + +const DEFAULT_CONTROL_SURFACE_HOSTNAME = '127.0.0.1' +const DEFAULT_CONTROL_SURFACE_CONNECTION_FILE = 'control-surface.json' +const CONTROL_SURFACE_CONNECTION_VERSION = 1 as const + +export interface ControlSurfaceConnectionInfo { + version: typeof CONTROL_SURFACE_CONNECTION_VERSION + pid: number + hostname: string + port: number + token: string + createdAt: string +} + +export interface ControlSurfaceServerDisposable { + dispose: () => void +} + +export interface ControlSurfaceHttpServerInstance extends ControlSurfaceServerDisposable { + ready: Promise +} + +function buildUnauthorizedResult(): ControlSurfaceInvokeResult { + return { + __opencoveControlEnvelope: true, + ok: false, + error: createAppErrorDescriptor('control_surface.unauthorized'), + } +} + +async function readJsonBody(req: IncomingMessage): Promise { + return await new Promise((resolveBody, reject) => { + const chunks: Buffer[] = [] + + req.on('data', chunk => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + }) + + req.once('error', reject) + req.once('end', () => { + const raw = Buffer.concat(chunks).toString('utf8') + if (raw.trim().length === 0) { + resolveBody(null) + return + } + + try { + resolveBody(JSON.parse(raw)) + } catch (error) { + reject(error) + } + }) + }) +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status + res.setHeader('content-type', 'application/json; charset=utf-8') + res.end(`${JSON.stringify(body)}\n`) +} + +function normalizeBearerToken(value: string | undefined): string | null { + if (!value) { + return null + } + + const trimmed = value.trim() + if (trimmed.length === 0) { + return null + } + + if (!trimmed.toLowerCase().startsWith('bearer ')) { + return null + } + + const token = trimmed.slice('bearer '.length).trim() + return token.length > 0 ? token : null +} + +function tokensEqual(a: string, b: string): boolean { + // Avoid leaking token length timing. + const aBytes = Buffer.from(a, 'utf8') + const bBytes = Buffer.from(b, 'utf8') + if (aBytes.length !== bBytes.length) { + return false + } + + return timingSafeEqual(aBytes, bBytes) +} + +async function writeConnectionFile( + userDataPath: string, + info: ControlSurfaceConnectionInfo, + fileName: string, +): Promise { + const filePath = resolve(userDataPath, fileName) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, `${JSON.stringify(info)}\n`, { encoding: 'utf8', mode: 0o600 }) +} + +async function removeConnectionFile(userDataPath: string, fileName: string): Promise { + const filePath = resolve(userDataPath, fileName) + await rm(filePath, { force: true }) +} + +export function registerControlSurfaceHttpServer(options: { + userDataPath: string + dbPath?: string + hostname?: string + port?: number + token?: string + connectionFileName?: string + approvedWorkspaces: ApprovedWorkspaceStore + ptyRuntime: ControlSurfacePtyRuntime & { dispose?: () => void } + ownsPtyRuntime?: boolean + enableWebShell?: boolean +}): ControlSurfaceHttpServerInstance { + const token = options.token ?? randomBytes(32).toString('base64url') + const hostname = options.hostname ?? DEFAULT_CONTROL_SURFACE_HOSTNAME + const port = options.port ?? 0 + const connectionFileName = options.connectionFileName ?? DEFAULT_CONTROL_SURFACE_CONNECTION_FILE + + const ctx: ControlSurfaceContext = { + now: () => new Date(), + } + + let persistenceStorePromise: Promise | null = null + const getPersistenceStore = async (): Promise => { + if (persistenceStorePromise) { + return await persistenceStorePromise + } + + const dbPath = options.dbPath ?? resolve(options.userDataPath, 'opencove.db') + const nextPromise = createPersistenceStore({ dbPath }).catch(error => { + if (persistenceStorePromise === nextPromise) { + persistenceStorePromise = null + } + + throw error + }) + + persistenceStorePromise = nextPromise + return await persistenceStorePromise + } + + const controlSurface = createControlSurface() + registerSystemHandlers(controlSurface) + registerProjectHandlers(controlSurface, getPersistenceStore) + registerSpaceHandlers(controlSurface, getPersistenceStore) + registerFilesystemHandlers(controlSurface, { + approvedWorkspaces: options.approvedWorkspaces, + }) + registerWorktreeHandlers(controlSurface, { + approvedWorkspaces: options.approvedWorkspaces, + getPersistenceStore, + }) + registerSessionHandlers(controlSurface, { + approvedWorkspaces: options.approvedWorkspaces, + getPersistenceStore, + ptyRuntime: options.ptyRuntime, + }) + + let closed = false + let closeRequested = false + let pendingConnectionWrite: Promise | null = null + + let resolveReady: ((info: ControlSurfaceConnectionInfo) => void) | null = null + let rejectReady: ((error: Error) => void) | null = null + const ready = new Promise((resolvePromise, rejectPromise) => { + resolveReady = resolvePromise + rejectReady = rejectPromise + }) + + const server = createServer(async (req, res) => { + if (closed) { + res.statusCode = 503 + res.end() + return + } + + if (options.enableWebShell && req.method === 'GET' && req.url) { + const url = new URL(req.url, 'http://localhost') + if (url.pathname === '/') { + const host = typeof req.headers.host === 'string' ? req.headers.host : '' + res.statusCode = 200 + res.setHeader('content-type', 'text/html; charset=utf-8') + res.end(` + + + + + OpenCove Worker Shell + + + +

OpenCove Worker Shell

+
+
+
POST /invoke
+
Host: ${host}
+
+ +
+ + + +
+ +
+ + + +
+ +
+
Payload (JSON)
+ +
+ +
+
Response
+

+      
+
+ + + +`) + return + } + } + + if (req.method !== 'POST' || req.url !== '/invoke') { + res.statusCode = 404 + res.end() + return + } + + const presentedToken = normalizeBearerToken(req.headers.authorization) + if (!presentedToken || !tokensEqual(presentedToken, token)) { + sendJson(res, 401, buildUnauthorizedResult()) + return + } + + try { + const body = await readJsonBody(req) + const request = normalizeInvokeRequest(body) + const result = await controlSurface.invoke(ctx, request) + sendJson(res, 200, result) + } catch (error) { + sendJson(res, 400, { + __opencoveControlEnvelope: true, + ok: false, + error: createAppErrorDescriptor('common.invalid_input', { + debugMessage: error instanceof Error ? error.message : 'Invalid request payload.', + }), + }) + } + }) + + server.on('error', error => { + const detail = error instanceof Error ? `${error.name}: ${error.message}` : 'unknown error' + process.stderr.write(`[opencove] control surface server error: ${detail}\n`) + rejectReady?.(new Error(detail)) + rejectReady = null + resolveReady = null + }) + + server.listen(port, hostname, () => { + const address = server.address() + if (!address || typeof address === 'string') { + const detail = '[opencove] control surface server did not return a TCP address.' + process.stderr.write(`${detail}\n`) + rejectReady?.(new Error(detail)) + rejectReady = null + resolveReady = null + return + } + + const info: ControlSurfaceConnectionInfo = { + version: CONTROL_SURFACE_CONNECTION_VERSION, + pid: process.pid, + hostname, + port: address.port, + token, + createdAt: new Date().toISOString(), + } + + pendingConnectionWrite = writeConnectionFile( + options.userDataPath, + info, + connectionFileName, + ).catch(error => { + const detail = error instanceof Error ? `${error.name}: ${error.message}` : 'unknown error' + process.stderr.write( + `[opencove] failed to write control surface connection file: ${detail}\n`, + ) + }) + + resolveReady?.(info) + resolveReady = null + rejectReady = null + }) + + return { + ready, + dispose: () => { + if (closeRequested) { + return + } + + closeRequested = true + + void (async () => { + const storePromise = persistenceStorePromise + persistenceStorePromise = null + + try { + await pendingConnectionWrite + } catch { + // ignore + } + + try { + await removeConnectionFile(options.userDataPath, connectionFileName) + } catch { + // ignore + } + + if (closed) { + return + } + + closed = true + + await new Promise(resolveClose => { + server.close(() => resolveClose()) + }) + + if (options.ownsPtyRuntime) { + try { + options.ptyRuntime.dispose?.() + } catch { + // ignore + } + } + + try { + if (storePromise) { + const store = await storePromise + store.dispose() + } + } catch { + // ignore + } + })() + }, + } +} diff --git a/src/app/main/controlSurface/handlers/sessionHandlers.ts b/src/app/main/controlSurface/handlers/sessionHandlers.ts index f6776d20..d7e7f78a 100644 --- a/src/app/main/controlSurface/handlers/sessionHandlers.ts +++ b/src/app/main/controlSurface/handlers/sessionHandlers.ts @@ -4,7 +4,6 @@ import type { PersistenceStore } from '../../../../platform/persistence/sqlite/P import { normalizePersistedAppState } from '../../../../platform/persistence/sqlite/normalize' import type { ApprovedWorkspaceStore } from '../../../../contexts/workspace/infrastructure/approval/ApprovedWorkspaceStore' import { createAppError } from '../../../../shared/errors/appError' -import type { PtyRuntime } from '../../../../contexts/terminal/presentation/main-ipc/runtime' import { buildAgentLaunchCommand } from '../../../../contexts/agent/infrastructure/cli/AgentCommandFactory' import { resolveAgentCliInvocation } from '../../../../contexts/agent/infrastructure/cli/AgentCliInvocation' import { locateAgentResumeSessionId } from '../../../../contexts/agent/infrastructure/cli/AgentSessionLocator' @@ -22,6 +21,7 @@ import { resolveAgentModel, type AgentProvider, } from '../../../../contexts/settings/domain/agentSettings' +import type { ControlSurfacePtyRuntime } from './sessionPtyRuntime' import type { AgentProviderId, ExecutionContextDto, @@ -246,7 +246,7 @@ export function registerSessionHandlers( deps: { approvedWorkspaces: ApprovedWorkspaceStore getPersistenceStore: () => Promise - ptyRuntime: PtyRuntime + ptyRuntime: ControlSurfacePtyRuntime }, ): void { const sessions = new Map() diff --git a/src/app/main/controlSurface/handlers/sessionPtyRuntime.ts b/src/app/main/controlSurface/handlers/sessionPtyRuntime.ts new file mode 100644 index 00000000..91bd2141 --- /dev/null +++ b/src/app/main/controlSurface/handlers/sessionPtyRuntime.ts @@ -0,0 +1,11 @@ +export interface ControlSurfacePtyRuntime { + spawnSession: (options: { + cwd: string + cols: number + rows: number + command: string + args: string[] + env?: NodeJS.ProcessEnv + }) => Promise<{ sessionId: string }> + kill: (sessionId: string) => void +} diff --git a/src/app/main/controlSurface/registerControlSurfaceServer.ts b/src/app/main/controlSurface/registerControlSurfaceServer.ts index eea827ee..e034e335 100644 --- a/src/app/main/controlSurface/registerControlSurfaceServer.ts +++ b/src/app/main/controlSurface/registerControlSurfaceServer.ts @@ -1,287 +1,29 @@ -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' -import { mkdir, rm, writeFile } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' -import { randomBytes, timingSafeEqual } from 'node:crypto' import { app } from 'electron' -import { createAppErrorDescriptor } from '../../../shared/errors/appError' -import type { ControlSurfaceInvokeResult } from '../../../shared/contracts/controlSurface' -import type { PersistenceStore } from '../../../platform/persistence/sqlite/PersistenceStore' -import { createPersistenceStore } from '../../../platform/persistence/sqlite/PersistenceStore' -import { createControlSurface } from './controlSurface' -import { normalizeInvokeRequest } from './validate' -import type { ControlSurfaceContext } from './types' -import { registerSystemHandlers } from './handlers/systemHandlers' -import { registerProjectHandlers } from './handlers/projectHandlers' -import { registerSpaceHandlers } from './handlers/spaceHandlers' -import { registerFilesystemHandlers } from './handlers/filesystemHandlers' import { createApprovedWorkspaceStore } from '../../../contexts/workspace/infrastructure/approval/ApprovedWorkspaceStore' -import { registerWorktreeHandlers } from './handlers/worktreeHandlers' -import { registerSessionHandlers } from './handlers/sessionHandlers' import { createPtyRuntime } from '../../../contexts/terminal/presentation/main-ipc/runtime' +import { + registerControlSurfaceHttpServer, + type ControlSurfaceServerDisposable, +} from './controlSurfaceHttpServer' -const CONTROL_SURFACE_HOSTNAME = '127.0.0.1' -const CONTROL_SURFACE_CONNECTION_FILE = 'control-surface.json' -const CONTROL_SURFACE_CONNECTION_VERSION = 1 as const - -export interface ControlSurfaceConnectionInfo { - version: typeof CONTROL_SURFACE_CONNECTION_VERSION - pid: number - hostname: typeof CONTROL_SURFACE_HOSTNAME - port: number - token: string - createdAt: string -} - -export interface ControlSurfaceServerDisposable { - dispose: () => void -} - -function buildUnauthorizedResult(): ControlSurfaceInvokeResult { - return { - __opencoveControlEnvelope: true, - ok: false, - error: createAppErrorDescriptor('control_surface.unauthorized'), - } -} - -async function readJsonBody(req: IncomingMessage): Promise { - return await new Promise((resolveBody, reject) => { - const chunks: Buffer[] = [] - - req.on('data', chunk => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) - }) - - req.once('error', reject) - req.once('end', () => { - const raw = Buffer.concat(chunks).toString('utf8') - if (raw.trim().length === 0) { - resolveBody(null) - return - } - - try { - resolveBody(JSON.parse(raw)) - } catch (error) { - reject(error) - } - }) - }) -} - -function sendJson(res: ServerResponse, status: number, body: unknown): void { - res.statusCode = status - res.setHeader('content-type', 'application/json; charset=utf-8') - res.end(`${JSON.stringify(body)}\n`) -} - -function normalizeBearerToken(value: string | undefined): string | null { - if (!value) { - return null - } - - const trimmed = value.trim() - if (trimmed.length === 0) { - return null - } - - if (!trimmed.toLowerCase().startsWith('bearer ')) { - return null - } - - const token = trimmed.slice('bearer '.length).trim() - return token.length > 0 ? token : null -} - -function tokensEqual(a: string, b: string): boolean { - // Avoid leaking token length timing. - const aBytes = Buffer.from(a, 'utf8') - const bBytes = Buffer.from(b, 'utf8') - if (aBytes.length !== bBytes.length) { - return false - } - - return timingSafeEqual(aBytes, bBytes) -} - -async function writeConnectionFile(info: ControlSurfaceConnectionInfo): Promise { - const userDataPath = app.getPath('userData') - const filePath = resolve(userDataPath, CONTROL_SURFACE_CONNECTION_FILE) - await mkdir(dirname(filePath), { recursive: true }) - await writeFile(filePath, `${JSON.stringify(info)}\n`, { encoding: 'utf8', mode: 0o600 }) -} - -async function removeConnectionFile(): Promise { - const userDataPath = app.getPath('userData') - const filePath = resolve(userDataPath, CONTROL_SURFACE_CONNECTION_FILE) - await rm(filePath, { force: true }) -} +export type { + ControlSurfaceConnectionInfo, + ControlSurfaceServerDisposable, +} from './controlSurfaceHttpServer' export function registerControlSurfaceServer(deps?: { approvedWorkspaces?: ReturnType ptyRuntime?: ReturnType }): ControlSurfaceServerDisposable { - const token = randomBytes(32).toString('base64url') - - const ctx: ControlSurfaceContext = { - now: () => new Date(), - } - - let persistenceStorePromise: Promise | null = null - const getPersistenceStore = async (): Promise => { - if (persistenceStorePromise) { - return await persistenceStorePromise - } - - const dbPath = resolve(app.getPath('userData'), 'opencove.db') - const nextPromise = createPersistenceStore({ dbPath }).catch(error => { - if (persistenceStorePromise === nextPromise) { - persistenceStorePromise = null - } - - throw error - }) - - persistenceStorePromise = nextPromise - return await persistenceStorePromise - } - - const controlSurface = createControlSurface() + const userDataPath = app.getPath('userData') const approvedWorkspaces = deps?.approvedWorkspaces ?? createApprovedWorkspaceStore() const ownsPtyRuntime = !deps?.ptyRuntime const ptyRuntime = deps?.ptyRuntime ?? createPtyRuntime() - registerSystemHandlers(controlSurface) - registerProjectHandlers(controlSurface, getPersistenceStore) - registerSpaceHandlers(controlSurface, getPersistenceStore) - registerFilesystemHandlers(controlSurface, { - approvedWorkspaces, - }) - registerWorktreeHandlers(controlSurface, { - approvedWorkspaces, - getPersistenceStore, - }) - registerSessionHandlers(controlSurface, { + + return registerControlSurfaceHttpServer({ + userDataPath, approvedWorkspaces, - getPersistenceStore, ptyRuntime, + ownsPtyRuntime, }) - - let closed = false - let closeRequested = false - let pendingConnectionWrite: Promise | null = null - - const server = createServer(async (req, res) => { - if (closed) { - res.statusCode = 503 - res.end() - return - } - - if (req.method !== 'POST' || req.url !== '/invoke') { - res.statusCode = 404 - res.end() - return - } - - const presentedToken = normalizeBearerToken(req.headers.authorization) - if (!presentedToken || !tokensEqual(presentedToken, token)) { - sendJson(res, 401, buildUnauthorizedResult()) - return - } - - try { - const body = await readJsonBody(req) - const request = normalizeInvokeRequest(body) - const result = await controlSurface.invoke(ctx, request) - sendJson(res, 200, result) - } catch (error) { - sendJson(res, 400, { - __opencoveControlEnvelope: true, - ok: false, - error: createAppErrorDescriptor('common.invalid_input', { - debugMessage: error instanceof Error ? error.message : 'Invalid request payload.', - }), - }) - } - }) - - server.on('error', error => { - const detail = error instanceof Error ? `${error.name}: ${error.message}` : 'unknown error' - process.stderr.write(`[opencove] control surface server error: ${detail}\n`) - }) - - server.listen(0, CONTROL_SURFACE_HOSTNAME, () => { - const address = server.address() - if (!address || typeof address === 'string') { - process.stderr.write('[opencove] control surface server did not return a TCP address.\n') - return - } - - const info: ControlSurfaceConnectionInfo = { - version: CONTROL_SURFACE_CONNECTION_VERSION, - pid: process.pid, - hostname: CONTROL_SURFACE_HOSTNAME, - port: address.port, - token, - createdAt: new Date().toISOString(), - } - - pendingConnectionWrite = writeConnectionFile(info).catch(error => { - const detail = error instanceof Error ? `${error.name}: ${error.message}` : 'unknown error' - process.stderr.write( - `[opencove] failed to write control surface connection file: ${detail}\n`, - ) - }) - }) - - return { - dispose: () => { - if (closeRequested) { - return - } - - closeRequested = true - - void (async () => { - const storePromise = persistenceStorePromise - persistenceStorePromise = null - - try { - await pendingConnectionWrite - } catch { - // ignore - } - - try { - await removeConnectionFile() - } catch { - // ignore - } - - try { - server.close(() => { - closed = true - }) - } catch { - closed = true - } - - try { - if (ownsPtyRuntime) { - ptyRuntime.dispose() - } - } catch { - // ignore - } - - storePromise - ?.then(store => { - store.dispose() - }) - .catch(() => { - // ignore - }) - })() - }, - } } diff --git a/src/app/worker/headlessPtyRuntime.ts b/src/app/worker/headlessPtyRuntime.ts new file mode 100644 index 00000000..0139a339 --- /dev/null +++ b/src/app/worker/headlessPtyRuntime.ts @@ -0,0 +1,64 @@ +import { fork } from 'node:child_process' +import { resolve } from 'node:path' +import { PtyHostSupervisor } from '../../platform/process/ptyHost/supervisor' + +type SpawnSessionOptions = { + cwd: string + cols: number + rows: number + command: string + args: string[] + env?: NodeJS.ProcessEnv +} + +export interface HeadlessPtyRuntime { + spawnSession: (options: SpawnSessionOptions) => Promise<{ sessionId: string }> + kill: (sessionId: string) => void + dispose: () => void +} + +export function createHeadlessPtyRuntime(options: { userDataPath: string }): HeadlessPtyRuntime { + const logsDir = resolve(options.userDataPath, 'logs') + const logFilePath = resolve(logsDir, 'pty-host.log') + + const supervisor = new PtyHostSupervisor({ + baseDir: __dirname, + logFilePath, + createProcess: modulePath => { + const child = fork(modulePath, [], { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + env: { ...process.env }, + }) + + return { + on: (event, listener) => { + if (event === 'message') { + child.on('message', listener) + return + } + + child.on('exit', listener) + }, + postMessage: message => { + child.send?.(message) + }, + kill: () => { + return child.kill() + }, + stdout: child.stdout ?? null, + stderr: child.stderr ?? null, + pid: child.pid, + } + }, + }) + + return { + spawnSession: async input => await supervisor.spawn(input), + kill: sessionId => { + supervisor.kill(sessionId) + }, + dispose: () => { + supervisor.dispose() + }, + } +} diff --git a/src/app/worker/index.ts b/src/app/worker/index.ts new file mode 100644 index 00000000..7ff18b80 --- /dev/null +++ b/src/app/worker/index.ts @@ -0,0 +1,105 @@ +import { resolve } from 'node:path' +import { registerControlSurfaceHttpServer } from '../main/controlSurface/controlSurfaceHttpServer' +import { createApprovedWorkspaceStoreForPath } from '../../contexts/workspace/infrastructure/approval/ApprovedWorkspaceStoreCore' +import { createHeadlessPtyRuntime } from './headlessPtyRuntime' +import { resolveWorkerUserDataDir } from './userData' + +function readFlagValue(argv: string[], flag: string): string | null { + const index = argv.indexOf(flag) + if (index === -1) { + return null + } + + const next = argv[index + 1] + if (!next || next.startsWith('-')) { + return null + } + + return next.trim() || null +} + +function resolvePort(argv: string[]): number | null { + const raw = readFlagValue(argv, '--port') + if (!raw) { + return null + } + + const value = Number(raw) + if (!Number.isFinite(value) || value < 0 || value > 65_535) { + throw new Error(`[worker] invalid --port: ${raw}`) + } + + return value +} + +function readRepeatedFlagValues(argv: string[], flag: string): string[] { + const values = [] + + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] !== flag) { + continue + } + + const next = argv[index + 1] + if (!next || next.startsWith('-')) { + continue + } + + const normalized = next.trim() + if (normalized.length > 0) { + values.push(normalized) + } + } + + return values +} + +async function main(): Promise { + const argv = process.argv.slice(2) + const userDataPath = readFlagValue(argv, '--user-data') ?? resolveWorkerUserDataDir() + const hostname = readFlagValue(argv, '--hostname') ?? '127.0.0.1' + const port = resolvePort(argv) ?? 0 + const token = readFlagValue(argv, '--token') + + const approvedWorkspaces = createApprovedWorkspaceStoreForPath( + resolve(userDataPath, 'approved-workspaces.json'), + ) + const approvedRoots = readRepeatedFlagValues(argv, '--approve-root') + await Promise.all(approvedRoots.map(rootPath => approvedWorkspaces.registerRoot(rootPath))) + + const ptyRuntime = createHeadlessPtyRuntime({ userDataPath }) + + const server = registerControlSurfaceHttpServer({ + userDataPath, + hostname, + port, + token: token ?? undefined, + approvedWorkspaces, + ptyRuntime, + ownsPtyRuntime: true, + dbPath: resolve(userDataPath, 'opencove.db'), + enableWebShell: true, + }) + + const info = await server.ready + process.stdout.write(`${JSON.stringify(info)}\n`) + process.stderr.write(`[opencove-worker] web shell: http://${info.hostname}:${info.port}/\n`) + process.stderr.write(`[opencove-worker] token required (use Authorization: Bearer )\n`) + + const disposeAndExit = (code: number): void => { + try { + server.dispose() + } finally { + setTimeout(() => process.exit(code), 250).unref() + } + } + + process.once('SIGINT', () => disposeAndExit(0)) + process.once('SIGTERM', () => disposeAndExit(0)) +} + +void main().catch(error => { + const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error) + process.stderr.write(`[opencove-worker] ${detail}\n`) + process.exit(1) +}) diff --git a/src/app/worker/userData.ts b/src/app/worker/userData.ts new file mode 100644 index 00000000..731a33b5 --- /dev/null +++ b/src/app/worker/userData.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +function normalizeEnvPath(value: unknown): string | null { + const normalized = typeof value === 'string' ? value.trim() : '' + return normalized.length > 0 ? normalized : null +} + +function resolveAppDataDir(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string { + const homeDir = os.homedir() + + if (platform === 'darwin') { + return path.join(homeDir, 'Library', 'Application Support') + } + + if (platform === 'win32') { + return env.APPDATA || path.join(homeDir, 'AppData', 'Roaming') + } + + return env.XDG_CONFIG_HOME || path.join(homeDir, '.config') +} + +export function resolveWorkerUserDataDir(): string { + const explicit = normalizeEnvPath(process.env.OPENCOVE_USER_DATA_DIR) + if (explicit) { + return path.resolve(explicit) + } + + const appDataDir = resolveAppDataDir(process.env, process.platform) + const devCandidate = path.join(appDataDir, 'opencove-dev') + const stableCandidate = path.join(appDataDir, 'opencove') + + if (fs.existsSync(devCandidate)) { + return devCandidate + } + + return stableCandidate +} diff --git a/src/contexts/workspace/infrastructure/approval/ApprovedWorkspaceStore.ts b/src/contexts/workspace/infrastructure/approval/ApprovedWorkspaceStore.ts index 4efb04c2..41d5dd97 100644 --- a/src/contexts/workspace/infrastructure/approval/ApprovedWorkspaceStore.ts +++ b/src/contexts/workspace/infrastructure/approval/ApprovedWorkspaceStore.ts @@ -1,146 +1,13 @@ -import fs from 'node:fs/promises' -import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' -import process from 'node:process' import { app } from 'electron' +import { resolve } from 'node:path' +import { + createApprovedWorkspaceStoreForPath, + type ApprovedWorkspaceStore, +} from './ApprovedWorkspaceStoreCore' -const STORE_VERSION = 1 - -interface ApprovedWorkspaceSnapshot { - version: number - roots: string[] -} - -function normalizePathForComparison(pathValue: string): string { - const normalized = resolve(pathValue) - return process.platform === 'win32' ? normalized.toLowerCase() : normalized -} - -function isPathWithinRoot(rootPath: string, targetPath: string): boolean { - const relativePath = relative(rootPath, targetPath) - - if (relativePath === '') { - return true - } - - if (relativePath === '..') { - return false - } - - if (relativePath.startsWith(`..${sep}`)) { - return false - } - - if (isAbsolute(relativePath)) { - return false - } - - return true -} - -async function readSnapshot(filePath: string): Promise { - try { - const raw = await fs.readFile(filePath, 'utf8') - const parsed = JSON.parse(raw) as unknown - if (!parsed || typeof parsed !== 'object') { - return null - } - - const record = parsed as { version?: unknown; roots?: unknown } - const version = typeof record.version === 'number' ? record.version : null - const roots = Array.isArray(record.roots) ? record.roots : null - - if (version !== STORE_VERSION || !roots) { - return null - } - - const normalizedRoots = roots - .filter((value): value is string => typeof value === 'string') - .map(value => value.trim()) - .filter(value => value.length > 0) - - return { version, roots: normalizedRoots } - } catch { - return null - } -} - -async function writeSnapshot(filePath: string, roots: string[]): Promise { - try { - await fs.mkdir(dirname(filePath), { recursive: true }) - const payload: ApprovedWorkspaceSnapshot = { version: STORE_VERSION, roots } - await fs.writeFile(filePath, `${JSON.stringify(payload)}\n`, 'utf8') - } catch { - // ignore persistence failures (permissions, read-only disks, etc.) - } -} - -export interface ApprovedWorkspaceStore { - registerRoot: (rootPath: string) => Promise - isPathApproved: (targetPath: string) => Promise -} +export type { ApprovedWorkspaceStore } from './ApprovedWorkspaceStoreCore' export function createApprovedWorkspaceStore(): ApprovedWorkspaceStore { const storePath = resolve(app.getPath('userData'), 'approved-workspaces.json') - - const approvedRoots = new Set() - let loadPromise: Promise | null = null - - const loadOnce = async (): Promise => { - if (loadPromise) { - return await loadPromise - } - - loadPromise = (async () => { - const snapshot = await readSnapshot(storePath) - if (!snapshot) { - return - } - - snapshot.roots.forEach(root => { - approvedRoots.add(normalizePathForComparison(root)) - }) - })() - - return await loadPromise - } - - const persist = async (): Promise => { - await writeSnapshot(storePath, [...approvedRoots.values()]) - } - - return { - registerRoot: async rootPath => { - const trimmed = rootPath.trim() - if (trimmed.length === 0) { - return - } - - const normalized = normalizePathForComparison(trimmed) - if (approvedRoots.has(normalized)) { - await loadOnce() - return - } - - approvedRoots.add(normalized) - await loadOnce() - await persist() - }, - isPathApproved: async targetPath => { - const trimmed = targetPath.trim() - if (trimmed.length === 0) { - return false - } - - await loadOnce() - - const normalizedTarget = normalizePathForComparison(trimmed) - for (const root of approvedRoots) { - if (isPathWithinRoot(root, normalizedTarget)) { - return true - } - } - - return false - }, - } + return createApprovedWorkspaceStoreForPath(storePath) } diff --git a/src/contexts/workspace/infrastructure/approval/ApprovedWorkspaceStoreCore.ts b/src/contexts/workspace/infrastructure/approval/ApprovedWorkspaceStoreCore.ts new file mode 100644 index 00000000..3c824154 --- /dev/null +++ b/src/contexts/workspace/infrastructure/approval/ApprovedWorkspaceStoreCore.ts @@ -0,0 +1,143 @@ +import fs from 'node:fs/promises' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import process from 'node:process' + +const STORE_VERSION = 1 + +interface ApprovedWorkspaceSnapshot { + version: number + roots: string[] +} + +function normalizePathForComparison(pathValue: string): string { + const normalized = resolve(pathValue) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function isPathWithinRoot(rootPath: string, targetPath: string): boolean { + const relativePath = relative(rootPath, targetPath) + + if (relativePath === '') { + return true + } + + if (relativePath === '..') { + return false + } + + if (relativePath.startsWith(`..${sep}`)) { + return false + } + + if (isAbsolute(relativePath)) { + return false + } + + return true +} + +async function readSnapshot(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, 'utf8') + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object') { + return null + } + + const record = parsed as { version?: unknown; roots?: unknown } + const version = typeof record.version === 'number' ? record.version : null + const roots = Array.isArray(record.roots) ? record.roots : null + + if (version !== STORE_VERSION || !roots) { + return null + } + + const normalizedRoots = roots + .filter((value): value is string => typeof value === 'string') + .map(value => value.trim()) + .filter(value => value.length > 0) + + return { version, roots: normalizedRoots } + } catch { + return null + } +} + +async function writeSnapshot(filePath: string, roots: string[]): Promise { + try { + await fs.mkdir(dirname(filePath), { recursive: true }) + const payload: ApprovedWorkspaceSnapshot = { version: STORE_VERSION, roots } + await fs.writeFile(filePath, `${JSON.stringify(payload)}\n`, 'utf8') + } catch { + // ignore persistence failures (permissions, read-only disks, etc.) + } +} + +export interface ApprovedWorkspaceStore { + registerRoot: (rootPath: string) => Promise + isPathApproved: (targetPath: string) => Promise +} + +export function createApprovedWorkspaceStoreForPath(storePath: string): ApprovedWorkspaceStore { + const approvedRoots = new Set() + let loadPromise: Promise | null = null + + const loadOnce = async (): Promise => { + if (loadPromise) { + return await loadPromise + } + + loadPromise = (async () => { + const snapshot = await readSnapshot(storePath) + if (!snapshot) { + return + } + + snapshot.roots.forEach(root => { + approvedRoots.add(normalizePathForComparison(root)) + }) + })() + + return await loadPromise + } + + const persist = async (): Promise => { + await writeSnapshot(storePath, [...approvedRoots.values()]) + } + + return { + registerRoot: async rootPath => { + const trimmed = rootPath.trim() + if (trimmed.length === 0) { + return + } + + const normalized = normalizePathForComparison(trimmed) + if (approvedRoots.has(normalized)) { + await loadOnce() + return + } + + approvedRoots.add(normalized) + await loadOnce() + await persist() + }, + isPathApproved: async targetPath => { + const trimmed = targetPath.trim() + if (trimmed.length === 0) { + return false + } + + await loadOnce() + + const normalizedTarget = normalizePathForComparison(trimmed) + for (const root of approvedRoots) { + if (isPathWithinRoot(root, normalizedTarget)) { + return true + } + } + + return false + }, + } +} diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceUi.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceUi.ts index ae35e49b..4c5fd4c3 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceUi.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceUi.ts @@ -150,12 +150,23 @@ export function useWorkspaceCanvasSpaceUi({ const copySpacePath = useCallback( async (spaceId: string) => { + const path = resolveSpacePath(spaceId) const copyPath = window.opencoveApi?.workspace?.copyPath if (typeof copyPath !== 'function') { + try { + const clipboard = + typeof navigator === 'undefined' ? null : (navigator as Navigator).clipboard + if (clipboard && typeof clipboard.writeText === 'function') { + await clipboard.writeText(path) + } + } catch { + // ignore clipboard failures (permissions, unavailable APIs, etc.) + } + return } - await copyPath({ path: resolveSpacePath(spaceId) }) + await copyPath({ path }) }, [resolveSpacePath], ) diff --git a/src/platform/process/ptyHost/entry.ts b/src/platform/process/ptyHost/entry.ts index 5ac326d8..b95a998d 100644 --- a/src/platform/process/ptyHost/entry.ts +++ b/src/platform/process/ptyHost/entry.ts @@ -1,6 +1,7 @@ import process from 'node:process' import type { IPty } from 'node-pty' import { spawn } from 'node-pty' +import { parentPort as workerParentPort } from 'node:worker_threads' import { isPtyHostRequest, PTY_HOST_PROTOCOL_VERSION, @@ -20,10 +21,49 @@ type ParentPort = { start: () => void } +type ChildProcessPort = { + on: (event: 'message', listener: (message: unknown) => void) => void + send?: (message: unknown) => void +} + function resolveParentPort(): ParentPort { const parentPort = (process as unknown as { parentPort?: ParentPort }).parentPort if (!parentPort) { - throw new Error('[pty-host] missing process.parentPort') + const port = workerParentPort + if (!port) { + const childProcessPort = process as unknown as ChildProcessPort + if (typeof childProcessPort.send !== 'function') { + throw new Error('[pty-host] missing parent port') + } + + return { + on: (_event, listener) => { + childProcessPort.on('message', message => { + listener({ data: message }) + }) + }, + postMessage: message => { + childProcessPort.send?.(message) + }, + start: () => { + // Node.js child_process IPC does not require an explicit start call. + }, + } + } + + return { + on: (_event, listener) => { + port.on('message', message => { + listener({ data: message }) + }) + }, + postMessage: message => { + port.postMessage(message) + }, + start: () => { + // Node.js worker_threads parentPort does not require an explicit start call. + }, + } } return parentPort diff --git a/tests/contract/controlSurface/controlSurfaceHttpServer.webShell.spec.ts b/tests/contract/controlSurface/controlSurfaceHttpServer.webShell.spec.ts new file mode 100644 index 00000000..a5483699 --- /dev/null +++ b/tests/contract/controlSurface/controlSurfaceHttpServer.webShell.spec.ts @@ -0,0 +1,255 @@ +// @vitest-environment node + +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { registerControlSurfaceHttpServer } from '../../../src/app/main/controlSurface/controlSurfaceHttpServer' +import { createApprovedWorkspaceStoreForPath } from '../../../src/contexts/workspace/infrastructure/approval/ApprovedWorkspaceStoreCore' + +async function fileExists(filePath: string): Promise { + try { + await readFile(filePath) + return true + } catch { + return false + } +} + +async function waitForCondition( + predicate: () => Promise, + options?: { timeoutMs?: number; intervalMs?: number }, +): Promise { + const timeoutMs = options?.timeoutMs ?? 2_000 + const intervalMs = options?.intervalMs ?? 50 + const startedAt = Date.now() + + const poll = async (): Promise => { + if (await predicate()) { + return + } + + if (Date.now() - startedAt >= timeoutMs) { + throw new Error('Timed out waiting for condition.') + } + + await new Promise(resolveDelay => setTimeout(resolveDelay, intervalMs)) + await poll() + } + + await poll() +} + +async function safeRemoveDirectory(directoryPath: string): Promise { + try { + await rm(directoryPath, { recursive: true, force: true }) + } catch (error) { + const code = error && typeof error === 'object' ? (error as { code?: string }).code : null + if (code === 'ENOENT') { + return + } + + throw error + } +} + +async function disposeAndCleanup(options: { + server: { dispose: () => void } + userDataPath: string + connectionFilePath: string + baseUrl: string +}): Promise { + options.server.dispose() + + await waitForCondition(async () => !(await fileExists(options.connectionFilePath)), { + timeoutMs: 5_000, + }) + + await waitForCondition( + async () => { + try { + await fetch(`${options.baseUrl}/`) + return false + } catch { + return true + } + }, + { timeoutMs: 5_000, intervalMs: 100 }, + ) + + await waitForCondition( + async () => { + try { + await safeRemoveDirectory(options.userDataPath) + return true + } catch { + return false + } + }, + { timeoutMs: 5_000, intervalMs: 100 }, + ) +} + +describe('Control Surface HTTP server (worker web shell)', () => { + it('serves GET / when enabled and enforces bearer token for POST /invoke', async () => { + const userDataPath = await mkdtemp(join(tmpdir(), 'opencove-control-surface-')) + const connectionFileName = 'control-surface.test.json' + const connectionFilePath = resolve(userDataPath, connectionFileName) + + const approvedWorkspaces = createApprovedWorkspaceStoreForPath( + resolve(userDataPath, 'approved-workspaces.json'), + ) + + const server = registerControlSurfaceHttpServer({ + userDataPath, + hostname: '127.0.0.1', + port: 0, + token: 'test-token', + connectionFileName, + approvedWorkspaces, + ptyRuntime: { + spawnSession: async () => ({ sessionId: 'test-session' }), + kill: () => undefined, + }, + enableWebShell: true, + }) + + try { + const info = await server.ready + + await waitForCondition(async () => await fileExists(connectionFilePath)) + const connectionRaw = await readFile(connectionFilePath, 'utf8') + const connection = JSON.parse(connectionRaw) as { token?: unknown } + expect(connection.token).toBe('test-token') + + const baseUrl = `http://${info.hostname}:${info.port}` + + const rootRes = await fetch(`${baseUrl}/`) + expect(rootRes.status).toBe(200) + const rootHtml = await rootRes.text() + expect(rootHtml).toContain('OpenCove Worker Shell') + expect(rootHtml).toContain('POST /invoke') + expect(rootHtml).toContain("fetch('/invoke'") + + const missingToken = await fetch(`${baseUrl}/invoke`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ kind: 'query', id: 'system.ping', payload: null }), + }) + expect(missingToken.status).toBe(401) + const missingTokenBody = (await missingToken.json()) as { + ok: boolean + error?: { code?: string } + } + expect(missingTokenBody.ok).toBe(false) + expect(missingTokenBody.error?.code).toBe('control_surface.unauthorized') + + const okRes = await fetch(`${baseUrl}/invoke`, { + method: 'POST', + headers: { + authorization: 'Bearer test-token', + 'content-type': 'application/json', + }, + body: JSON.stringify({ kind: 'query', id: 'system.ping', payload: null }), + }) + expect(okRes.status).toBe(200) + const okBody = (await okRes.json()) as { ok: boolean; value?: { ok?: boolean } } + expect(okBody.ok).toBe(true) + expect(okBody.value?.ok).toBe(true) + } finally { + await disposeAndCleanup({ + server, + userDataPath, + connectionFilePath, + baseUrl: `http://127.0.0.1:${(await server.ready).port}`, + }) + } + }) + + it('returns 404 for GET / when web shell is disabled', async () => { + const userDataPath = await mkdtemp(join(tmpdir(), 'opencove-control-surface-')) + const connectionFileName = 'control-surface.disabled.test.json' + const connectionFilePath = resolve(userDataPath, connectionFileName) + + const approvedWorkspaces = createApprovedWorkspaceStoreForPath( + resolve(userDataPath, 'approved-workspaces.json'), + ) + + const server = registerControlSurfaceHttpServer({ + userDataPath, + hostname: '127.0.0.1', + port: 0, + token: 'test-token', + connectionFileName, + approvedWorkspaces, + ptyRuntime: { + spawnSession: async () => ({ sessionId: 'test-session' }), + kill: () => undefined, + }, + }) + + try { + const info = await server.ready + const baseUrl = `http://${info.hostname}:${info.port}` + const rootRes = await fetch(`${baseUrl}/`) + expect(rootRes.status).toBe(404) + } finally { + await disposeAndCleanup({ + server, + userDataPath, + connectionFilePath, + baseUrl: `http://127.0.0.1:${(await server.ready).port}`, + }) + } + }) + + it('returns 400 for invalid JSON payloads', async () => { + const userDataPath = await mkdtemp(join(tmpdir(), 'opencove-control-surface-')) + const connectionFileName = 'control-surface.invalid-json.test.json' + const connectionFilePath = resolve(userDataPath, connectionFileName) + + const approvedWorkspaces = createApprovedWorkspaceStoreForPath( + resolve(userDataPath, 'approved-workspaces.json'), + ) + + const server = registerControlSurfaceHttpServer({ + userDataPath, + hostname: '127.0.0.1', + port: 0, + token: 'test-token', + connectionFileName, + approvedWorkspaces, + ptyRuntime: { + spawnSession: async () => ({ sessionId: 'test-session' }), + kill: () => undefined, + }, + }) + + try { + const info = await server.ready + const baseUrl = `http://${info.hostname}:${info.port}` + const res = await fetch(`${baseUrl}/invoke`, { + method: 'POST', + headers: { + authorization: 'Bearer test-token', + 'content-type': 'application/json', + }, + body: '{', + }) + + expect(res.status).toBe(400) + const body = (await res.json()) as { ok: boolean; error?: { code?: string } } + expect(body.ok).toBe(false) + expect(body.error?.code).toBe('common.invalid_input') + } finally { + await disposeAndCleanup({ + server, + userDataPath, + connectionFilePath, + baseUrl: `http://127.0.0.1:${(await server.ready).port}`, + }) + } + }) +}) diff --git a/tests/e2e-web-shell/workerWebShell.spec.ts b/tests/e2e-web-shell/workerWebShell.spec.ts new file mode 100644 index 00000000..17315d36 --- /dev/null +++ b/tests/e2e-web-shell/workerWebShell.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test' + +function requireEnv(name: string): string { + const value = process.env[name] + if (!value || value.trim().length === 0) { + throw new Error(`[web-shell-e2e] Missing required env var: ${name}`) + } + + return value +} + +async function readOutputJson(page: { locator: (selector: string) => any }): Promise { + const outputText = (await page.locator('#output').textContent()) ?? '' + const trimmed = outputText.trim() + if (trimmed.length === 0) { + return null + } + + return JSON.parse(trimmed) as unknown +} + +test.describe('Worker web shell', () => { + test('loads the shell page', async ({ page }) => { + const token = requireEnv('OPENCOVE_WEB_SHELL_TOKEN') + await page.goto(`/?token=${encodeURIComponent(token)}`) + + await expect(page).toHaveTitle('OpenCove Worker Shell') + await expect(page.locator('#token')).toBeVisible() + await expect(page.locator('#ping')).toBeVisible() + await expect(page.locator('#send')).toBeVisible() + await expect(page.locator('#output')).toBeVisible() + }) + + test('ping works with a valid token', async ({ page }) => { + const token = requireEnv('OPENCOVE_WEB_SHELL_TOKEN') + await page.goto(`/?token=${encodeURIComponent(token)}`) + + await page.locator('#ping').click() + await expect(page.locator('#output')).not.toHaveText('') + + const result = (await readOutputJson(page)) as { + httpStatus?: number + data?: { ok?: boolean } + } + + expect(result.httpStatus).toBe(200) + expect(result.data?.ok).toBe(true) + }) + + test('ping fails with 401 when token is invalid', async ({ page }) => { + await page.goto('/?token=invalid-token') + + await page.locator('#ping').click() + await expect(page.locator('#output')).not.toHaveText('') + + const result = (await readOutputJson(page)) as { + httpStatus?: number + data?: { ok?: boolean; error?: { code?: string } } + } + + expect(result.httpStatus).toBe(401) + expect(result.data?.ok).toBe(false) + expect(result.data?.error?.code).toBe('control_surface.unauthorized') + }) + + test('can read an approved file via filesystem.readFileText', async ({ page }) => { + const token = requireEnv('OPENCOVE_WEB_SHELL_TOKEN') + const fileUri = requireEnv('OPENCOVE_WEB_SHELL_TEST_FILE_URI') + + await page.goto(`/?token=${encodeURIComponent(token)}`) + + await page.locator('#kind').selectOption('query') + await page.locator('#opId').fill('filesystem.readFileText') + await page.locator('#payload').fill(JSON.stringify({ uri: fileUri })) + + await page.locator('#send').click() + await expect(page.locator('#output')).not.toHaveText('') + + const result = (await readOutputJson(page)) as { + httpStatus?: number + data?: { ok?: boolean; value?: { content?: string } } + } + + expect(result.httpStatus).toBe(200) + expect(result.data?.ok).toBe(true) + expect(result.data?.value?.content).toBe('hello from opencove web shell e2e\n') + }) + + test('does not expose desktop-only open-path actions via control surface', async ({ page }) => { + const token = requireEnv('OPENCOVE_WEB_SHELL_TOKEN') + await page.goto(`/?token=${encodeURIComponent(token)}`) + + await page.locator('#kind').selectOption('command') + await page.locator('#opId').fill('workspace.openPath') + await page.locator('#payload').fill(JSON.stringify({ path: '/tmp', openerId: 'finder' })) + + await page.locator('#send').click() + await expect(page.locator('#output')).not.toHaveText('') + + const result = (await readOutputJson(page)) as { + httpStatus?: number + data?: { ok?: boolean; error?: { code?: string } } + } + + expect(result.httpStatus).toBe(200) + expect(result.data?.ok).toBe(false) + expect(result.data?.error?.code).toBe('common.invalid_input') + }) +}) diff --git a/tests/unit/contexts/workspaceCanvasSpaceUi.web.spec.tsx b/tests/unit/contexts/workspaceCanvasSpaceUi.web.spec.tsx new file mode 100644 index 00000000..0bf54208 --- /dev/null +++ b/tests/unit/contexts/workspaceCanvasSpaceUi.web.spec.tsx @@ -0,0 +1,149 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useWorkspaceCanvasSpaceUi } from '../../../src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceUi' +import type { WorkspaceSpaceState } from '../../../src/contexts/workspace/presentation/renderer/types' + +function HookHost(): React.JSX.Element { + const [contextMenu, setContextMenu] = React.useState(null) + const [, setEmptySelectionPrompt] = React.useState(null) + + const spacesRef = React.useRef([ + { + id: 'space-1', + name: 'Space 1', + directoryPath: '/tmp/opencove-space', + labelColor: null, + nodeIds: [], + rect: null, + }, + ]) + + const ui = useWorkspaceCanvasSpaceUi({ + contextMenu, + setContextMenu, + setEmptySelectionPrompt, + cancelSpaceRename: () => undefined, + workspacePath: '/tmp/opencove-workspace', + spacesRef, + handlePaneClick: () => undefined, + handlePaneContextMenu: () => undefined, + handleNodeContextMenu: () => undefined, + handleSelectionContextMenu: () => undefined, + }) + + return ( +
+ {ui.availablePathOpeners.length} + + +
+ ) +} + +describe('useWorkspaceCanvasSpaceUi (web UI differences)', () => { + beforeEach(() => { + Object.defineProperty(window, 'opencoveApi', { + configurable: true, + value: undefined, + }) + }) + + afterEach(() => { + delete (window as { opencoveApi?: unknown }).opencoveApi + }) + + it('uses browser clipboard when Electron IPC copyPath API is unavailable', async () => { + const writeText = vi.fn(async () => undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + + render() + fireEvent.click(screen.getByTestId('copy-space-path')) + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith('/tmp/opencove-space') + }) + }) + + it('prefers Electron IPC copyPath API when available (desktop)', async () => { + const copyPath = vi.fn(async () => undefined) + const writeText = vi.fn(async () => undefined) + + Object.defineProperty(window, 'opencoveApi', { + configurable: true, + value: { + workspace: { + copyPath, + }, + }, + }) + + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + + render() + fireEvent.click(screen.getByTestId('copy-space-path')) + + await waitFor(() => { + expect(copyPath).toHaveBeenCalledWith({ path: '/tmp/opencove-space' }) + }) + + expect(writeText).not.toHaveBeenCalled() + }) + + it('keeps the path openers empty when listPathOpeners is unavailable (web)', async () => { + render() + + fireEvent.click(screen.getByTestId('open-space-menu')) + + await waitFor(() => { + expect(screen.getByTestId('openers-count')).toHaveTextContent('0') + }) + }) + + it('loads available path openers when listPathOpeners is available (desktop)', async () => { + const listPathOpeners = vi.fn(async () => ({ + openers: [ + { id: 'finder', label: 'Finder' }, + { id: 'terminal', label: 'Terminal' }, + ], + })) + + Object.defineProperty(window, 'opencoveApi', { + configurable: true, + value: { + workspace: { + listPathOpeners, + }, + }, + }) + + render() + fireEvent.click(screen.getByTestId('open-space-menu')) + + await waitFor(() => { + expect(screen.getByTestId('openers-count')).toHaveTextContent('2') + }) + + expect(listPathOpeners).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/unit/contexts/workspaceSpaceActionMenu.spec.tsx b/tests/unit/contexts/workspaceSpaceActionMenu.spec.tsx index 4d13e4b6..9bb79667 100644 --- a/tests/unit/contexts/workspaceSpaceActionMenu.spec.tsx +++ b/tests/unit/contexts/workspaceSpaceActionMenu.spec.tsx @@ -119,4 +119,11 @@ describe('WorkspaceSpaceActionMenu', () => { 'workspace-space-action-arrange', ]) }) + + it('does not show the Open submenu when no path openers are available (web)', () => { + renderMenu([]) + + expect(screen.queryByTestId('workspace-space-action-open')).not.toBeInTheDocument() + expect(screen.getByTestId('workspace-space-action-copy-path')).toBeVisible() + }) })