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()
+ }}
+ >
+ }>
+ {(icon) => {icon()}}
+
+ {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) => {
+
+
+
+ {t("instanceInfo.labels.executionProfile")}
+
+
+ {currentInstance().executionProfileName}
+
+
+
+
0}>
diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx
index 04c15e7ac..07d6a9af7 100644
--- a/packages/ui/src/components/message-block.tsx
+++ b/packages/ui/src/components/message-block.tsx
@@ -1,5 +1,5 @@
import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js"
-import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
+import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash, Volume2 } from "lucide-solid"
import MessageItem from "./message-item"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message"
@@ -18,6 +18,7 @@ import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
import { createFollowScroll } from "../lib/follow-scroll"
import type { SessionSearchMatch } from "../lib/session-search"
+import ActionOverflowMenu, { type ActionOverflowMenuItem } from "./action-overflow-menu"
function DeleteUpToIcon() {
return (
@@ -486,6 +487,12 @@ function ToolCallItem(props: ToolCallItemProps) {
navigateToTaskSession(location)
}
+ const goToTaskSession = () => {
+ const location = taskLocation()
+ if (!location) return
+ navigateToTaskSession(location)
+ }
+
const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -507,9 +514,7 @@ function ToolCallItem(props: ToolCallItemProps) {
}
}
- const handleDeleteUpTo = async (event: MouseEvent) => {
- event.preventDefault()
- event.stopPropagation()
+ const deleteUpTo = async () => {
if (!props.showDeleteMessage) return
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
@@ -522,11 +527,72 @@ function ToolCallItem(props: ToolCallItemProps) {
}
}
+ const handleDeleteUpTo = async (event: MouseEvent) => {
+ event.preventDefault()
+ event.stopPropagation()
+ await deleteUpTo()
+ }
+
+ const actionMenuItems = (): ActionOverflowMenuItem[] => {
+ const items: ActionOverflowMenuItem[] = []
+
+ if (taskSessionId()) {
+ items.push({
+ key: "go-to-session",
+ label: t("messageBlock.tool.goToSession.label"),
+ icon:
,
+ disabled: !taskLocation(),
+ onSelect: goToTaskSession,
+ })
+ }
+
+ if (props.showDeleteMessage) {
+ items.push(
+ {
+ key: "delete-up-to",
+ label: t("messageItem.actions.deleteMessagesUpTo"),
+ icon:
,
+ disabled: !props.onDeleteMessagesUpTo || deletingUpTo(),
+ destructive: true,
+ onMouseEnter: () => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId }),
+ onMouseLeave: () => props.onDeleteHoverChange?.({ kind: "none" }),
+ onSelect: deleteUpTo,
+ },
+ {
+ key: "delete-message",
+ label: deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage"),
+ icon:
,
+ disabled: deletingMessage(),
+ destructive: true,
+ onMouseEnter: () => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId }),
+ onMouseLeave: () => props.onDeleteHoverChange?.({ kind: "none" }),
+ onSelect: async () => {
+ if (deletingMessage()) return
+ setDeletingMessage(true)
+ try {
+ await deleteMessage(props.instanceId, props.sessionId, props.messageId)
+ } catch (error) {
+ showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
+ title: t("messageItem.actions.deleteMessageFailedTitle"),
+ detail: error instanceof Error ? error.message : String(error),
+ variant: "error",
+ })
+ } finally {
+ setDeletingMessage(false)
+ }
+ },
+ },
+ )
+ }
+
+ return items
+ }
+
return (
{(resolvedToolPart) => (
-