diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts
index d2349cde9..89114c7bd 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 71a325438..7053c4caa 100644
--- a/packages/server/src/server/http-server.ts
+++ b/packages/server/src/server/http-server.ts
@@ -76,6 +76,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.
@@ -119,7 +129,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()
})
@@ -697,7 +708,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, {
@@ -735,7 +746,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/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx
index 86fc52a73..cb71fc833 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)
@@ -71,6 +76,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" },
@@ -83,6 +89,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()
@@ -100,6 +118,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
@@ -210,7 +256,7 @@ const FolderSelectionView: Component = (props) => {
const server = serverList()[index]
if (server) {
- void handleConnectSavedServer(server.id)
+ void handleConnectSavedConnection(server.id)
}
}
@@ -305,7 +351,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() {
@@ -363,7 +411,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()
@@ -434,7 +482,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
@@ -451,6 +499,10 @@ const FolderSelectionView: Component = (props) => {
}
}
+ async function handleRemoveSavedConnection(profile: RemoteServerProfile) {
+ removeRemoteServerProfile(profile.id)
+ }
+
async function handleBrowse() {
if (isLoading()) return
setFocusMode("new")
@@ -786,7 +838,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 })}
@@ -797,7 +849,7 @@ const FolderSelectionView: Component = (props) => {
when={activeTab() === "local"}
fallback={
0}
+ when={canUseRemoteServerWindows() && serverList().length > 0}
fallback={
@@ -822,7 +874,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) => {
↵}>
@@ -856,7 +920,7 @@ const FolderSelectionView: Component = (props) => {
+
+
+
+ {t("instanceInfo.labels.executionProfile")}
+
+
+ {currentInstance().executionProfileName}
+
+
+
+
0}>
diff --git a/packages/ui/src/components/settings/execution-profiles-settings-section.tsx b/packages/ui/src/components/settings/execution-profiles-settings-section.tsx
new file mode 100644
index 000000000..f8b55fb65
--- /dev/null
+++ b/packages/ui/src/components/settings/execution-profiles-settings-section.tsx
@@ -0,0 +1,622 @@
+import { createEffect, createMemo, createSignal, For, Show, type Component } from "solid-js"
+import { Copy, Pencil, Plus, Star, Trash2 } from "lucide-solid"
+import type { ExecutionProfile, ExecutionProfilePreviewResponse, ExecutionProfileTestResponse } from "../../../../server/src/api-types"
+import { serverApi } from "../../lib/api-client"
+import { useConfig } from "../../stores/preferences"
+import { useI18n } from "../../lib/i18n"
+
+function createProfileId(): string {
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
+ return crypto.randomUUID()
+ }
+ return `exec-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
+}
+
+function formatStringList(values?: string[]): string {
+ return values?.join("\n") ?? ""
+}
+
+function parseStringList(value: string): string[] | undefined {
+ const lines = value
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+ return lines.length > 0 ? lines : undefined
+}
+
+function buildProfileSummary(profile: ExecutionProfile): string {
+ switch (profile.kind) {
+ case "local":
+ return profile.binaryPath
+ case "wsl":
+ return `${profile.distro} · ${profile.binaryPath}`
+ case "docker":
+ return `${profile.image} · ${profile.workspaceMountPath}`
+ case "command":
+ return profile.executable
+ case "ssh":
+ return `${profile.username ? `${profile.username}@` : ""}${profile.host}${profile.port ? `:${profile.port}` : ""} · ${profile.remotePath}`
+ }
+}
+
+function formatPreviewEnvironment(environment: Record
): string {
+ return Object.entries(environment)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, value]) => `${key}=${value}`)
+ .join("\n")
+}
+
+function duplicateProfile(profile: ExecutionProfile, nameSuffix: string): ExecutionProfile {
+ return {
+ ...profile,
+ id: createProfileId(),
+ name: `${profile.name} ${nameSuffix}`.trim(),
+ }
+}
+
+export const ExecutionProfilesSettingsSection: Component = () => {
+ const { t } = useI18n()
+ const {
+ executionProfiles,
+ defaultExecutionProfileId,
+ saveExecutionProfile,
+ setDefaultExecutionProfileId,
+ removeExecutionProfile,
+ } = useConfig()
+
+ const [editingId, setEditingId] = createSignal(null)
+ const [kind, setKind] = createSignal("local")
+ const [name, setName] = createSignal("")
+ const [binaryPath, setBinaryPath] = createSignal("")
+ const [distro, setDistro] = createSignal("")
+ const [image, setImage] = createSignal("")
+ const [workspaceMountPath, setWorkspaceMountPath] = createSignal("/workspace")
+ const [configMountPath, setConfigMountPath] = createSignal("/root/.config/opencode")
+ const [commandText, setCommandText] = createSignal("")
+ const [extraDockerArgsText, setExtraDockerArgsText] = createSignal("")
+ const [executable, setExecutable] = createSignal("")
+ const [argsText, setArgsText] = createSignal("")
+ const [cwdMode, setCwdMode] = createSignal<"workspace" | "inherit">("workspace")
+ const [sshHost, setSshHost] = createSignal("")
+ const [sshPort, setSshPort] = createSignal("22")
+ const [sshUsername, setSshUsername] = createSignal("")
+ const [sshRemotePath, setSshRemotePath] = createSignal("")
+ const [previewWorkspacePath, setPreviewWorkspacePath] = createSignal("")
+ const [saving, setSaving] = createSignal(false)
+ const [previewing, setPreviewing] = createSignal(false)
+ const [testing, setTesting] = createSignal(false)
+ const [formError, setFormError] = createSignal(null)
+ const [previewError, setPreviewError] = createSignal(null)
+ const [testError, setTestError] = createSignal(null)
+ const [previewResult, setPreviewResult] = createSignal(null)
+ const [testResult, setTestResult] = createSignal(null)
+
+ const kindOptions = createMemo(() => [
+ { value: "local" as const, label: t("settings.opencode.executionProfiles.kind.local") },
+ { value: "wsl" as const, label: t("settings.opencode.executionProfiles.kind.wsl") },
+ { value: "docker" as const, label: t("settings.opencode.executionProfiles.kind.docker") },
+ { value: "command" as const, label: t("settings.opencode.executionProfiles.kind.command") },
+ { value: "ssh" as const, label: t("settings.opencode.executionProfiles.kind.ssh") },
+ ])
+
+ createEffect(() => {
+ kind()
+ name()
+ binaryPath()
+ distro()
+ image()
+ workspaceMountPath()
+ configMountPath()
+ commandText()
+ extraDockerArgsText()
+ executable()
+ argsText()
+ cwdMode()
+ sshHost()
+ sshPort()
+ sshUsername()
+ sshRemotePath()
+ previewWorkspacePath()
+ setPreviewError(null)
+ setTestError(null)
+ setPreviewResult(null)
+ setTestResult(null)
+ })
+
+ function resetForm(profile?: ExecutionProfile) {
+ setEditingId(profile?.id ?? null)
+ setKind(profile?.kind ?? "local")
+ setName(profile?.name ?? "")
+ setBinaryPath(profile?.kind === "local" || profile?.kind === "wsl" || profile?.kind === "ssh" ? profile.binaryPath : "")
+ setDistro(profile?.kind === "wsl" ? profile.distro : "")
+ setImage(profile?.kind === "docker" ? profile.image : "")
+ setWorkspaceMountPath(profile?.kind === "docker" ? profile.workspaceMountPath : "/workspace")
+ setConfigMountPath(profile?.kind === "docker" ? profile.configMountPath : "/root/.config/opencode")
+ setCommandText(profile?.kind === "docker" ? formatStringList(profile.command) : "")
+ setExtraDockerArgsText(profile?.kind === "docker" ? formatStringList(profile.extraDockerArgs) : "")
+ setExecutable(profile?.kind === "command" ? profile.executable : "")
+ setArgsText(profile?.kind === "command" || profile?.kind === "ssh" ? formatStringList(profile.args) : "")
+ setCwdMode(profile?.kind === "command" ? profile.cwdMode ?? "workspace" : "workspace")
+ setSshHost(profile?.kind === "ssh" ? profile.host : "")
+ setSshPort(profile?.kind === "ssh" ? String(profile.port ?? 22) : "22")
+ setSshUsername(profile?.kind === "ssh" ? profile.username ?? "" : "")
+ setSshRemotePath(profile?.kind === "ssh" ? profile.remotePath : "")
+ setPreviewWorkspacePath("")
+ setFormError(null)
+ }
+
+ function requireValue(value: string): string | null {
+ return value.trim().length > 0 ? value.trim() : null
+ }
+
+ function buildProfileFromForm(): ExecutionProfile {
+ const trimmedName = requireValue(name())
+ if (!trimmedName) {
+ throw new Error(t("settings.opencode.executionProfiles.validation.name"))
+ }
+
+ if (kind() === "local") {
+ const trimmedBinaryPath = requireValue(binaryPath())
+ if (!trimmedBinaryPath) {
+ throw new Error(t("settings.opencode.executionProfiles.validation.binaryPath"))
+ }
+ return {
+ id: editingId() ?? createProfileId(),
+ kind: "local",
+ name: trimmedName,
+ binaryPath: trimmedBinaryPath,
+ }
+ }
+
+ if (kind() === "wsl") {
+ const trimmedDistro = requireValue(distro())
+ const trimmedBinaryPath = requireValue(binaryPath())
+ if (!trimmedDistro) {
+ throw new Error(t("settings.opencode.executionProfiles.validation.distro"))
+ }
+ if (!trimmedBinaryPath) {
+ throw new Error(t("settings.opencode.executionProfiles.validation.binaryPath"))
+ }
+ return {
+ id: editingId() ?? createProfileId(),
+ kind: "wsl",
+ name: trimmedName,
+ distro: trimmedDistro,
+ binaryPath: trimmedBinaryPath,
+ }
+ }
+
+ if (kind() === "docker") {
+ const trimmedImage = requireValue(image())
+ const trimmedWorkspaceMountPath = requireValue(workspaceMountPath())
+ const trimmedConfigMountPath = requireValue(configMountPath())
+ if (!trimmedImage || !trimmedWorkspaceMountPath || !trimmedConfigMountPath) {
+ throw new Error(t("settings.opencode.executionProfiles.validation.docker"))
+ }
+ return {
+ id: editingId() ?? createProfileId(),
+ kind: "docker",
+ name: trimmedName,
+ image: trimmedImage,
+ workspaceMountPath: trimmedWorkspaceMountPath,
+ configMountPath: trimmedConfigMountPath,
+ command: parseStringList(commandText()),
+ extraDockerArgs: parseStringList(extraDockerArgsText()),
+ }
+ }
+
+ if (kind() === "ssh") {
+ const trimmedHost = requireValue(sshHost())
+ const trimmedRemotePath = requireValue(sshRemotePath())
+ const trimmedBinaryPath = requireValue(binaryPath())
+ const nextPort = sshPort().trim().length > 0 ? Number(sshPort()) : undefined
+ if (!trimmedHost) {
+ throw new Error(t("settings.opencode.executionProfiles.validation.sshHost"))
+ }
+ if (nextPort !== undefined && (!Number.isInteger(nextPort) || nextPort <= 0 || nextPort > 65535)) {
+ throw new Error(t("settings.opencode.executionProfiles.validation.sshPort"))
+ }
+ if (!trimmedRemotePath) {
+ throw new Error(t("settings.opencode.executionProfiles.validation.sshRemotePath"))
+ }
+ if (!trimmedBinaryPath) {
+ throw new Error(t("settings.opencode.executionProfiles.validation.binaryPath"))
+ }
+ return {
+ id: editingId() ?? createProfileId(),
+ kind: "ssh",
+ name: trimmedName,
+ host: trimmedHost,
+ port: nextPort,
+ username: sshUsername().trim() || undefined,
+ remotePath: trimmedRemotePath,
+ binaryPath: trimmedBinaryPath,
+ args: parseStringList(argsText()),
+ }
+ }
+
+ const trimmedExecutable = requireValue(executable())
+ if (!trimmedExecutable) {
+ throw new Error(t("settings.opencode.executionProfiles.validation.executable"))
+ }
+ return {
+ id: editingId() ?? createProfileId(),
+ kind: "command",
+ name: trimmedName,
+ executable: trimmedExecutable,
+ args: parseStringList(argsText()),
+ cwdMode: cwdMode(),
+ }
+ }
+
+ async function handleSave() {
+ let profile: ExecutionProfile
+ setFormError(null)
+ try {
+ profile = buildProfileFromForm()
+ } catch (error) {
+ setFormError(error instanceof Error ? error.message : String(error))
+ return
+ }
+
+ setSaving(true)
+ try {
+ await saveExecutionProfile(profile)
+ resetForm()
+ } catch (error) {
+ setFormError(error instanceof Error ? error.message : String(error))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ async function handlePreview() {
+ let profile: ExecutionProfile
+ setFormError(null)
+ setPreviewError(null)
+
+ try {
+ profile = buildProfileFromForm()
+ } catch (error) {
+ setFormError(error instanceof Error ? error.message : String(error))
+ return
+ }
+
+ setPreviewing(true)
+ try {
+ const result = await serverApi.previewExecutionProfile({
+ profile,
+ workspacePath: requireValue(previewWorkspacePath()) ?? undefined,
+ })
+ setPreviewResult(result)
+ } catch (error) {
+ setPreviewResult(null)
+ setPreviewError(error instanceof Error ? error.message : String(error))
+ } finally {
+ setPreviewing(false)
+ }
+ }
+
+ async function handleTest() {
+ let profile: ExecutionProfile
+ setFormError(null)
+ setTestError(null)
+
+ try {
+ profile = buildProfileFromForm()
+ } catch (error) {
+ setFormError(error instanceof Error ? error.message : String(error))
+ return
+ }
+
+ setTesting(true)
+ try {
+ const result = await serverApi.testExecutionProfile({
+ profile,
+ workspacePath: requireValue(previewWorkspacePath()) ?? undefined,
+ })
+ setPreviewResult(result)
+ setTestResult(result)
+ } catch (error) {
+ setTestResult(null)
+ setTestError(error instanceof Error ? error.message : String(error))
+ } finally {
+ setTesting(false)
+ }
+ }
+
+ async function handleDuplicate(profile: ExecutionProfile) {
+ setFormError(null)
+ try {
+ await saveExecutionProfile(duplicateProfile(profile, t("settings.common.duplicateSuffix")))
+ } catch (error) {
+ setFormError(error instanceof Error ? error.message : String(error))
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
{t("settings.opencode.executionProfiles.form.kind.label")}
+
{t("settings.opencode.executionProfiles.form.kind.subtitle")}
+
+
+
+
+
+
+
{t("settings.opencode.executionProfiles.form.name.label")}
+
{t("settings.opencode.executionProfiles.form.name.subtitle")}
+
+
setName(event.currentTarget.value)} />
+
+
+
+
+
+
{t("settings.opencode.executionProfiles.form.binaryPath.label")}
+
{t("settings.opencode.executionProfiles.form.binaryPath.subtitle")}
+
+
setBinaryPath(event.currentTarget.value)} />
+
+
+
+
+
+
+
{t("settings.opencode.executionProfiles.form.sshHost.label")}
+
{t("settings.opencode.executionProfiles.form.sshHost.subtitle")}
+
+
setSshHost(event.currentTarget.value)} />
+
+
+
+
+
{t("settings.opencode.executionProfiles.form.sshPort.label")}
+
{t("settings.opencode.executionProfiles.form.sshPort.subtitle")}
+
+
setSshPort(event.currentTarget.value)} />
+
+
+
+
+
{t("settings.opencode.executionProfiles.form.sshUsername.label")}
+
{t("settings.opencode.executionProfiles.form.sshUsername.subtitle")}
+
+
setSshUsername(event.currentTarget.value)} />
+
+
+
+
+
{t("settings.opencode.executionProfiles.form.sshRemotePath.label")}
+
{t("settings.opencode.executionProfiles.form.sshRemotePath.subtitle")}
+
+
setSshRemotePath(event.currentTarget.value)} />
+
+
+
+
+
+
+
+
+
+
+
{t("settings.opencode.executionProfiles.form.distro.label")}
+
{t("settings.opencode.executionProfiles.form.distro.subtitle")}
+
+
setDistro(event.currentTarget.value)} />
+
+
+
+
+
+
+
{t("settings.opencode.executionProfiles.form.image.label")}
+
{t("settings.opencode.executionProfiles.form.image.subtitle")}
+
+
setImage(event.currentTarget.value)} />
+
+
+
+
+
{t("settings.opencode.executionProfiles.form.workspaceMountPath.label")}
+
{t("settings.opencode.executionProfiles.form.workspaceMountPath.subtitle")}
+
+
setWorkspaceMountPath(event.currentTarget.value)} />
+
+
+
+
+
{t("settings.opencode.executionProfiles.form.configMountPath.label")}
+
{t("settings.opencode.executionProfiles.form.configMountPath.subtitle")}
+
+
setConfigMountPath(event.currentTarget.value)} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{t("settings.opencode.executionProfiles.form.executable.label")}
+
{t("settings.opencode.executionProfiles.form.executable.subtitle")}
+
+
setExecutable(event.currentTarget.value)} />
+
+
+
+
+
{t("settings.opencode.executionProfiles.form.cwdMode.label")}
+
{t("settings.opencode.executionProfiles.form.cwdMode.subtitle")}
+
+
+
+
+
+
+
+
+
+
+
+
{t("settings.opencode.executionProfiles.form.previewWorkspacePath.label")}
+
{t("settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle")}
+
+
setPreviewWorkspacePath(event.currentTarget.value)} />
+
+
+
+ {formError()}
+
+
+
+ {previewError()}
+
+
+
+ {testError()}
+
+
+
+
+ resetForm()}>
+ {t("settings.opencode.executionProfiles.form.cancelEdit")}
+
+
+
void handleTest()}>
+ {testing() ? t("settings.opencode.executionProfiles.form.testing") : t("settings.opencode.executionProfiles.form.test")}
+
+
void handlePreview()}>
+ {previewing() ? t("settings.opencode.executionProfiles.form.previewing") : t("settings.opencode.executionProfiles.form.preview")}
+
+
void handleSave()}>
+ }>
+
+
+ {editingId() ? t("settings.opencode.executionProfiles.form.update") : t("settings.opencode.executionProfiles.form.save")}
+
+
+
+
+ {(result) => (
+
+ )}
+
+
+
+ {(result) => (
+
+ )}
+
+
+
+
+
+
+
+
+
0} fallback={{t("settings.opencode.executionProfiles.list.empty")}
}>
+
+ {(profile) => {
+ const isDefault = () => defaultExecutionProfileId() === profile.id
+ return (
+
+
+
+ {profile.name}
+ {t(`settings.opencode.executionProfiles.kind.${profile.kind}`)}
+
+ {t("settings.opencode.executionProfiles.list.defaultBadge")}
+
+
+
{buildProfileSummary(profile)}
+
+
+
+
resetForm(profile)} title={t("settings.opencode.executionProfiles.list.actions.edit")}>
+
+
+
void handleDuplicate(profile)} title={t("settings.opencode.executionProfiles.list.actions.duplicate")}>
+
+
+
void setDefaultExecutionProfileId(profile.id)} title={t("settings.opencode.executionProfiles.list.actions.makeDefault")}>
+
+
+
removeExecutionProfile(profile.id)} title={t("settings.opencode.executionProfiles.list.actions.delete")}>
+
+
+
+
+ )
+ }}
+
+
+
+
+
+ )
+}
diff --git a/packages/ui/src/components/settings/opencode-settings-section.tsx b/packages/ui/src/components/settings/opencode-settings-section.tsx
index 8c0bc01b7..07a74a4bf 100644
--- a/packages/ui/src/components/settings/opencode-settings-section.tsx
+++ b/packages/ui/src/components/settings/opencode-settings-section.tsx
@@ -3,6 +3,7 @@ import { createEffect, createMemo, createSignal, type Component } from "solid-js
import { ChevronDown, Terminal } from "lucide-solid"
import OpenCodeBinarySelector from "../opencode-binary-selector"
import EnvironmentVariablesEditor from "../environment-variables-editor"
+import { ExecutionProfilesSettingsSection } from "./execution-profiles-settings-section"
import { useConfig } from "../../stores/preferences"
import type { ServerLogLevel } from "../../stores/preferences"
import { useI18n } from "../../lib/i18n"
@@ -53,6 +54,8 @@ export const OpenCodeSettingsSection: Component = () => {
+
+