diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index d591dba44..61d1ddb3f 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -14,6 +14,73 @@ import type { export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error" +export type ExecutionProfileKind = "local" | "wsl" | "docker" | "command" | "ssh" +export type ExecutionProfileCwdMode = "workspace" | "inherit" + +export interface ExecutionProfileBase { + id: string + name: string + kind: ExecutionProfileKind +} + +export interface LocalExecutionProfile extends ExecutionProfileBase { + kind: "local" + binaryPath: string +} + +export interface WslExecutionProfile extends ExecutionProfileBase { + kind: "wsl" + distro: string + binaryPath: string +} + +export interface DockerExecutionProfile extends ExecutionProfileBase { + kind: "docker" + image: string + workspaceMountPath: string + configMountPath: string + command?: string[] + extraDockerArgs?: string[] +} + +export interface CommandExecutionProfile extends ExecutionProfileBase { + kind: "command" + executable: string + args?: string[] + cwdMode?: ExecutionProfileCwdMode +} + +export interface SshExecutionProfile extends ExecutionProfileBase { + kind: "ssh" + host: string + port?: number + username?: string + remotePath: string + binaryPath: string + args?: string[] +} + +export type ExecutionProfile = LocalExecutionProfile | WslExecutionProfile | DockerExecutionProfile | CommandExecutionProfile | SshExecutionProfile + +export interface ExecutionProfilePreviewRequest { + profile: ExecutionProfile + workspacePath?: string +} + +export interface ExecutionProfilePreviewResponse { + command: string + args: string[] + commandLine: string + cwd?: string + environment: Record +} + +export interface ExecutionProfileTestResponse extends ExecutionProfilePreviewResponse { + valid: boolean + version?: string + error?: string +} + export interface WorkspaceDescriptor { id: string /** Absolute path on the server host. */ @@ -29,6 +96,9 @@ export interface WorkspaceDescriptor { binaryId: string binaryLabel: string binaryVersion?: string + executionProfileId?: string + executionProfileName?: string + executionProfileKind?: ExecutionProfileKind createdAt: string updatedAt: string /** Present when `status` is "error". */ @@ -38,6 +108,7 @@ export interface WorkspaceDescriptor { export interface WorkspaceCreateRequest { path: string name?: string + executionProfileId?: string } export interface WorkspaceCloneRequest { diff --git a/packages/server/src/opencode-plugin.test.ts b/packages/server/src/opencode-plugin.test.ts index dda5f9881..a0a5705fd 100644 --- a/packages/server/src/opencode-plugin.test.ts +++ b/packages/server/src/opencode-plugin.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { buildOpencodeConfigContent } from "./opencode-plugin" +import { + buildOpencodeConfigContent, + findPackagedCodeNomadPluginReference, + rewritePackagedCodeNomadPluginReference, +} from "./opencode-plugin" describe("buildOpencodeConfigContent", () => { it("creates config content with the CodeNomad plugin", () => { @@ -35,4 +39,37 @@ describe("buildOpencodeConfigContent", () => { assert.deepEqual(JSON.parse(content).plugin, ["file:///plugin.tgz"]) }) + + it("finds the packaged CodeNomad plugin tarball reference", () => { + const reference = findPackagedCodeNomadPluginReference(`{ + "plugin": [ + "npm:user-plugin", + "@codenomad/codenomad-opencode-plugin@file:C:/Users/dev/AppData/Roaming/CodeNomad/codenomad-opencode-plugin.tgz" + ] + }`) + + assert.deepEqual(reference, { + specifier: "@codenomad/codenomad-opencode-plugin@file:C:/Users/dev/AppData/Roaming/CodeNomad/codenomad-opencode-plugin.tgz", + filePath: "C:/Users/dev/AppData/Roaming/CodeNomad/codenomad-opencode-plugin.tgz", + }) + }) + + it("rewrites the packaged CodeNomad plugin tarball reference", () => { + const content = rewritePackagedCodeNomadPluginReference( + `{ + "plugin": [ + "npm:user-plugin", + "@codenomad/codenomad-opencode-plugin@file:C:/Users/dev/AppData/Roaming/CodeNomad/codenomad-opencode-plugin.tgz" + ] + }`, + "/tmp/codenomad-opencode-plugin.tgz", + ) + + assert.deepEqual(JSON.parse(content), { + plugin: [ + "npm:user-plugin", + "@codenomad/codenomad-opencode-plugin@file:/tmp/codenomad-opencode-plugin.tgz", + ], + }) + }) }) diff --git a/packages/server/src/opencode-plugin.ts b/packages/server/src/opencode-plugin.ts index 761292da5..1889f4732 100644 --- a/packages/server/src/opencode-plugin.ts +++ b/packages/server/src/opencode-plugin.ts @@ -68,6 +68,49 @@ export function resolveExistingOpencodeConfigContent(userEnvironment: Record { + const reference = parsePackagedPluginSpecifier(entry) + if (!reference) { + return entry + } + + changed = true + return toNpmFileSpecifier(filePath) + }) + + if (!changed) { + return configContent + } + + return JSON.stringify( + { + ...config, + plugin: typeof config.plugin === "string" ? nextPluginEntries[0] ?? toNpmFileSpecifier(filePath) : nextPluginEntries, + }, + null, + 2, + ) +} + function toNpmFileSpecifier(filePath: string): string { return `${pluginPackageName}@file:${filePath.replace(/\\/g, "/")}` } @@ -87,6 +130,20 @@ function normalizeConfigContentValue(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value : undefined } +function parsePackagedPluginSpecifier(value: string): { specifier: string; filePath: string } | null { + const prefix = `${pluginPackageName}@file:` + if (!value.startsWith(prefix)) { + return null + } + + const filePath = value.slice(prefix.length).trim() + if (!filePath.endsWith(".tgz")) { + return null + } + + return { specifier: value, filePath } +} + function parseJsoncObject(content: string): Record { try { const parsed = JSON.parse(stripJsonc(content)) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index faa53d3fd..ba3858922 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -75,6 +75,16 @@ interface HttpServerStartResult { displayHost: string } +function redactSensitivePayload(value: unknown): unknown { + if (!value || typeof value !== "object") return value + if (Array.isArray(value)) return value.map(redactSensitivePayload) + const output: Record = {} + for (const [key, entry] of Object.entries(value as Record)) { + output[key] = /(PASSWORD|TOKEN|SECRET|API[_-]?KEY)/i.test(key) ? "[REDACTED]" : redactSensitivePayload(entry) + } + return output +} + export function createHttpServer(deps: HttpServerDeps) { // Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS. // We keep the runtime behavior correct and cast the instance to a generic FastifyInstance. @@ -118,7 +128,8 @@ export function createHttpServer(deps: HttpServerDeps) { } apiLogger.debug(base, "HTTP request completed") if (apiLogger.isLevelEnabled("trace")) { - apiLogger.trace({ ...base, params: request.params, query: request.query, body: request.body }, "HTTP request payload") + const body = redactSensitivePayload(request.body) + apiLogger.trace({ ...base, params: request.params, query: request.query, body }, "HTTP request payload") } done() }) @@ -695,7 +706,7 @@ async function proxyWorkspaceRequest(args: { logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance") if (logger.isLevelEnabled("trace")) { - logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload") + logger.trace({ workspaceId, targetUrl, body: redactSensitivePayload(request.body) }, "Instance proxy payload") } return reply.from(targetUrl, { @@ -733,7 +744,7 @@ async function proxyWorkspaceRequest(args: { worktreeSlug, directory, contentType: request.headers["content-type"], - body: bodyToJson(request.body), + body: redactSensitivePayload(bodyToJson(request.body)), headers: outgoing, }, "Proxy -> OpenCode request", diff --git a/packages/server/src/server/routes/settings.ts b/packages/server/src/server/routes/settings.ts index 5d18e4fd5..1fe77c036 100644 --- a/packages/server/src/server/routes/settings.ts +++ b/packages/server/src/server/routes/settings.ts @@ -1,5 +1,19 @@ -import { FastifyInstance } from "fastify" +import { spawnSync } from "child_process" +import { FastifyInstance, type FastifyRequest } from "fastify" import { z } from "zod" +import type { ExecutionProfilePreviewResponse, ExecutionProfileTestResponse } from "../../api-types" +import { + buildOpencodeConfigContent, + getCodeNomadPluginUrl, + resolveExistingOpencodeConfigContent, +} from "../../opencode-plugin.js" +import { buildLaunchPreview, formatCommandLine } from "../../workspaces/execution-launch" +import { + OPENCODE_SERVER_BASE_URL_ENV, + OPENCODE_SERVER_PASSWORD_ENV, + OPENCODE_SERVER_USERNAME_ENV, + resolveOpencodeServerAuth, +} from "../../workspaces/opencode-auth" import { probeBinaryVersion } from "../../workspaces/spawn" import type { SettingsService } from "../../settings/service" import type { Logger } from "../../logger" @@ -14,11 +28,275 @@ const ValidateBinarySchema = z.object({ path: z.string(), }) -function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } { - const result = probeBinaryVersion(binaryPath) +const ExecutionProfileSchema = z.discriminatedUnion("kind", [ + z.object({ + id: z.string().trim().min(1), + name: z.string().trim().min(1), + kind: z.literal("local"), + binaryPath: z.string().trim().min(1), + }), + z.object({ + id: z.string().trim().min(1), + name: z.string().trim().min(1), + kind: z.literal("wsl"), + distro: z.string().trim().min(1), + binaryPath: z.string().trim().min(1), + }), + z.object({ + id: z.string().trim().min(1), + name: z.string().trim().min(1), + kind: z.literal("docker"), + image: z.string().trim().min(1), + workspaceMountPath: z.string().trim().min(1), + configMountPath: z.string().trim().min(1), + command: z.array(z.string().trim().min(1)).optional(), + extraDockerArgs: z.array(z.string().trim().min(1)).optional(), + }), + z.object({ + id: z.string().trim().min(1), + name: z.string().trim().min(1), + kind: z.literal("command"), + executable: z.string().trim().min(1), + args: z.array(z.string().trim().min(1)).optional(), + cwdMode: z.enum(["workspace", "inherit"]).optional(), + }), + z.object({ + id: z.string().trim().min(1), + name: z.string().trim().min(1), + kind: z.literal("ssh"), + host: z.string().trim().min(1), + port: z.number().int().positive().max(65535).optional(), + username: z.string().trim().optional(), + remotePath: z.string().trim().min(1), + binaryPath: z.string().trim().min(1), + args: z.array(z.string().trim().min(1)).optional(), + }), +]) + +const ExecutionProfilePreviewSchema = z.object({ + profile: ExecutionProfileSchema, + workspacePath: z.string().trim().optional(), +}) + +const PREVIEW_SECRET_KEY = /(PASSWORD|TOKEN|SECRET|API[_-]?KEY)/i + +function validateBinaryPath(binaryPath: string, options: { wslDistro?: string } = {}): { valid: boolean; version?: string; error?: string } { + const result = probeBinaryVersion(binaryPath, options) return { valid: result.valid, version: result.version, error: result.error } } +function validateDockerImage(image: string): { valid: boolean; version?: string; error?: string } { + const docker = validateBinaryPath("docker") + if (!docker.valid) { + return docker + } + + try { + const result = spawnSync("docker", ["image", "inspect", image], { encoding: "utf8" }) + if (result.error) { + return { valid: false, version: docker.version, error: result.error.message } + } + + if (result.status !== 0) { + const stderr = result.stderr?.trim() + const stdout = result.stdout?.trim() + const combined = stderr || stdout + const details = combined ? `: ${combined}` : "" + return { + valid: false, + version: docker.version, + error: `Docker image \"${image}\" is not available locally${details}`, + } + } + + return { valid: true, version: docker.version } + } catch (error) { + return { valid: false, version: docker.version, error: error instanceof Error ? error.message : String(error) } + } +} + +function normalizeRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {} + } + + const output: Record = {} + for (const [key, entry] of Object.entries(value as Record)) { + if (typeof entry !== "string") { + continue + } + const trimmed = entry.trim() + if (trimmed) { + output[key] = trimmed + } + } + + return output +} + +function readConfiguredServerEnvironment(settings: SettingsService): Record { + const serverConfig = settings.getOwner("config", "server") + return normalizeRecord((serverConfig as any)?.environmentVariables) +} + +function readConfiguredLogLevel(settings: SettingsService): string { + const serverConfig = settings.getOwner("config", "server") + const logLevel = (serverConfig as any)?.logLevel + return typeof logLevel === "string" && logLevel.trim() ? logLevel.toUpperCase() : "DEBUG" +} + +function redactPreviewEnvironment(environment: Record): Record { + const redacted: Record = {} + for (const [key, value] of Object.entries(environment)) { + redacted[key] = PREVIEW_SECRET_KEY.test(key) ? "REDACTED" : value + } + return redacted +} + +function redactPreviewArgs(args: string[]): string[] { + return args.map((arg, index) => { + const [key] = arg.split("=", 1) + if (key && PREVIEW_SECRET_KEY.test(key)) { + return arg.includes("=") ? `${key}=REDACTED` : "REDACTED" + } + + const previous = args[index - 1] + if ((previous === "-e" || previous === "--env") && PREVIEW_SECRET_KEY.test(key || arg)) { + return arg.includes("=") ? `${key}=REDACTED` : arg + } + + return arg + }) +} + +function buildRequestBaseUrl(request: FastifyRequest): string { + const host = request.headers.host?.trim() + if (!host) { + return "https://127.0.0.1:9898" + } + return `${request.protocol}://${host}`.replace(/\/+$/, "") +} + +function getPreviewReservedPort(profile: z.infer): number | undefined { + return profile.kind === "docker" || profile.kind === "ssh" ? 17600 : undefined +} + +function getPreviewCallbackPort(profile: z.infer): number | undefined { + return profile.kind === "ssh" ? 17601 : undefined +} + +function buildExecutionProfilePreview( + input: z.infer, + options: { settings: SettingsService; requestBaseUrl: string }, +): ExecutionProfilePreviewResponse { + const workspacePath = input.workspacePath?.trim() || (process.platform === "win32" ? "C:/workspace" : "/workspace") + const execution = + input.profile.kind === "local" + ? { + kind: "local" as const, + path: input.profile.binaryPath, + label: input.profile.name, + } + : input.profile.kind === "wsl" + ? { + kind: "wsl" as const, + path: input.profile.binaryPath, + wslDistro: input.profile.distro, + label: input.profile.name, + } + : input.profile.kind === "docker" + ? { + kind: "docker" as const, + label: input.profile.name, + image: input.profile.image, + workspaceMountPath: input.profile.workspaceMountPath, + configMountPath: input.profile.configMountPath, + command: input.profile.command, + extraDockerArgs: input.profile.extraDockerArgs, + } + : input.profile.kind === "command" + ? { + kind: "command" as const, + label: input.profile.name, + executable: input.profile.executable, + args: input.profile.args, + cwdMode: input.profile.cwdMode, + } + : { + kind: "ssh" as const, + label: input.profile.name, + host: input.profile.host, + port: input.profile.port, + username: input.profile.username, + remotePath: input.profile.remotePath, + binaryPath: input.profile.binaryPath, + args: input.profile.args, + } + + const userEnvironment = readConfiguredServerEnvironment(options.settings) + const previewInstanceId = "preview-instance" + const normalizedBaseUrl = options.requestBaseUrl.replace(/\/+$/, "") + const opencodeConfigContent = buildOpencodeConfigContent( + resolveExistingOpencodeConfigContent(userEnvironment), + getCodeNomadPluginUrl(), + ) + const { username } = resolveOpencodeServerAuth({ + userEnvironment, + processEnv: process.env, + }) + + const environment = { + ...redactPreviewEnvironment(userEnvironment), + OPENCODE_CONFIG_CONTENT: opencodeConfigContent, + CODENOMAD_INSTANCE_ID: previewInstanceId, + CODENOMAD_BASE_URL: normalizedBaseUrl, + [OPENCODE_SERVER_BASE_URL_ENV]: `${normalizedBaseUrl}/workspaces/${previewInstanceId}/worktrees/root/instance`, + [OPENCODE_SERVER_USERNAME_ENV]: username, + [OPENCODE_SERVER_PASSWORD_ENV]: "REDACTED", + } + + const launch = buildLaunchPreview({ + execution, + workspacePath, + environment, + logLevel: readConfiguredLogLevel(options.settings), + reservedPort: getPreviewReservedPort(input.profile), + callbackPort: getPreviewCallbackPort(input.profile), + }) + + const redactedArgs = redactPreviewArgs(launch.args) + + return { + command: launch.command, + args: redactedArgs, + commandLine: formatCommandLine(launch.command, redactedArgs), + cwd: launch.cwd, + environment: launch.environment ?? {}, + } +} + +function testExecutionProfile( + input: z.infer, + options: { settings: SettingsService; requestBaseUrl: string }, +): ExecutionProfileTestResponse { + const preview = buildExecutionProfilePreview(input, options) + const validation = + input.profile.kind === "docker" + ? validateDockerImage(input.profile.image) + : input.profile.kind === "command" + ? validateBinaryPath(input.profile.executable) + : input.profile.kind === "ssh" + ? validateBinaryPath("ssh") + : validateBinaryPath(input.profile.binaryPath, input.profile.kind === "wsl" ? { wslDistro: input.profile.distro } : {}) + + return { + ...preview, + valid: validation.valid, + version: validation.version, + ...(validation.error ? { error: validation.error } : {}), + } +} + export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { // Full-document access app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config"))) @@ -81,4 +359,32 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { return { valid: false, error: error instanceof Error ? error.message : "Invalid request" } } }) + + app.post("/api/storage/execution-profiles/preview", async (request, reply) => { + try { + const body = ExecutionProfilePreviewSchema.parse(request.body ?? {}) + return buildExecutionProfilePreview(body, { + settings: deps.settings, + requestBaseUrl: buildRequestBaseUrl(request), + }) + } catch (error) { + deps.logger.warn({ err: error }, "Failed to preview execution profile") + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid request" } + } + }) + + app.post("/api/storage/execution-profiles/test", async (request, reply) => { + try { + const body = ExecutionProfilePreviewSchema.parse(request.body ?? {}) + return testExecutionProfile(body, { + settings: deps.settings, + requestBaseUrl: buildRequestBaseUrl(request), + }) + } catch (error) { + deps.logger.warn({ err: error }, "Failed to test execution profile") + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid request" } + } + }) } diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 517367f56..539aa0e2d 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -14,6 +14,7 @@ interface RouteDeps { const WorkspaceCreateSchema = z.object({ path: z.string(), name: z.string().optional(), + executionProfileId: z.string().trim().optional(), }) const WorkspaceCloneSchema = z.object({ @@ -67,7 +68,9 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { app.post("/api/workspaces", async (request, reply) => { try { const body = WorkspaceCreateSchema.parse(request.body ?? {}) - const workspace = await deps.workspaceManager.create(body.path, body.name) + const workspace = await deps.workspaceManager.create(body.path, body.name, { + executionProfileId: body.executionProfileId, + }) reply.code(201) return workspace } catch (error) { diff --git a/packages/server/src/settings/binaries.test.ts b/packages/server/src/settings/binaries.test.ts new file mode 100644 index 000000000..ff0730ba3 --- /dev/null +++ b/packages/server/src/settings/binaries.test.ts @@ -0,0 +1,193 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import type { ExecutionProfile } from "../api-types" +import { BinaryResolver } from "./binaries" + +function createSettings(input?: { + server?: Record + ui?: Record +}) { + return { + getOwner(kind: "config" | "state", owner: string) { + if (kind === "config" && owner === "server") { + return input?.server ?? {} + } + if (kind === "state" && owner === "ui") { + return input?.ui ?? {} + } + return {} + }, + } +} + +describe("BinaryResolver", () => { + it("falls back to the configured default binary when no launch profile is selected", () => { + const resolver = new BinaryResolver( + createSettings({ + server: { opencodeBinary: "opencode-custom" }, + ui: { opencodeBinaries: [{ path: "opencode-custom", label: "Custom OpenCode", version: "1.2.3" }] }, + }) as any, + ) + + assert.deepEqual(resolver.resolveActive(), { + kind: "local", + path: "opencode-custom", + label: "Custom OpenCode", + version: "1.2.3", + }) + }) + + it("resolves an explicit local launch profile", () => { + const profile: ExecutionProfile = { + id: "local-default", + name: "Local Default", + kind: "local", + binaryPath: "C:/Tools/opencode.exe", + } + + const resolver = new BinaryResolver( + createSettings({ + server: { executionProfiles: [profile] }, + }) as any, + ) + + assert.deepEqual(resolver.resolveActive(profile.id), { + kind: "local", + path: "C:/Tools/opencode.exe", + label: "Local Default", + executionProfileId: "local-default", + executionProfileName: "Local Default", + executionProfileKind: "local", + }) + }) + + it("resolves a default WSL launch profile from server config", () => { + const profile: ExecutionProfile = { + id: "wsl-ubuntu", + name: "WSL Ubuntu", + kind: "wsl", + distro: "Ubuntu", + binaryPath: String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, + } + + const resolver = new BinaryResolver( + createSettings({ + server: { + executionProfiles: [profile], + defaultExecutionProfileId: profile.id, + opencodeBinary: "opencode", + }, + }) as any, + ) + + assert.deepEqual(resolver.resolveActive(), { + kind: "wsl", + path: String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, + wslDistro: "Ubuntu", + label: "WSL Ubuntu", + executionProfileId: "wsl-ubuntu", + executionProfileName: "WSL Ubuntu", + executionProfileKind: "wsl", + }) + }) + + it("resolves a docker execution profile", () => { + const profile: ExecutionProfile = { + id: "docker-sandbox", + name: "Docker Sandbox", + kind: "docker", + image: "ghcr.io/example/opencode:latest", + workspaceMountPath: "/workspace", + configMountPath: "/root/.config/opencode", + command: ["opencode"], + extraDockerArgs: ["--init"], + } + + const resolver = new BinaryResolver( + createSettings({ + server: { executionProfiles: [profile] }, + }) as any, + ) + + assert.deepEqual(resolver.resolveActive(profile.id), { + kind: "docker", + label: "Docker Sandbox", + image: "ghcr.io/example/opencode:latest", + workspaceMountPath: "/workspace", + configMountPath: "/root/.config/opencode", + command: ["opencode"], + extraDockerArgs: ["--init"], + executionProfileId: "docker-sandbox", + executionProfileName: "Docker Sandbox", + executionProfileKind: "docker", + }) + }) + + it("resolves a command execution profile", () => { + const profile: ExecutionProfile = { + id: "custom-wrapper", + name: "Custom Wrapper", + kind: "command", + executable: "node", + args: ["scripts/opencode-wrapper.mjs"], + cwdMode: "inherit", + } + + const resolver = new BinaryResolver( + createSettings({ + server: { executionProfiles: [profile] }, + }) as any, + ) + + assert.deepEqual(resolver.resolveActive(profile.id), { + kind: "command", + label: "Custom Wrapper", + executable: "node", + args: ["scripts/opencode-wrapper.mjs"], + cwdMode: "inherit", + executionProfileId: "custom-wrapper", + executionProfileName: "Custom Wrapper", + executionProfileKind: "command", + }) + }) + + it("resolves an SSH execution profile", () => { + const profile: ExecutionProfile = { + id: "ssh-linux", + name: "SSH Linux", + kind: "ssh", + host: "vm.example.com", + port: 2222, + username: "ubuntu", + remotePath: "/srv/project", + binaryPath: "opencode", + args: ["--experimental"], + } + + const resolver = new BinaryResolver( + createSettings({ + server: { executionProfiles: [profile] }, + }) as any, + ) + + assert.deepEqual(resolver.resolveActive(profile.id), { + kind: "ssh", + label: "SSH Linux", + host: "vm.example.com", + port: 2222, + username: "ubuntu", + remotePath: "/srv/project", + binaryPath: "opencode", + args: ["--experimental"], + executionProfileId: "ssh-linux", + executionProfileName: "SSH Linux", + executionProfileKind: "ssh", + }) + }) + + it("throws when an explicit execution profile id does not exist", () => { + const resolver = new BinaryResolver(createSettings() as any) + assert.throws(() => resolver.resolveActive("missing-profile"), /Execution profile not found/) + }) +}) diff --git a/packages/server/src/settings/binaries.ts b/packages/server/src/settings/binaries.ts index e4b25960b..00592296e 100644 --- a/packages/server/src/settings/binaries.ts +++ b/packages/server/src/settings/binaries.ts @@ -1,4 +1,13 @@ import type { SettingsService } from "./service" +import type { + CommandExecutionProfile, + DockerExecutionProfile, + ExecutionProfile, + ExecutionProfileKind, + LocalExecutionProfile, + SshExecutionProfile, + WslExecutionProfile, +} from "../api-types" export interface OpenCodeBinaryEntry { path: string @@ -7,12 +16,48 @@ export interface OpenCodeBinaryEntry { label?: string } -export interface ResolvedBinary { - path: string +interface ResolvedExecutionBase { label: string version?: string + executionProfileId?: string + executionProfileName?: string + executionProfileKind?: ExecutionProfileKind +} + +export interface ResolvedHostExecution extends ResolvedExecutionBase { + kind: "local" | "wsl" + path: string + wslDistro?: string +} + +export interface ResolvedDockerExecution extends ResolvedExecutionBase { + kind: "docker" + image: string + workspaceMountPath: string + configMountPath: string + command?: string[] + extraDockerArgs?: string[] +} + +export interface ResolvedCommandExecution extends ResolvedExecutionBase { + kind: "command" + executable: string + args?: string[] + cwdMode?: "workspace" | "inherit" } +export interface ResolvedSshExecution extends ResolvedExecutionBase { + kind: "ssh" + host: string + port?: number + username?: string + remotePath: string + binaryPath: string + args?: string[] +} + +export type ResolvedBinary = ResolvedHostExecution | ResolvedDockerExecution | ResolvedCommandExecution | ResolvedSshExecution + function prettyLabel(p: string): string { const parts = p.split(/[\\/]/) const last = parts[parts.length - 1] || p @@ -32,6 +77,23 @@ function readDefaultBinaryPath(settings: SettingsService): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined } +function isExecutionProfile(value: unknown): value is ExecutionProfile { + return !!value && typeof value === "object" && typeof (value as any).id === "string" && typeof (value as any).kind === "string" +} + +function readExecutionProfiles(settings: SettingsService): ExecutionProfile[] { + const server = settings.getOwner("config", "server") + const list = (server as any)?.executionProfiles + if (!Array.isArray(list)) return [] + return list.filter(isExecutionProfile) +} + +function readDefaultExecutionProfileId(settings: SettingsService): string | undefined { + const server = settings.getOwner("config", "server") + const value = (server as any)?.defaultExecutionProfileId + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined +} + export class BinaryResolver { constructor(private readonly settings: SettingsService) {} @@ -39,6 +101,28 @@ export class BinaryResolver { return readUiBinaries(this.settings) } + listExecutionProfiles(): ExecutionProfile[] { + return readExecutionProfiles(this.settings) + } + + resolveActive(executionProfileId?: string): ResolvedBinary { + const profiles = this.listExecutionProfiles() + const requestedId = executionProfileId?.trim() || readDefaultExecutionProfileId(this.settings) + if (!requestedId) { + return this.resolveDefault() + } + + const profile = profiles.find((entry) => entry.id === requestedId) + if (!profile) { + if (executionProfileId?.trim()) { + throw new Error(`Execution profile not found: ${executionProfileId}`) + } + return this.resolveDefault() + } + + return this.resolveProfile(profile) + } + resolveDefault(): ResolvedBinary { const binaries = this.list() const configuredDefault = readDefaultBinaryPath(this.settings) @@ -47,9 +131,89 @@ export class BinaryResolver { const entry = binaries.find((b) => b.path === path) return { + kind: "local", path, label: entry?.label ?? prettyLabel(path), version: entry?.version, } } + + private resolveProfile(profile: ExecutionProfile): ResolvedBinary { + const shared = { + label: profile.name, + executionProfileId: profile.id, + executionProfileName: profile.name, + executionProfileKind: profile.kind, + } + + if (profile.kind === "local") { + return this.resolveLocalProfile(profile, shared) + } + + if (profile.kind === "wsl") { + return this.resolveWslProfile(profile, shared) + } + + if (profile.kind === "docker") { + return this.resolveDockerProfile(profile, shared) + } + + if (profile.kind === "command") { + return this.resolveCommandProfile(profile, shared) + } + + return this.resolveSshProfile(profile, shared) + } + + private resolveLocalProfile(profile: LocalExecutionProfile, shared: Omit): ResolvedHostExecution { + return { + ...shared, + kind: "local", + path: profile.binaryPath, + } + } + + private resolveWslProfile(profile: WslExecutionProfile, shared: Omit): ResolvedHostExecution { + return { + ...shared, + kind: "wsl", + path: profile.binaryPath, + wslDistro: profile.distro, + } + } + + private resolveDockerProfile(profile: DockerExecutionProfile, shared: Omit): ResolvedDockerExecution { + return { + ...shared, + kind: "docker", + image: profile.image, + workspaceMountPath: profile.workspaceMountPath, + configMountPath: profile.configMountPath, + command: profile.command, + extraDockerArgs: profile.extraDockerArgs, + } + } + + private resolveCommandProfile(profile: CommandExecutionProfile, shared: Omit): ResolvedCommandExecution { + return { + ...shared, + kind: "command", + executable: profile.executable, + args: profile.args, + cwdMode: profile.cwdMode, + } + } + + private resolveSshProfile(profile: SshExecutionProfile, shared: Omit): ResolvedSshExecution { + return { + ...shared, + kind: "ssh", + host: profile.host, + port: profile.port, + username: profile.username, + remotePath: profile.remotePath, + binaryPath: profile.binaryPath, + args: profile.args, + } + } } diff --git a/packages/server/src/settings/service.ts b/packages/server/src/settings/service.ts index f4f0409c2..b5a40bc49 100644 --- a/packages/server/src/settings/service.ts +++ b/packages/server/src/settings/service.ts @@ -14,6 +14,65 @@ const CanonicalLogLevelSchema = z.preprocess( z.enum(["DEBUG", "INFO", "WARN", "ERROR"]), ) +const ExecutionProfileIdSchema = z.string().trim().min(1) +const ExecutionProfileNameSchema = z.string().trim().min(1) +const ExecutionProfileStringListSchema = z.array(z.string().trim().min(1)).max(64) + +const LocalExecutionProfileSchema = z.object({ + id: ExecutionProfileIdSchema, + name: ExecutionProfileNameSchema, + kind: z.literal("local"), + binaryPath: z.string().trim().min(1), +}) + +const WslExecutionProfileSchema = z.object({ + id: ExecutionProfileIdSchema, + name: ExecutionProfileNameSchema, + kind: z.literal("wsl"), + distro: z.string().trim().min(1), + binaryPath: z.string().trim().min(1), +}) + +const DockerExecutionProfileSchema = z.object({ + id: ExecutionProfileIdSchema, + name: ExecutionProfileNameSchema, + kind: z.literal("docker"), + image: z.string().trim().min(1), + workspaceMountPath: z.string().trim().min(1), + configMountPath: z.string().trim().min(1), + command: ExecutionProfileStringListSchema.optional(), + extraDockerArgs: ExecutionProfileStringListSchema.optional(), +}) + +const CommandExecutionProfileSchema = z.object({ + id: ExecutionProfileIdSchema, + name: ExecutionProfileNameSchema, + kind: z.literal("command"), + executable: z.string().trim().min(1), + args: ExecutionProfileStringListSchema.optional(), + cwdMode: z.enum(["workspace", "inherit"]).optional(), +}) + +const SshExecutionProfileSchema = z.object({ + id: ExecutionProfileIdSchema, + name: ExecutionProfileNameSchema, + kind: z.literal("ssh"), + host: z.string().trim().min(1), + port: z.number().int().positive().max(65535).optional(), + username: z.string().trim().optional(), + remotePath: z.string().trim().min(1), + binaryPath: z.string().trim().min(1), + args: ExecutionProfileStringListSchema.optional(), +}) + +const ExecutionProfileSchema = z.discriminatedUnion("kind", [ + LocalExecutionProfileSchema, + WslExecutionProfileSchema, + DockerExecutionProfileSchema, + CommandExecutionProfileSchema, + SshExecutionProfileSchema, +]) + function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } @@ -39,6 +98,25 @@ function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc { } else if (next.logLevel !== undefined) { next.logLevel = "DEBUG" } + + if (Array.isArray(next.executionProfiles)) { + next.executionProfiles = next.executionProfiles.flatMap((profile) => { + const parsed = ExecutionProfileSchema.safeParse(profile) + return parsed.success ? [parsed.data] : [] + }) + } else if (next.executionProfiles !== undefined) { + next.executionProfiles = [] + } + + const parsedDefaultExecutionProfileId = ExecutionProfileIdSchema.safeParse(next.defaultExecutionProfileId) + if (parsedDefaultExecutionProfileId.success) { + const profiles = Array.isArray(next.executionProfiles) ? next.executionProfiles : [] + const exists = profiles.some((profile) => isPlainObject(profile) && profile.id === parsedDefaultExecutionProfileId.data) + next.defaultExecutionProfileId = exists ? parsedDefaultExecutionProfileId.data : undefined + } else if (next.defaultExecutionProfileId !== undefined) { + next.defaultExecutionProfileId = undefined + } + return next } diff --git a/packages/server/src/workspaces/__tests__/spawn.test.ts b/packages/server/src/workspaces/__tests__/spawn.test.ts index 7b829ac75..785dcc599 100644 --- a/packages/server/src/workspaces/__tests__/spawn.test.ts +++ b/packages/server/src/workspaces/__tests__/spawn.test.ts @@ -79,6 +79,27 @@ describe("buildWindowsSpawnSpec", () => { assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_CONTENT:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_BASE_URL:OPENCODE_SERVER_PASSWORD") }) + it("wraps plain Linux binary paths when a WSL distro is provided", () => { + const spec = buildWindowsSpawnSpec("/home/dev/.opencode/bin/opencode", ["serve"], { + cwd: String.raw`C:\Users\dev\workspace`, + wslDistro: "Ubuntu", + }) + + assert.equal(spec.command, "wsl.exe") + assert.deepEqual(spec.args, [ + "--distribution", + "Ubuntu", + "--exec", + "sh", + "-lc", + 'cd "$(wslpath -au "$1")" && shift && exec "$@"', + "codenomad-wsl-launch", + String.raw`C:\Users\dev\workspace`, + "/home/dev/.opencode/bin/opencode", + "serve", + ]) + }) + it("preserves non-path OPENCODE_CONFIG_CONTENT WSLENV entries", () => { const spec = buildWindowsSpawnSpec( String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, diff --git a/packages/server/src/workspaces/execution-launch.test.ts b/packages/server/src/workspaces/execution-launch.test.ts new file mode 100644 index 000000000..8746e3758 --- /dev/null +++ b/packages/server/src/workspaces/execution-launch.test.ts @@ -0,0 +1,186 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import type { ResolvedBinary } from "../settings/binaries" +import { buildLaunchCommand, buildLaunchPreview, formatCommandLine } from "./execution-launch" + +describe("buildLaunchCommand", () => { + it("builds a command execution profile launch", () => { + const execution: ResolvedBinary = { + kind: "command", + label: "Wrapper", + executable: "node", + args: ["scripts/opencode-wrapper.mjs"], + cwdMode: "inherit", + } + + const result = buildLaunchCommand({ + execution, + workspacePath: "D:/CodeNomad", + environment: { CODENOMAD_INSTANCE_ID: "abc123" }, + logLevel: "DEBUG", + }) + + assert.equal(result.command, "node") + assert.deepEqual(result.args, ["scripts/opencode-wrapper.mjs", "serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]) + assert.equal(result.cwd, undefined) + assert.deepEqual(result.environment, { CODENOMAD_INSTANCE_ID: "abc123" }) + }) + + it("builds a docker execution profile launch with rewritten paths and URLs", () => { + const execution: ResolvedBinary = { + kind: "docker", + label: "Docker Sandbox", + image: "ghcr.io/example/opencode:latest", + workspaceMountPath: "/workspace", + configMountPath: "/root/.config/opencode", + command: ["opencode"], + extraDockerArgs: ["--init"], + } + + const result = buildLaunchCommand({ + execution, + workspacePath: "D:/CodeNomad", + environment: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + plugin: [ + "@codenomad/codenomad-opencode-plugin@file:C:/Users/Admin/.config/CodeNomad/codenomad-opencode-plugin.tgz", + ], + }), + NODE_EXTRA_CA_CERTS: "C:/Users/Admin/.config/codenomad/certs.pem", + CODENOMAD_BASE_URL: "https://127.0.0.1:9898", + OPENCODE_SERVER_BASE_URL: "https://127.0.0.1:9898/workspaces/abc/worktrees/root/instance", + }, + logLevel: "INFO", + reservedPort: 17600, + }) + + assert.equal(result.command, "docker") + assert.ok(result.args.includes("ghcr.io/example/opencode:latest")) + assert.ok(result.args.includes("D:/CodeNomad:/workspace")) + assert.ok(result.args.includes("C:/Users/Admin/.config/CodeNomad/codenomad-opencode-plugin.tgz:/root/.config/opencode/codenomad-opencode-plugin.tgz:ro")) + assert.ok(result.args.includes("C:/Users/Admin/.config/codenomad/certs.pem:/tmp/codenomad-node-extra-ca.pem:ro")) + assert.ok(result.args.includes("127.0.0.1:17600:17600")) + assert.ok(result.args.includes("CODENOMAD_BASE_URL")) + assert.ok(result.args.includes("OPENCODE_CONFIG_CONTENT")) + assert.ok(result.args.includes("NODE_EXTRA_CA_CERTS")) + assert.equal(result.environment?.CODENOMAD_BASE_URL, "https://host.docker.internal:9898") + assert.match(result.environment?.OPENCODE_CONFIG_CONTENT ?? "", /\/root\/\.config\/opencode\/codenomad-opencode-plugin\.tgz/) + assert.equal(result.environment?.NODE_EXTRA_CA_CERTS, "/tmp/codenomad-node-extra-ca.pem") + assert.deepEqual(result.args.slice(-8), ["serve", "--port", "17600", "--print-logs", "--log-level", "INFO", "--hostname", "0.0.0.0"]) + }) + + it("requires a reserved local port for Docker execution profiles", () => { + const execution: ResolvedBinary = { + kind: "docker", + label: "Docker Sandbox", + image: "ghcr.io/example/opencode:latest", + workspaceMountPath: "/workspace", + configMountPath: "/root/.config/opencode", + } + + assert.throws( + () => buildLaunchCommand({ + execution, + workspacePath: "D:/CodeNomad", + environment: { OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: [] }) }, + logLevel: "INFO", + }), + /Reserved local port is required/, + ) + }) + + it("builds an SSH execution profile launch with forward and reverse tunnels", () => { + const execution: ResolvedBinary = { + kind: "ssh", + label: "SSH Linux", + host: "vm.example.com", + port: 2222, + username: "ubuntu", + remotePath: "/srv/project", + binaryPath: "opencode", + } + + const result = buildLaunchCommand({ + execution, + workspacePath: "/unused/local/path", + environment: { + CODENOMAD_BASE_URL: "http://127.0.0.1:9898", + OPENCODE_SERVER_BASE_URL: "http://127.0.0.1:9898/workspaces/abc/worktrees/root/instance", + }, + logLevel: "DEBUG", + reservedPort: 17600, + callbackPort: 17601, + }) + + assert.equal(result.command, "ssh") + assert.ok(result.args.includes("127.0.0.1:17600:127.0.0.1:17600")) + assert.ok(result.args.includes("127.0.0.1:17601:127.0.0.1:9898")) + assert.ok(result.args.includes("ubuntu@vm.example.com")) + assert.deepEqual(result.args.slice(-2), ["sh", "-s"]) + assert.ok(result.stdin?.includes("exec env")) + assert.ok(result.stdin?.includes("opencode")) + assert.ok(result.stdin?.includes("--port")) + assert.ok(result.stdin?.includes("17600")) + assert.ok(result.stdin?.includes("OPENCODE_SERVER_BASE_URL='http://127.0.0.1:17601/workspaces/abc/worktrees/root/instance'")) + }) + + it("rejects unsafe SSH environment variable names", () => { + const execution: ResolvedBinary = { + kind: "ssh", + label: "SSH Linux", + host: "vm.example.com", + remotePath: "/srv/project", + binaryPath: "opencode", + } + + assert.throws( + () => buildLaunchCommand({ + execution, + workspacePath: "/unused/local/path", + environment: { "BAD;touch /tmp/pwned": "value" }, + logLevel: "DEBUG", + reservedPort: 17600, + callbackPort: 17601, + }), + /Invalid environment variable name/, + ) + }) + + it("formats preview command lines with quoting", () => { + assert.equal(formatCommandLine("docker", ["run", "C:/Program Files/OpenCode/opencode.exe", "--flag"]), 'docker run "C:/Program Files/OpenCode/opencode.exe" --flag') + }) + + if (process.platform === "win32") { + it("builds a WSL preview using the actual spawn command", () => { + const execution: ResolvedBinary = { + kind: "wsl", + label: "Ubuntu", + path: String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, + } + + const result = buildLaunchPreview({ + execution, + workspacePath: String.raw`D:\CodeNomad`, + environment: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: ["npm:user-plugin"] }), + CODENOMAD_INSTANCE_ID: "preview-instance", + OPENCODE_SERVER_BASE_URL: "https://127.0.0.1:9898/workspaces/preview-instance/worktrees/root/instance", + OPENCODE_SERVER_PASSWORD: "REDACTED", + }, + logLevel: "DEBUG", + }) + + assert.equal(result.command, "wsl.exe") + assert.deepEqual(result.args.slice(0, 6), [ + "--distribution", + "Ubuntu", + "--exec", + "sh", + "-lc", + 'printf \'%s%s\\n\' \'__CODENOMAD_WSL_PID__:\' "$$" && cd "$(wslpath -au "$1")" && shift && exec "$@"', + ]) + assert.equal(result.environment?.WSLENV, "OPENCODE_CONFIG_CONTENT:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_BASE_URL:OPENCODE_SERVER_PASSWORD") + }) + } +}) diff --git a/packages/server/src/workspaces/execution-launch.ts b/packages/server/src/workspaces/execution-launch.ts new file mode 100644 index 000000000..2889a508d --- /dev/null +++ b/packages/server/src/workspaces/execution-launch.ts @@ -0,0 +1,320 @@ +import { URL } from "url" +import type { ResolvedBinary } from "../settings/binaries" +import { + findPackagedCodeNomadPluginReference, + rewritePackagedCodeNomadPluginReference, +} from "../opencode-plugin.js" +import { buildSpawnSpec, WSL_PID_MARKER } from "./spawn" + +const DOCKER_HOST_ALIAS = "host.docker.internal" +const DOCKER_CA_CERT_PATH = "/tmp/codenomad-node-extra-ca.pem" +const DOCKER_PLUGIN_TARBALL_NAME = "codenomad-opencode-plugin.tgz" + +export interface LaunchCommandSpec { + command: string + args: string[] + cwd?: string + environment?: Record + wslDistro?: string + stdin?: string +} + +interface BuildLaunchCommandParams { + execution: ResolvedBinary + workspacePath: string + environment: Record + logLevel: string + reservedPort?: number + callbackPort?: number +} + +export function buildLaunchCommand(params: BuildLaunchCommandParams): LaunchCommandSpec { + const openCodePort = (params.execution.kind === "docker" || params.execution.kind === "ssh") && params.reservedPort ? String(params.reservedPort) : "0" + const openCodeArgs = ["serve", "--port", openCodePort, "--print-logs", "--log-level", params.logLevel] + if (params.execution.kind === "docker") { + openCodeArgs.push("--hostname", "0.0.0.0") + } + + if (params.execution.kind === "docker") { + if (!params.reservedPort) { + throw new Error("Reserved local port is required for Docker execution profiles") + } + return buildDockerLaunchCommand(params.execution, params.workspacePath, params.environment, openCodeArgs, params.reservedPort) + } + + if (params.execution.kind === "command") { + return { + command: params.execution.executable, + args: [...(params.execution.args ?? []), ...openCodeArgs], + cwd: params.execution.cwdMode === "inherit" ? undefined : params.workspacePath, + environment: params.environment, + } + } + + if (params.execution.kind === "ssh") { + if (!params.reservedPort || !params.callbackPort) { + throw new Error("Reserved local and callback ports are required for SSH execution profiles") + } + return buildSshLaunchCommand(params.execution, params.reservedPort, params.callbackPort, params.environment, openCodeArgs) + } + + return { + command: params.execution.path, + args: openCodeArgs, + cwd: params.workspacePath, + environment: params.environment, + wslDistro: params.execution.kind === "wsl" ? params.execution.wslDistro : undefined, + } +} + +function buildSshLaunchCommand( + execution: Extract, + forwardedPort: number, + callbackPort: number, + environment: Record, + openCodeArgs: string[], +): LaunchCommandSpec { + const host = execution.host.trim() + if (!host || host.startsWith("-") || /\s/.test(host)) { + throw new Error("SSH host must not be empty, start with '-', or contain whitespace") + } + + const username = execution.username?.trim() + if (username && (username.startsWith("-") || /[@\s]/.test(username))) { + throw new Error("SSH username must not start with '-' or contain '@' or whitespace") + } + + const target = username ? `${username}@${host}` : host + const remoteEnvironment = rewriteSshCallbackEnvironment(environment, callbackPort) + const remoteScript = buildSshRemoteScript(execution, remoteEnvironment, openCodeArgs) + + return { + command: "ssh", + args: [ + "-p", + String(execution.port ?? 22), + "-o", + "BatchMode=yes", + "-o", + "ExitOnForwardFailure=yes", + "-L", + `127.0.0.1:${forwardedPort}:127.0.0.1:${forwardedPort}`, + "-R", + `127.0.0.1:${callbackPort}:127.0.0.1:${getUrlPort(environment.CODENOMAD_BASE_URL) ?? 9898}`, + target, + "sh", + "-s", + ], + environment: {}, + stdin: remoteScript, + } +} + +function buildSshRemoteScript( + execution: Extract, + environment: Record, + openCodeArgs: string[], +): string { + const assignments = Object.entries(environment).map(([key, value]) => { + if (!isEnvironmentVariableName(key)) { + throw new Error(`Invalid environment variable name for SSH execution profile: ${key}`) + } + return `${key}=${shellQuote(value)}` + }) + + const command = [ + "exec", + "env", + ...assignments, + shellQuote(execution.binaryPath), + ...(execution.args ?? []).map(shellQuote), + ...openCodeArgs.map(shellQuote), + ].join(" ") + + return ["set -eu", `cd ${shellQuote(execution.remotePath)}`, command, ""].join("\n") +} + +export function buildLaunchPreview(params: BuildLaunchCommandParams): LaunchCommandSpec { + const launch = buildLaunchCommand(params) + const explicitEnvironment = launch.environment ?? {} + const mergedEnvironment = { ...process.env, ...explicitEnvironment } + const spawnSpec = buildSpawnSpec(launch.command, launch.args, { + cwd: launch.cwd, + env: mergedEnvironment, + propagateEnvKeys: Object.keys(explicitEnvironment), + wslPidMarker: WSL_PID_MARKER, + wslDistro: launch.wslDistro, + }) + + return { + command: spawnSpec.command, + args: spawnSpec.args, + cwd: spawnSpec.cwd, + environment: collectPreviewEnvironment(explicitEnvironment, mergedEnvironment, spawnSpec.env), + } +} + +export function formatCommandLine(command: string, args: string[]): string { + return [command, ...args].map(formatCommandToken).join(" ") +} + +function buildDockerLaunchCommand( + execution: Extract, + workspacePath: string, + environment: Record, + openCodeArgs: string[], + forwardedPort: number, +): LaunchCommandSpec { + const configContent = environment.OPENCODE_CONFIG_CONTENT?.trim() + if (!configContent) { + throw new Error("OPENCODE_CONFIG_CONTENT is required for Docker execution profiles") + } + + const containerEnvironment: Record = { ...environment } + const packagedPlugin = findPackagedCodeNomadPluginReference(configContent) + + if (containerEnvironment.CODENOMAD_BASE_URL) { + containerEnvironment.CODENOMAD_BASE_URL = rewriteDockerBaseUrl(containerEnvironment.CODENOMAD_BASE_URL) + } + if (containerEnvironment.OPENCODE_SERVER_BASE_URL) { + containerEnvironment.OPENCODE_SERVER_BASE_URL = rewriteDockerBaseUrl(containerEnvironment.OPENCODE_SERVER_BASE_URL) + } + + const nodeExtraCaCerts = containerEnvironment.NODE_EXTRA_CA_CERTS?.trim() + const dockerArgs = [ + "run", + "--rm", + "-i", + "--workdir", + execution.workspaceMountPath, + "--add-host", + `${DOCKER_HOST_ALIAS}:host-gateway`, + "-p", + `127.0.0.1:${forwardedPort}:${forwardedPort}`, + "-v", + `${workspacePath}:${execution.workspaceMountPath}`, + ] + + if (packagedPlugin) { + const containerPluginPath = joinPosixPath(execution.configMountPath, DOCKER_PLUGIN_TARBALL_NAME) + containerEnvironment.OPENCODE_CONFIG_CONTENT = rewritePackagedCodeNomadPluginReference(configContent, containerPluginPath) + dockerArgs.push("-v", `${packagedPlugin.filePath.replace(/\\/g, "/")}:${containerPluginPath}:ro`) + } + + if (nodeExtraCaCerts) { + dockerArgs.push("-v", `${nodeExtraCaCerts}:${DOCKER_CA_CERT_PATH}:ro`) + containerEnvironment.NODE_EXTRA_CA_CERTS = DOCKER_CA_CERT_PATH + } + + for (const [key, value] of Object.entries(containerEnvironment)) { + dockerArgs.push("-e", key) + } + + if (execution.extraDockerArgs?.length) { + dockerArgs.push(...execution.extraDockerArgs) + } + + dockerArgs.push(execution.image) + dockerArgs.push(...(execution.command?.length ? execution.command : ["opencode"])) + dockerArgs.push(...openCodeArgs) + + return { + command: "docker", + args: dockerArgs, + environment: containerEnvironment, + } +} + +function collectPreviewEnvironment( + explicitEnvironment: Record, + mergedEnvironment: NodeJS.ProcessEnv, + spawnEnvironment: NodeJS.ProcessEnv | undefined, +): Record { + const previewKeys = new Set(Object.keys(explicitEnvironment)) + + if (spawnEnvironment) { + for (const [key, value] of Object.entries(spawnEnvironment)) { + if (typeof value !== "string") { + continue + } + if (value !== mergedEnvironment[key]) { + previewKeys.add(key) + } + } + } + + const previewEnvironment: Record = {} + for (const key of previewKeys) { + const value = spawnEnvironment?.[key] ?? mergedEnvironment[key] + if (typeof value === "string") { + previewEnvironment[key] = value + } + } + + return previewEnvironment +} + +function formatCommandToken(token: string): string { + if (!token) { + return '""' + } + + return /[\s"'`$&|<>()[\]{};\\]/.test(token) ? JSON.stringify(token) : token +} + +function shellQuote(value: string): string { + if (!value) return "''" + return `'${value.replace(/'/g, `'"'"'`)}'` +} + +function isEnvironmentVariableName(value: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value) +} + +function rewriteSshCallbackEnvironment(environment: Record, callbackPort: number): Record { + const rewritten = { ...environment } + for (const key of ["CODENOMAD_BASE_URL", "OPENCODE_SERVER_BASE_URL"]) { + const value = rewritten[key] + if (!value) continue + rewritten[key] = rewriteUrlHostPort(value, "127.0.0.1", callbackPort) + } + return rewritten +} + +function rewriteUrlHostPort(value: string, host: string, port: number): string { + try { + const url = new URL(value) + url.hostname = host + url.port = String(port) + return url.toString().replace(/\/$/, "") + } catch { + return value + } +} + +function getUrlPort(value?: string): number | undefined { + if (!value) return undefined + try { + const url = new URL(value) + const parsed = Number(url.port || (url.protocol === "https:" ? 443 : 80)) + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined + } catch { + return undefined + } +} + +function rewriteDockerBaseUrl(input: string): string { + try { + const url = new URL(input) + if (url.hostname === "127.0.0.1" || url.hostname === "localhost") { + url.hostname = DOCKER_HOST_ALIAS + } + return url.toString().replace(/\/$/, "") + } catch { + return input + } +} + +function joinPosixPath(base: string, name: string): string { + return `${base.replace(/\/+$/, "")}/${name}` +} diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index cd1933b2f..4424d617a 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -1,9 +1,9 @@ import path from "path" import { spawnSync } from "child_process" -import { connect } from "net" +import { connect, createServer } from "net" import { EventBus } from "../events/bus" import type { SettingsService } from "../settings/service" -import type { BinaryResolver } from "../settings/binaries" +import type { BinaryResolver, ResolvedBinary } from "../settings/binaries" import { FileSystemBrowser } from "../filesystem/browser" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { clearWorkspaceSearchCache } from "../filesystem/search-cache" @@ -12,9 +12,12 @@ import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { Logger } from "../logger" import { buildOpencodeConfigContent, + findPackagedCodeNomadPluginReference, getCodeNomadPluginUrl, + rewritePackagedCodeNomadPluginReference, resolveExistingOpencodeConfigContent, } from "../opencode-plugin.js" +import { buildLaunchCommand } from "./execution-launch" import { OPENCODE_SERVER_BASE_URL_ENV, buildOpencodeBasicAuthHeader, @@ -38,6 +41,16 @@ interface WorkspaceManagerOptions { interface WorkspaceRecord extends WorkspaceDescriptor {} +interface SshPackagedPluginArtifact { + remotePath: string + configContent: string +} + +function shellQuote(value: string): string { + if (!value) return "''" + return `'${value.replace(/'/g, `'"'"'`)}'` +} + export class WorkspaceManager { private readonly workspaces = new Map() private readonly runtime: WorkspaceRuntime @@ -95,15 +108,17 @@ export class WorkspaceManager { browser.writeFile(relativePath, contents) } - async create(folder: string, name?: string): Promise { + async create(folder: string, name?: string, options?: { executionProfileId?: string }): Promise { const id = `${Date.now().toString(36)}` - const binary = this.options.binaryResolver.resolveDefault() - const resolvedBinaryPath = this.resolveBinaryPath(binary.path) + const execution = this.options.binaryResolver.resolveActive(options?.executionProfileId) + const resolvedBinaryPath = this.resolveBinaryPath( + execution.kind === "command" ? execution.executable : execution.kind === "docker" ? "docker" : execution.kind === "ssh" ? "ssh" : execution.path, + ) const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) clearWorkspaceSearchCache(workspacePath) - this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace") + this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath, executionKind: execution.kind }, "Creating workspace") const proxyPath = `/workspaces/${id}/worktrees/root/instance` @@ -115,8 +130,11 @@ export class WorkspaceManager { status: "starting", proxyPath, binaryId: resolvedBinaryPath, - binaryLabel: binary.label, - binaryVersion: binary.version, + binaryLabel: execution.label, + binaryVersion: execution.version, + executionProfileId: execution.executionProfileId, + executionProfileName: execution.executionProfileName, + executionProfileKind: execution.executionProfileKind, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } @@ -157,17 +175,45 @@ export class WorkspaceManager { [OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword, } + const sshPackagedPlugin = + execution.kind === "ssh" ? await this.syncSshPackagedPlugin(execution, id, environment.OPENCODE_CONFIG_CONTENT) : undefined + if (sshPackagedPlugin) { + environment.OPENCODE_CONFIG_CONTENT = sshPackagedPlugin.configContent + } + const logLevel = (serverConfig as any)?.logLevel + const reservedPort = execution.kind === "docker" || execution.kind === "ssh" ? await this.getAvailablePort() : undefined + const callbackPort = execution.kind === "ssh" ? await this.getAvailablePort() : undefined + const launchCommand = buildLaunchCommand({ + execution, + workspacePath, + environment, + logLevel: typeof logLevel === "string" ? logLevel.toUpperCase() : "DEBUG", + reservedPort, + callbackPort, + }) + + let launchedPid: number | undefined try { const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({ workspaceId: id, folder: workspacePath, - binaryPath: resolvedBinaryPath, - environment, + binaryPath: launchCommand.command, + commandArgs: launchCommand.args, + spawnCwd: launchCommand.cwd, + environment: launchCommand.environment, + wslDistro: launchCommand.wslDistro, + stdin: launchCommand.stdin, logLevel, - onExit: (info) => this.handleProcessExit(info.workspaceId, info), + onExit: (info) => { + if (execution.kind === "ssh" && sshPackagedPlugin) { + this.cleanupSshPackagedPlugin(execution, sshPackagedPlugin.remotePath) + } + this.handleProcessExit(info.workspaceId, info) + }, }) + launchedPid = pid const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput }) if (runtimeVersion) { @@ -187,6 +233,14 @@ export class WorkspaceManager { descriptor.updatedAt = new Date().toISOString() this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor }) this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start") + if (launchedPid !== undefined) { + await this.runtime.stop(id).catch((stopError) => { + this.options.logger.warn({ workspaceId: id, err: stopError }, "Failed to stop workspace after startup failure") + }) + } + if (execution.kind === "ssh" && sshPackagedPlugin) { + this.cleanupSshPackagedPlugin(execution, sshPackagedPlugin.remotePath) + } throw error } } @@ -461,6 +515,125 @@ export class WorkspaceManager { }) } + private async getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer() + server.unref() + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + const address = server.address() + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to reserve a local port for SSH execution profile"))) + return + } + const port = address.port + server.close((error) => { + if (error) { + reject(error) + return + } + resolve(port) + }) + }) + }) + } + + private async syncSshPackagedPlugin( + execution: Extract, + workspaceId: string, + configContent: string | undefined, + ): Promise { + const packagedPlugin = findPackagedCodeNomadPluginReference(configContent) + if (!packagedPlugin || !configContent) { + return undefined + } + + const localPluginPath = path.normalize(packagedPlugin.filePath) + const remotePluginPath = `/tmp/codenomad-opencode-plugin-${workspaceId}.tgz` + const sshArgs = this.buildSshCommandArgs(execution, [ + "sh", + "-lc", + `rm -f ${shellQuote(remotePluginPath)}`, + ]) + const cleanupResult = spawnSync("ssh", sshArgs, { + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }) + if (cleanupResult.error) { + throw cleanupResult.error + } + if (cleanupResult.status !== 0) { + throw new Error(`Failed to prepare SSH OpenCode plugin path: ${cleanupResult.stderr || `ssh exited with ${cleanupResult.status}`}`) + } + + const scpResult = spawnSync( + "scp", + this.buildScpCommandArgs(execution, [localPluginPath, `${this.buildSshTarget(execution)}:${remotePluginPath}`]), + { + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }, + ) + if (scpResult.error) { + throw scpResult.error + } + if (scpResult.status !== 0) { + throw new Error(`Failed to copy OpenCode plugin to SSH host: ${scpResult.stderr || `scp exited with ${scpResult.status}`}`) + } + + return { + remotePath: remotePluginPath, + configContent: rewritePackagedCodeNomadPluginReference(configContent, remotePluginPath), + } + } + + private cleanupSshPackagedPlugin(execution: Extract, remotePluginPath: string): void { + const result = spawnSync("ssh", this.buildSshCommandArgs(execution, ["sh", "-lc", `rm -f ${shellQuote(remotePluginPath)}`]), { + encoding: "utf8", + timeout: 10_000, + }) + if (result.error || result.status !== 0) { + this.options.logger.debug({ err: result.error, stderr: result.stderr, status: result.status }, "Failed to clean SSH OpenCode plugin path") + } + } + + private buildSshCommandArgs(execution: Extract, remoteArgs: string[]): string[] { + return [ + "-p", + String(execution.port ?? 22), + "-o", + "BatchMode=yes", + "-o", + "ExitOnForwardFailure=yes", + this.buildSshTarget(execution), + ...remoteArgs, + ] + } + + private buildScpCommandArgs(execution: Extract, args: string[]): string[] { + return [ + "-P", + String(execution.port ?? 22), + "-o", + "BatchMode=yes", + ...args, + ] + } + + private buildSshTarget(execution: Extract): string { + const host = execution.host.trim() + if (!host || host.startsWith("-") || /\s/.test(host)) { + throw new Error("SSH host must not be empty, start with '-', or contain whitespace") + } + + const username = execution.username?.trim() + if (username && (username.startsWith("-") || /[@\s]/.test(username))) { + throw new Error("SSH username must not start with '-' or contain '@' or whitespace") + } + + return username ? `${username}@${host}` : host + } + private delay(durationMs: number): Promise { if (durationMs <= 0) { return Promise.resolve() diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index efc77f9a1..0f597d428 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -4,10 +4,9 @@ import path from "path" import { EventBus } from "../events/bus" import { LogLevel, WorkspaceLogEntry } from "../api-types" import { Logger } from "../logger" -import { buildSpawnSpec, buildWslSignalSpec } from "./spawn" +import { buildSpawnSpec, buildWslSignalSpec, WSL_PID_MARKER } from "./spawn" -const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i -const WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:" +const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET|API[_-]?KEY)/i function redactEnvironment(env: Record): Record { const redacted: Record = {} @@ -21,11 +20,31 @@ function redactEnvironment(env: Record): Record { + const [key] = arg.split("=", 1) + if (key && SENSITIVE_ENV_KEY.test(key)) { + return arg.includes("=") ? `${key}=[REDACTED]` : "[REDACTED]" + } + + const previous = args[index - 1] + if ((previous === "-e" || previous === "--env") && SENSITIVE_ENV_KEY.test(key ?? arg)) { + return arg.includes("=") ? `${key}=[REDACTED]` : arg + } + + return arg + }) +} + interface LaunchOptions { workspaceId: string folder: string binaryPath: string + commandArgs?: string[] + spawnCwd?: string environment?: Record + wslDistro?: string + stdin?: string logLevel?: string onExit?: (info: ProcessExitInfo) => void } @@ -55,7 +74,7 @@ export class WorkspaceRuntime { this.validateFolder(options.folder) const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG" - const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel] + const args = options.commandArgs ?? ["serve", "--port", "0", "--print-logs", "--log-level", logLevel] const env = { ...process.env, ...(options.environment ?? {}) } let exitResolve: ((info: ProcessExitInfo) => void) | null = null @@ -83,12 +102,14 @@ export class WorkspaceRuntime { return new Promise((resolve, reject) => { const propagatedEnvKeys = Object.keys(options.environment ?? {}) const spec = buildSpawnSpec(options.binaryPath, args, { - cwd: options.folder, + cwd: options.spawnCwd ?? options.folder, env, propagateEnvKeys: propagatedEnvKeys, wslPidMarker: WSL_PID_MARKER, + wslDistro: options.wslDistro, }) - const commandLine = [spec.command, ...spec.args].join(" ") + const redactedArgs = redactArgs(spec.args) + const commandLine = [spec.command, ...redactedArgs].join(" ") this.logger.info( { workspaceId: options.workspaceId, @@ -103,7 +124,7 @@ export class WorkspaceRuntime { this.logger.debug( { workspaceId: options.workspaceId, - spawnArgs: spec.args, + spawnArgs: redactedArgs, }, "OpenCode spawn args", ) @@ -119,11 +140,15 @@ export class WorkspaceRuntime { const child = spawn(spec.command, spec.args, { cwd: spec.cwd, env: spec.env, - stdio: ["ignore", "pipe", "pipe"], + stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], detached, ...spec.options, }) + if (options.stdin !== undefined) { + child.stdin?.end(options.stdin) + } + const managed: ManagedProcess = { child, requestedStop: false, diff --git a/packages/server/src/workspaces/spawn.ts b/packages/server/src/workspaces/spawn.ts index f40dcdb02..0e3416959 100644 --- a/packages/server/src/workspaces/spawn.ts +++ b/packages/server/src/workspaces/spawn.ts @@ -3,6 +3,7 @@ import path from "path" export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]) export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) +export const WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:" const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/ const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i @@ -33,6 +34,7 @@ interface BuildSpawnSpecOptions { env?: NodeJS.ProcessEnv propagateEnvKeys?: string[] wslPidMarker?: string + wslDistro?: string } interface WslPath { @@ -77,6 +79,11 @@ export function buildWindowsSpawnSpec(binaryPath: string, args: string[], option return buildWslSpawnSpec(wslPath, args, options) } + const wslDistro = options.wslDistro?.trim() + if (wslDistro) { + return buildWslSpawnSpec({ distro: wslDistro, linuxPath: binaryPath }, args, options) + } + const extension = path.extname(binaryPath).toLowerCase() if (WINDOWS_CMD_EXTENSIONS.has(extension)) { @@ -137,7 +144,7 @@ export function buildWslSignalSpec(distro: string, linuxPid: number, signal: Nod } } -export function probeBinaryVersion(binaryPath: string): { +export function probeBinaryVersion(binaryPath: string, options: { wslDistro?: string } = {}): { valid: boolean version?: string reported?: string @@ -148,7 +155,7 @@ export function probeBinaryVersion(binaryPath: string): { } try { - const spec = buildSpawnSpec(binaryPath, ["--version"]) + const spec = buildSpawnSpec(binaryPath, ["--version"], { wslDistro: options.wslDistro }) const result = spawnSync(spec.command, spec.args, { encoding: "utf8", cwd: spec.cwd, diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 2aa430fcd..ed7e10490 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -264,7 +264,7 @@ const App: Component = () => { const launchErrorMessage = () => launchError()?.message ?? "" - async function handleSelectFolder(folderPath: string, binaryPath?: string, options?: { forceNew?: boolean }) { + async function handleSelectFolder(folderPath: string, binaryPath?: string, options?: { executionProfileId?: string; forceNew?: boolean }) { if (!folderPath) { return } @@ -282,7 +282,9 @@ const App: Component = () => { setIsSelectingFolder(true) try { - const instanceId = await createInstance(folderPath, selectedBinary) + const instanceId = await createInstance(folderPath, selectedBinary, { + executionProfileId: options?.executionProfileId, + }) selectInstanceTab(instanceId) setShowFolderSelection(false) diff --git a/packages/ui/src/components/action-overflow-menu.tsx b/packages/ui/src/components/action-overflow-menu.tsx new file mode 100644 index 000000000..a66a25f3f --- /dev/null +++ b/packages/ui/src/components/action-overflow-menu.tsx @@ -0,0 +1,85 @@ +import { DropdownMenu } from "@kobalte/core/dropdown-menu" +import { For, Show, createSignal, onCleanup, type JSXElement } from "solid-js" +import { MoreHorizontal } from "lucide-solid" + +export interface ActionOverflowMenuItem { + key: string + label: string + icon?: JSXElement + disabled?: boolean + destructive?: boolean + onSelect: () => void | Promise + onMouseEnter?: () => void + onMouseLeave?: () => void +} + +interface ActionOverflowMenuProps { + items: ActionOverflowMenuItem[] + label: string + triggerClass?: string + minItems?: number +} + +export default function ActionOverflowMenu(props: ActionOverflowMenuProps) { + const [hoveredItem, setHoveredItem] = createSignal(null) + const enabledItems = () => props.items.filter((item) => !item.disabled) + const hasItems = () => props.items.length >= (props.minItems ?? 1) + const clearHoveredItem = () => { + const item = hoveredItem() + if (!item) return + item.onMouseLeave?.() + setHoveredItem(null) + } + + onCleanup(clearHoveredItem) + + return ( + + { if (!open) clearHoveredItem() }}> + + + + + + + {(item) => ( + { + if (item.disabled) return + const previous = hoveredItem() + if (previous !== item) previous?.onMouseLeave?.() + setHoveredItem(item) + item.onMouseEnter?.() + }} + onPointerLeave={() => { + if (item.disabled) return + if (hoveredItem() === item) setHoveredItem(null) + item.onMouseLeave?.() + }} + onSelect={() => { + clearHoveredItem() + void item.onSelect() + }} + > + + {item.label} + + )} + + + + + + ) +} diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 035efb708..ff4fecdb0 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -1,6 +1,7 @@ import { Dialog } from "@kobalte/core/dialog" import { Select } from "@kobalte/core/select" -import { Component, createMemo, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" +import type { RemoteServerProfile } from "../../../server/src/api-types" +import { Component, createSignal, Show, For, onMount, onCleanup, createEffect, createMemo } from "solid-js" import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2, GitBranch } from "lucide-solid" import { useConfig } from "../stores/preferences" import DirectoryBrowserDialog from "./directory-browser-dialog" @@ -26,9 +27,8 @@ const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028 type HomeTab = "local" | "servers" - interface FolderSelectionViewProps { - onSelectFolder: (folder: string, binaryPath?: string, options?: { forceNew?: boolean }) => void + onSelectFolder: (folder: string, binaryPath?: string, options?: { executionProfileId?: string; forceNew?: boolean }) => void onOpenSidecar?: () => void isLoading?: boolean onClose?: () => void @@ -41,6 +41,10 @@ const FolderSelectionView: Component = (props) => { preferences, updatePreferences, serverSettings, + executionProfiles, + defaultExecutionProfileId, + lastSelectedExecutionProfileId, + setLastSelectedExecutionProfileId, remoteServers, saveRemoteServerProfile, markRemoteServerConnected, @@ -50,6 +54,7 @@ const FolderSelectionView: Component = (props) => { const [selectedIndex, setSelectedIndex] = createSignal(0) const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") + const [selectedExecutionProfileId, setSelectedExecutionProfileId] = createSignal(lastSelectedExecutionProfileId() ?? defaultExecutionProfileId() ?? null) const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false) const [isCloneDialogOpen, setIsCloneDialogOpen] = createSignal(false) const [isCloneDestinationBrowserOpen, setIsCloneDestinationBrowserOpen] = createSignal(false) @@ -69,6 +74,7 @@ const FolderSelectionView: Component = (props) => { let recentListRef: HTMLDivElement | undefined type LanguageOption = { value: Locale; label: string } + type ExecutionProfileOption = { value: string; label: string; subtitle: string } const languageOptions: LanguageOption[] = [ { value: "en", label: "English" }, @@ -81,6 +87,18 @@ const FolderSelectionView: Component = (props) => { ] const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0] + const executionProfileOptions = createMemo(() => + executionProfiles().map((profile) => ({ + value: profile.id, + label: profile.name, + subtitle: t(`settings.opencode.executionProfiles.kind.${profile.kind}`), + })), + ) + const selectedExecutionProfileOption = createMemo(() => { + const options = executionProfileOptions() + const selectedId = selectedExecutionProfileId() + return options.find((option) => option.value === selectedId) ?? options[0] + }) const folders = () => recentFolders() const serverList = () => remoteServers() @@ -98,6 +116,34 @@ const FolderSelectionView: Component = (props) => { setSelectedBinary((current) => (current === lastUsed ? current : lastUsed)) }) + createEffect(() => { + const options = executionProfileOptions() + if (options.length === 0) { + setSelectedExecutionProfileId(null) + return + } + + const defaultId = defaultExecutionProfileId() + const selectedId = selectedExecutionProfileId() + const targetId = + selectedId && options.some((option) => option.value === selectedId) + ? selectedId + : lastSelectedExecutionProfileId() && options.some((option) => option.value === lastSelectedExecutionProfileId()) + ? lastSelectedExecutionProfileId()! + : defaultId && options.some((option) => option.value === defaultId) + ? defaultId + : options[0]?.value + + setSelectedExecutionProfileId((current) => (current === targetId ? current : targetId ?? null)) + }) + + createEffect(() => { + const selectedId = selectedExecutionProfileId() + if (!selectedId) return + if (lastSelectedExecutionProfileId() === selectedId) return + setLastSelectedExecutionProfileId(selectedId) + }) + function scrollToIndex(index: number) { const container = recentListRef @@ -208,7 +254,7 @@ const FolderSelectionView: Component = (props) => { const server = serverList()[index] if (server) { - void handleConnectSavedServer(server.id) + void handleConnectSavedConnection(server.id) } } @@ -281,7 +327,9 @@ const FolderSelectionView: Component = (props) => { function handleFolderSelect(path: string) { if (isLoading()) return - props.onSelectFolder(path, selectedBinary()) + props.onSelectFolder(path, selectedBinary(), { + executionProfileId: selectedExecutionProfileId() ?? undefined, + }) } function resetCloneDialog() { @@ -339,7 +387,7 @@ const FolderSelectionView: Component = (props) => { async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) { if (openWindow && !canUseRemoteServerWindows()) { - throw new Error("Remote server windows can only be opened from a local desktop window") + throw new Error(t("folderSelection.servers.errorDesktopOnly")) } const trimmedName = input.name.trim() @@ -410,7 +458,7 @@ const FolderSelectionView: Component = (props) => { } } - async function handleConnectSavedServer(id: string) { + async function handleConnectSavedConnection(id: string) { if (!canUseRemoteServerWindows()) return const target = remoteServers().find((entry) => entry.id === id) if (!target || connectingServerId()) return @@ -427,6 +475,10 @@ const FolderSelectionView: Component = (props) => { } } + async function handleRemoveSavedConnection(profile: RemoteServerProfile) { + removeRemoteServerProfile(profile.id) + } + async function handleBrowse() { if (isLoading()) return setFocusMode("new") @@ -762,7 +814,7 @@ const FolderSelectionView: Component = (props) => { color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)", }} > - {t("folderSelection.servers.count", { count: remoteServers().length })} + {t("folderSelection.servers.count", { count: serverList().length })}

@@ -773,7 +825,7 @@ const FolderSelectionView: Component = (props) => { when={activeTab() === "local"} fallback={ 0} + when={canUseRemoteServerWindows() && serverList().length > 0} fallback={
@@ -798,7 +850,7 @@ const FolderSelectionView: Component = (props) => { class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto" ref={(el) => (recentListRef = el)} > - + {(server, index) => (
= (props) => {
}> @@ -832,7 +896,7 @@ const FolderSelectionView: Component = (props) => {
-
+
1 ? "true" : undefined}> { event.preventDefault() event.stopPropagation() @@ -1713,6 +1843,13 @@ function ReasoningCard(props: ReasoningCardProps) { + + {timestamp()}
diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 1a6668510..00785441f 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -1,6 +1,6 @@ import { For, Show, createEffect, createSignal, onCleanup } from "solid-js" import { Portal } from "solid-js/web" -import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid" +import { Copy, ListStart, Split, Trash, Undo, Volume2 } from "lucide-solid" import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message" import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message" import type { MessageRecord } from "../stores/message-v2/types" @@ -13,6 +13,7 @@ import { isTauriHost } from "../lib/runtime-env" import type { DeleteHoverState } from "../types/delete-hover" import { useSpeech } from "../lib/hooks/use-speech" import SpeechActionButton from "./speech-action-button" +import ActionOverflowMenu, { type ActionOverflowMenuItem } from "./action-overflow-menu" function DeleteUpToIcon() { return ( @@ -388,6 +389,71 @@ export default function MessageItem(props: MessageItemProps) { return segments.join(" • ") } + const actionMenuItems = (): ActionOverflowMenuItem[] => { + const items: ActionOverflowMenuItem[] = [ + { + key: "copy", + label: copyLabel(), + icon: