From 79eebf4a9923888f0387312498518559f5362c5c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pascal=20Andr=C3=A9?=
Date: Fri, 8 May 2026 19:18:55 +0200
Subject: [PATCH 01/17] feat(runtime): add execution and connection profile
foundations
Introduce server-scoped execution profiles for local, WSL, Docker, and command runtimes so workspace creation can choose a runtime strategy instead of relying on a single default binary path.
Add device-scoped connection profile foundations for remote server shortcuts and SSH bootstrap metadata, then surface both profile systems in the home screen and settings flows so runtime selection and remote reconnect UX can evolve together.
Implement initial Docker and custom-command launch builders plus SSH bootstrap and tunnel session management for remote connections. Validation ran with the server and UI typechecks, the UI production build, and targeted tests for execution profile resolution and launch command generation.
---
packages/server/src/api-types.ts | 89 +++++
packages/server/src/index.ts | 10 +
packages/server/src/server/http-server.ts | 7 +
.../src/server/routes/remote-connections.ts | 44 +++
.../server/src/server/routes/workspaces.ts | 5 +-
packages/server/src/server/ssh-connections.ts | 272 +++++++++++++
packages/server/src/settings/binaries.test.ts | 158 ++++++++
packages/server/src/settings/binaries.ts | 138 ++++++-
packages/server/src/settings/service.ts | 63 +++
.../src/workspaces/execution-launch.test.ts | 63 +++
.../server/src/workspaces/execution-launch.ts | 114 ++++++
packages/server/src/workspaces/manager.ts | 30 +-
packages/server/src/workspaces/runtime.ts | 6 +-
packages/ui/src/App.tsx | 6 +-
.../src/components/folder-selection-view.tsx | 179 ++++++++-
.../connection-profiles-settings-section.tsx | 244 ++++++++++++
.../execution-profiles-settings-section.tsx | 355 +++++++++++++++++
.../settings/opencode-settings-section.tsx | 3 +
.../remote-access-settings-section.tsx | 3 +
packages/ui/src/lib/api-client.ts | 11 +
.../lib/i18n/messages/en/folderSelection.ts | 2 +
.../ui/src/lib/i18n/messages/en/settings.ts | 84 ++++
.../lib/i18n/messages/es/folderSelection.ts | 2 +
.../ui/src/lib/i18n/messages/es/settings.ts | 84 ++++
.../lib/i18n/messages/fr/folderSelection.ts | 2 +
.../ui/src/lib/i18n/messages/fr/settings.ts | 84 ++++
.../lib/i18n/messages/he/folderSelection.ts | 2 +
.../ui/src/lib/i18n/messages/he/settings.ts | 84 ++++
.../lib/i18n/messages/ja/folderSelection.ts | 2 +
.../ui/src/lib/i18n/messages/ja/settings.ts | 84 ++++
.../lib/i18n/messages/ru/folderSelection.ts | 2 +
.../ui/src/lib/i18n/messages/ru/settings.ts | 84 ++++
.../i18n/messages/zh-Hans/folderSelection.ts | 2 +
.../src/lib/i18n/messages/zh-Hans/settings.ts | 84 ++++
packages/ui/src/stores/instances.ts | 7 +-
packages/ui/src/stores/preferences.tsx | 359 ++++++++++++++++--
packages/ui/src/types/instance.ts | 3 +
37 files changed, 2699 insertions(+), 72 deletions(-)
create mode 100644 packages/server/src/server/routes/remote-connections.ts
create mode 100644 packages/server/src/server/ssh-connections.ts
create mode 100644 packages/server/src/settings/binaries.test.ts
create mode 100644 packages/server/src/workspaces/execution-launch.test.ts
create mode 100644 packages/server/src/workspaces/execution-launch.ts
create mode 100644 packages/ui/src/components/settings/connection-profiles-settings-section.tsx
create mode 100644 packages/ui/src/components/settings/execution-profiles-settings-section.tsx
diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts
index c40d05e37..f7c522409 100644
--- a/packages/server/src/api-types.ts
+++ b/packages/server/src/api-types.ts
@@ -14,6 +14,44 @@ import type {
export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error"
+export type ExecutionProfileKind = "local" | "wsl" | "docker" | "command"
+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 type ExecutionProfile = LocalExecutionProfile | WslExecutionProfile | DockerExecutionProfile | CommandExecutionProfile
+
export interface WorkspaceDescriptor {
id: string
/** Absolute path on the server host. */
@@ -29,6 +67,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 +79,7 @@ export interface WorkspaceDescriptor {
export interface WorkspaceCreateRequest {
path: string
name?: string
+ executionProfileId?: string
}
export type WorkspaceCreateResponse = WorkspaceDescriptor
@@ -320,6 +362,17 @@ export interface VoiceModeStateResponse {
enabled: boolean
}
+export type ConnectionProfileKind = "remote-server" | "ssh"
+
+export interface ConnectionProfileBase {
+ id: string
+ name: string
+ kind: ConnectionProfileKind
+ createdAt: string
+ updatedAt: string
+ lastConnectedAt?: string
+}
+
export interface RemoteServerProfile {
id: string
name: string
@@ -330,6 +383,42 @@ export interface RemoteServerProfile {
lastConnectedAt?: string
}
+export interface RemoteServerConnectionProfile extends ConnectionProfileBase {
+ kind: "remote-server"
+ baseUrl: string
+ skipTlsVerify: boolean
+}
+
+export interface SshConnectionProfile extends ConnectionProfileBase {
+ kind: "ssh"
+ host: string
+ port?: number
+ username?: string
+ remotePath?: string
+ remoteServerPort?: number
+ bootstrapScript?: string
+}
+
+export interface SshConnectionBootstrapRequest {
+ connectionProfileId?: string
+ name?: string
+ host: string
+ port?: number
+ username?: string
+ remotePath?: string
+ remoteServerPort?: number
+ bootstrapScript?: string
+}
+
+export interface SshConnectionBootstrapResponse {
+ sessionId: string
+ baseUrl: string
+ localPort: number
+ remoteServerPort: number
+}
+
+export type ConnectionProfile = RemoteServerConnectionProfile | SshConnectionProfile
+
export interface RemoteServerProbeRequest {
baseUrl: string
skipTlsVerify?: boolean
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 17de82f7f..a340b0986 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -22,6 +22,7 @@ import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls"
import { RemoteProxySessionManager } from "./server/remote-proxy"
+import { SshConnectionSessionManager } from "./server/ssh-connections"
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service"
@@ -400,6 +401,7 @@ async function main() {
logger: logger.child({ component: "remote-proxy" }),
httpsOptions: tlsResolution?.httpsOptions,
})
+ const sshConnectionSessionManager = new SshConnectionSessionManager(logger.child({ component: "ssh-connections" }))
const voiceModeManager = new VoiceModeManager({
connections: clientConnectionManager,
channel: pluginChannel,
@@ -440,6 +442,7 @@ async function main() {
pluginChannel,
voiceModeManager,
remoteProxySessionManager,
+ sshConnectionSessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
@@ -466,6 +469,7 @@ async function main() {
pluginChannel,
voiceModeManager,
remoteProxySessionManager,
+ sshConnectionSessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,
logger,
@@ -576,6 +580,12 @@ async function main() {
logger.warn({ err: error }, "Client connection manager shutdown failed")
}
+ try {
+ await sshConnectionSessionManager.shutdown()
+ } catch (error) {
+ logger.warn({ err: error }, "SSH connection session shutdown failed")
+ }
+
try {
await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete")
diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts
index cf7dae364..ba5f6d471 100644
--- a/packages/server/src/server/http-server.ts
+++ b/packages/server/src/server/http-server.ts
@@ -27,6 +27,7 @@ import { registerWorktreeRoutes } from "./routes/worktrees"
import { registerSpeechRoutes } from "./routes/speech"
import { registerRemoteServerRoutes } from "./routes/remote-servers"
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
+import { registerRemoteConnectionRoutes } from "./routes/remote-connections"
import { registerSideCarRoutes } from "./routes/sidecars"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
@@ -40,6 +41,7 @@ import { PluginChannelManager } from "../plugins/channel"
import { VoiceModeManager } from "../plugins/voice-mode"
import type { SideCarManager } from "../sidecars/manager"
import type { RemoteProxySessionManager } from "./remote-proxy"
+import type { SshConnectionSessionManager } from "./ssh-connections"
interface HttpServerDeps {
bindHost: string
@@ -61,6 +63,7 @@ interface HttpServerDeps {
pluginChannel: PluginChannelManager
voiceModeManager: VoiceModeManager
remoteProxySessionManager: RemoteProxySessionManager
+ sshConnectionSessionManager: SshConnectionSessionManager
uiStaticDir: string
uiDevServerUrl?: string
logger: Logger
@@ -282,6 +285,10 @@ export function createHttpServer(deps: HttpServerDeps) {
workspaceManager: deps.workspaceManager,
})
registerRemoteServerRoutes(app, { logger: apiLogger })
+ registerRemoteConnectionRoutes(app, {
+ logger: apiLogger,
+ sshConnectionSessionManager: deps.sshConnectionSessionManager,
+ })
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager })
registerSpeechRoutes(app, { speechService: deps.speechService })
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
diff --git a/packages/server/src/server/routes/remote-connections.ts b/packages/server/src/server/routes/remote-connections.ts
new file mode 100644
index 000000000..d76480720
--- /dev/null
+++ b/packages/server/src/server/routes/remote-connections.ts
@@ -0,0 +1,44 @@
+import type { FastifyInstance } from "fastify"
+import { z } from "zod"
+import type { SshConnectionBootstrapResponse } from "../../api-types"
+import type { Logger } from "../../logger"
+import type { SshConnectionSessionManager } from "../ssh-connections"
+
+interface RouteDeps {
+ logger: Logger
+ sshConnectionSessionManager: SshConnectionSessionManager
+}
+
+const SshConnectSchema = z.object({
+ connectionProfileId: z.string().trim().optional(),
+ name: z.string().trim().optional(),
+ host: z.string().trim().min(1),
+ port: z.number().int().positive().max(65535).optional(),
+ username: z.string().trim().optional(),
+ remotePath: z.string().trim().optional(),
+ remoteServerPort: z.number().int().positive().max(65535).optional(),
+ bootstrapScript: z.string().optional(),
+})
+
+export function registerRemoteConnectionRoutes(app: FastifyInstance, deps: RouteDeps) {
+ app.post(
+ "/api/remote-connections/ssh/connect",
+ async (request, reply): Promise => {
+ try {
+ const body = SshConnectSchema.parse(request.body ?? {})
+ reply.code(201)
+ return await deps.sshConnectionSessionManager.connect(body)
+ } catch (error) {
+ deps.logger.warn({ err: error }, "Failed to establish SSH remote connection")
+ reply.code(400)
+ return { error: error instanceof Error ? error.message : "Failed to establish SSH remote connection" }
+ }
+ },
+ )
+
+ app.delete<{ Params: { id: string } }>("/api/remote-connections/ssh/:id", async (request, reply) => {
+ await deps.sshConnectionSessionManager.disposeByProfileId(request.params.id)
+ reply.code(204)
+ return undefined
+ })
+}
diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts
index 9f2a68a2e..67b43132e 100644
--- a/packages/server/src/server/routes/workspaces.ts
+++ b/packages/server/src/server/routes/workspaces.ts
@@ -13,6 +13,7 @@ interface RouteDeps {
const WorkspaceCreateSchema = z.object({
path: z.string(),
name: z.string().optional(),
+ executionProfileId: z.string().trim().optional(),
})
const WorkspaceFilesQuerySchema = z.object({
@@ -59,7 +60,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/server/ssh-connections.ts b/packages/server/src/server/ssh-connections.ts
new file mode 100644
index 000000000..73e6ac777
--- /dev/null
+++ b/packages/server/src/server/ssh-connections.ts
@@ -0,0 +1,272 @@
+import { spawn, type ChildProcess } from "child_process"
+import { randomUUID } from "crypto"
+import { createServer } from "net"
+import { fetch } from "undici"
+import type { Logger } from "../logger"
+import type { SshConnectionBootstrapRequest, SshConnectionBootstrapResponse } from "../api-types"
+
+const LOOPBACK_HOST = "127.0.0.1"
+const DEFAULT_SSH_PORT = 22
+const DEFAULT_REMOTE_SERVER_PORT = 9898
+const PROBE_TIMEOUT_MS = 15_000
+const PROBE_INTERVAL_MS = 500
+
+interface ActiveSshSession {
+ sessionId: string
+ connectionProfileId?: string
+ child: ChildProcess
+ baseUrl: string
+ localPort: number
+ remoteServerPort: number
+ stderr: string[]
+}
+
+export class SshConnectionSessionManager {
+ private readonly sessions = new Map()
+ private readonly sessionIdByProfileId = new Map()
+
+ constructor(private readonly logger: Logger) {}
+
+ async connect(request: SshConnectionBootstrapRequest): Promise {
+ const connectionProfileId = request.connectionProfileId?.trim() || undefined
+ if (connectionProfileId) {
+ const existingSessionId = this.sessionIdByProfileId.get(connectionProfileId)
+ if (existingSessionId) {
+ const existing = this.sessions.get(existingSessionId)
+ if (existing && (await this.isReachable(existing.baseUrl))) {
+ return {
+ sessionId: existing.sessionId,
+ baseUrl: existing.baseUrl,
+ localPort: existing.localPort,
+ remoteServerPort: existing.remoteServerPort,
+ }
+ }
+ await this.disposeByProfileId(connectionProfileId)
+ }
+ }
+
+ const remoteServerPort = request.remoteServerPort ?? DEFAULT_REMOTE_SERVER_PORT
+ if (request.bootstrapScript?.trim()) {
+ await this.runBootstrapScript({ ...request, remoteServerPort })
+ }
+
+ const localPort = await getAvailablePort()
+ const target = buildSshTarget(request)
+ const args = [
+ "-p",
+ String(request.port ?? DEFAULT_SSH_PORT),
+ "-o",
+ "ExitOnForwardFailure=yes",
+ "-o",
+ "ServerAliveInterval=30",
+ "-N",
+ "-L",
+ `${LOOPBACK_HOST}:${localPort}:${LOOPBACK_HOST}:${remoteServerPort}`,
+ target,
+ ]
+
+ const child = spawn("ssh", args, {
+ stdio: ["ignore", "ignore", "pipe"],
+ detached: false,
+ })
+
+ const sessionId = randomUUID()
+ const baseUrl = `http://${LOOPBACK_HOST}:${localPort}`
+ const active: ActiveSshSession = {
+ sessionId,
+ connectionProfileId,
+ child,
+ baseUrl,
+ localPort,
+ remoteServerPort,
+ stderr: [],
+ }
+
+ child.stderr?.on("data", (chunk: Buffer) => {
+ const text = chunk.toString("utf8").trim()
+ if (!text) return
+ active.stderr.push(text)
+ if (active.stderr.length > 20) {
+ active.stderr.shift()
+ }
+ })
+
+ child.once("exit", (code, signal) => {
+ this.logger.info({ sessionId, code, signal }, "SSH tunnel session exited")
+ this.sessions.delete(sessionId)
+ if (connectionProfileId) {
+ this.sessionIdByProfileId.delete(connectionProfileId)
+ }
+ })
+
+ this.sessions.set(sessionId, active)
+ if (connectionProfileId) {
+ this.sessionIdByProfileId.set(connectionProfileId, sessionId)
+ }
+
+ try {
+ await this.waitForReachable(baseUrl, child, active.stderr)
+ } catch (error) {
+ await this.disposeSession(sessionId)
+ throw error
+ }
+
+ return {
+ sessionId,
+ baseUrl,
+ localPort,
+ remoteServerPort,
+ }
+ }
+
+ async disposeByProfileId(connectionProfileId: string): Promise {
+ const sessionId = this.sessionIdByProfileId.get(connectionProfileId)
+ if (!sessionId) return
+ await this.disposeSession(sessionId)
+ }
+
+ async shutdown(): Promise {
+ await Promise.allSettled(Array.from(this.sessions.keys()).map((sessionId) => this.disposeSession(sessionId)))
+ }
+
+ private async disposeSession(sessionId: string): Promise {
+ const session = this.sessions.get(sessionId)
+ if (!session) return
+
+ this.sessions.delete(sessionId)
+ if (session.connectionProfileId) {
+ this.sessionIdByProfileId.delete(session.connectionProfileId)
+ }
+
+ if (!session.child.killed) {
+ session.child.kill("SIGTERM")
+ await waitForChildExit(session.child, 2_000).catch(() => {
+ if (!session.child.killed) {
+ session.child.kill("SIGKILL")
+ }
+ })
+ }
+ }
+
+ private async runBootstrapScript(request: SshConnectionBootstrapRequest & { remoteServerPort: number }): Promise {
+ const target = buildSshTarget(request)
+ const args = [
+ "-p",
+ String(request.port ?? DEFAULT_SSH_PORT),
+ target,
+ "sh",
+ "-s",
+ "--",
+ String(request.remoteServerPort),
+ request.remotePath?.trim() || "",
+ ]
+
+ const prelude = [
+ 'export CODENOMAD_REMOTE_PORT="$1"',
+ 'export CODENOMAD_REMOTE_PATH="$2"',
+ "shift 2",
+ request.bootstrapScript?.trim() || "",
+ "",
+ ].join("\n")
+
+ await new Promise((resolve, reject) => {
+ const child = spawn("ssh", args, { stdio: ["pipe", "pipe", "pipe"] })
+ let stderr = ""
+ let stdout = ""
+
+ child.stdout?.on("data", (chunk: Buffer) => {
+ stdout += chunk.toString("utf8")
+ })
+ child.stderr?.on("data", (chunk: Buffer) => {
+ stderr += chunk.toString("utf8")
+ })
+ child.once("error", reject)
+ child.once("close", (code) => {
+ if (code === 0) {
+ resolve()
+ return
+ }
+ const detail = stderr.trim() || stdout.trim() || `SSH bootstrap exited with code ${code}`
+ reject(new Error(detail))
+ })
+
+ child.stdin?.end(prelude)
+ })
+ }
+
+ private async waitForReachable(baseUrl: string, child: ChildProcess, stderrLines: string[]): Promise {
+ const startedAt = Date.now()
+ while (Date.now() - startedAt < PROBE_TIMEOUT_MS) {
+ if (child.exitCode !== null) {
+ throw new Error(stderrLines[stderrLines.length - 1] || "SSH tunnel exited before the remote server became reachable")
+ }
+
+ if (await this.isReachable(baseUrl)) {
+ return
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, PROBE_INTERVAL_MS))
+ }
+
+ throw new Error(stderrLines[stderrLines.length - 1] || "Timed out waiting for the remote server over SSH")
+ }
+
+ private async isReachable(baseUrl: string): Promise {
+ try {
+ const response = await fetch(new URL("/api/auth/status", `${baseUrl}/`), {
+ method: "GET",
+ headers: { Accept: "application/json" },
+ })
+ if (!response.ok) {
+ return false
+ }
+ const payload = (await response.json()) as { authenticated?: unknown }
+ return typeof payload?.authenticated === "boolean"
+ } catch {
+ return false
+ }
+ }
+}
+
+function buildSshTarget(request: Pick): string {
+ const host = request.host.trim()
+ if (!host) {
+ throw new Error("SSH host is required")
+ }
+ const username = request.username?.trim()
+ return username ? `${username}@${host}` : host
+}
+
+async function getAvailablePort(): Promise {
+ return await new Promise((resolve, reject) => {
+ const server = createServer()
+ server.unref()
+ server.once("error", reject)
+ server.listen(0, LOOPBACK_HOST, () => {
+ const address = server.address()
+ if (!address || typeof address === "string") {
+ server.close(() => reject(new Error("Failed to reserve a local port for SSH tunneling")))
+ return
+ }
+
+ const port = address.port
+ server.close((error) => {
+ if (error) {
+ reject(error)
+ return
+ }
+ resolve(port)
+ })
+ })
+ })
+}
+
+async function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise {
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error("Timed out waiting for SSH process to exit")), timeoutMs)
+ child.once("exit", () => {
+ clearTimeout(timeout)
+ resolve()
+ })
+ })
+}
diff --git a/packages/server/src/settings/binaries.test.ts b/packages/server/src/settings/binaries.test.ts
new file mode 100644
index 000000000..e728b244d
--- /dev/null
+++ b/packages/server/src/settings/binaries.test.ts
@@ -0,0 +1,158 @@
+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`,
+ 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: "ssh-wrapper",
+ name: "SSH Wrapper",
+ kind: "command",
+ executable: "ssh",
+ args: ["user@example.com"],
+ cwdMode: "inherit",
+ }
+
+ const resolver = new BinaryResolver(
+ createSettings({
+ server: { executionProfiles: [profile] },
+ }) as any,
+ )
+
+ assert.deepEqual(resolver.resolveActive(profile.id), {
+ kind: "command",
+ label: "SSH Wrapper",
+ executable: "ssh",
+ args: ["user@example.com"],
+ cwdMode: "inherit",
+ executionProfileId: "ssh-wrapper",
+ executionProfileName: "SSH Wrapper",
+ executionProfileKind: "command",
+ })
+ })
+
+ 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..59a08b057 100644
--- a/packages/server/src/settings/binaries.ts
+++ b/packages/server/src/settings/binaries.ts
@@ -1,4 +1,12 @@
import type { SettingsService } from "./service"
+import type {
+ CommandExecutionProfile,
+ DockerExecutionProfile,
+ ExecutionProfile,
+ ExecutionProfileKind,
+ LocalExecutionProfile,
+ WslExecutionProfile,
+} from "../api-types"
export interface OpenCodeBinaryEntry {
path: string
@@ -7,12 +15,37 @@ 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
+}
+
+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 type ResolvedBinary = ResolvedHostExecution | ResolvedDockerExecution | ResolvedCommandExecution
+
function prettyLabel(p: string): string {
const parts = p.split(/[\\/]/)
const last = parts[parts.length - 1] || p
@@ -32,6 +65,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 +89,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 +119,71 @@ 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)
+ }
+
+ return this.resolveCommandProfile(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,
+ }
+ }
+
+ 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,
+ }
+ }
}
diff --git a/packages/server/src/settings/service.ts b/packages/server/src/settings/service.ts
index f4f0409c2..c4f3ba7d4 100644
--- a/packages/server/src/settings/service.ts
+++ b/packages/server/src/settings/service.ts
@@ -14,6 +14,52 @@ 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 ExecutionProfileSchema = z.discriminatedUnion("kind", [
+ LocalExecutionProfileSchema,
+ WslExecutionProfileSchema,
+ DockerExecutionProfileSchema,
+ CommandExecutionProfileSchema,
+])
+
function isPlainObject(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -39,6 +85,23 @@ function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc {
} else if (next.logLevel !== undefined) {
next.logLevel = "DEBUG"
}
+
+ const parsedExecutionProfiles = z.array(ExecutionProfileSchema).safeParse(next.executionProfiles)
+ if (parsedExecutionProfiles.success) {
+ next.executionProfiles = parsedExecutionProfiles.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/execution-launch.test.ts b/packages/server/src/workspaces/execution-launch.test.ts
new file mode 100644
index 000000000..c5e0ea76a
--- /dev/null
+++ b/packages/server/src/workspaces/execution-launch.test.ts
@@ -0,0 +1,63 @@
+import assert from "node:assert/strict"
+import { describe, it } from "node:test"
+
+import type { ResolvedBinary } from "../settings/binaries"
+import { buildLaunchCommand } from "./execution-launch"
+
+describe("buildLaunchCommand", () => {
+ it("builds a command execution profile launch", () => {
+ const execution: ResolvedBinary = {
+ kind: "command",
+ label: "Wrapper",
+ executable: "ssh",
+ args: ["user@example.com"],
+ cwdMode: "inherit",
+ }
+
+ const result = buildLaunchCommand({
+ execution,
+ workspacePath: "D:/CodeNomad",
+ environment: { CODENOMAD_INSTANCE_ID: "abc123" },
+ logLevel: "DEBUG",
+ })
+
+ assert.equal(result.command, "ssh")
+ assert.deepEqual(result.args, ["user@example.com", "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_DIR: "C:/Users/Admin/.config/opencode",
+ 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",
+ })
+
+ 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/opencode:/root/.config/opencode"))
+ assert.ok(result.args.includes("C:/Users/Admin/.config/codenomad/certs.pem:/tmp/codenomad-node-extra-ca.pem:ro"))
+ assert.ok(result.args.includes("CODENOMAD_BASE_URL=https://host.docker.internal:9898"))
+ assert.ok(result.args.includes("OPENCODE_CONFIG_DIR=/root/.config/opencode"))
+ assert.ok(result.args.includes("NODE_EXTRA_CA_CERTS=/tmp/codenomad-node-extra-ca.pem"))
+ assert.deepEqual(result.args.slice(-6), ["serve", "--port", "0", "--print-logs", "--log-level", "INFO"])
+ })
+})
diff --git a/packages/server/src/workspaces/execution-launch.ts b/packages/server/src/workspaces/execution-launch.ts
new file mode 100644
index 000000000..53bd65105
--- /dev/null
+++ b/packages/server/src/workspaces/execution-launch.ts
@@ -0,0 +1,114 @@
+import { URL } from "url"
+import type { ResolvedBinary } from "../settings/binaries"
+
+const DOCKER_HOST_ALIAS = "host.docker.internal"
+const DOCKER_CA_CERT_PATH = "/tmp/codenomad-node-extra-ca.pem"
+
+export interface LaunchCommandSpec {
+ command: string
+ args: string[]
+ cwd?: string
+ environment?: Record
+}
+
+interface BuildLaunchCommandParams {
+ execution: ResolvedBinary
+ workspacePath: string
+ environment: Record
+ logLevel: string
+}
+
+export function buildLaunchCommand(params: BuildLaunchCommandParams): LaunchCommandSpec {
+ const openCodeArgs = ["serve", "--port", "0", "--print-logs", "--log-level", params.logLevel]
+
+ if (params.execution.kind === "docker") {
+ return buildDockerLaunchCommand(params.execution, params.workspacePath, params.environment, openCodeArgs)
+ }
+
+ 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,
+ }
+ }
+
+ return {
+ command: params.execution.path,
+ args: openCodeArgs,
+ cwd: params.workspacePath,
+ environment: params.environment,
+ }
+}
+
+function buildDockerLaunchCommand(
+ execution: Extract,
+ workspacePath: string,
+ environment: Record,
+ openCodeArgs: string[],
+): LaunchCommandSpec {
+ const configDir = environment.OPENCODE_CONFIG_DIR?.trim()
+ if (!configDir) {
+ throw new Error("OPENCODE_CONFIG_DIR is required for Docker execution profiles")
+ }
+
+ const containerEnvironment: Record = { ...environment }
+ containerEnvironment.OPENCODE_CONFIG_DIR = execution.configMountPath
+
+ 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`,
+ "-v",
+ `${workspacePath}:${execution.workspaceMountPath}`,
+ "-v",
+ `${configDir}:${execution.configMountPath}`,
+ ]
+
+ 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}=${value}`)
+ }
+
+ 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,
+ }
+}
+
+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
+ }
+}
diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts
index 063c2cbb9..3355cc7e3 100644
--- a/packages/server/src/workspaces/manager.ts
+++ b/packages/server/src/workspaces/manager.ts
@@ -11,6 +11,7 @@ import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config.js"
+import { buildLaunchCommand } from "./execution-launch"
import {
OPENCODE_SERVER_BASE_URL_ENV,
buildOpencodeBasicAuthHeader,
@@ -89,15 +90,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.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`
@@ -109,8 +112,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(),
}
@@ -148,13 +154,21 @@ export class WorkspaceManager {
}
const logLevel = (serverConfig as any)?.logLevel
+ const launchCommand = buildLaunchCommand({
+ execution,
+ workspacePath,
+ environment,
+ logLevel: typeof logLevel === "string" ? logLevel.toUpperCase() : "DEBUG",
+ })
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,
logLevel,
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})
diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts
index efc77f9a1..06aa772d2 100644
--- a/packages/server/src/workspaces/runtime.ts
+++ b/packages/server/src/workspaces/runtime.ts
@@ -25,6 +25,8 @@ interface LaunchOptions {
workspaceId: string
folder: string
binaryPath: string
+ commandArgs?: string[]
+ spawnCwd?: string
environment?: Record
logLevel?: string
onExit?: (info: ProcessExitInfo) => void
@@ -55,7 +57,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,7 +85,7 @@ 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,
diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx
index 774d16b84..bcaff2ed2 100644
--- a/packages/ui/src/App.tsx
+++ b/packages/ui/src/App.tsx
@@ -257,7 +257,7 @@ const App: Component = () => {
const launchErrorMessage = () => launchError()?.message ?? ""
- async function handleSelectFolder(folderPath: string, binaryPath?: string) {
+ async function handleSelectFolder(folderPath: string, binaryPath?: string, options?: { executionProfileId?: string }) {
if (!folderPath) {
return
}
@@ -266,7 +266,9 @@ const App: Component = () => {
try {
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
- 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 41c627898..3eebbedce 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, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
+import { Component, createSignal, Show, For, onMount, onCleanup, createEffect, createMemo } from "solid-js"
+import type { ConnectionProfile, RemoteServerConnectionProfile, SshConnectionProfile } from "../../../server/src/api-types"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2 } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import DirectoryBrowserDialog from "./directory-browser-dialog"
@@ -27,7 +28,7 @@ type HomeTab = "local" | "servers"
interface FolderSelectionViewProps {
- onSelectFolder: (folder: string, binaryPath?: string) => void
+ onSelectFolder: (folder: string, binaryPath?: string, options?: { executionProfileId?: string }) => void
onOpenSidecar?: () => void
isLoading?: boolean
onClose?: () => void
@@ -40,15 +41,21 @@ const FolderSelectionView: Component = (props) => {
preferences,
updatePreferences,
serverSettings,
- remoteServers,
+ executionProfiles,
+ defaultExecutionProfileId,
+ lastSelectedExecutionProfileId,
+ setLastSelectedExecutionProfileId,
+ connectionProfiles,
+ saveConnectionProfile,
+ removeConnectionProfile,
saveRemoteServerProfile,
markRemoteServerConnected,
- removeRemoteServerProfile,
} = useConfig()
const { t, locale } = useI18n()
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 [activeTab, setActiveTab] = createSignal("local")
const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false)
@@ -61,6 +68,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" },
@@ -73,9 +81,21 @@ 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: 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()
+ const serverList = () => connectionProfiles()
const isLoading = () => Boolean(props.isLoading)
const canUseRemoteServerWindows = () => canOpenRemoteWindows()
@@ -90,6 +110,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
@@ -200,7 +248,7 @@ const FolderSelectionView: Component = (props) => {
const server = serverList()[index]
if (server) {
- void handleConnectSavedServer(server.id)
+ void handleConnectSavedConnection(server.id)
}
}
@@ -273,7 +321,9 @@ const FolderSelectionView: Component = (props) => {
function handleFolderSelect(path: string) {
if (isLoading()) return
- props.onSelectFolder(path, selectedBinary())
+ props.onSelectFolder(path, selectedBinary(), {
+ executionProfileId: selectedExecutionProfileId() ?? undefined,
+ })
}
function resetServerDialog() {
@@ -362,13 +412,41 @@ 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)
+ const target = connectionProfiles().find((entry) => entry.id === id)
if (!target || connectingServerId()) return
setConnectingServerId(id)
try {
- await probeAndOpenServer(target, true)
+ if (target.kind === "remote-server") {
+ await probeAndOpenServer(target, true)
+ return
+ }
+
+ const result = await serverApi.connectSshRemote({
+ connectionProfileId: target.id,
+ name: target.name,
+ host: target.host,
+ port: target.port,
+ username: target.username,
+ remotePath: target.remotePath,
+ remoteServerPort: target.remoteServerPort,
+ bootstrapScript: target.bootstrapScript,
+ })
+
+ const now = new Date().toISOString()
+ await saveConnectionProfile({
+ ...target,
+ updatedAt: now,
+ lastConnectedAt: now,
+ })
+
+ await openRemoteServerWindow({
+ id: target.id,
+ name: target.name,
+ baseUrl: result.baseUrl,
+ skipTlsVerify: false,
+ })
} catch (error) {
showAlertDialog(error instanceof Error ? error.message : String(error), {
title: t("folderSelection.servers.errorTitle"),
@@ -379,6 +457,13 @@ const FolderSelectionView: Component = (props) => {
}
}
+ async function handleRemoveSavedConnection(profile: ConnectionProfile) {
+ if (profile.kind === "ssh") {
+ await serverApi.disconnectSshRemote(profile.id).catch(() => undefined)
+ }
+ removeConnectionProfile(profile.id)
+ }
+
async function handleBrowse() {
if (isLoading()) return
setFocusMode("new")
@@ -685,7 +770,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 })}
@@ -696,7 +781,7 @@ const FolderSelectionView: Component = (props) => {
when={activeTab() === "local"}
fallback={
0}
+ when={canUseRemoteServerWindows() && serverList().length > 0}
fallback={
@@ -721,7 +806,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) => {
-
+
+
0}>
+
+
+
{t("folderSelection.executionProfile.label")}
+
{t("folderSelection.executionProfile.subtitle")}
+
+
+
+
+
+
+
)
}
diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts
index f107e1a97..017ddf2fe 100644
--- a/packages/ui/src/lib/api-client.ts
+++ b/packages/ui/src/lib/api-client.ts
@@ -14,6 +14,8 @@ import type {
ServerMeta,
RemoteProxySessionCreateRequest,
RemoteProxySessionCreateResponse,
+ SshConnectionBootstrapRequest,
+ SshConnectionBootstrapResponse,
RemoteServerProbeRequest,
RemoteServerProbeResponse,
VoiceModeStateResponse,
@@ -265,6 +267,15 @@ export const serverApi = {
body: JSON.stringify(payload),
})
},
+ connectSshRemote(payload: SshConnectionBootstrapRequest): Promise
{
+ return request("/api/remote-connections/ssh/connect", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ })
+ },
+ disconnectSshRemote(id: string): Promise {
+ return request(`/api/remote-connections/ssh/${encodeURIComponent(id)}`, { method: "DELETE" })
+ },
deleteRemoteProxySession(id: string): Promise {
return request(`/api/remote-proxy/sessions/${encodeURIComponent(id)}`, { method: "DELETE" })
},
diff --git a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts
index 841086c5e..554c1b5b7 100644
--- a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts
@@ -23,6 +23,8 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Open Folder or Connect Server",
"folderSelection.actions.subtitle": "Open local folder or connect to a CodeNomad server",
"folderSelection.actions.connectButton": "Connect CodeNomad Server",
+ "folderSelection.executionProfile.label": "Execution Profile",
+ "folderSelection.executionProfile.subtitle": "Choose how new local workspaces launch OpenCode",
"folderSelection.advancedSettings": "Advanced Settings",
"folderSelection.opencode": "OpenCode",
diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts
index 53a113192..6ea027e21 100644
--- a/packages/ui/src/lib/i18n/messages/en/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/en/settings.ts
@@ -109,6 +109,36 @@ export const settingsMessages = {
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
+ "settings.remoteConnections.form.title": "SSH connection profiles",
+ "settings.remoteConnections.form.subtitle": "Save reusable SSH bootstrap targets and scripts for remote environments.",
+ "settings.remoteConnections.kind.remote-server": "Remote server",
+ "settings.remoteConnections.kind.ssh": "SSH",
+ "settings.remoteConnections.form.name.label": "Profile name",
+ "settings.remoteConnections.form.name.placeholder": "Production VM",
+ "settings.remoteConnections.form.host.label": "Host",
+ "settings.remoteConnections.form.host.placeholder": "vm.example.com",
+ "settings.remoteConnections.form.port.label": "Port",
+ "settings.remoteConnections.form.port.placeholder": "22",
+ "settings.remoteConnections.form.remoteServerPort.label": "Remote server port",
+ "settings.remoteConnections.form.remoteServerPort.placeholder": "9898",
+ "settings.remoteConnections.form.username.label": "Username",
+ "settings.remoteConnections.form.username.placeholder": "ubuntu",
+ "settings.remoteConnections.form.remotePath.label": "Remote workspace path",
+ "settings.remoteConnections.form.remotePath.placeholder": "/srv/project",
+ "settings.remoteConnections.form.bootstrapScript.label": "Bootstrap script",
+ "settings.remoteConnections.form.bootstrapScript.placeholder": "Optional shell script run after connecting",
+ "settings.remoteConnections.form.save": "Save connection",
+ "settings.remoteConnections.form.update": "Update connection",
+ "settings.remoteConnections.form.cancelEdit": "Cancel edit",
+ "settings.remoteConnections.list.title": "Saved and recent connections",
+ "settings.remoteConnections.list.subtitle": "Review remote server shortcuts and SSH bootstrap profiles available on this device.",
+ "settings.remoteConnections.list.empty": "No connection profiles saved yet.",
+ "settings.remoteConnections.list.actions.edit": "Edit connection",
+ "settings.remoteConnections.list.actions.delete": "Delete connection",
+ "settings.remoteConnections.validation.name": "Connection profile name is required.",
+ "settings.remoteConnections.validation.host": "SSH host is required.",
+ "settings.remoteConnections.validation.port": "Port must be between 1 and 65535.",
+ "settings.remoteConnections.validation.remoteServerPort": "Remote server port must be between 1 and 65535.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
@@ -121,6 +151,60 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.info": "Info",
"settings.opencode.logLevel.option.warn": "Warn",
"settings.opencode.logLevel.option.error": "Error",
+ "settings.opencode.executionProfiles.title": "Execution Profiles",
+ "settings.opencode.executionProfiles.subtitle": "Define how CodeNomad should run OpenCode for local workspaces.",
+ "settings.opencode.executionProfiles.kind.local": "Local",
+ "settings.opencode.executionProfiles.kind.wsl": "WSL",
+ "settings.opencode.executionProfiles.kind.docker": "Docker",
+ "settings.opencode.executionProfiles.kind.command": "Command",
+ "settings.opencode.executionProfiles.form.kind.label": "Profile type",
+ "settings.opencode.executionProfiles.form.kind.subtitle": "Choose the runtime strategy for this profile.",
+ "settings.opencode.executionProfiles.form.name.label": "Profile name",
+ "settings.opencode.executionProfiles.form.name.subtitle": "Use a clear label you can recognise later.",
+ "settings.opencode.executionProfiles.form.name.placeholder": "Docker Sandbox",
+ "settings.opencode.executionProfiles.form.binaryPath.label": "Binary path",
+ "settings.opencode.executionProfiles.form.binaryPath.subtitle": "Absolute path to the OpenCode executable.",
+ "settings.opencode.executionProfiles.form.binaryPath.placeholder": "C:\\Tools\\opencode.exe",
+ "settings.opencode.executionProfiles.form.distro.label": "WSL distro",
+ "settings.opencode.executionProfiles.form.distro.subtitle": "Name of the WSL distribution to use.",
+ "settings.opencode.executionProfiles.form.distro.placeholder": "Ubuntu",
+ "settings.opencode.executionProfiles.form.image.label": "Docker image",
+ "settings.opencode.executionProfiles.form.image.subtitle": "Container image used to run OpenCode.",
+ "settings.opencode.executionProfiles.form.image.placeholder": "ghcr.io/example/opencode:latest",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.label": "Workspace mount path",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.subtitle": "Path inside the container where the workspace is mounted.",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.placeholder": "/workspace",
+ "settings.opencode.executionProfiles.form.configMountPath.label": "Config mount path",
+ "settings.opencode.executionProfiles.form.configMountPath.subtitle": "Path inside the container where the OpenCode config directory is mounted.",
+ "settings.opencode.executionProfiles.form.configMountPath.placeholder": "/root/.config/opencode",
+ "settings.opencode.executionProfiles.form.command.label": "Container command",
+ "settings.opencode.executionProfiles.form.command.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.extraDockerArgs.label": "Extra Docker args",
+ "settings.opencode.executionProfiles.form.extraDockerArgs.placeholder": "One docker argument per line",
+ "settings.opencode.executionProfiles.form.executable.label": "Executable",
+ "settings.opencode.executionProfiles.form.executable.subtitle": "Program or script to launch for this profile.",
+ "settings.opencode.executionProfiles.form.executable.placeholder": "ssh",
+ "settings.opencode.executionProfiles.form.cwdMode.label": "Working directory",
+ "settings.opencode.executionProfiles.form.cwdMode.subtitle": "Choose whether the command starts in the workspace directory.",
+ "settings.opencode.executionProfiles.form.cwdMode.workspace": "Workspace folder",
+ "settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
+ "settings.opencode.executionProfiles.form.args.label": "Arguments",
+ "settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.save": "Save profile",
+ "settings.opencode.executionProfiles.form.update": "Update profile",
+ "settings.opencode.executionProfiles.form.cancelEdit": "Cancel edit",
+ "settings.opencode.executionProfiles.list.title": "Saved execution profiles",
+ "settings.opencode.executionProfiles.list.subtitle": "Review and manage the execution profiles available on this server.",
+ "settings.opencode.executionProfiles.list.empty": "No execution profiles configured yet.",
+ "settings.opencode.executionProfiles.list.defaultBadge": "Default",
+ "settings.opencode.executionProfiles.list.actions.edit": "Edit profile",
+ "settings.opencode.executionProfiles.list.actions.delete": "Delete profile",
+ "settings.opencode.executionProfiles.list.actions.makeDefault": "Set as default profile",
+ "settings.opencode.executionProfiles.validation.name": "Profile name is required.",
+ "settings.opencode.executionProfiles.validation.binaryPath": "Binary path is required.",
+ "settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
+ "settings.opencode.executionProfiles.validation.docker": "Docker image and mount paths are required.",
+ "settings.opencode.executionProfiles.validation.executable": "Executable is required.",
"settings.appearance.behavior.title": "Interaction",
diff --git a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts
index 1c1e47dca..df6021030 100644
--- a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts
@@ -23,6 +23,8 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Abrir carpeta o conectar servidor",
"folderSelection.actions.subtitle": "Abre una carpeta local o conéctate a un servidor de CodeNomad",
"folderSelection.actions.connectButton": "Conectar servidor CodeNomad",
+ "folderSelection.executionProfile.label": "Perfil de ejecución",
+ "folderSelection.executionProfile.subtitle": "Elige cómo los nuevos espacios de trabajo locales inician OpenCode",
"folderSelection.advancedSettings": "Configuración avanzada",
"folderSelection.opencode": "OpenCode",
diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts
index 7cc845099..0921fbe55 100644
--- a/packages/ui/src/lib/i18n/messages/es/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/es/settings.ts
@@ -109,6 +109,36 @@ export const settingsMessages = {
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
+ "settings.remoteConnections.form.title": "SSH connection profiles",
+ "settings.remoteConnections.form.subtitle": "Save reusable SSH bootstrap targets and scripts for remote environments.",
+ "settings.remoteConnections.kind.remote-server": "Remote server",
+ "settings.remoteConnections.kind.ssh": "SSH",
+ "settings.remoteConnections.form.name.label": "Profile name",
+ "settings.remoteConnections.form.name.placeholder": "Production VM",
+ "settings.remoteConnections.form.host.label": "Host",
+ "settings.remoteConnections.form.host.placeholder": "vm.example.com",
+ "settings.remoteConnections.form.port.label": "Port",
+ "settings.remoteConnections.form.port.placeholder": "22",
+ "settings.remoteConnections.form.remoteServerPort.label": "Remote server port",
+ "settings.remoteConnections.form.remoteServerPort.placeholder": "9898",
+ "settings.remoteConnections.form.username.label": "Username",
+ "settings.remoteConnections.form.username.placeholder": "ubuntu",
+ "settings.remoteConnections.form.remotePath.label": "Remote workspace path",
+ "settings.remoteConnections.form.remotePath.placeholder": "/srv/project",
+ "settings.remoteConnections.form.bootstrapScript.label": "Bootstrap script",
+ "settings.remoteConnections.form.bootstrapScript.placeholder": "Optional shell script run after connecting",
+ "settings.remoteConnections.form.save": "Save connection",
+ "settings.remoteConnections.form.update": "Update connection",
+ "settings.remoteConnections.form.cancelEdit": "Cancel edit",
+ "settings.remoteConnections.list.title": "Saved and recent connections",
+ "settings.remoteConnections.list.subtitle": "Review remote server shortcuts and SSH bootstrap profiles available on this device.",
+ "settings.remoteConnections.list.empty": "No connection profiles saved yet.",
+ "settings.remoteConnections.list.actions.edit": "Edit connection",
+ "settings.remoteConnections.list.actions.delete": "Delete connection",
+ "settings.remoteConnections.validation.name": "Connection profile name is required.",
+ "settings.remoteConnections.validation.host": "SSH host is required.",
+ "settings.remoteConnections.validation.port": "Port must be between 1 and 65535.",
+ "settings.remoteConnections.validation.remoteServerPort": "Remote server port must be between 1 and 65535.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
@@ -121,6 +151,60 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.info": "Informacion",
"settings.opencode.logLevel.option.warn": "Advertencia",
"settings.opencode.logLevel.option.error": "Error",
+ "settings.opencode.executionProfiles.title": "Execution Profiles",
+ "settings.opencode.executionProfiles.subtitle": "Define how CodeNomad should run OpenCode for local workspaces.",
+ "settings.opencode.executionProfiles.kind.local": "Local",
+ "settings.opencode.executionProfiles.kind.wsl": "WSL",
+ "settings.opencode.executionProfiles.kind.docker": "Docker",
+ "settings.opencode.executionProfiles.kind.command": "Command",
+ "settings.opencode.executionProfiles.form.kind.label": "Profile type",
+ "settings.opencode.executionProfiles.form.kind.subtitle": "Choose the runtime strategy for this profile.",
+ "settings.opencode.executionProfiles.form.name.label": "Profile name",
+ "settings.opencode.executionProfiles.form.name.subtitle": "Use a clear label you can recognise later.",
+ "settings.opencode.executionProfiles.form.name.placeholder": "Docker Sandbox",
+ "settings.opencode.executionProfiles.form.binaryPath.label": "Binary path",
+ "settings.opencode.executionProfiles.form.binaryPath.subtitle": "Absolute path to the OpenCode executable.",
+ "settings.opencode.executionProfiles.form.binaryPath.placeholder": "C:\\Tools\\opencode.exe",
+ "settings.opencode.executionProfiles.form.distro.label": "WSL distro",
+ "settings.opencode.executionProfiles.form.distro.subtitle": "Name of the WSL distribution to use.",
+ "settings.opencode.executionProfiles.form.distro.placeholder": "Ubuntu",
+ "settings.opencode.executionProfiles.form.image.label": "Docker image",
+ "settings.opencode.executionProfiles.form.image.subtitle": "Container image used to run OpenCode.",
+ "settings.opencode.executionProfiles.form.image.placeholder": "ghcr.io/example/opencode:latest",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.label": "Workspace mount path",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.subtitle": "Path inside the container where the workspace is mounted.",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.placeholder": "/workspace",
+ "settings.opencode.executionProfiles.form.configMountPath.label": "Config mount path",
+ "settings.opencode.executionProfiles.form.configMountPath.subtitle": "Path inside the container where the OpenCode config directory is mounted.",
+ "settings.opencode.executionProfiles.form.configMountPath.placeholder": "/root/.config/opencode",
+ "settings.opencode.executionProfiles.form.command.label": "Container command",
+ "settings.opencode.executionProfiles.form.command.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.extraDockerArgs.label": "Extra Docker args",
+ "settings.opencode.executionProfiles.form.extraDockerArgs.placeholder": "One docker argument per line",
+ "settings.opencode.executionProfiles.form.executable.label": "Executable",
+ "settings.opencode.executionProfiles.form.executable.subtitle": "Program or script to launch for this profile.",
+ "settings.opencode.executionProfiles.form.executable.placeholder": "ssh",
+ "settings.opencode.executionProfiles.form.cwdMode.label": "Working directory",
+ "settings.opencode.executionProfiles.form.cwdMode.subtitle": "Choose whether the command starts in the workspace directory.",
+ "settings.opencode.executionProfiles.form.cwdMode.workspace": "Workspace folder",
+ "settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
+ "settings.opencode.executionProfiles.form.args.label": "Arguments",
+ "settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.save": "Save profile",
+ "settings.opencode.executionProfiles.form.update": "Update profile",
+ "settings.opencode.executionProfiles.form.cancelEdit": "Cancel edit",
+ "settings.opencode.executionProfiles.list.title": "Saved execution profiles",
+ "settings.opencode.executionProfiles.list.subtitle": "Review and manage the execution profiles available on this server.",
+ "settings.opencode.executionProfiles.list.empty": "No execution profiles configured yet.",
+ "settings.opencode.executionProfiles.list.defaultBadge": "Default",
+ "settings.opencode.executionProfiles.list.actions.edit": "Edit profile",
+ "settings.opencode.executionProfiles.list.actions.delete": "Delete profile",
+ "settings.opencode.executionProfiles.list.actions.makeDefault": "Set as default profile",
+ "settings.opencode.executionProfiles.validation.name": "Profile name is required.",
+ "settings.opencode.executionProfiles.validation.binaryPath": "Binary path is required.",
+ "settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
+ "settings.opencode.executionProfiles.validation.docker": "Docker image and mount paths are required.",
+ "settings.opencode.executionProfiles.validation.executable": "Executable is required.",
"settings.appearance.behavior.title": "Interaccion",
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
diff --git a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts
index 6932e434b..de47b27c8 100644
--- a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts
@@ -23,6 +23,8 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Ouvrir un dossier ou se connecter à un serveur",
"folderSelection.actions.subtitle": "Ouvrez un dossier local ou connectez-vous à un serveur CodeNomad",
"folderSelection.actions.connectButton": "Se connecter au serveur CodeNomad",
+ "folderSelection.executionProfile.label": "Profil d'exécution",
+ "folderSelection.executionProfile.subtitle": "Choisissez comment les nouveaux espaces de travail locaux lancent OpenCode",
"folderSelection.advancedSettings": "Paramètres avancés",
"folderSelection.opencode": "OpenCode",
diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts
index dcb4ef6ee..7f82aa08d 100644
--- a/packages/ui/src/lib/i18n/messages/fr/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts
@@ -109,6 +109,36 @@ export const settingsMessages = {
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
+ "settings.remoteConnections.form.title": "SSH connection profiles",
+ "settings.remoteConnections.form.subtitle": "Save reusable SSH bootstrap targets and scripts for remote environments.",
+ "settings.remoteConnections.kind.remote-server": "Remote server",
+ "settings.remoteConnections.kind.ssh": "SSH",
+ "settings.remoteConnections.form.name.label": "Profile name",
+ "settings.remoteConnections.form.name.placeholder": "Production VM",
+ "settings.remoteConnections.form.host.label": "Host",
+ "settings.remoteConnections.form.host.placeholder": "vm.example.com",
+ "settings.remoteConnections.form.port.label": "Port",
+ "settings.remoteConnections.form.port.placeholder": "22",
+ "settings.remoteConnections.form.remoteServerPort.label": "Remote server port",
+ "settings.remoteConnections.form.remoteServerPort.placeholder": "9898",
+ "settings.remoteConnections.form.username.label": "Username",
+ "settings.remoteConnections.form.username.placeholder": "ubuntu",
+ "settings.remoteConnections.form.remotePath.label": "Remote workspace path",
+ "settings.remoteConnections.form.remotePath.placeholder": "/srv/project",
+ "settings.remoteConnections.form.bootstrapScript.label": "Bootstrap script",
+ "settings.remoteConnections.form.bootstrapScript.placeholder": "Optional shell script run after connecting",
+ "settings.remoteConnections.form.save": "Save connection",
+ "settings.remoteConnections.form.update": "Update connection",
+ "settings.remoteConnections.form.cancelEdit": "Cancel edit",
+ "settings.remoteConnections.list.title": "Saved and recent connections",
+ "settings.remoteConnections.list.subtitle": "Review remote server shortcuts and SSH bootstrap profiles available on this device.",
+ "settings.remoteConnections.list.empty": "No connection profiles saved yet.",
+ "settings.remoteConnections.list.actions.edit": "Edit connection",
+ "settings.remoteConnections.list.actions.delete": "Delete connection",
+ "settings.remoteConnections.validation.name": "Connection profile name is required.",
+ "settings.remoteConnections.validation.host": "SSH host is required.",
+ "settings.remoteConnections.validation.port": "Port must be between 1 and 65535.",
+ "settings.remoteConnections.validation.remoteServerPort": "Remote server port must be between 1 and 65535.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
@@ -121,6 +151,60 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.info": "Info",
"settings.opencode.logLevel.option.warn": "Avertissement",
"settings.opencode.logLevel.option.error": "Erreur",
+ "settings.opencode.executionProfiles.title": "Execution Profiles",
+ "settings.opencode.executionProfiles.subtitle": "Define how CodeNomad should run OpenCode for local workspaces.",
+ "settings.opencode.executionProfiles.kind.local": "Local",
+ "settings.opencode.executionProfiles.kind.wsl": "WSL",
+ "settings.opencode.executionProfiles.kind.docker": "Docker",
+ "settings.opencode.executionProfiles.kind.command": "Command",
+ "settings.opencode.executionProfiles.form.kind.label": "Profile type",
+ "settings.opencode.executionProfiles.form.kind.subtitle": "Choose the runtime strategy for this profile.",
+ "settings.opencode.executionProfiles.form.name.label": "Profile name",
+ "settings.opencode.executionProfiles.form.name.subtitle": "Use a clear label you can recognise later.",
+ "settings.opencode.executionProfiles.form.name.placeholder": "Docker Sandbox",
+ "settings.opencode.executionProfiles.form.binaryPath.label": "Binary path",
+ "settings.opencode.executionProfiles.form.binaryPath.subtitle": "Absolute path to the OpenCode executable.",
+ "settings.opencode.executionProfiles.form.binaryPath.placeholder": "C:\\Tools\\opencode.exe",
+ "settings.opencode.executionProfiles.form.distro.label": "WSL distro",
+ "settings.opencode.executionProfiles.form.distro.subtitle": "Name of the WSL distribution to use.",
+ "settings.opencode.executionProfiles.form.distro.placeholder": "Ubuntu",
+ "settings.opencode.executionProfiles.form.image.label": "Docker image",
+ "settings.opencode.executionProfiles.form.image.subtitle": "Container image used to run OpenCode.",
+ "settings.opencode.executionProfiles.form.image.placeholder": "ghcr.io/example/opencode:latest",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.label": "Workspace mount path",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.subtitle": "Path inside the container where the workspace is mounted.",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.placeholder": "/workspace",
+ "settings.opencode.executionProfiles.form.configMountPath.label": "Config mount path",
+ "settings.opencode.executionProfiles.form.configMountPath.subtitle": "Path inside the container where the OpenCode config directory is mounted.",
+ "settings.opencode.executionProfiles.form.configMountPath.placeholder": "/root/.config/opencode",
+ "settings.opencode.executionProfiles.form.command.label": "Container command",
+ "settings.opencode.executionProfiles.form.command.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.extraDockerArgs.label": "Extra Docker args",
+ "settings.opencode.executionProfiles.form.extraDockerArgs.placeholder": "One docker argument per line",
+ "settings.opencode.executionProfiles.form.executable.label": "Executable",
+ "settings.opencode.executionProfiles.form.executable.subtitle": "Program or script to launch for this profile.",
+ "settings.opencode.executionProfiles.form.executable.placeholder": "ssh",
+ "settings.opencode.executionProfiles.form.cwdMode.label": "Working directory",
+ "settings.opencode.executionProfiles.form.cwdMode.subtitle": "Choose whether the command starts in the workspace directory.",
+ "settings.opencode.executionProfiles.form.cwdMode.workspace": "Workspace folder",
+ "settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
+ "settings.opencode.executionProfiles.form.args.label": "Arguments",
+ "settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.save": "Save profile",
+ "settings.opencode.executionProfiles.form.update": "Update profile",
+ "settings.opencode.executionProfiles.form.cancelEdit": "Cancel edit",
+ "settings.opencode.executionProfiles.list.title": "Saved execution profiles",
+ "settings.opencode.executionProfiles.list.subtitle": "Review and manage the execution profiles available on this server.",
+ "settings.opencode.executionProfiles.list.empty": "No execution profiles configured yet.",
+ "settings.opencode.executionProfiles.list.defaultBadge": "Default",
+ "settings.opencode.executionProfiles.list.actions.edit": "Edit profile",
+ "settings.opencode.executionProfiles.list.actions.delete": "Delete profile",
+ "settings.opencode.executionProfiles.list.actions.makeDefault": "Set as default profile",
+ "settings.opencode.executionProfiles.validation.name": "Profile name is required.",
+ "settings.opencode.executionProfiles.validation.binaryPath": "Binary path is required.",
+ "settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
+ "settings.opencode.executionProfiles.validation.docker": "Docker image and mount paths are required.",
+ "settings.opencode.executionProfiles.validation.executable": "Executable is required.",
"settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
diff --git a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts
index 69de36c09..8fc05f673 100644
--- a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts
@@ -23,6 +23,8 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "פתח תיקייה או התחבר לשרת",
"folderSelection.actions.subtitle": "פתח תיקייה מקומית או התחבר לשרת CodeNomad",
"folderSelection.actions.connectButton": "התחבר לשרת CodeNomad",
+ "folderSelection.executionProfile.label": "פרופיל הרצה",
+ "folderSelection.executionProfile.subtitle": "בחר איך להפעיל את OpenCode עבור סביבות עבודה מקומיות חדשות",
"folderSelection.advancedSettings": "הגדרות מתקדמות",
"folderSelection.opencode": "OpenCode",
diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts
index 58657d0b1..a7dbef2e3 100644
--- a/packages/ui/src/lib/i18n/messages/he/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/he/settings.ts
@@ -108,6 +108,36 @@ export const settingsMessages = {
"settings.notifications.status.unsupported": "התראות לא נתמכות",
"settings.section.remote.title": "גישה מרוחקת",
"settings.section.remote.subtitle": "בדוק כיצד שרת זה חשוף ברשת שלך ואבטח אישורי גישה.",
+ "settings.remoteConnections.form.title": "SSH connection profiles",
+ "settings.remoteConnections.form.subtitle": "Save reusable SSH bootstrap targets and scripts for remote environments.",
+ "settings.remoteConnections.kind.remote-server": "Remote server",
+ "settings.remoteConnections.kind.ssh": "SSH",
+ "settings.remoteConnections.form.name.label": "Profile name",
+ "settings.remoteConnections.form.name.placeholder": "Production VM",
+ "settings.remoteConnections.form.host.label": "Host",
+ "settings.remoteConnections.form.host.placeholder": "vm.example.com",
+ "settings.remoteConnections.form.port.label": "Port",
+ "settings.remoteConnections.form.port.placeholder": "22",
+ "settings.remoteConnections.form.remoteServerPort.label": "Remote server port",
+ "settings.remoteConnections.form.remoteServerPort.placeholder": "9898",
+ "settings.remoteConnections.form.username.label": "Username",
+ "settings.remoteConnections.form.username.placeholder": "ubuntu",
+ "settings.remoteConnections.form.remotePath.label": "Remote workspace path",
+ "settings.remoteConnections.form.remotePath.placeholder": "/srv/project",
+ "settings.remoteConnections.form.bootstrapScript.label": "Bootstrap script",
+ "settings.remoteConnections.form.bootstrapScript.placeholder": "Optional shell script run after connecting",
+ "settings.remoteConnections.form.save": "Save connection",
+ "settings.remoteConnections.form.update": "Update connection",
+ "settings.remoteConnections.form.cancelEdit": "Cancel edit",
+ "settings.remoteConnections.list.title": "Saved and recent connections",
+ "settings.remoteConnections.list.subtitle": "Review remote server shortcuts and SSH bootstrap profiles available on this device.",
+ "settings.remoteConnections.list.empty": "No connection profiles saved yet.",
+ "settings.remoteConnections.list.actions.edit": "Edit connection",
+ "settings.remoteConnections.list.actions.delete": "Delete connection",
+ "settings.remoteConnections.validation.name": "Connection profile name is required.",
+ "settings.remoteConnections.validation.host": "SSH host is required.",
+ "settings.remoteConnections.validation.port": "Port must be between 1 and 65535.",
+ "settings.remoteConnections.validation.remoteServerPort": "Remote server port must be between 1 and 65535.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.",
"settings.opencode.runtime.title": "סביבת ריצה",
@@ -120,6 +150,60 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.info": "מידע",
"settings.opencode.logLevel.option.warn": "אזהרה",
"settings.opencode.logLevel.option.error": "שגיאה",
+ "settings.opencode.executionProfiles.title": "Execution Profiles",
+ "settings.opencode.executionProfiles.subtitle": "Define how CodeNomad should run OpenCode for local workspaces.",
+ "settings.opencode.executionProfiles.kind.local": "Local",
+ "settings.opencode.executionProfiles.kind.wsl": "WSL",
+ "settings.opencode.executionProfiles.kind.docker": "Docker",
+ "settings.opencode.executionProfiles.kind.command": "Command",
+ "settings.opencode.executionProfiles.form.kind.label": "Profile type",
+ "settings.opencode.executionProfiles.form.kind.subtitle": "Choose the runtime strategy for this profile.",
+ "settings.opencode.executionProfiles.form.name.label": "Profile name",
+ "settings.opencode.executionProfiles.form.name.subtitle": "Use a clear label you can recognise later.",
+ "settings.opencode.executionProfiles.form.name.placeholder": "Docker Sandbox",
+ "settings.opencode.executionProfiles.form.binaryPath.label": "Binary path",
+ "settings.opencode.executionProfiles.form.binaryPath.subtitle": "Absolute path to the OpenCode executable.",
+ "settings.opencode.executionProfiles.form.binaryPath.placeholder": "C:\\Tools\\opencode.exe",
+ "settings.opencode.executionProfiles.form.distro.label": "WSL distro",
+ "settings.opencode.executionProfiles.form.distro.subtitle": "Name of the WSL distribution to use.",
+ "settings.opencode.executionProfiles.form.distro.placeholder": "Ubuntu",
+ "settings.opencode.executionProfiles.form.image.label": "Docker image",
+ "settings.opencode.executionProfiles.form.image.subtitle": "Container image used to run OpenCode.",
+ "settings.opencode.executionProfiles.form.image.placeholder": "ghcr.io/example/opencode:latest",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.label": "Workspace mount path",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.subtitle": "Path inside the container where the workspace is mounted.",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.placeholder": "/workspace",
+ "settings.opencode.executionProfiles.form.configMountPath.label": "Config mount path",
+ "settings.opencode.executionProfiles.form.configMountPath.subtitle": "Path inside the container where the OpenCode config directory is mounted.",
+ "settings.opencode.executionProfiles.form.configMountPath.placeholder": "/root/.config/opencode",
+ "settings.opencode.executionProfiles.form.command.label": "Container command",
+ "settings.opencode.executionProfiles.form.command.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.extraDockerArgs.label": "Extra Docker args",
+ "settings.opencode.executionProfiles.form.extraDockerArgs.placeholder": "One docker argument per line",
+ "settings.opencode.executionProfiles.form.executable.label": "Executable",
+ "settings.opencode.executionProfiles.form.executable.subtitle": "Program or script to launch for this profile.",
+ "settings.opencode.executionProfiles.form.executable.placeholder": "ssh",
+ "settings.opencode.executionProfiles.form.cwdMode.label": "Working directory",
+ "settings.opencode.executionProfiles.form.cwdMode.subtitle": "Choose whether the command starts in the workspace directory.",
+ "settings.opencode.executionProfiles.form.cwdMode.workspace": "Workspace folder",
+ "settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
+ "settings.opencode.executionProfiles.form.args.label": "Arguments",
+ "settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.save": "Save profile",
+ "settings.opencode.executionProfiles.form.update": "Update profile",
+ "settings.opencode.executionProfiles.form.cancelEdit": "Cancel edit",
+ "settings.opencode.executionProfiles.list.title": "Saved execution profiles",
+ "settings.opencode.executionProfiles.list.subtitle": "Review and manage the execution profiles available on this server.",
+ "settings.opencode.executionProfiles.list.empty": "No execution profiles configured yet.",
+ "settings.opencode.executionProfiles.list.defaultBadge": "Default",
+ "settings.opencode.executionProfiles.list.actions.edit": "Edit profile",
+ "settings.opencode.executionProfiles.list.actions.delete": "Delete profile",
+ "settings.opencode.executionProfiles.list.actions.makeDefault": "Set as default profile",
+ "settings.opencode.executionProfiles.validation.name": "Profile name is required.",
+ "settings.opencode.executionProfiles.validation.binaryPath": "Binary path is required.",
+ "settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
+ "settings.opencode.executionProfiles.validation.docker": "Docker image and mount paths are required.",
+ "settings.opencode.executionProfiles.validation.executable": "Executable is required.",
"settings.appearance.behavior.title": "אינטראקציה",
"settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.",
diff --git a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts
index b1b2f4787..faf9fb9f2 100644
--- a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts
@@ -23,6 +23,8 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "フォルダを開くかサーバーに接続",
"folderSelection.actions.subtitle": "ローカルフォルダを開くか CodeNomad サーバーに接続します",
"folderSelection.actions.connectButton": "CodeNomad サーバーに接続",
+ "folderSelection.executionProfile.label": "実行プロファイル",
+ "folderSelection.executionProfile.subtitle": "新しいローカルワークスペースで OpenCode をどう起動するか選択します",
"folderSelection.advancedSettings": "詳細設定",
"folderSelection.opencode": "OpenCode",
diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts
index 7cec7f3f1..80a71a830 100644
--- a/packages/ui/src/lib/i18n/messages/ja/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts
@@ -109,6 +109,36 @@ export const settingsMessages = {
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
+ "settings.remoteConnections.form.title": "SSH connection profiles",
+ "settings.remoteConnections.form.subtitle": "Save reusable SSH bootstrap targets and scripts for remote environments.",
+ "settings.remoteConnections.kind.remote-server": "Remote server",
+ "settings.remoteConnections.kind.ssh": "SSH",
+ "settings.remoteConnections.form.name.label": "Profile name",
+ "settings.remoteConnections.form.name.placeholder": "Production VM",
+ "settings.remoteConnections.form.host.label": "Host",
+ "settings.remoteConnections.form.host.placeholder": "vm.example.com",
+ "settings.remoteConnections.form.port.label": "Port",
+ "settings.remoteConnections.form.port.placeholder": "22",
+ "settings.remoteConnections.form.remoteServerPort.label": "Remote server port",
+ "settings.remoteConnections.form.remoteServerPort.placeholder": "9898",
+ "settings.remoteConnections.form.username.label": "Username",
+ "settings.remoteConnections.form.username.placeholder": "ubuntu",
+ "settings.remoteConnections.form.remotePath.label": "Remote workspace path",
+ "settings.remoteConnections.form.remotePath.placeholder": "/srv/project",
+ "settings.remoteConnections.form.bootstrapScript.label": "Bootstrap script",
+ "settings.remoteConnections.form.bootstrapScript.placeholder": "Optional shell script run after connecting",
+ "settings.remoteConnections.form.save": "Save connection",
+ "settings.remoteConnections.form.update": "Update connection",
+ "settings.remoteConnections.form.cancelEdit": "Cancel edit",
+ "settings.remoteConnections.list.title": "Saved and recent connections",
+ "settings.remoteConnections.list.subtitle": "Review remote server shortcuts and SSH bootstrap profiles available on this device.",
+ "settings.remoteConnections.list.empty": "No connection profiles saved yet.",
+ "settings.remoteConnections.list.actions.edit": "Edit connection",
+ "settings.remoteConnections.list.actions.delete": "Delete connection",
+ "settings.remoteConnections.validation.name": "Connection profile name is required.",
+ "settings.remoteConnections.validation.host": "SSH host is required.",
+ "settings.remoteConnections.validation.port": "Port must be between 1 and 65535.",
+ "settings.remoteConnections.validation.remoteServerPort": "Remote server port must be between 1 and 65535.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
@@ -121,6 +151,60 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.info": "情報",
"settings.opencode.logLevel.option.warn": "警告",
"settings.opencode.logLevel.option.error": "エラー",
+ "settings.opencode.executionProfiles.title": "Execution Profiles",
+ "settings.opencode.executionProfiles.subtitle": "Define how CodeNomad should run OpenCode for local workspaces.",
+ "settings.opencode.executionProfiles.kind.local": "Local",
+ "settings.opencode.executionProfiles.kind.wsl": "WSL",
+ "settings.opencode.executionProfiles.kind.docker": "Docker",
+ "settings.opencode.executionProfiles.kind.command": "Command",
+ "settings.opencode.executionProfiles.form.kind.label": "Profile type",
+ "settings.opencode.executionProfiles.form.kind.subtitle": "Choose the runtime strategy for this profile.",
+ "settings.opencode.executionProfiles.form.name.label": "Profile name",
+ "settings.opencode.executionProfiles.form.name.subtitle": "Use a clear label you can recognise later.",
+ "settings.opencode.executionProfiles.form.name.placeholder": "Docker Sandbox",
+ "settings.opencode.executionProfiles.form.binaryPath.label": "Binary path",
+ "settings.opencode.executionProfiles.form.binaryPath.subtitle": "Absolute path to the OpenCode executable.",
+ "settings.opencode.executionProfiles.form.binaryPath.placeholder": "C:\\Tools\\opencode.exe",
+ "settings.opencode.executionProfiles.form.distro.label": "WSL distro",
+ "settings.opencode.executionProfiles.form.distro.subtitle": "Name of the WSL distribution to use.",
+ "settings.opencode.executionProfiles.form.distro.placeholder": "Ubuntu",
+ "settings.opencode.executionProfiles.form.image.label": "Docker image",
+ "settings.opencode.executionProfiles.form.image.subtitle": "Container image used to run OpenCode.",
+ "settings.opencode.executionProfiles.form.image.placeholder": "ghcr.io/example/opencode:latest",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.label": "Workspace mount path",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.subtitle": "Path inside the container where the workspace is mounted.",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.placeholder": "/workspace",
+ "settings.opencode.executionProfiles.form.configMountPath.label": "Config mount path",
+ "settings.opencode.executionProfiles.form.configMountPath.subtitle": "Path inside the container where the OpenCode config directory is mounted.",
+ "settings.opencode.executionProfiles.form.configMountPath.placeholder": "/root/.config/opencode",
+ "settings.opencode.executionProfiles.form.command.label": "Container command",
+ "settings.opencode.executionProfiles.form.command.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.extraDockerArgs.label": "Extra Docker args",
+ "settings.opencode.executionProfiles.form.extraDockerArgs.placeholder": "One docker argument per line",
+ "settings.opencode.executionProfiles.form.executable.label": "Executable",
+ "settings.opencode.executionProfiles.form.executable.subtitle": "Program or script to launch for this profile.",
+ "settings.opencode.executionProfiles.form.executable.placeholder": "ssh",
+ "settings.opencode.executionProfiles.form.cwdMode.label": "Working directory",
+ "settings.opencode.executionProfiles.form.cwdMode.subtitle": "Choose whether the command starts in the workspace directory.",
+ "settings.opencode.executionProfiles.form.cwdMode.workspace": "Workspace folder",
+ "settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
+ "settings.opencode.executionProfiles.form.args.label": "Arguments",
+ "settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.save": "Save profile",
+ "settings.opencode.executionProfiles.form.update": "Update profile",
+ "settings.opencode.executionProfiles.form.cancelEdit": "Cancel edit",
+ "settings.opencode.executionProfiles.list.title": "Saved execution profiles",
+ "settings.opencode.executionProfiles.list.subtitle": "Review and manage the execution profiles available on this server.",
+ "settings.opencode.executionProfiles.list.empty": "No execution profiles configured yet.",
+ "settings.opencode.executionProfiles.list.defaultBadge": "Default",
+ "settings.opencode.executionProfiles.list.actions.edit": "Edit profile",
+ "settings.opencode.executionProfiles.list.actions.delete": "Delete profile",
+ "settings.opencode.executionProfiles.list.actions.makeDefault": "Set as default profile",
+ "settings.opencode.executionProfiles.validation.name": "Profile name is required.",
+ "settings.opencode.executionProfiles.validation.binaryPath": "Binary path is required.",
+ "settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
+ "settings.opencode.executionProfiles.validation.docker": "Docker image and mount paths are required.",
+ "settings.opencode.executionProfiles.validation.executable": "Executable is required.",
"settings.appearance.behavior.title": "操作",
"settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。",
diff --git a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts
index bc88c6013..4542dd021 100644
--- a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts
@@ -23,6 +23,8 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Открыть папку или подключить сервер",
"folderSelection.actions.subtitle": "Откройте локальную папку или подключитесь к серверу CodeNomad",
"folderSelection.actions.connectButton": "Подключить сервер CodeNomad",
+ "folderSelection.executionProfile.label": "Профиль выполнения",
+ "folderSelection.executionProfile.subtitle": "Выберите, как OpenCode должен запускаться для новых локальных рабочих пространств",
"folderSelection.advancedSettings": "Расширенные настройки",
"folderSelection.opencode": "OpenCode",
diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts
index f7f967df4..eb672f5b6 100644
--- a/packages/ui/src/lib/i18n/messages/ru/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts
@@ -109,6 +109,36 @@ export const settingsMessages = {
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
+ "settings.remoteConnections.form.title": "SSH connection profiles",
+ "settings.remoteConnections.form.subtitle": "Save reusable SSH bootstrap targets and scripts for remote environments.",
+ "settings.remoteConnections.kind.remote-server": "Remote server",
+ "settings.remoteConnections.kind.ssh": "SSH",
+ "settings.remoteConnections.form.name.label": "Profile name",
+ "settings.remoteConnections.form.name.placeholder": "Production VM",
+ "settings.remoteConnections.form.host.label": "Host",
+ "settings.remoteConnections.form.host.placeholder": "vm.example.com",
+ "settings.remoteConnections.form.port.label": "Port",
+ "settings.remoteConnections.form.port.placeholder": "22",
+ "settings.remoteConnections.form.remoteServerPort.label": "Remote server port",
+ "settings.remoteConnections.form.remoteServerPort.placeholder": "9898",
+ "settings.remoteConnections.form.username.label": "Username",
+ "settings.remoteConnections.form.username.placeholder": "ubuntu",
+ "settings.remoteConnections.form.remotePath.label": "Remote workspace path",
+ "settings.remoteConnections.form.remotePath.placeholder": "/srv/project",
+ "settings.remoteConnections.form.bootstrapScript.label": "Bootstrap script",
+ "settings.remoteConnections.form.bootstrapScript.placeholder": "Optional shell script run after connecting",
+ "settings.remoteConnections.form.save": "Save connection",
+ "settings.remoteConnections.form.update": "Update connection",
+ "settings.remoteConnections.form.cancelEdit": "Cancel edit",
+ "settings.remoteConnections.list.title": "Saved and recent connections",
+ "settings.remoteConnections.list.subtitle": "Review remote server shortcuts and SSH bootstrap profiles available on this device.",
+ "settings.remoteConnections.list.empty": "No connection profiles saved yet.",
+ "settings.remoteConnections.list.actions.edit": "Edit connection",
+ "settings.remoteConnections.list.actions.delete": "Delete connection",
+ "settings.remoteConnections.validation.name": "Connection profile name is required.",
+ "settings.remoteConnections.validation.host": "SSH host is required.",
+ "settings.remoteConnections.validation.port": "Port must be between 1 and 65535.",
+ "settings.remoteConnections.validation.remoteServerPort": "Remote server port must be between 1 and 65535.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
@@ -121,6 +151,60 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.info": "Информация",
"settings.opencode.logLevel.option.warn": "Предупреждение",
"settings.opencode.logLevel.option.error": "Ошибка",
+ "settings.opencode.executionProfiles.title": "Execution Profiles",
+ "settings.opencode.executionProfiles.subtitle": "Define how CodeNomad should run OpenCode for local workspaces.",
+ "settings.opencode.executionProfiles.kind.local": "Local",
+ "settings.opencode.executionProfiles.kind.wsl": "WSL",
+ "settings.opencode.executionProfiles.kind.docker": "Docker",
+ "settings.opencode.executionProfiles.kind.command": "Command",
+ "settings.opencode.executionProfiles.form.kind.label": "Profile type",
+ "settings.opencode.executionProfiles.form.kind.subtitle": "Choose the runtime strategy for this profile.",
+ "settings.opencode.executionProfiles.form.name.label": "Profile name",
+ "settings.opencode.executionProfiles.form.name.subtitle": "Use a clear label you can recognise later.",
+ "settings.opencode.executionProfiles.form.name.placeholder": "Docker Sandbox",
+ "settings.opencode.executionProfiles.form.binaryPath.label": "Binary path",
+ "settings.opencode.executionProfiles.form.binaryPath.subtitle": "Absolute path to the OpenCode executable.",
+ "settings.opencode.executionProfiles.form.binaryPath.placeholder": "C:\\Tools\\opencode.exe",
+ "settings.opencode.executionProfiles.form.distro.label": "WSL distro",
+ "settings.opencode.executionProfiles.form.distro.subtitle": "Name of the WSL distribution to use.",
+ "settings.opencode.executionProfiles.form.distro.placeholder": "Ubuntu",
+ "settings.opencode.executionProfiles.form.image.label": "Docker image",
+ "settings.opencode.executionProfiles.form.image.subtitle": "Container image used to run OpenCode.",
+ "settings.opencode.executionProfiles.form.image.placeholder": "ghcr.io/example/opencode:latest",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.label": "Workspace mount path",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.subtitle": "Path inside the container where the workspace is mounted.",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.placeholder": "/workspace",
+ "settings.opencode.executionProfiles.form.configMountPath.label": "Config mount path",
+ "settings.opencode.executionProfiles.form.configMountPath.subtitle": "Path inside the container where the OpenCode config directory is mounted.",
+ "settings.opencode.executionProfiles.form.configMountPath.placeholder": "/root/.config/opencode",
+ "settings.opencode.executionProfiles.form.command.label": "Container command",
+ "settings.opencode.executionProfiles.form.command.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.extraDockerArgs.label": "Extra Docker args",
+ "settings.opencode.executionProfiles.form.extraDockerArgs.placeholder": "One docker argument per line",
+ "settings.opencode.executionProfiles.form.executable.label": "Executable",
+ "settings.opencode.executionProfiles.form.executable.subtitle": "Program or script to launch for this profile.",
+ "settings.opencode.executionProfiles.form.executable.placeholder": "ssh",
+ "settings.opencode.executionProfiles.form.cwdMode.label": "Working directory",
+ "settings.opencode.executionProfiles.form.cwdMode.subtitle": "Choose whether the command starts in the workspace directory.",
+ "settings.opencode.executionProfiles.form.cwdMode.workspace": "Workspace folder",
+ "settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
+ "settings.opencode.executionProfiles.form.args.label": "Arguments",
+ "settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.save": "Save profile",
+ "settings.opencode.executionProfiles.form.update": "Update profile",
+ "settings.opencode.executionProfiles.form.cancelEdit": "Cancel edit",
+ "settings.opencode.executionProfiles.list.title": "Saved execution profiles",
+ "settings.opencode.executionProfiles.list.subtitle": "Review and manage the execution profiles available on this server.",
+ "settings.opencode.executionProfiles.list.empty": "No execution profiles configured yet.",
+ "settings.opencode.executionProfiles.list.defaultBadge": "Default",
+ "settings.opencode.executionProfiles.list.actions.edit": "Edit profile",
+ "settings.opencode.executionProfiles.list.actions.delete": "Delete profile",
+ "settings.opencode.executionProfiles.list.actions.makeDefault": "Set as default profile",
+ "settings.opencode.executionProfiles.validation.name": "Profile name is required.",
+ "settings.opencode.executionProfiles.validation.binaryPath": "Binary path is required.",
+ "settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
+ "settings.opencode.executionProfiles.validation.docker": "Docker image and mount paths are required.",
+ "settings.opencode.executionProfiles.validation.executable": "Executable is required.",
"settings.appearance.behavior.title": "Взаимодействие",
"settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.",
diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts
index 445cdb000..093135480 100644
--- a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts
@@ -23,6 +23,8 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "打开文件夹或连接服务器",
"folderSelection.actions.subtitle": "打开本地文件夹或连接到 CodeNomad 服务器",
"folderSelection.actions.connectButton": "连接 CodeNomad 服务器",
+ "folderSelection.executionProfile.label": "执行配置",
+ "folderSelection.executionProfile.subtitle": "选择新本地工作区启动 OpenCode 的方式",
"folderSelection.advancedSettings": "高级设置",
"folderSelection.opencode": "OpenCode",
diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts
index 6ad90944c..38fd493bc 100644
--- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts
@@ -109,6 +109,36 @@ export const settingsMessages = {
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
+ "settings.remoteConnections.form.title": "SSH connection profiles",
+ "settings.remoteConnections.form.subtitle": "Save reusable SSH bootstrap targets and scripts for remote environments.",
+ "settings.remoteConnections.kind.remote-server": "Remote server",
+ "settings.remoteConnections.kind.ssh": "SSH",
+ "settings.remoteConnections.form.name.label": "Profile name",
+ "settings.remoteConnections.form.name.placeholder": "Production VM",
+ "settings.remoteConnections.form.host.label": "Host",
+ "settings.remoteConnections.form.host.placeholder": "vm.example.com",
+ "settings.remoteConnections.form.port.label": "Port",
+ "settings.remoteConnections.form.port.placeholder": "22",
+ "settings.remoteConnections.form.remoteServerPort.label": "Remote server port",
+ "settings.remoteConnections.form.remoteServerPort.placeholder": "9898",
+ "settings.remoteConnections.form.username.label": "Username",
+ "settings.remoteConnections.form.username.placeholder": "ubuntu",
+ "settings.remoteConnections.form.remotePath.label": "Remote workspace path",
+ "settings.remoteConnections.form.remotePath.placeholder": "/srv/project",
+ "settings.remoteConnections.form.bootstrapScript.label": "Bootstrap script",
+ "settings.remoteConnections.form.bootstrapScript.placeholder": "Optional shell script run after connecting",
+ "settings.remoteConnections.form.save": "Save connection",
+ "settings.remoteConnections.form.update": "Update connection",
+ "settings.remoteConnections.form.cancelEdit": "Cancel edit",
+ "settings.remoteConnections.list.title": "Saved and recent connections",
+ "settings.remoteConnections.list.subtitle": "Review remote server shortcuts and SSH bootstrap profiles available on this device.",
+ "settings.remoteConnections.list.empty": "No connection profiles saved yet.",
+ "settings.remoteConnections.list.actions.edit": "Edit connection",
+ "settings.remoteConnections.list.actions.delete": "Delete connection",
+ "settings.remoteConnections.validation.name": "Connection profile name is required.",
+ "settings.remoteConnections.validation.host": "SSH host is required.",
+ "settings.remoteConnections.validation.port": "Port must be between 1 and 65535.",
+ "settings.remoteConnections.validation.remoteServerPort": "Remote server port must be between 1 and 65535.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
@@ -121,6 +151,60 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.info": "信息",
"settings.opencode.logLevel.option.warn": "警告",
"settings.opencode.logLevel.option.error": "错误",
+ "settings.opencode.executionProfiles.title": "Execution Profiles",
+ "settings.opencode.executionProfiles.subtitle": "Define how CodeNomad should run OpenCode for local workspaces.",
+ "settings.opencode.executionProfiles.kind.local": "Local",
+ "settings.opencode.executionProfiles.kind.wsl": "WSL",
+ "settings.opencode.executionProfiles.kind.docker": "Docker",
+ "settings.opencode.executionProfiles.kind.command": "Command",
+ "settings.opencode.executionProfiles.form.kind.label": "Profile type",
+ "settings.opencode.executionProfiles.form.kind.subtitle": "Choose the runtime strategy for this profile.",
+ "settings.opencode.executionProfiles.form.name.label": "Profile name",
+ "settings.opencode.executionProfiles.form.name.subtitle": "Use a clear label you can recognise later.",
+ "settings.opencode.executionProfiles.form.name.placeholder": "Docker Sandbox",
+ "settings.opencode.executionProfiles.form.binaryPath.label": "Binary path",
+ "settings.opencode.executionProfiles.form.binaryPath.subtitle": "Absolute path to the OpenCode executable.",
+ "settings.opencode.executionProfiles.form.binaryPath.placeholder": "C:\\Tools\\opencode.exe",
+ "settings.opencode.executionProfiles.form.distro.label": "WSL distro",
+ "settings.opencode.executionProfiles.form.distro.subtitle": "Name of the WSL distribution to use.",
+ "settings.opencode.executionProfiles.form.distro.placeholder": "Ubuntu",
+ "settings.opencode.executionProfiles.form.image.label": "Docker image",
+ "settings.opencode.executionProfiles.form.image.subtitle": "Container image used to run OpenCode.",
+ "settings.opencode.executionProfiles.form.image.placeholder": "ghcr.io/example/opencode:latest",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.label": "Workspace mount path",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.subtitle": "Path inside the container where the workspace is mounted.",
+ "settings.opencode.executionProfiles.form.workspaceMountPath.placeholder": "/workspace",
+ "settings.opencode.executionProfiles.form.configMountPath.label": "Config mount path",
+ "settings.opencode.executionProfiles.form.configMountPath.subtitle": "Path inside the container where the OpenCode config directory is mounted.",
+ "settings.opencode.executionProfiles.form.configMountPath.placeholder": "/root/.config/opencode",
+ "settings.opencode.executionProfiles.form.command.label": "Container command",
+ "settings.opencode.executionProfiles.form.command.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.extraDockerArgs.label": "Extra Docker args",
+ "settings.opencode.executionProfiles.form.extraDockerArgs.placeholder": "One docker argument per line",
+ "settings.opencode.executionProfiles.form.executable.label": "Executable",
+ "settings.opencode.executionProfiles.form.executable.subtitle": "Program or script to launch for this profile.",
+ "settings.opencode.executionProfiles.form.executable.placeholder": "ssh",
+ "settings.opencode.executionProfiles.form.cwdMode.label": "Working directory",
+ "settings.opencode.executionProfiles.form.cwdMode.subtitle": "Choose whether the command starts in the workspace directory.",
+ "settings.opencode.executionProfiles.form.cwdMode.workspace": "Workspace folder",
+ "settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
+ "settings.opencode.executionProfiles.form.args.label": "Arguments",
+ "settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.save": "Save profile",
+ "settings.opencode.executionProfiles.form.update": "Update profile",
+ "settings.opencode.executionProfiles.form.cancelEdit": "Cancel edit",
+ "settings.opencode.executionProfiles.list.title": "Saved execution profiles",
+ "settings.opencode.executionProfiles.list.subtitle": "Review and manage the execution profiles available on this server.",
+ "settings.opencode.executionProfiles.list.empty": "No execution profiles configured yet.",
+ "settings.opencode.executionProfiles.list.defaultBadge": "Default",
+ "settings.opencode.executionProfiles.list.actions.edit": "Edit profile",
+ "settings.opencode.executionProfiles.list.actions.delete": "Delete profile",
+ "settings.opencode.executionProfiles.list.actions.makeDefault": "Set as default profile",
+ "settings.opencode.executionProfiles.validation.name": "Profile name is required.",
+ "settings.opencode.executionProfiles.validation.binaryPath": "Binary path is required.",
+ "settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
+ "settings.opencode.executionProfiles.validation.docker": "Docker image and mount paths are required.",
+ "settings.opencode.executionProfiles.validation.executable": "Executable is required.",
"settings.appearance.behavior.title": "交互",
"settings.appearance.behavior.subtitle": "消息、差异与输入的默认值。",
diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts
index c6b494827..a18a1049e 100644
--- a/packages/ui/src/stores/instances.ts
+++ b/packages/ui/src/stores/instances.ts
@@ -105,6 +105,9 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath,
binaryLabel: descriptor.binaryLabel,
binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion,
+ executionProfileId: descriptor.executionProfileId ?? existing?.executionProfileId,
+ executionProfileName: descriptor.executionProfileName ?? existing?.executionProfileName,
+ executionProfileKind: descriptor.executionProfileKind ?? existing?.executionProfileKind,
environmentVariables: existing?.environmentVariables ?? serverSettings().environmentVariables ?? {},
}
}
@@ -526,9 +529,9 @@ function removeInstance(id: string) {
syncHasInstancesFlag()
}
-async function createInstance(folder: string, _binaryPath?: string): Promise {
+async function createInstance(folder: string, _binaryPath?: string, options?: { executionProfileId?: string }): Promise {
try {
- const workspace = await serverApi.createWorkspace({ path: folder })
+ const workspace = await serverApi.createWorkspace({ path: folder, executionProfileId: options?.executionProfileId })
upsertWorkspace(workspace)
setActiveInstanceId(workspace.id)
return workspace.id
diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx
index 7fb9310e7..bc594330e 100644
--- a/packages/ui/src/stores/preferences.tsx
+++ b/packages/ui/src/stores/preferences.tsx
@@ -1,7 +1,13 @@
import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js"
import type { Accessor, ParentComponent } from "solid-js"
import { storage, type OwnerBucket } from "../lib/storage"
-import type { RemoteServerProfile } from "../../../server/src/api-types"
+import type {
+ ConnectionProfile,
+ ExecutionProfile,
+ RemoteServerConnectionProfile,
+ RemoteServerProfile,
+ SshConnectionProfile,
+} from "../../../server/src/api-types"
import {
ensureInstanceConfigLoaded,
getInstanceConfig,
@@ -101,6 +107,8 @@ interface ServerConfigBucket {
logLevel?: ServerLogLevel
environmentVariables?: Record
opencodeBinary?: string
+ executionProfiles?: ExecutionProfile[]
+ defaultExecutionProfileId?: string
speech?: Partial
}
@@ -108,6 +116,8 @@ interface UiStateBucket {
recentFolders?: RecentFolder[]
opencodeBinaries?: OpenCodeBinary[]
remoteServers?: RemoteServerProfile[]
+ connectionProfiles?: ConnectionProfile[]
+ lastSelectedExecutionProfileId?: string
models?: {
recents?: ModelPreference[]
favorites?: ModelPreference[]
@@ -119,6 +129,8 @@ interface NormalizedUiState {
recentFolders: RecentFolder[]
opencodeBinaries: OpenCodeBinary[]
remoteServers: RemoteServerProfile[]
+ connectionProfiles: ConnectionProfile[]
+ lastSelectedExecutionProfileId?: string
models: {
recents: ModelPreference[]
favorites: ModelPreference[]
@@ -202,6 +214,75 @@ function normalizeRecord(value: unknown): Record {
return out
}
+function normalizeStringList(value: unknown): string[] | undefined {
+ if (!Array.isArray(value)) return undefined
+ const out = value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter((item) => item.length > 0)
+ return out.length > 0 ? out : undefined
+}
+
+function normalizeExecutionProfiles(value: unknown): ExecutionProfile[] {
+ if (!Array.isArray(value)) return []
+
+ const profiles: ExecutionProfile[] = []
+ for (const entry of value) {
+ if (!entry || typeof entry !== "object") continue
+
+ const id = typeof (entry as any).id === "string" ? (entry as any).id.trim() : ""
+ const name = typeof (entry as any).name === "string" ? (entry as any).name.trim() : ""
+ const kind = typeof (entry as any).kind === "string" ? (entry as any).kind.trim() : ""
+ if (!id || !name) continue
+
+ if (kind === "local") {
+ const binaryPath = typeof (entry as any).binaryPath === "string" ? (entry as any).binaryPath.trim() : ""
+ if (!binaryPath) continue
+ profiles.push({ id, name, kind, binaryPath })
+ continue
+ }
+
+ if (kind === "wsl") {
+ const distro = typeof (entry as any).distro === "string" ? (entry as any).distro.trim() : ""
+ const binaryPath = typeof (entry as any).binaryPath === "string" ? (entry as any).binaryPath.trim() : ""
+ if (!distro || !binaryPath) continue
+ profiles.push({ id, name, kind, distro, binaryPath })
+ continue
+ }
+
+ if (kind === "docker") {
+ const image = typeof (entry as any).image === "string" ? (entry as any).image.trim() : ""
+ const workspaceMountPath = typeof (entry as any).workspaceMountPath === "string" ? (entry as any).workspaceMountPath.trim() : ""
+ const configMountPath = typeof (entry as any).configMountPath === "string" ? (entry as any).configMountPath.trim() : ""
+ if (!image || !workspaceMountPath || !configMountPath) continue
+ profiles.push({
+ id,
+ name,
+ kind,
+ image,
+ workspaceMountPath,
+ configMountPath,
+ command: normalizeStringList((entry as any).command),
+ extraDockerArgs: normalizeStringList((entry as any).extraDockerArgs),
+ })
+ continue
+ }
+
+ if (kind === "command") {
+ const executable = typeof (entry as any).executable === "string" ? (entry as any).executable.trim() : ""
+ const cwdMode = (entry as any).cwdMode === "inherit" ? "inherit" : (entry as any).cwdMode === "workspace" ? "workspace" : undefined
+ if (!executable) continue
+ profiles.push({
+ id,
+ name,
+ kind,
+ executable,
+ args: normalizeStringList((entry as any).args),
+ cwdMode,
+ })
+ }
+ }
+
+ return profiles
+}
+
function normalizeSpeechSettings(input?: Partial | null): SpeechSettings {
const sanitized = input ?? {}
return {
@@ -242,8 +323,120 @@ function cloneArray(value: unknown, mapper: (item: any) => T | null): T[] {
return out
}
+function sortByRecentActivity(entries: T[]): T[] {
+ return [...entries].sort((a, b) => {
+ const left = a.lastConnectedAt ?? a.updatedAt
+ const right = b.lastConnectedAt ?? b.updatedAt
+ return right.localeCompare(left)
+ })
+}
+
+function normalizeRemoteServerProfile(value: unknown): RemoteServerProfile | null {
+ if (!value || typeof value !== "object") return null
+ const id = typeof (value as any).id === "string" ? (value as any).id.trim() : ""
+ const name = typeof (value as any).name === "string" ? (value as any).name.trim() : ""
+ const baseUrl = typeof (value as any).baseUrl === "string" ? (value as any).baseUrl.trim() : ""
+ if (!id || !name || !baseUrl) return null
+ const createdAt = typeof (value as any).createdAt === "string" ? (value as any).createdAt : new Date().toISOString()
+ const updatedAt = typeof (value as any).updatedAt === "string" ? (value as any).updatedAt : createdAt
+ const lastConnectedAt = typeof (value as any).lastConnectedAt === "string" ? (value as any).lastConnectedAt : undefined
+ return {
+ id,
+ name,
+ baseUrl,
+ skipTlsVerify: Boolean((value as any).skipTlsVerify),
+ createdAt,
+ updatedAt,
+ lastConnectedAt,
+ }
+}
+
+function remoteServerToConnectionProfile(server: RemoteServerProfile): RemoteServerConnectionProfile {
+ return {
+ id: server.id,
+ name: server.name,
+ kind: "remote-server",
+ baseUrl: server.baseUrl,
+ skipTlsVerify: server.skipTlsVerify,
+ createdAt: server.createdAt,
+ updatedAt: server.updatedAt,
+ lastConnectedAt: server.lastConnectedAt,
+ }
+}
+
+function connectionProfileToRemoteServer(profile: ConnectionProfile): RemoteServerProfile | null {
+ if (profile.kind !== "remote-server") return null
+ return {
+ id: profile.id,
+ name: profile.name,
+ baseUrl: profile.baseUrl,
+ skipTlsVerify: profile.skipTlsVerify,
+ createdAt: profile.createdAt,
+ updatedAt: profile.updatedAt,
+ lastConnectedAt: profile.lastConnectedAt,
+ }
+}
+
+function normalizeConnectionProfiles(value: unknown, remoteServers: RemoteServerProfile[]): ConnectionProfile[] {
+ const profiles: ConnectionProfile[] = []
+ if (Array.isArray(value)) {
+ for (const entry of value) {
+ if (!entry || typeof entry !== "object") continue
+ const id = typeof (entry as any).id === "string" ? (entry as any).id.trim() : ""
+ const name = typeof (entry as any).name === "string" ? (entry as any).name.trim() : ""
+ const kind = typeof (entry as any).kind === "string" ? (entry as any).kind.trim() : ""
+ if (!id || !name) continue
+
+ const createdAt = typeof (entry as any).createdAt === "string" ? (entry as any).createdAt : new Date().toISOString()
+ const updatedAt = typeof (entry as any).updatedAt === "string" ? (entry as any).updatedAt : createdAt
+ const lastConnectedAt = typeof (entry as any).lastConnectedAt === "string" ? (entry as any).lastConnectedAt : undefined
+
+ if (kind === "remote-server") {
+ const remote = normalizeRemoteServerProfile(entry)
+ if (!remote) continue
+ profiles.push(remoteServerToConnectionProfile(remote))
+ continue
+ }
+
+ if (kind === "ssh") {
+ const host = typeof (entry as any).host === "string" ? (entry as any).host.trim() : ""
+ if (!host) continue
+ const profile: SshConnectionProfile = {
+ id,
+ name,
+ kind,
+ host,
+ createdAt,
+ updatedAt,
+ lastConnectedAt,
+ port: typeof (entry as any).port === "number" ? (entry as any).port : undefined,
+ remoteServerPort: typeof (entry as any).remoteServerPort === "number" ? (entry as any).remoteServerPort : undefined,
+ username: typeof (entry as any).username === "string" && (entry as any).username.trim() ? (entry as any).username.trim() : undefined,
+ remotePath: typeof (entry as any).remotePath === "string" && (entry as any).remotePath.trim() ? (entry as any).remotePath.trim() : undefined,
+ bootstrapScript:
+ typeof (entry as any).bootstrapScript === "string" && (entry as any).bootstrapScript.trim()
+ ? (entry as any).bootstrapScript
+ : undefined,
+ }
+ profiles.push(profile)
+ }
+ }
+ }
+
+ const byId = new Map(profiles.map((profile) => [profile.id, profile] as const))
+ for (const server of remoteServers) {
+ byId.set(server.id, remoteServerToConnectionProfile(server))
+ }
+
+ return sortByRecentActivity(Array.from(byId.values()))
+}
+
function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
const source = input ?? {}
+ const remoteServers = sortByRecentActivity(
+ cloneArray(source.remoteServers, (server) => normalizeRemoteServerProfile(server)),
+ )
+
return {
recentFolders: cloneArray(source.recentFolders, (f) => {
if (!f || typeof f !== "object") return null
@@ -262,29 +455,12 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
const label = typeof (b as any).label === "string" ? (b as any).label : undefined
return { path: p, version, label, lastUsed }
}),
- remoteServers: cloneArray(source.remoteServers, (server) => {
- if (!server || typeof server !== "object") return null
- const id = typeof (server as any).id === "string" ? (server as any).id.trim() : ""
- const name = typeof (server as any).name === "string" ? (server as any).name.trim() : ""
- const baseUrl = typeof (server as any).baseUrl === "string" ? (server as any).baseUrl.trim() : ""
- if (!id || !name || !baseUrl) return null
- const createdAt = typeof (server as any).createdAt === "string" ? (server as any).createdAt : new Date().toISOString()
- const updatedAt = typeof (server as any).updatedAt === "string" ? (server as any).updatedAt : createdAt
- const lastConnectedAt = typeof (server as any).lastConnectedAt === "string" ? (server as any).lastConnectedAt : undefined
- return {
- id,
- name,
- baseUrl,
- skipTlsVerify: Boolean((server as any).skipTlsVerify),
- createdAt,
- updatedAt,
- lastConnectedAt,
- }
- }).sort((a, b) => {
- const left = a.lastConnectedAt ?? a.updatedAt
- const right = b.lastConnectedAt ?? b.updatedAt
- return right.localeCompare(left)
- }),
+ remoteServers,
+ connectionProfiles: normalizeConnectionProfiles(source.connectionProfiles, remoteServers),
+ lastSelectedExecutionProfileId:
+ typeof source.lastSelectedExecutionProfileId === "string" && source.lastSelectedExecutionProfileId.trim()
+ ? source.lastSelectedExecutionProfileId.trim()
+ : undefined,
models: {
recents: cloneArray((source.models as any)?.recents, (m) => {
if (!m || typeof m !== "object") return null
@@ -307,7 +483,11 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
function normalizeServerConfig(
input?: ServerConfigBucket | null,
-): Required> & { speech: SpeechSettings } {
+): Required> & {
+ executionProfiles: ExecutionProfile[]
+ defaultExecutionProfileId?: string
+ speech: SpeechSettings
+} {
const source = input ?? {}
const listeningMode = source.listeningMode === "all" ? "all" : "local"
const logLevel =
@@ -316,8 +496,13 @@ function normalizeServerConfig(
: "DEBUG"
const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode"
const environmentVariables = normalizeRecord(source.environmentVariables)
+ const executionProfiles = normalizeExecutionProfiles(source.executionProfiles)
+ const defaultExecutionProfileId =
+ typeof source.defaultExecutionProfileId === "string" && executionProfiles.some((profile) => profile.id === source.defaultExecutionProfileId)
+ ? source.defaultExecutionProfileId
+ : undefined
const speech = normalizeSpeechSettings(source.speech)
- return { listeningMode, logLevel, opencodeBinary, environmentVariables, speech }
+ return { listeningMode, logLevel, opencodeBinary, environmentVariables, executionProfiles, defaultExecutionProfileId, speech }
}
function getModelKey(model: { providerId: string; modelId: string }): string {
@@ -367,11 +552,17 @@ function buildRemoteServerProfile(input: RemoteServerProfileInput, source: Remot
function buildRemoteServerList(profile: RemoteServerProfile, source: RemoteServerProfile[]): RemoteServerProfile[] {
const remaining = source.filter((entry) => entry.id !== profile.id)
- return [profile, ...remaining].sort((a, b) => {
- const left = a.lastConnectedAt ?? a.updatedAt
- const right = b.lastConnectedAt ?? b.updatedAt
- return right.localeCompare(left)
- })
+ return sortByRecentActivity([profile, ...remaining])
+}
+
+function buildExecutionProfileList(profile: ExecutionProfile, source: ExecutionProfile[]): ExecutionProfile[] {
+ const remaining = source.filter((entry) => entry.id !== profile.id)
+ return [profile, ...remaining]
+}
+
+function buildConnectionProfileList(profile: ConnectionProfile, source: ConnectionProfile[]): ConnectionProfile[] {
+ const remaining = source.filter((entry) => entry.id !== profile.id)
+ return sortByRecentActivity([profile, ...remaining])
}
function createRandomId(): string {
@@ -395,6 +586,10 @@ const preferences = uiSettings
const recentFolders = createMemo(() => uiState().recentFolders)
const opencodeBinaries = createMemo(() => uiState().opencodeBinaries)
const remoteServers = createMemo(() => uiState().remoteServers)
+const connectionProfiles = createMemo(() => uiState().connectionProfiles)
+const lastSelectedExecutionProfileId = createMemo(() => uiState().lastSelectedExecutionProfileId)
+const executionProfiles = createMemo(() => serverSettings().executionProfiles)
+const defaultExecutionProfileId = createMemo(() => serverSettings().defaultExecutionProfileId)
let loadPromise: Promise | null = null
@@ -540,25 +735,85 @@ function removeRecentFolder(folderPath: string): void {
async function saveRemoteServerProfile(input: RemoteServerProfileInput): Promise {
const profile = buildRemoteServerProfile(input, remoteServers())
- await patchStateOwner("ui", { remoteServers: buildRemoteServerList(profile, remoteServers()) })
+ await saveConnectionProfile(remoteServerToConnectionProfile(profile))
return profile
}
async function markRemoteServerConnected(id: string): Promise {
- const current = remoteServers().find((entry) => entry.id === id)
+ const current = connectionProfiles().find((entry) => entry.id === id)
if (!current) return
const now = new Date().toISOString()
- const updated: RemoteServerProfile = {
+ const updated: ConnectionProfile = {
...current,
updatedAt: now,
lastConnectedAt: now,
}
- await patchStateOwner("ui", { remoteServers: buildRemoteServerList(updated, remoteServers()) })
+ await saveConnectionProfile(updated)
}
function removeRemoteServerProfile(id: string): void {
- const next = remoteServers().filter((entry) => entry.id !== id)
- void patchStateOwner("ui", { remoteServers: next }).catch((error) => log.error("Failed to remove remote server", error))
+ removeConnectionProfile(id)
+}
+
+async function saveConnectionProfile(profile: ConnectionProfile): Promise {
+ const nextProfiles = buildConnectionProfileList(profile, connectionProfiles())
+ const nextRemoteServers = sortByRecentActivity(
+ nextProfiles
+ .map((entry) => connectionProfileToRemoteServer(entry))
+ .filter((entry): entry is RemoteServerProfile => Boolean(entry)),
+ )
+
+ await patchStateOwner("ui", {
+ connectionProfiles: nextProfiles,
+ remoteServers: nextRemoteServers,
+ })
+ return profile
+}
+
+function removeConnectionProfile(id: string): void {
+ const nextProfiles = connectionProfiles().filter((entry) => entry.id !== id)
+ const nextRemoteServers = sortByRecentActivity(
+ nextProfiles
+ .map((entry) => connectionProfileToRemoteServer(entry))
+ .filter((entry): entry is RemoteServerProfile => Boolean(entry)),
+ )
+
+ void patchStateOwner("ui", {
+ connectionProfiles: nextProfiles,
+ remoteServers: nextRemoteServers,
+ }).catch((error) => log.error("Failed to remove connection profile", error))
+}
+
+function setLastSelectedExecutionProfileId(profileId: string | undefined): void {
+ const nextId = profileId?.trim() || undefined
+ void patchStateOwner("ui", { lastSelectedExecutionProfileId: nextId }).catch((error) =>
+ log.error("Failed to save last selected execution profile", error),
+ )
+}
+
+async function saveExecutionProfile(profile: ExecutionProfile): Promise {
+ const nextProfiles = buildExecutionProfileList(profile, executionProfiles())
+ const nextDefaultExecutionProfileId = defaultExecutionProfileId() ?? profile.id
+ await patchConfigOwner("server", {
+ executionProfiles: nextProfiles,
+ defaultExecutionProfileId: nextDefaultExecutionProfileId,
+ })
+ return profile
+}
+
+async function setDefaultExecutionProfileId(profileId: string | undefined): Promise {
+ const trimmed = profileId?.trim()
+ const nextDefault = trimmed && executionProfiles().some((profile) => profile.id === trimmed) ? trimmed : undefined
+ await patchConfigOwner("server", { defaultExecutionProfileId: nextDefault })
+}
+
+function removeExecutionProfile(profileId: string): void {
+ const nextProfiles = executionProfiles().filter((profile) => profile.id !== profileId)
+ const nextDefault = defaultExecutionProfileId() === profileId ? nextProfiles[0]?.id : defaultExecutionProfileId()
+ void patchConfigOwner("server", {
+ executionProfiles: nextProfiles,
+ defaultExecutionProfileId: nextDefault,
+ }).catch((error) => log.error("Failed to remove execution profile", error))
}
function recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void {
@@ -713,6 +968,8 @@ interface ConfigContextValue {
// server-owned stable config
serverSettings: typeof serverSettings
+ executionProfiles: typeof executionProfiles
+ defaultExecutionProfileId: typeof defaultExecutionProfileId
setListeningMode: typeof setListeningMode
updateEnvironmentVariables: typeof updateEnvironmentVariables
addEnvironmentVariable: typeof addEnvironmentVariable
@@ -720,16 +977,24 @@ interface ConfigContextValue {
updateLastUsedBinary: typeof updateLastUsedBinary
updateLogLevel: typeof updateLogLevel
updateSpeechSettings: typeof updateSpeechSettings
+ saveExecutionProfile: typeof saveExecutionProfile
+ setDefaultExecutionProfileId: typeof setDefaultExecutionProfileId
+ removeExecutionProfile: typeof removeExecutionProfile
// ui-owned state
recentFolders: typeof recentFolders
opencodeBinaries: typeof opencodeBinaries
remoteServers: typeof remoteServers
+ connectionProfiles: typeof connectionProfiles
+ lastSelectedExecutionProfileId: typeof lastSelectedExecutionProfileId
uiState: typeof uiState
addRecentFolder: typeof addRecentFolder
removeRecentFolder: typeof removeRecentFolder
addOpenCodeBinary: typeof addOpenCodeBinary
removeOpenCodeBinary: typeof removeOpenCodeBinary
+ saveConnectionProfile: typeof saveConnectionProfile
+ removeConnectionProfile: typeof removeConnectionProfile
+ setLastSelectedExecutionProfileId: typeof setLastSelectedExecutionProfileId
saveRemoteServerProfile: typeof saveRemoteServerProfile
markRemoteServerConnected: typeof markRemoteServerConnected
removeRemoteServerProfile: typeof removeRemoteServerProfile
@@ -768,6 +1033,8 @@ const configContextValue: ConfigContextValue = {
themePreference,
setThemePreference,
serverSettings,
+ executionProfiles,
+ defaultExecutionProfileId,
setListeningMode,
updateEnvironmentVariables,
addEnvironmentVariable,
@@ -775,14 +1042,22 @@ const configContextValue: ConfigContextValue = {
updateLastUsedBinary,
updateLogLevel,
updateSpeechSettings,
+ saveExecutionProfile,
+ setDefaultExecutionProfileId,
+ removeExecutionProfile,
recentFolders,
opencodeBinaries,
remoteServers,
+ connectionProfiles,
+ lastSelectedExecutionProfileId,
uiState,
addRecentFolder,
removeRecentFolder,
addOpenCodeBinary,
removeOpenCodeBinary,
+ saveConnectionProfile,
+ removeConnectionProfile,
+ setLastSelectedExecutionProfileId,
saveRemoteServerProfile,
markRemoteServerConnected,
removeRemoteServerProfile,
@@ -851,8 +1126,12 @@ export {
preferences,
uiState,
serverSettings,
+ executionProfiles,
+ defaultExecutionProfileId,
recentFolders,
opencodeBinaries,
+ connectionProfiles,
+ lastSelectedExecutionProfileId,
themePreference,
setThemePreference,
updatePreferences,
@@ -863,10 +1142,16 @@ export {
updateLastUsedBinary,
updateLogLevel,
updateSpeechSettings,
+ saveExecutionProfile,
+ setDefaultExecutionProfileId,
+ removeExecutionProfile,
addRecentFolder,
removeRecentFolder,
addOpenCodeBinary,
removeOpenCodeBinary,
+ saveConnectionProfile,
+ removeConnectionProfile,
+ setLastSelectedExecutionProfileId,
recordWorkspaceLaunch,
addRecentModelPreference,
isFavoriteModelPreference,
diff --git a/packages/ui/src/types/instance.ts b/packages/ui/src/types/instance.ts
index 71486150d..52bae6eea 100644
--- a/packages/ui/src/types/instance.ts
+++ b/packages/ui/src/types/instance.ts
@@ -43,5 +43,8 @@ export interface Instance {
binaryPath?: string
binaryLabel?: string
binaryVersion?: string
+ executionProfileId?: string
+ executionProfileName?: string
+ executionProfileKind?: "local" | "wsl" | "docker" | "command"
environmentVariables?: Record
}
From 7f60def62b00f40f38d29dbd7dcd1d4c2d00ece3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pascal=20Andr=C3=A9?=
Date: Fri, 8 May 2026 20:17:22 +0200
Subject: [PATCH 02/17] feat(ui): add ssh host connection flow
Add a first-class SSH connection flow to the home screen so users can save an SSH bootstrap target, reconnect from recent connections, and open the remote server window without dropping into settings first.
Expose the active execution profile in instance information and add the required localized strings so the selected runtime and SSH connection metadata remain visible after launch. Validation: npm run typecheck --workspace @codenomad/ui; npm run typecheck --workspace @neuralnomads/codenomad; npm run build --workspace @codenomad/ui.
---
.../src/components/folder-selection-view.tsx | 286 ++++++++++++++++--
packages/ui/src/components/instance-info.tsx | 11 +
.../lib/i18n/messages/en/folderSelection.ts | 24 ++
.../ui/src/lib/i18n/messages/en/instance.ts | 1 +
.../lib/i18n/messages/es/folderSelection.ts | 24 ++
.../ui/src/lib/i18n/messages/es/instance.ts | 1 +
.../lib/i18n/messages/fr/folderSelection.ts | 24 ++
.../ui/src/lib/i18n/messages/fr/instance.ts | 1 +
.../lib/i18n/messages/he/folderSelection.ts | 24 ++
.../ui/src/lib/i18n/messages/he/instance.ts | 1 +
.../lib/i18n/messages/ja/folderSelection.ts | 24 ++
.../ui/src/lib/i18n/messages/ja/instance.ts | 1 +
.../lib/i18n/messages/ru/folderSelection.ts | 24 ++
.../ui/src/lib/i18n/messages/ru/instance.ts | 1 +
.../i18n/messages/zh-Hans/folderSelection.ts | 24 ++
.../src/lib/i18n/messages/zh-Hans/instance.ts | 1 +
16 files changed, 446 insertions(+), 26 deletions(-)
diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx
index 3eebbedce..76fa127da 100644
--- a/packages/ui/src/components/folder-selection-view.tsx
+++ b/packages/ui/src/components/folder-selection-view.tsx
@@ -1,8 +1,8 @@
import { Dialog } from "@kobalte/core/dialog"
import { Select } from "@kobalte/core/select"
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect, createMemo } from "solid-js"
-import type { ConnectionProfile, RemoteServerConnectionProfile, SshConnectionProfile } from "../../../server/src/api-types"
-import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2 } from "lucide-solid"
+import type { ConnectionProfile, SshConnectionProfile } from "../../../server/src/api-types"
+import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2, Terminal } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
@@ -64,6 +64,16 @@ const FolderSelectionView: Component = (props) => {
const [skipTlsVerify, setSkipTlsVerify] = createSignal(false)
const [serverDialogError, setServerDialogError] = createSignal(null)
const [isSavingServer, setIsSavingServer] = createSignal(false)
+ const [isSshDialogOpen, setIsSshDialogOpen] = createSignal(false)
+ const [sshProfileName, setSshProfileName] = createSignal("")
+ const [sshHost, setSshHost] = createSignal("")
+ const [sshPort, setSshPort] = createSignal("22")
+ const [sshRemoteServerPort, setSshRemoteServerPort] = createSignal("9898")
+ const [sshUsername, setSshUsername] = createSignal("")
+ const [sshRemotePath, setSshRemotePath] = createSignal("")
+ const [sshBootstrapScript, setSshBootstrapScript] = createSignal("")
+ const [sshDialogError, setSshDialogError] = createSignal(null)
+ const [isSavingSsh, setIsSavingSsh] = createSignal(false)
const [connectingServerId, setConnectingServerId] = createSignal(null)
let recentListRef: HTMLDivElement | undefined
@@ -339,6 +349,23 @@ const FolderSelectionView: Component = (props) => {
setIsServerDialogOpen(true)
}
+ function resetSshDialog() {
+ setSshProfileName("")
+ setSshHost("")
+ setSshPort("22")
+ setSshRemoteServerPort("9898")
+ setSshUsername("")
+ setSshRemotePath("")
+ setSshBootstrapScript("")
+ setSshDialogError(null)
+ }
+
+ function openSshDialog() {
+ if (!canUseRemoteServerWindows()) return
+ resetSshDialog()
+ setIsSshDialogOpen(true)
+ }
+
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")
@@ -423,30 +450,7 @@ const FolderSelectionView: Component = (props) => {
return
}
- const result = await serverApi.connectSshRemote({
- connectionProfileId: target.id,
- name: target.name,
- host: target.host,
- port: target.port,
- username: target.username,
- remotePath: target.remotePath,
- remoteServerPort: target.remoteServerPort,
- bootstrapScript: target.bootstrapScript,
- })
-
- const now = new Date().toISOString()
- await saveConnectionProfile({
- ...target,
- updatedAt: now,
- lastConnectedAt: now,
- })
-
- await openRemoteServerWindow({
- id: target.id,
- name: target.name,
- baseUrl: result.baseUrl,
- skipTlsVerify: false,
- })
+ await connectSshProfile(target)
} catch (error) {
showAlertDialog(error instanceof Error ? error.message : String(error), {
title: t("folderSelection.servers.errorTitle"),
@@ -464,6 +468,97 @@ const FolderSelectionView: Component = (props) => {
removeConnectionProfile(profile.id)
}
+ function createConnectionProfileId(): string {
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
+ return crypto.randomUUID()
+ }
+ return `conn-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
+ }
+
+ function buildSshConnectionProfile(): SshConnectionProfile {
+ const host = sshHost().trim()
+ const profileName = sshProfileName().trim() || host
+ const port = sshPort().trim().length > 0 ? Number(sshPort()) : undefined
+ const remoteServerPort = sshRemoteServerPort().trim().length > 0 ? Number(sshRemoteServerPort()) : 9898
+
+ return {
+ id: createConnectionProfileId(),
+ kind: "ssh",
+ name: profileName,
+ host,
+ port,
+ remoteServerPort,
+ username: sshUsername().trim() || undefined,
+ remotePath: sshRemotePath().trim() || undefined,
+ bootstrapScript: sshBootstrapScript().trim() || undefined,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ }
+ }
+
+ async function connectSshProfile(profile: SshConnectionProfile) {
+ const result = await serverApi.connectSshRemote({
+ connectionProfileId: profile.id,
+ name: profile.name,
+ host: profile.host,
+ port: profile.port,
+ username: profile.username,
+ remotePath: profile.remotePath,
+ remoteServerPort: profile.remoteServerPort,
+ bootstrapScript: profile.bootstrapScript,
+ })
+
+ const now = new Date().toISOString()
+ await saveConnectionProfile({
+ ...profile,
+ updatedAt: now,
+ lastConnectedAt: now,
+ })
+
+ await openRemoteServerWindow({
+ id: profile.id,
+ name: profile.name,
+ baseUrl: result.baseUrl,
+ skipTlsVerify: false,
+ })
+ }
+
+ async function handleSaveSsh(openWindow: boolean) {
+ if (isSavingSsh()) return
+ const host = sshHost().trim()
+ const port = sshPort().trim().length > 0 ? Number(sshPort()) : undefined
+ const remoteServerPort = sshRemoteServerPort().trim().length > 0 ? Number(sshRemoteServerPort()) : 9898
+
+ if (!host) {
+ setSshDialogError(t("folderSelection.ssh.dialog.errorHost"))
+ return
+ }
+ if (port !== undefined && (!Number.isInteger(port) || port <= 0 || port > 65535)) {
+ setSshDialogError(t("folderSelection.ssh.dialog.errorPort"))
+ return
+ }
+ if (!Number.isInteger(remoteServerPort) || remoteServerPort <= 0 || remoteServerPort > 65535) {
+ setSshDialogError(t("folderSelection.ssh.dialog.errorRemoteServerPort"))
+ return
+ }
+
+ setIsSavingSsh(true)
+ setSshDialogError(null)
+ try {
+ const profile = buildSshConnectionProfile()
+ await saveConnectionProfile(profile)
+ if (openWindow) {
+ await connectSshProfile(profile)
+ }
+ setIsSshDialogOpen(false)
+ resetSshDialog()
+ } catch (error) {
+ setSshDialogError(error instanceof Error ? error.message : String(error))
+ } finally {
+ setIsSavingSsh(false)
+ }
+ }
+
async function handleBrowse() {
if (isLoading()) return
setFocusMode("new")
@@ -798,6 +893,14 @@ const FolderSelectionView: Component = (props) => {
{t("folderSelection.actions.connectButton")}
+
+
+ {t("folderSelection.actions.connectSshButton")}
+
}
@@ -1035,6 +1138,16 @@ const FolderSelectionView: Component
= (props) => {
{t("folderSelection.actions.connectButton")}
+
+
+
+ {t("folderSelection.actions.connectSshButton")}
+
+
@@ -1184,6 +1297,127 @@ const FolderSelectionView: Component = (props) => {
+
+
>
)
}
diff --git a/packages/ui/src/components/instance-info.tsx b/packages/ui/src/components/instance-info.tsx
index 8f9c893c1..5cfbbb22a 100644
--- a/packages/ui/src/components/instance-info.tsx
+++ b/packages/ui/src/components/instance-info.tsx
@@ -144,6 +144,17 @@ const InstanceInfo: Component = (props) => {
+
+
+
+ {t("instanceInfo.labels.executionProfile")}
+
+
+ {currentInstance().executionProfileName}
+
+
+
+
0}>
diff --git a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts
index 554c1b5b7..1de90db2a 100644
--- a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts
@@ -23,6 +23,7 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Open Folder or Connect Server",
"folderSelection.actions.subtitle": "Open local folder or connect to a CodeNomad server",
"folderSelection.actions.connectButton": "Connect CodeNomad Server",
+ "folderSelection.actions.connectSshButton": "Connect SSH Host",
"folderSelection.executionProfile.label": "Execution Profile",
"folderSelection.executionProfile.subtitle": "Choose how new local workspaces launch OpenCode",
@@ -71,6 +72,29 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Connecting...",
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
+ "folderSelection.ssh.dialog.title": "Connect SSH Host",
+ "folderSelection.ssh.dialog.description": "Save an SSH bootstrap target and optionally open it right away.",
+ "folderSelection.ssh.dialog.name": "Connection name",
+ "folderSelection.ssh.dialog.namePlaceholder": "Production VM",
+ "folderSelection.ssh.dialog.host": "Host",
+ "folderSelection.ssh.dialog.hostPlaceholder": "vm.example.com",
+ "folderSelection.ssh.dialog.port": "SSH port",
+ "folderSelection.ssh.dialog.portPlaceholder": "22",
+ "folderSelection.ssh.dialog.remoteServerPort": "Remote server port",
+ "folderSelection.ssh.dialog.remoteServerPortPlaceholder": "9898",
+ "folderSelection.ssh.dialog.username": "Username",
+ "folderSelection.ssh.dialog.usernamePlaceholder": "ubuntu",
+ "folderSelection.ssh.dialog.remotePath": "Remote workspace path",
+ "folderSelection.ssh.dialog.remotePathPlaceholder": "/srv/project",
+ "folderSelection.ssh.dialog.bootstrapScript": "Bootstrap script",
+ "folderSelection.ssh.dialog.bootstrapScriptPlaceholder": "Optional shell script run after the SSH connection is established",
+ "folderSelection.ssh.dialog.cancel": "Cancel",
+ "folderSelection.ssh.dialog.save": "Save",
+ "folderSelection.ssh.dialog.connect": "Connect",
+ "folderSelection.ssh.dialog.connecting": "Connecting...",
+ "folderSelection.ssh.dialog.errorHost": "SSH host is required.",
+ "folderSelection.ssh.dialog.errorPort": "SSH port must be between 1 and 65535.",
+ "folderSelection.ssh.dialog.errorRemoteServerPort": "Remote server port must be between 1 and 65535.",
"folderSelection.servers.certificateInstall.title": "Install Local Certificate",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad needs to install a local certificate to open self-signed HTTPS remote windows. This certificate is only used for local desktop proxy traffic on your machine. Your operating system may show a second certificate prompt after this.",
"folderSelection.servers.certificateInstall.confirmLabel": "Continue",
diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts
index d88196c94..73a0dd59a 100644
--- a/packages/ui/src/lib/i18n/messages/en/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/en/instance.ts
@@ -10,6 +10,7 @@ export const instanceMessages = {
"instanceInfo.labels.versionControl": "Version Control",
"instanceInfo.labels.opencodeVersion": "OpenCode Version",
"instanceInfo.labels.binaryPath": "Binary Path",
+ "instanceInfo.labels.executionProfile": "Execution Profile",
"instanceInfo.labels.environmentVariables": "Environment Variables ({count})",
"instanceInfo.loading": "Loading...",
"instanceInfo.server.title": "Server",
diff --git a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts
index df6021030..0a6603bae 100644
--- a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts
@@ -23,6 +23,7 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Abrir carpeta o conectar servidor",
"folderSelection.actions.subtitle": "Abre una carpeta local o conéctate a un servidor de CodeNomad",
"folderSelection.actions.connectButton": "Conectar servidor CodeNomad",
+ "folderSelection.actions.connectSshButton": "Connect SSH Host",
"folderSelection.executionProfile.label": "Perfil de ejecución",
"folderSelection.executionProfile.subtitle": "Elige cómo los nuevos espacios de trabajo locales inician OpenCode",
@@ -71,6 +72,29 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Conectando...",
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
+ "folderSelection.ssh.dialog.title": "Connect SSH Host",
+ "folderSelection.ssh.dialog.description": "Save an SSH bootstrap target and optionally open it right away.",
+ "folderSelection.ssh.dialog.name": "Connection name",
+ "folderSelection.ssh.dialog.namePlaceholder": "Production VM",
+ "folderSelection.ssh.dialog.host": "Host",
+ "folderSelection.ssh.dialog.hostPlaceholder": "vm.example.com",
+ "folderSelection.ssh.dialog.port": "SSH port",
+ "folderSelection.ssh.dialog.portPlaceholder": "22",
+ "folderSelection.ssh.dialog.remoteServerPort": "Remote server port",
+ "folderSelection.ssh.dialog.remoteServerPortPlaceholder": "9898",
+ "folderSelection.ssh.dialog.username": "Username",
+ "folderSelection.ssh.dialog.usernamePlaceholder": "ubuntu",
+ "folderSelection.ssh.dialog.remotePath": "Remote workspace path",
+ "folderSelection.ssh.dialog.remotePathPlaceholder": "/srv/project",
+ "folderSelection.ssh.dialog.bootstrapScript": "Bootstrap script",
+ "folderSelection.ssh.dialog.bootstrapScriptPlaceholder": "Optional shell script run after the SSH connection is established",
+ "folderSelection.ssh.dialog.cancel": "Cancel",
+ "folderSelection.ssh.dialog.save": "Save",
+ "folderSelection.ssh.dialog.connect": "Connect",
+ "folderSelection.ssh.dialog.connecting": "Connecting...",
+ "folderSelection.ssh.dialog.errorHost": "SSH host is required.",
+ "folderSelection.ssh.dialog.errorPort": "SSH port must be between 1 and 65535.",
+ "folderSelection.ssh.dialog.errorRemoteServerPort": "Remote server port must be between 1 and 65535.",
"folderSelection.servers.certificateInstall.title": "Instalar certificado local",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad necesita instalar un certificado local para abrir ventanas remotas HTTPS autofirmadas. Este certificado solo se usa para el trafico del proxy local de escritorio en tu equipo. Es posible que tu sistema operativo muestre un segundo aviso de certificado despues de esto.",
"folderSelection.servers.certificateInstall.confirmLabel": "Continuar",
diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts
index 872231e62..4636052ba 100644
--- a/packages/ui/src/lib/i18n/messages/es/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/es/instance.ts
@@ -10,6 +10,7 @@ export const instanceMessages = {
"instanceInfo.labels.versionControl": "Control de versiones",
"instanceInfo.labels.opencodeVersion": "Versión de OpenCode",
"instanceInfo.labels.binaryPath": "Ruta del binario",
+ "instanceInfo.labels.executionProfile": "Perfil de ejecucion",
"instanceInfo.labels.environmentVariables": "Variables de entorno ({count})",
"instanceInfo.loading": "Cargando...",
"instanceInfo.server.title": "Servidor",
diff --git a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts
index de47b27c8..9b709e76c 100644
--- a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts
@@ -23,6 +23,7 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Ouvrir un dossier ou se connecter à un serveur",
"folderSelection.actions.subtitle": "Ouvrez un dossier local ou connectez-vous à un serveur CodeNomad",
"folderSelection.actions.connectButton": "Se connecter au serveur CodeNomad",
+ "folderSelection.actions.connectSshButton": "Connect SSH Host",
"folderSelection.executionProfile.label": "Profil d'exécution",
"folderSelection.executionProfile.subtitle": "Choisissez comment les nouveaux espaces de travail locaux lancent OpenCode",
@@ -71,6 +72,29 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Connexion...",
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
+ "folderSelection.ssh.dialog.title": "Connect SSH Host",
+ "folderSelection.ssh.dialog.description": "Save an SSH bootstrap target and optionally open it right away.",
+ "folderSelection.ssh.dialog.name": "Connection name",
+ "folderSelection.ssh.dialog.namePlaceholder": "Production VM",
+ "folderSelection.ssh.dialog.host": "Host",
+ "folderSelection.ssh.dialog.hostPlaceholder": "vm.example.com",
+ "folderSelection.ssh.dialog.port": "SSH port",
+ "folderSelection.ssh.dialog.portPlaceholder": "22",
+ "folderSelection.ssh.dialog.remoteServerPort": "Remote server port",
+ "folderSelection.ssh.dialog.remoteServerPortPlaceholder": "9898",
+ "folderSelection.ssh.dialog.username": "Username",
+ "folderSelection.ssh.dialog.usernamePlaceholder": "ubuntu",
+ "folderSelection.ssh.dialog.remotePath": "Remote workspace path",
+ "folderSelection.ssh.dialog.remotePathPlaceholder": "/srv/project",
+ "folderSelection.ssh.dialog.bootstrapScript": "Bootstrap script",
+ "folderSelection.ssh.dialog.bootstrapScriptPlaceholder": "Optional shell script run after the SSH connection is established",
+ "folderSelection.ssh.dialog.cancel": "Cancel",
+ "folderSelection.ssh.dialog.save": "Save",
+ "folderSelection.ssh.dialog.connect": "Connect",
+ "folderSelection.ssh.dialog.connecting": "Connecting...",
+ "folderSelection.ssh.dialog.errorHost": "SSH host is required.",
+ "folderSelection.ssh.dialog.errorPort": "SSH port must be between 1 and 65535.",
+ "folderSelection.ssh.dialog.errorRemoteServerPort": "Remote server port must be between 1 and 65535.",
"folderSelection.servers.certificateInstall.title": "Installer le certificat local",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad doit installer un certificat local pour ouvrir des fenetres distantes HTTPS auto-signees. Ce certificat est utilise uniquement pour le trafic du proxy local de bureau sur votre machine. Votre systeme d'exploitation peut afficher une seconde invite de certificat apres cela.",
"folderSelection.servers.certificateInstall.confirmLabel": "Continuer",
diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts
index bb7a22d00..e2ecd9637 100644
--- a/packages/ui/src/lib/i18n/messages/fr/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts
@@ -10,6 +10,7 @@ export const instanceMessages = {
"instanceInfo.labels.versionControl": "Contrôle de version",
"instanceInfo.labels.opencodeVersion": "Version d'OpenCode",
"instanceInfo.labels.binaryPath": "Chemin du binaire",
+ "instanceInfo.labels.executionProfile": "Profil d'execution",
"instanceInfo.labels.environmentVariables": "Variables d'environnement ({count})",
"instanceInfo.loading": "Chargement...",
"instanceInfo.server.title": "Serveur",
diff --git a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts
index 8fc05f673..da0f88ae1 100644
--- a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts
@@ -23,6 +23,7 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "פתח תיקייה או התחבר לשרת",
"folderSelection.actions.subtitle": "פתח תיקייה מקומית או התחבר לשרת CodeNomad",
"folderSelection.actions.connectButton": "התחבר לשרת CodeNomad",
+ "folderSelection.actions.connectSshButton": "Connect SSH Host",
"folderSelection.executionProfile.label": "פרופיל הרצה",
"folderSelection.executionProfile.subtitle": "בחר איך להפעיל את OpenCode עבור סביבות עבודה מקומיות חדשות",
@@ -71,6 +72,29 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "מתחבר...",
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
+ "folderSelection.ssh.dialog.title": "Connect SSH Host",
+ "folderSelection.ssh.dialog.description": "Save an SSH bootstrap target and optionally open it right away.",
+ "folderSelection.ssh.dialog.name": "Connection name",
+ "folderSelection.ssh.dialog.namePlaceholder": "Production VM",
+ "folderSelection.ssh.dialog.host": "Host",
+ "folderSelection.ssh.dialog.hostPlaceholder": "vm.example.com",
+ "folderSelection.ssh.dialog.port": "SSH port",
+ "folderSelection.ssh.dialog.portPlaceholder": "22",
+ "folderSelection.ssh.dialog.remoteServerPort": "Remote server port",
+ "folderSelection.ssh.dialog.remoteServerPortPlaceholder": "9898",
+ "folderSelection.ssh.dialog.username": "Username",
+ "folderSelection.ssh.dialog.usernamePlaceholder": "ubuntu",
+ "folderSelection.ssh.dialog.remotePath": "Remote workspace path",
+ "folderSelection.ssh.dialog.remotePathPlaceholder": "/srv/project",
+ "folderSelection.ssh.dialog.bootstrapScript": "Bootstrap script",
+ "folderSelection.ssh.dialog.bootstrapScriptPlaceholder": "Optional shell script run after the SSH connection is established",
+ "folderSelection.ssh.dialog.cancel": "Cancel",
+ "folderSelection.ssh.dialog.save": "Save",
+ "folderSelection.ssh.dialog.connect": "Connect",
+ "folderSelection.ssh.dialog.connecting": "Connecting...",
+ "folderSelection.ssh.dialog.errorHost": "SSH host is required.",
+ "folderSelection.ssh.dialog.errorPort": "SSH port must be between 1 and 65535.",
+ "folderSelection.ssh.dialog.errorRemoteServerPort": "Remote server port must be between 1 and 65535.",
"folderSelection.servers.certificateInstall.title": "התקנת אישור מקומי",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad צריך להתקין אישור מקומי כדי לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית. האישור הזה משמש רק לתעבורת ה-proxy המקומי של האפליקציה במחשב שלך. ייתכן שמערכת ההפעלה תציג לאחר מכן בקשת אישור נוספת.",
"folderSelection.servers.certificateInstall.confirmLabel": "המשך",
diff --git a/packages/ui/src/lib/i18n/messages/he/instance.ts b/packages/ui/src/lib/i18n/messages/he/instance.ts
index a8c9fcc1d..578d6bf61 100644
--- a/packages/ui/src/lib/i18n/messages/he/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/he/instance.ts
@@ -10,6 +10,7 @@ export const instanceMessages = {
"instanceInfo.labels.versionControl": "בקרת גרסאות",
"instanceInfo.labels.opencodeVersion": "גרסת OpenCode",
"instanceInfo.labels.binaryPath": "נתיב קובץ בינארי",
+ "instanceInfo.labels.executionProfile": "פרופיל הרצה",
"instanceInfo.labels.environmentVariables": "משתני סביבה ({count})",
"instanceInfo.loading": "טוען...",
"instanceInfo.server.title": "שרת",
diff --git a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts
index faf9fb9f2..7e8b67485 100644
--- a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts
@@ -23,6 +23,7 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "フォルダを開くかサーバーに接続",
"folderSelection.actions.subtitle": "ローカルフォルダを開くか CodeNomad サーバーに接続します",
"folderSelection.actions.connectButton": "CodeNomad サーバーに接続",
+ "folderSelection.actions.connectSshButton": "Connect SSH Host",
"folderSelection.executionProfile.label": "実行プロファイル",
"folderSelection.executionProfile.subtitle": "新しいローカルワークスペースで OpenCode をどう起動するか選択します",
@@ -71,6 +72,29 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "接続中...",
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
+ "folderSelection.ssh.dialog.title": "Connect SSH Host",
+ "folderSelection.ssh.dialog.description": "Save an SSH bootstrap target and optionally open it right away.",
+ "folderSelection.ssh.dialog.name": "Connection name",
+ "folderSelection.ssh.dialog.namePlaceholder": "Production VM",
+ "folderSelection.ssh.dialog.host": "Host",
+ "folderSelection.ssh.dialog.hostPlaceholder": "vm.example.com",
+ "folderSelection.ssh.dialog.port": "SSH port",
+ "folderSelection.ssh.dialog.portPlaceholder": "22",
+ "folderSelection.ssh.dialog.remoteServerPort": "Remote server port",
+ "folderSelection.ssh.dialog.remoteServerPortPlaceholder": "9898",
+ "folderSelection.ssh.dialog.username": "Username",
+ "folderSelection.ssh.dialog.usernamePlaceholder": "ubuntu",
+ "folderSelection.ssh.dialog.remotePath": "Remote workspace path",
+ "folderSelection.ssh.dialog.remotePathPlaceholder": "/srv/project",
+ "folderSelection.ssh.dialog.bootstrapScript": "Bootstrap script",
+ "folderSelection.ssh.dialog.bootstrapScriptPlaceholder": "Optional shell script run after the SSH connection is established",
+ "folderSelection.ssh.dialog.cancel": "Cancel",
+ "folderSelection.ssh.dialog.save": "Save",
+ "folderSelection.ssh.dialog.connect": "Connect",
+ "folderSelection.ssh.dialog.connecting": "Connecting...",
+ "folderSelection.ssh.dialog.errorHost": "SSH host is required.",
+ "folderSelection.ssh.dialog.errorPort": "SSH port must be between 1 and 65535.",
+ "folderSelection.ssh.dialog.errorRemoteServerPort": "Remote server port must be between 1 and 65535.",
"folderSelection.servers.certificateInstall.title": "ローカル証明書をインストール",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad は自己署名 HTTPS のリモートウィンドウを開くために、ローカル証明書をインストールする必要があります。この証明書は、このマシン上のローカルデスクトッププロキシ通信にのみ使用されます。この後、OS が追加の証明書プロンプトを表示する場合があります。",
"folderSelection.servers.certificateInstall.confirmLabel": "続行",
diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts
index d21b17602..795d2dda2 100644
--- a/packages/ui/src/lib/i18n/messages/ja/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts
@@ -10,6 +10,7 @@ export const instanceMessages = {
"instanceInfo.labels.versionControl": "バージョン管理",
"instanceInfo.labels.opencodeVersion": "OpenCode バージョン",
"instanceInfo.labels.binaryPath": "バイナリのパス",
+ "instanceInfo.labels.executionProfile": "実行プロファイル",
"instanceInfo.labels.environmentVariables": "環境変数 ({count})",
"instanceInfo.loading": "読み込み中...",
"instanceInfo.server.title": "サーバー",
diff --git a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts
index 4542dd021..96a8624c9 100644
--- a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts
@@ -23,6 +23,7 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Открыть папку или подключить сервер",
"folderSelection.actions.subtitle": "Откройте локальную папку или подключитесь к серверу CodeNomad",
"folderSelection.actions.connectButton": "Подключить сервер CodeNomad",
+ "folderSelection.actions.connectSshButton": "Connect SSH Host",
"folderSelection.executionProfile.label": "Профиль выполнения",
"folderSelection.executionProfile.subtitle": "Выберите, как OpenCode должен запускаться для новых локальных рабочих пространств",
@@ -71,6 +72,29 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Подключение...",
"folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.",
"folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.",
+ "folderSelection.ssh.dialog.title": "Connect SSH Host",
+ "folderSelection.ssh.dialog.description": "Save an SSH bootstrap target and optionally open it right away.",
+ "folderSelection.ssh.dialog.name": "Connection name",
+ "folderSelection.ssh.dialog.namePlaceholder": "Production VM",
+ "folderSelection.ssh.dialog.host": "Host",
+ "folderSelection.ssh.dialog.hostPlaceholder": "vm.example.com",
+ "folderSelection.ssh.dialog.port": "SSH port",
+ "folderSelection.ssh.dialog.portPlaceholder": "22",
+ "folderSelection.ssh.dialog.remoteServerPort": "Remote server port",
+ "folderSelection.ssh.dialog.remoteServerPortPlaceholder": "9898",
+ "folderSelection.ssh.dialog.username": "Username",
+ "folderSelection.ssh.dialog.usernamePlaceholder": "ubuntu",
+ "folderSelection.ssh.dialog.remotePath": "Remote workspace path",
+ "folderSelection.ssh.dialog.remotePathPlaceholder": "/srv/project",
+ "folderSelection.ssh.dialog.bootstrapScript": "Bootstrap script",
+ "folderSelection.ssh.dialog.bootstrapScriptPlaceholder": "Optional shell script run after the SSH connection is established",
+ "folderSelection.ssh.dialog.cancel": "Cancel",
+ "folderSelection.ssh.dialog.save": "Save",
+ "folderSelection.ssh.dialog.connect": "Connect",
+ "folderSelection.ssh.dialog.connecting": "Connecting...",
+ "folderSelection.ssh.dialog.errorHost": "SSH host is required.",
+ "folderSelection.ssh.dialog.errorPort": "SSH port must be between 1 and 65535.",
+ "folderSelection.ssh.dialog.errorRemoteServerPort": "Remote server port must be between 1 and 65535.",
"folderSelection.servers.certificateInstall.title": "Установить локальный сертификат",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad должен установить локальный сертификат, чтобы открывать удаленные HTTPS-окна с самоподписанным сертификатом. Этот сертификат используется только для трафика локального настольного прокси на вашем устройстве. После этого ваша операционная система может показать второе предупреждение о сертификате.",
"folderSelection.servers.certificateInstall.confirmLabel": "Продолжить",
diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts
index 42fbbe3bc..7c521a386 100644
--- a/packages/ui/src/lib/i18n/messages/ru/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts
@@ -10,6 +10,7 @@ export const instanceMessages = {
"instanceInfo.labels.versionControl": "Система контроля версий",
"instanceInfo.labels.opencodeVersion": "Версия OpenCode",
"instanceInfo.labels.binaryPath": "Путь к бинарнику",
+ "instanceInfo.labels.executionProfile": "Профиль выполнения",
"instanceInfo.labels.environmentVariables": "Переменные окружения ({count})",
"instanceInfo.loading": "Загрузка…",
"instanceInfo.server.title": "Сервер",
diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts
index 093135480..30dd78c52 100644
--- a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts
@@ -23,6 +23,7 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "打开文件夹或连接服务器",
"folderSelection.actions.subtitle": "打开本地文件夹或连接到 CodeNomad 服务器",
"folderSelection.actions.connectButton": "连接 CodeNomad 服务器",
+ "folderSelection.actions.connectSshButton": "Connect SSH Host",
"folderSelection.executionProfile.label": "执行配置",
"folderSelection.executionProfile.subtitle": "选择新本地工作区启动 OpenCode 的方式",
@@ -71,6 +72,29 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "连接中...",
"folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。",
"folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。",
+ "folderSelection.ssh.dialog.title": "Connect SSH Host",
+ "folderSelection.ssh.dialog.description": "Save an SSH bootstrap target and optionally open it right away.",
+ "folderSelection.ssh.dialog.name": "Connection name",
+ "folderSelection.ssh.dialog.namePlaceholder": "Production VM",
+ "folderSelection.ssh.dialog.host": "Host",
+ "folderSelection.ssh.dialog.hostPlaceholder": "vm.example.com",
+ "folderSelection.ssh.dialog.port": "SSH port",
+ "folderSelection.ssh.dialog.portPlaceholder": "22",
+ "folderSelection.ssh.dialog.remoteServerPort": "Remote server port",
+ "folderSelection.ssh.dialog.remoteServerPortPlaceholder": "9898",
+ "folderSelection.ssh.dialog.username": "Username",
+ "folderSelection.ssh.dialog.usernamePlaceholder": "ubuntu",
+ "folderSelection.ssh.dialog.remotePath": "Remote workspace path",
+ "folderSelection.ssh.dialog.remotePathPlaceholder": "/srv/project",
+ "folderSelection.ssh.dialog.bootstrapScript": "Bootstrap script",
+ "folderSelection.ssh.dialog.bootstrapScriptPlaceholder": "Optional shell script run after the SSH connection is established",
+ "folderSelection.ssh.dialog.cancel": "Cancel",
+ "folderSelection.ssh.dialog.save": "Save",
+ "folderSelection.ssh.dialog.connect": "Connect",
+ "folderSelection.ssh.dialog.connecting": "Connecting...",
+ "folderSelection.ssh.dialog.errorHost": "SSH host is required.",
+ "folderSelection.ssh.dialog.errorPort": "SSH port must be between 1 and 65535.",
+ "folderSelection.ssh.dialog.errorRemoteServerPort": "Remote server port must be between 1 and 65535.",
"folderSelection.servers.certificateInstall.title": "安装本地证书",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad 需要安装本地证书,才能打开使用自签名 HTTPS 的远程窗口。此证书仅用于你这台设备上的本地桌面代理流量。之后你的操作系统可能还会显示第二个证书提示。",
"folderSelection.servers.certificateInstall.confirmLabel": "继续",
diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts
index cad1d3e9a..d02488483 100644
--- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts
@@ -10,6 +10,7 @@ export const instanceMessages = {
"instanceInfo.labels.versionControl": "版本控制",
"instanceInfo.labels.opencodeVersion": "OpenCode 版本",
"instanceInfo.labels.binaryPath": "可执行文件路径",
+ "instanceInfo.labels.executionProfile": "执行配置",
"instanceInfo.labels.environmentVariables": "环境变量({count})",
"instanceInfo.loading": "正在加载...",
"instanceInfo.server.title": "服务器",
From 951f13b3f16e01958a0d9820fe3a7e5f75ac5f10 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pascal=20Andr=C3=A9?=
Date: Fri, 8 May 2026 20:25:27 +0200
Subject: [PATCH 03/17] feat(ui): wire ssh connections into home screen
Add a first-class 'Connect SSH Host' flow to the home screen so users can save SSH bootstrap targets, reconnect them from the shared connections list, and open the remote window without detouring through settings.
Rename the home-screen remote tab from 'Servers' to 'Connections' so it accurately covers both remote server URLs and SSH-based entries, and expose the active execution profile inside instance information for easier runtime verification. Validation: npm run typecheck --workspace @codenomad/ui; npm run typecheck --workspace @neuralnomads/codenomad; npm run build --workspace @codenomad/ui.
---
.../ui/src/lib/i18n/messages/en/folderSelection.ts | 14 +++++++-------
.../ui/src/lib/i18n/messages/es/folderSelection.ts | 14 +++++++-------
.../ui/src/lib/i18n/messages/fr/folderSelection.ts | 14 +++++++-------
.../ui/src/lib/i18n/messages/he/folderSelection.ts | 14 +++++++-------
.../ui/src/lib/i18n/messages/ja/folderSelection.ts | 14 +++++++-------
.../ui/src/lib/i18n/messages/ru/folderSelection.ts | 14 +++++++-------
.../lib/i18n/messages/zh-Hans/folderSelection.ts | 14 +++++++-------
7 files changed, 49 insertions(+), 49 deletions(-)
diff --git a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts
index 1de90db2a..648d74a5c 100644
--- a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts
@@ -47,16 +47,16 @@ export const folderSelectionMessages = {
"folderSelection.dialog.description": "Select workspace to start coding.",
"folderSelection.tabs.local": "Local Folders",
- "folderSelection.tabs.servers": "Servers",
- "folderSelection.servers.title": "Saved Servers",
- "folderSelection.servers.subtitle": "Open a saved remote CodeNomad server in a new window",
- "folderSelection.servers.count": "{count} Servers",
- "folderSelection.servers.empty.title": "No Saved Servers",
- "folderSelection.servers.empty.description": "Add a remote server to reconnect quickly from this device",
+ "folderSelection.tabs.servers": "Connections",
+ "folderSelection.servers.title": "Saved Connections",
+ "folderSelection.servers.subtitle": "Open a saved remote connection from this device",
+ "folderSelection.servers.count": "{count} Connections",
+ "folderSelection.servers.empty.title": "No Saved Connections",
+ "folderSelection.servers.empty.description": "Add a remote server or SSH host to reconnect quickly from this device",
"folderSelection.servers.connectTitle": "Connect to Server",
"folderSelection.servers.connectSubtitle": "Save a remote CodeNomad server and open it in a new window",
"folderSelection.servers.connectButton": "Connect to Server",
- "folderSelection.servers.remove": "Remove saved server",
+ "folderSelection.servers.remove": "Remove saved connection",
"folderSelection.servers.skipTls": "Self-signed TLS",
"folderSelection.servers.errorTitle": "Remote Connection Failed",
"folderSelection.servers.dialog.title": "Connect to Server",
diff --git a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts
index 0a6603bae..40152d9c1 100644
--- a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts
@@ -47,16 +47,16 @@ export const folderSelectionMessages = {
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
"folderSelection.tabs.local": "Carpetas locales",
- "folderSelection.tabs.servers": "Servidores",
- "folderSelection.servers.title": "Servidores guardados",
- "folderSelection.servers.subtitle": "Abre un servidor remoto de CodeNomad guardado en una ventana nueva",
- "folderSelection.servers.count": "{count} servidores",
- "folderSelection.servers.empty.title": "No hay servidores guardados",
- "folderSelection.servers.empty.description": "Añade un servidor remoto para volver a conectarte rápidamente desde este dispositivo",
+ "folderSelection.tabs.servers": "Conexiones",
+ "folderSelection.servers.title": "Conexiones guardadas",
+ "folderSelection.servers.subtitle": "Abre una conexión remota guardada desde este dispositivo",
+ "folderSelection.servers.count": "{count} conexiones",
+ "folderSelection.servers.empty.title": "No hay conexiones guardadas",
+ "folderSelection.servers.empty.description": "Añade un servidor remoto o un host SSH para reconectarte rápidamente desde este dispositivo",
"folderSelection.servers.connectTitle": "Conectar a un servidor",
"folderSelection.servers.connectSubtitle": "Guarda un servidor remoto de CodeNomad y ábrelo en una ventana nueva",
"folderSelection.servers.connectButton": "Conectar a un servidor",
- "folderSelection.servers.remove": "Eliminar servidor guardado",
+ "folderSelection.servers.remove": "Eliminar conexión guardada",
"folderSelection.servers.skipTls": "TLS autofirmado",
"folderSelection.servers.errorTitle": "Falló la conexión remota",
"folderSelection.servers.dialog.title": "Conectar a un servidor",
diff --git a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts
index 9b709e76c..bbfce49cb 100644
--- a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts
@@ -47,16 +47,16 @@ export const folderSelectionMessages = {
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
"folderSelection.tabs.local": "Dossiers locaux",
- "folderSelection.tabs.servers": "Serveurs",
- "folderSelection.servers.title": "Serveurs enregistrés",
- "folderSelection.servers.subtitle": "Ouvrez un serveur CodeNomad distant enregistré dans une nouvelle fenêtre",
- "folderSelection.servers.count": "{count} serveurs",
- "folderSelection.servers.empty.title": "Aucun serveur enregistré",
- "folderSelection.servers.empty.description": "Ajoutez un serveur distant pour vous reconnecter rapidement depuis cet appareil",
+ "folderSelection.tabs.servers": "Connexions",
+ "folderSelection.servers.title": "Connexions enregistrées",
+ "folderSelection.servers.subtitle": "Ouvrez une connexion distante enregistrée depuis cet appareil",
+ "folderSelection.servers.count": "{count} connexions",
+ "folderSelection.servers.empty.title": "Aucune connexion enregistrée",
+ "folderSelection.servers.empty.description": "Ajoutez un serveur distant ou un hôte SSH pour vous reconnecter rapidement depuis cet appareil",
"folderSelection.servers.connectTitle": "Se connecter à un serveur",
"folderSelection.servers.connectSubtitle": "Enregistrez un serveur CodeNomad distant et ouvrez-le dans une nouvelle fenêtre",
"folderSelection.servers.connectButton": "Se connecter à un serveur",
- "folderSelection.servers.remove": "Supprimer le serveur enregistré",
+ "folderSelection.servers.remove": "Supprimer la connexion enregistrée",
"folderSelection.servers.skipTls": "TLS auto-signé",
"folderSelection.servers.errorTitle": "Échec de la connexion distante",
"folderSelection.servers.dialog.title": "Se connecter à un serveur",
diff --git a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts
index da0f88ae1..fe665f25f 100644
--- a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts
@@ -47,16 +47,16 @@ export const folderSelectionMessages = {
"folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.",
"folderSelection.tabs.local": "תיקיות מקומיות",
- "folderSelection.tabs.servers": "שרתים",
- "folderSelection.servers.title": "שרתים שמורים",
- "folderSelection.servers.subtitle": "פתח שרת CodeNomad מרוחק שמור בחלון חדש",
- "folderSelection.servers.count": "{count} שרתים",
- "folderSelection.servers.empty.title": "אין שרתים שמורים",
- "folderSelection.servers.empty.description": "הוסף שרת מרוחק כדי להתחבר אליו במהירות מהמכשיר הזה",
+ "folderSelection.tabs.servers": "חיבורים",
+ "folderSelection.servers.title": "חיבורים שמורים",
+ "folderSelection.servers.subtitle": "פתח חיבור מרוחק שמור מהמכשיר הזה",
+ "folderSelection.servers.count": "{count} חיבורים",
+ "folderSelection.servers.empty.title": "אין חיבורים שמורים",
+ "folderSelection.servers.empty.description": "הוסף שרת מרוחק או מארח SSH כדי להתחבר אליו במהירות מהמכשיר הזה",
"folderSelection.servers.connectTitle": "התחבר לשרת",
"folderSelection.servers.connectSubtitle": "שמור שרת CodeNomad מרוחק ופתח אותו בחלון חדש",
"folderSelection.servers.connectButton": "התחבר לשרת",
- "folderSelection.servers.remove": "הסר שרת שמור",
+ "folderSelection.servers.remove": "הסר חיבור שמור",
"folderSelection.servers.skipTls": "TLS בחתימה עצמית",
"folderSelection.servers.errorTitle": "החיבור המרוחק נכשל",
"folderSelection.servers.dialog.title": "התחבר לשרת",
diff --git a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts
index 7e8b67485..6dd975126 100644
--- a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts
@@ -47,16 +47,16 @@ export const folderSelectionMessages = {
"folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。",
"folderSelection.tabs.local": "ローカルフォルダ",
- "folderSelection.tabs.servers": "サーバー",
- "folderSelection.servers.title": "保存済みサーバー",
- "folderSelection.servers.subtitle": "保存したリモート CodeNomad サーバーを新しいウィンドウで開きます",
- "folderSelection.servers.count": "{count} サーバー",
- "folderSelection.servers.empty.title": "保存済みサーバーはありません",
- "folderSelection.servers.empty.description": "この端末からすばやく再接続できるように、リモートサーバーを追加してください",
+ "folderSelection.tabs.servers": "接続",
+ "folderSelection.servers.title": "保存済み接続",
+ "folderSelection.servers.subtitle": "この端末から保存済みのリモート接続を開きます",
+ "folderSelection.servers.count": "{count} 接続",
+ "folderSelection.servers.empty.title": "保存済み接続はありません",
+ "folderSelection.servers.empty.description": "この端末からすばやく再接続できるように、リモートサーバーまたは SSH ホストを追加してください",
"folderSelection.servers.connectTitle": "サーバーに接続",
"folderSelection.servers.connectSubtitle": "リモート CodeNomad サーバーを保存して新しいウィンドウで開きます",
"folderSelection.servers.connectButton": "サーバーに接続",
- "folderSelection.servers.remove": "保存したサーバーを削除",
+ "folderSelection.servers.remove": "保存した接続を削除",
"folderSelection.servers.skipTls": "自己署名 TLS",
"folderSelection.servers.errorTitle": "リモート接続に失敗しました",
"folderSelection.servers.dialog.title": "サーバーに接続",
diff --git a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts
index 96a8624c9..f68a86c2c 100644
--- a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts
@@ -47,16 +47,16 @@ export const folderSelectionMessages = {
"folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.",
"folderSelection.tabs.local": "Локальные папки",
- "folderSelection.tabs.servers": "Серверы",
- "folderSelection.servers.title": "Сохраненные серверы",
- "folderSelection.servers.subtitle": "Откройте сохраненный удаленный сервер CodeNomad в новом окне",
- "folderSelection.servers.count": "{count} серверов",
- "folderSelection.servers.empty.title": "Нет сохраненных серверов",
- "folderSelection.servers.empty.description": "Добавьте удаленный сервер, чтобы быстро подключаться к нему с этого устройства",
+ "folderSelection.tabs.servers": "Подключения",
+ "folderSelection.servers.title": "Сохраненные подключения",
+ "folderSelection.servers.subtitle": "Откройте сохраненное удаленное подключение с этого устройства",
+ "folderSelection.servers.count": "{count} подключений",
+ "folderSelection.servers.empty.title": "Нет сохраненных подключений",
+ "folderSelection.servers.empty.description": "Добавьте удаленный сервер или SSH-хост, чтобы быстро подключаться к нему с этого устройства",
"folderSelection.servers.connectTitle": "Подключиться к серверу",
"folderSelection.servers.connectSubtitle": "Сохраните удаленный сервер CodeNomad и откройте его в новом окне",
"folderSelection.servers.connectButton": "Подключиться к серверу",
- "folderSelection.servers.remove": "Удалить сохраненный сервер",
+ "folderSelection.servers.remove": "Удалить сохраненное подключение",
"folderSelection.servers.skipTls": "Самоподписанный TLS",
"folderSelection.servers.errorTitle": "Ошибка удаленного подключения",
"folderSelection.servers.dialog.title": "Подключиться к серверу",
diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts
index 30dd78c52..4364b043d 100644
--- a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts
@@ -47,16 +47,16 @@ export const folderSelectionMessages = {
"folderSelection.dialog.description": "选择工作区以开始编码。",
"folderSelection.tabs.local": "本地文件夹",
- "folderSelection.tabs.servers": "服务器",
- "folderSelection.servers.title": "已保存的服务器",
- "folderSelection.servers.subtitle": "在新窗口中打开已保存的远程 CodeNomad 服务器",
- "folderSelection.servers.count": "{count} 个服务器",
- "folderSelection.servers.empty.title": "没有已保存的服务器",
- "folderSelection.servers.empty.description": "添加远程服务器,以便在此设备上快速重新连接",
+ "folderSelection.tabs.servers": "连接",
+ "folderSelection.servers.title": "已保存连接",
+ "folderSelection.servers.subtitle": "从此设备打开已保存的远程连接",
+ "folderSelection.servers.count": "{count} 个连接",
+ "folderSelection.servers.empty.title": "没有已保存连接",
+ "folderSelection.servers.empty.description": "添加远程服务器或 SSH 主机,以便在此设备上快速重新连接",
"folderSelection.servers.connectTitle": "连接到服务器",
"folderSelection.servers.connectSubtitle": "保存远程 CodeNomad 服务器并在新窗口中打开它",
"folderSelection.servers.connectButton": "连接到服务器",
- "folderSelection.servers.remove": "删除已保存服务器",
+ "folderSelection.servers.remove": "删除已保存连接",
"folderSelection.servers.skipTls": "自签名 TLS",
"folderSelection.servers.errorTitle": "远程连接失败",
"folderSelection.servers.dialog.title": "连接到服务器",
From a251fd80beb6f77c40f62778e13386f833cf38be Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pascal=20Andr=C3=A9?=
Date: Fri, 8 May 2026 21:15:12 +0200
Subject: [PATCH 04/17] feat(ui): preview execution profile launch commands
Add a Preview command flow to Execution Profiles so operators can inspect the exact launcher command, working directory, and injected environment before targeting custom runtimes. The settings UI now requests a server-generated preview and renders the resolved command line alongside an optional sample workspace path for Docker mounts and cwd resolution.
Build previews from the same launch and spawn pipeline used at runtime so WSL profiles show the real wsl.exe wrapper, WSLENV propagation, and quoting behavior instead of a simplified placeholder command. Reuse current server config for log level, environment variables, auth endpoints, and config paths while redacting secrets in the response.
Validation: npm run typecheck --workspace @neuralnomads/codenomad; npm run typecheck --workspace @codenomad/ui; npm run build --workspace @codenomad/ui; node --import tsx --test packages/server/src/workspaces/execution-launch.test.ts
---
packages/server/src/api-types.ts | 13 ++
packages/server/src/server/routes/settings.ts | 182 +++++++++++++++++-
.../src/workspaces/execution-launch.test.ts | 39 +++-
.../server/src/workspaces/execution-launch.ts | 61 ++++++
packages/server/src/workspaces/runtime.ts | 3 +-
packages/server/src/workspaces/spawn.ts | 1 +
.../execution-profiles-settings-section.tsx | 176 +++++++++++++----
packages/ui/src/lib/api-client.ts | 8 +
.../ui/src/lib/i18n/messages/en/settings.ts | 11 ++
.../ui/src/lib/i18n/messages/es/settings.ts | 11 ++
.../ui/src/lib/i18n/messages/fr/settings.ts | 11 ++
.../ui/src/lib/i18n/messages/he/settings.ts | 11 ++
.../ui/src/lib/i18n/messages/ja/settings.ts | 11 ++
.../ui/src/lib/i18n/messages/ru/settings.ts | 11 ++
.../src/lib/i18n/messages/zh-Hans/settings.ts | 11 ++
15 files changed, 521 insertions(+), 39 deletions(-)
diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts
index f7c522409..2ef0f00fd 100644
--- a/packages/server/src/api-types.ts
+++ b/packages/server/src/api-types.ts
@@ -52,6 +52,19 @@ export interface CommandExecutionProfile extends ExecutionProfileBase {
export type ExecutionProfile = LocalExecutionProfile | WslExecutionProfile | DockerExecutionProfile | CommandExecutionProfile
+export interface ExecutionProfilePreviewRequest {
+ profile: ExecutionProfile
+ workspacePath?: string
+}
+
+export interface ExecutionProfilePreviewResponse {
+ command: string
+ args: string[]
+ commandLine: string
+ cwd?: string
+ environment: Record
+}
+
export interface WorkspaceDescriptor {
id: string
/** Absolute path on the server host. */
diff --git a/packages/server/src/server/routes/settings.ts b/packages/server/src/server/routes/settings.ts
index 5d18e4fd5..9b07cbbab 100644
--- a/packages/server/src/server/routes/settings.ts
+++ b/packages/server/src/server/routes/settings.ts
@@ -1,5 +1,14 @@
-import { FastifyInstance } from "fastify"
+import { FastifyInstance, type FastifyRequest } from "fastify"
import { z } from "zod"
+import type { ExecutionProfilePreviewResponse } from "../../api-types"
+import { getOpencodeConfigDir } from "../../opencode-config.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 +23,168 @@ const ValidateBinarySchema = z.object({
path: z.string(),
})
+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(),
+ }),
+])
+
+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): { valid: boolean; version?: string; error?: string } {
const result = probeBinaryVersion(binaryPath)
return { valid: result.valid, version: result.version, error: result.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 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 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,
+ 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,
+ }
+ : {
+ kind: "command" as const,
+ label: input.profile.name,
+ executable: input.profile.executable,
+ args: input.profile.args,
+ cwdMode: input.profile.cwdMode,
+ }
+
+ const userEnvironment = readConfiguredServerEnvironment(options.settings)
+ const previewInstanceId = "preview-instance"
+ const normalizedBaseUrl = options.requestBaseUrl.replace(/\/+$/, "")
+ const { username } = resolveOpencodeServerAuth({
+ userEnvironment,
+ processEnv: process.env,
+ })
+
+ const environment = {
+ ...redactPreviewEnvironment(userEnvironment),
+ OPENCODE_CONFIG_DIR: getOpencodeConfigDir(),
+ 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),
+ })
+
+ return {
+ command: launch.command,
+ args: launch.args,
+ commandLine: formatCommandLine(launch.command, launch.args),
+ cwd: launch.cwd,
+ environment: launch.environment ?? {},
+ }
+}
+
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
// Full-document access
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
@@ -81,4 +247,18 @@ 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" }
+ }
+ })
}
diff --git a/packages/server/src/workspaces/execution-launch.test.ts b/packages/server/src/workspaces/execution-launch.test.ts
index c5e0ea76a..e89077653 100644
--- a/packages/server/src/workspaces/execution-launch.test.ts
+++ b/packages/server/src/workspaces/execution-launch.test.ts
@@ -2,7 +2,7 @@ import assert from "node:assert/strict"
import { describe, it } from "node:test"
import type { ResolvedBinary } from "../settings/binaries"
-import { buildLaunchCommand } from "./execution-launch"
+import { buildLaunchCommand, buildLaunchPreview, formatCommandLine } from "./execution-launch"
describe("buildLaunchCommand", () => {
it("builds a command execution profile launch", () => {
@@ -60,4 +60,41 @@ describe("buildLaunchCommand", () => {
assert.ok(result.args.includes("NODE_EXTRA_CA_CERTS=/tmp/codenomad-node-extra-ca.pem"))
assert.deepEqual(result.args.slice(-6), ["serve", "--port", "0", "--print-logs", "--log-level", "INFO"])
})
+
+ 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_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
+ 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_DIR/p: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
index 53bd65105..89407b8cf 100644
--- a/packages/server/src/workspaces/execution-launch.ts
+++ b/packages/server/src/workspaces/execution-launch.ts
@@ -1,5 +1,6 @@
import { URL } from "url"
import type { ResolvedBinary } from "../settings/binaries"
+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"
@@ -42,6 +43,29 @@ export function buildLaunchCommand(params: BuildLaunchCommandParams): LaunchComm
}
}
+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,
+ })
+
+ 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,
@@ -101,6 +125,43 @@ function buildDockerLaunchCommand(
}
}
+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 rewriteDockerBaseUrl(input: string): string {
try {
const url = new URL(input)
diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts
index 06aa772d2..039a22ddd 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__:"
function redactEnvironment(env: Record): Record {
const redacted: Record = {}
diff --git a/packages/server/src/workspaces/spawn.ts b/packages/server/src/workspaces/spawn.ts
index 8add4aa48..733c88185 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
diff --git a/packages/ui/src/components/settings/execution-profiles-settings-section.tsx b/packages/ui/src/components/settings/execution-profiles-settings-section.tsx
index 5eccb37b6..8acb8cfd3 100644
--- a/packages/ui/src/components/settings/execution-profiles-settings-section.tsx
+++ b/packages/ui/src/components/settings/execution-profiles-settings-section.tsx
@@ -1,6 +1,7 @@
-import { createMemo, createSignal, For, Show, type Component } from "solid-js"
+import { createEffect, createMemo, createSignal, For, Show, type Component } from "solid-js"
import { Pencil, Plus, Star, Trash2 } from "lucide-solid"
-import type { ExecutionProfile } from "../../../../server/src/api-types"
+import type { ExecutionProfile, ExecutionProfilePreviewResponse } from "../../../../server/src/api-types"
+import { serverApi } from "../../lib/api-client"
import { useConfig } from "../../stores/preferences"
import { useI18n } from "../../lib/i18n"
@@ -36,6 +37,13 @@ function buildProfileSummary(profile: ExecutionProfile): string {
}
}
+function formatPreviewEnvironment(environment: Record): string {
+ return Object.entries(environment)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, value]) => `${key}=${value}`)
+ .join("\n")
+}
+
export const ExecutionProfilesSettingsSection: Component = () => {
const { t } = useI18n()
const {
@@ -59,8 +67,12 @@ export const ExecutionProfilesSettingsSection: Component = () => {
const [executable, setExecutable] = createSignal("")
const [argsText, setArgsText] = createSignal("")
const [cwdMode, setCwdMode] = createSignal<"workspace" | "inherit">("workspace")
+ const [previewWorkspacePath, setPreviewWorkspacePath] = createSignal("")
const [saving, setSaving] = createSignal(false)
+ const [previewing, setPreviewing] = createSignal(false)
const [formError, setFormError] = createSignal(null)
+ const [previewError, setPreviewError] = createSignal(null)
+ const [previewResult, setPreviewResult] = createSignal(null)
const kindOptions = createMemo(() => [
{ value: "local" as const, label: t("settings.opencode.executionProfiles.kind.local") },
@@ -69,6 +81,24 @@ export const ExecutionProfilesSettingsSection: Component = () => {
{ value: "command" as const, label: t("settings.opencode.executionProfiles.kind.command") },
])
+ createEffect(() => {
+ kind()
+ name()
+ binaryPath()
+ distro()
+ image()
+ workspaceMountPath()
+ configMountPath()
+ commandText()
+ extraDockerArgsText()
+ executable()
+ argsText()
+ cwdMode()
+ previewWorkspacePath()
+ setPreviewError(null)
+ setPreviewResult(null)
+ })
+
function resetForm(profile?: ExecutionProfile) {
setEditingId(profile?.id ?? null)
setKind(profile?.kind ?? "local")
@@ -83,6 +113,7 @@ export const ExecutionProfilesSettingsSection: Component = () => {
setExecutable(profile?.kind === "command" ? profile.executable : "")
setArgsText(profile?.kind === "command" ? formatStringList(profile.args) : "")
setCwdMode(profile?.kind === "command" ? profile.cwdMode ?? "workspace" : "workspace")
+ setPreviewWorkspacePath("")
setFormError(null)
}
@@ -90,53 +121,51 @@ export const ExecutionProfilesSettingsSection: Component = () => {
return value.trim().length > 0 ? value.trim() : null
}
- async function handleSave() {
+ function buildProfileFromForm(): ExecutionProfile {
const trimmedName = requireValue(name())
if (!trimmedName) {
- setFormError(t("settings.opencode.executionProfiles.validation.name"))
- return
+ throw new Error(t("settings.opencode.executionProfiles.validation.name"))
}
- let profile: ExecutionProfile | null = null
if (kind() === "local") {
const trimmedBinaryPath = requireValue(binaryPath())
if (!trimmedBinaryPath) {
- setFormError(t("settings.opencode.executionProfiles.validation.binaryPath"))
- return
+ throw new Error(t("settings.opencode.executionProfiles.validation.binaryPath"))
}
- profile = {
+ return {
id: editingId() ?? createProfileId(),
kind: "local",
name: trimmedName,
binaryPath: trimmedBinaryPath,
}
- } else if (kind() === "wsl") {
+ }
+
+ if (kind() === "wsl") {
const trimmedDistro = requireValue(distro())
const trimmedBinaryPath = requireValue(binaryPath())
if (!trimmedDistro) {
- setFormError(t("settings.opencode.executionProfiles.validation.distro"))
- return
+ throw new Error(t("settings.opencode.executionProfiles.validation.distro"))
}
if (!trimmedBinaryPath) {
- setFormError(t("settings.opencode.executionProfiles.validation.binaryPath"))
- return
+ throw new Error(t("settings.opencode.executionProfiles.validation.binaryPath"))
}
- profile = {
+ return {
id: editingId() ?? createProfileId(),
kind: "wsl",
name: trimmedName,
distro: trimmedDistro,
binaryPath: trimmedBinaryPath,
}
- } else if (kind() === "docker") {
+ }
+
+ if (kind() === "docker") {
const trimmedImage = requireValue(image())
const trimmedWorkspaceMountPath = requireValue(workspaceMountPath())
const trimmedConfigMountPath = requireValue(configMountPath())
if (!trimmedImage || !trimmedWorkspaceMountPath || !trimmedConfigMountPath) {
- setFormError(t("settings.opencode.executionProfiles.validation.docker"))
- return
+ throw new Error(t("settings.opencode.executionProfiles.validation.docker"))
}
- profile = {
+ return {
id: editingId() ?? createProfileId(),
kind: "docker",
name: trimmedName,
@@ -146,24 +175,33 @@ export const ExecutionProfilesSettingsSection: Component = () => {
command: parseStringList(commandText()),
extraDockerArgs: parseStringList(extraDockerArgsText()),
}
- } else {
- const trimmedExecutable = requireValue(executable())
- if (!trimmedExecutable) {
- setFormError(t("settings.opencode.executionProfiles.validation.executable"))
- return
- }
- profile = {
- id: editingId() ?? createProfileId(),
- kind: "command",
- name: trimmedName,
- executable: trimmedExecutable,
- args: parseStringList(argsText()),
- cwdMode: cwdMode(),
- }
}
- setSaving(true)
+ 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()
@@ -174,6 +212,33 @@ export const ExecutionProfilesSettingsSection: Component = () => {
}
}
+ 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)
+ }
+ }
+
return (
@@ -286,23 +351,64 @@ export const ExecutionProfilesSettingsSection: Component = () => {
+
+
+
{t("settings.opencode.executionProfiles.form.previewWorkspacePath.label")}
+
{t("settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle")}
+
+
setPreviewWorkspacePath(event.currentTarget.value)} />
+
+
{formError()}
+
+ {previewError()}
+
+
resetForm()}>
{t("settings.opencode.executionProfiles.form.cancelEdit")}
-
void handleSave()}>
+ 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) => (
+
+ )}
+
diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts
index 017ddf2fe..84b1fee00 100644
--- a/packages/ui/src/lib/api-client.ts
+++ b/packages/ui/src/lib/api-client.ts
@@ -3,6 +3,8 @@ import type {
BackgroundProcessListResponse,
BackgroundProcessOutputResponse,
BinaryValidationResult,
+ ExecutionProfilePreviewRequest,
+ ExecutionProfilePreviewResponse,
FileSystemEntry,
FileSystemCreateFolderResponse,
FileSystemListResponse,
@@ -398,6 +400,12 @@ export const serverApi = {
body: JSON.stringify({ path }),
})
},
+ previewExecutionProfile(payload: ExecutionProfilePreviewRequest): Promise
{
+ return request("/api/storage/execution-profiles/preview", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ })
+ },
fetchSpeechCapabilities(): Promise {
return request("/api/speech/capabilities")
},
diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts
index 6ea027e21..fd10d3958 100644
--- a/packages/ui/src/lib/i18n/messages/en/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/en/settings.ts
@@ -190,6 +190,11 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
"settings.opencode.executionProfiles.form.args.label": "Arguments",
"settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.label": "Preview workspace path",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle": "Optional sample workspace path used to resolve cwd and Docker mounts.",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.placeholder": "D:\\CodeNomad",
+ "settings.opencode.executionProfiles.form.preview": "Preview command",
+ "settings.opencode.executionProfiles.form.previewing": "Previewing...",
"settings.opencode.executionProfiles.form.save": "Save profile",
"settings.opencode.executionProfiles.form.update": "Update profile",
"settings.opencode.executionProfiles.form.cancelEdit": "Cancel edit",
@@ -200,6 +205,12 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.list.actions.edit": "Edit profile",
"settings.opencode.executionProfiles.list.actions.delete": "Delete profile",
"settings.opencode.executionProfiles.list.actions.makeDefault": "Set as default profile",
+ "settings.opencode.executionProfiles.preview.title": "Command preview",
+ "settings.opencode.executionProfiles.preview.subtitle": "Shows the generated launcher command with current server settings. Secrets are redacted.",
+ "settings.opencode.executionProfiles.preview.commandLine": "Command line",
+ "settings.opencode.executionProfiles.preview.cwd": "Working directory",
+ "settings.opencode.executionProfiles.preview.cwd.inherit": "Inherit current process cwd",
+ "settings.opencode.executionProfiles.preview.environment": "Environment",
"settings.opencode.executionProfiles.validation.name": "Profile name is required.",
"settings.opencode.executionProfiles.validation.binaryPath": "Binary path is required.",
"settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts
index 0921fbe55..4946f9252 100644
--- a/packages/ui/src/lib/i18n/messages/es/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/es/settings.ts
@@ -190,6 +190,11 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
"settings.opencode.executionProfiles.form.args.label": "Arguments",
"settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.label": "Preview workspace path",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle": "Optional sample workspace path used to resolve cwd and Docker mounts.",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.placeholder": "D:\\CodeNomad",
+ "settings.opencode.executionProfiles.form.preview": "Preview command",
+ "settings.opencode.executionProfiles.form.previewing": "Previewing...",
"settings.opencode.executionProfiles.form.save": "Save profile",
"settings.opencode.executionProfiles.form.update": "Update profile",
"settings.opencode.executionProfiles.form.cancelEdit": "Cancel edit",
@@ -200,6 +205,12 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.list.actions.edit": "Edit profile",
"settings.opencode.executionProfiles.list.actions.delete": "Delete profile",
"settings.opencode.executionProfiles.list.actions.makeDefault": "Set as default profile",
+ "settings.opencode.executionProfiles.preview.title": "Command preview",
+ "settings.opencode.executionProfiles.preview.subtitle": "Shows the generated launcher command with current server settings. Secrets are redacted.",
+ "settings.opencode.executionProfiles.preview.commandLine": "Command line",
+ "settings.opencode.executionProfiles.preview.cwd": "Working directory",
+ "settings.opencode.executionProfiles.preview.cwd.inherit": "Inherit current process cwd",
+ "settings.opencode.executionProfiles.preview.environment": "Environment",
"settings.opencode.executionProfiles.validation.name": "Profile name is required.",
"settings.opencode.executionProfiles.validation.binaryPath": "Binary path is required.",
"settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts
index 7f82aa08d..92f56f1d0 100644
--- a/packages/ui/src/lib/i18n/messages/fr/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts
@@ -190,6 +190,11 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
"settings.opencode.executionProfiles.form.args.label": "Arguments",
"settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.label": "Preview workspace path",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle": "Optional sample workspace path used to resolve cwd and Docker mounts.",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.placeholder": "D:\\CodeNomad",
+ "settings.opencode.executionProfiles.form.preview": "Preview command",
+ "settings.opencode.executionProfiles.form.previewing": "Previewing...",
"settings.opencode.executionProfiles.form.save": "Save profile",
"settings.opencode.executionProfiles.form.update": "Update profile",
"settings.opencode.executionProfiles.form.cancelEdit": "Cancel edit",
@@ -200,6 +205,12 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.list.actions.edit": "Edit profile",
"settings.opencode.executionProfiles.list.actions.delete": "Delete profile",
"settings.opencode.executionProfiles.list.actions.makeDefault": "Set as default profile",
+ "settings.opencode.executionProfiles.preview.title": "Command preview",
+ "settings.opencode.executionProfiles.preview.subtitle": "Shows the generated launcher command with current server settings. Secrets are redacted.",
+ "settings.opencode.executionProfiles.preview.commandLine": "Command line",
+ "settings.opencode.executionProfiles.preview.cwd": "Working directory",
+ "settings.opencode.executionProfiles.preview.cwd.inherit": "Inherit current process cwd",
+ "settings.opencode.executionProfiles.preview.environment": "Environment",
"settings.opencode.executionProfiles.validation.name": "Profile name is required.",
"settings.opencode.executionProfiles.validation.binaryPath": "Binary path is required.",
"settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts
index a7dbef2e3..32b5f53ac 100644
--- a/packages/ui/src/lib/i18n/messages/he/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/he/settings.ts
@@ -189,6 +189,11 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
"settings.opencode.executionProfiles.form.args.label": "Arguments",
"settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.label": "Preview workspace path",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle": "Optional sample workspace path used to resolve cwd and Docker mounts.",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.placeholder": "D:\\CodeNomad",
+ "settings.opencode.executionProfiles.form.preview": "Preview command",
+ "settings.opencode.executionProfiles.form.previewing": "Previewing...",
"settings.opencode.executionProfiles.form.save": "Save profile",
"settings.opencode.executionProfiles.form.update": "Update profile",
"settings.opencode.executionProfiles.form.cancelEdit": "Cancel edit",
@@ -199,6 +204,12 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.list.actions.edit": "Edit profile",
"settings.opencode.executionProfiles.list.actions.delete": "Delete profile",
"settings.opencode.executionProfiles.list.actions.makeDefault": "Set as default profile",
+ "settings.opencode.executionProfiles.preview.title": "Command preview",
+ "settings.opencode.executionProfiles.preview.subtitle": "Shows the generated launcher command with current server settings. Secrets are redacted.",
+ "settings.opencode.executionProfiles.preview.commandLine": "Command line",
+ "settings.opencode.executionProfiles.preview.cwd": "Working directory",
+ "settings.opencode.executionProfiles.preview.cwd.inherit": "Inherit current process cwd",
+ "settings.opencode.executionProfiles.preview.environment": "Environment",
"settings.opencode.executionProfiles.validation.name": "Profile name is required.",
"settings.opencode.executionProfiles.validation.binaryPath": "Binary path is required.",
"settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts
index 80a71a830..2a8993dc3 100644
--- a/packages/ui/src/lib/i18n/messages/ja/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts
@@ -190,6 +190,11 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
"settings.opencode.executionProfiles.form.args.label": "Arguments",
"settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.label": "Preview workspace path",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle": "Optional sample workspace path used to resolve cwd and Docker mounts.",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.placeholder": "D:\\CodeNomad",
+ "settings.opencode.executionProfiles.form.preview": "Preview command",
+ "settings.opencode.executionProfiles.form.previewing": "Previewing...",
"settings.opencode.executionProfiles.form.save": "Save profile",
"settings.opencode.executionProfiles.form.update": "Update profile",
"settings.opencode.executionProfiles.form.cancelEdit": "Cancel edit",
@@ -200,6 +205,12 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.list.actions.edit": "Edit profile",
"settings.opencode.executionProfiles.list.actions.delete": "Delete profile",
"settings.opencode.executionProfiles.list.actions.makeDefault": "Set as default profile",
+ "settings.opencode.executionProfiles.preview.title": "Command preview",
+ "settings.opencode.executionProfiles.preview.subtitle": "Shows the generated launcher command with current server settings. Secrets are redacted.",
+ "settings.opencode.executionProfiles.preview.commandLine": "Command line",
+ "settings.opencode.executionProfiles.preview.cwd": "Working directory",
+ "settings.opencode.executionProfiles.preview.cwd.inherit": "Inherit current process cwd",
+ "settings.opencode.executionProfiles.preview.environment": "Environment",
"settings.opencode.executionProfiles.validation.name": "Profile name is required.",
"settings.opencode.executionProfiles.validation.binaryPath": "Binary path is required.",
"settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts
index eb672f5b6..ed56ce294 100644
--- a/packages/ui/src/lib/i18n/messages/ru/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts
@@ -190,6 +190,11 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
"settings.opencode.executionProfiles.form.args.label": "Arguments",
"settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.label": "Preview workspace path",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle": "Optional sample workspace path used to resolve cwd and Docker mounts.",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.placeholder": "D:\\CodeNomad",
+ "settings.opencode.executionProfiles.form.preview": "Preview command",
+ "settings.opencode.executionProfiles.form.previewing": "Previewing...",
"settings.opencode.executionProfiles.form.save": "Save profile",
"settings.opencode.executionProfiles.form.update": "Update profile",
"settings.opencode.executionProfiles.form.cancelEdit": "Cancel edit",
@@ -200,6 +205,12 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.list.actions.edit": "Edit profile",
"settings.opencode.executionProfiles.list.actions.delete": "Delete profile",
"settings.opencode.executionProfiles.list.actions.makeDefault": "Set as default profile",
+ "settings.opencode.executionProfiles.preview.title": "Command preview",
+ "settings.opencode.executionProfiles.preview.subtitle": "Shows the generated launcher command with current server settings. Secrets are redacted.",
+ "settings.opencode.executionProfiles.preview.commandLine": "Command line",
+ "settings.opencode.executionProfiles.preview.cwd": "Working directory",
+ "settings.opencode.executionProfiles.preview.cwd.inherit": "Inherit current process cwd",
+ "settings.opencode.executionProfiles.preview.environment": "Environment",
"settings.opencode.executionProfiles.validation.name": "Profile name is required.",
"settings.opencode.executionProfiles.validation.binaryPath": "Binary path is required.",
"settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts
index 38fd493bc..1465dcb15 100644
--- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts
@@ -190,6 +190,11 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
"settings.opencode.executionProfiles.form.args.label": "Arguments",
"settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.label": "Preview workspace path",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle": "Optional sample workspace path used to resolve cwd and Docker mounts.",
+ "settings.opencode.executionProfiles.form.previewWorkspacePath.placeholder": "D:\\CodeNomad",
+ "settings.opencode.executionProfiles.form.preview": "Preview command",
+ "settings.opencode.executionProfiles.form.previewing": "Previewing...",
"settings.opencode.executionProfiles.form.save": "Save profile",
"settings.opencode.executionProfiles.form.update": "Update profile",
"settings.opencode.executionProfiles.form.cancelEdit": "Cancel edit",
@@ -200,6 +205,12 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.list.actions.edit": "Edit profile",
"settings.opencode.executionProfiles.list.actions.delete": "Delete profile",
"settings.opencode.executionProfiles.list.actions.makeDefault": "Set as default profile",
+ "settings.opencode.executionProfiles.preview.title": "Command preview",
+ "settings.opencode.executionProfiles.preview.subtitle": "Shows the generated launcher command with current server settings. Secrets are redacted.",
+ "settings.opencode.executionProfiles.preview.commandLine": "Command line",
+ "settings.opencode.executionProfiles.preview.cwd": "Working directory",
+ "settings.opencode.executionProfiles.preview.cwd.inherit": "Inherit current process cwd",
+ "settings.opencode.executionProfiles.preview.environment": "Environment",
"settings.opencode.executionProfiles.validation.name": "Profile name is required.",
"settings.opencode.executionProfiles.validation.binaryPath": "Binary path is required.",
"settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
From 0da41e737f34d0ee023f8275a97fb898a7f69113 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pascal=20Andr=C3=A9?=
Date: Fri, 8 May 2026 21:30:09 +0200
Subject: [PATCH 05/17] feat(ui): add execution profile test flow
Add a lightweight Test profile action beside the execution profile preview so operators can verify that a runtime is actually reachable from the CodeNomad host before using it for new workspaces. The settings screen now reports a pass/fail result and reuses the resolved preview payload for context.
Use a server-side test endpoint that stays intentionally minimal: local and WSL profiles validate the target binary, custom command profiles validate the wrapper executable, and Docker profiles validate both the Docker CLI and the configured local image. This keeps the first test pass fast and low-risk without spawning an OpenCode server.
Validation: npm run typecheck --workspace @neuralnomads/codenomad; npm run typecheck --workspace @codenomad/ui; npm run build --workspace @codenomad/ui
---
packages/server/src/api-types.ts | 6 ++
packages/server/src/server/routes/settings.ts | 67 ++++++++++++++++++-
.../execution-profiles-settings-section.tsx | 63 ++++++++++++++++-
packages/ui/src/lib/api-client.ts | 7 ++
.../ui/src/lib/i18n/messages/en/settings.ts | 6 ++
.../ui/src/lib/i18n/messages/es/settings.ts | 6 ++
.../ui/src/lib/i18n/messages/fr/settings.ts | 6 ++
.../ui/src/lib/i18n/messages/he/settings.ts | 6 ++
.../ui/src/lib/i18n/messages/ja/settings.ts | 6 ++
.../ui/src/lib/i18n/messages/ru/settings.ts | 6 ++
.../src/lib/i18n/messages/zh-Hans/settings.ts | 6 ++
11 files changed, 181 insertions(+), 4 deletions(-)
diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts
index 2ef0f00fd..f8c6c29d5 100644
--- a/packages/server/src/api-types.ts
+++ b/packages/server/src/api-types.ts
@@ -65,6 +65,12 @@ export interface ExecutionProfilePreviewResponse {
environment: Record
}
+export interface ExecutionProfileTestResponse extends ExecutionProfilePreviewResponse {
+ valid: boolean
+ version?: string
+ error?: string
+}
+
export interface WorkspaceDescriptor {
id: string
/** Absolute path on the server host. */
diff --git a/packages/server/src/server/routes/settings.ts b/packages/server/src/server/routes/settings.ts
index 9b07cbbab..dc9796998 100644
--- a/packages/server/src/server/routes/settings.ts
+++ b/packages/server/src/server/routes/settings.ts
@@ -1,6 +1,7 @@
+import { spawnSync } from "child_process"
import { FastifyInstance, type FastifyRequest } from "fastify"
import { z } from "zod"
-import type { ExecutionProfilePreviewResponse } from "../../api-types"
+import type { ExecutionProfilePreviewResponse, ExecutionProfileTestResponse } from "../../api-types"
import { getOpencodeConfigDir } from "../../opencode-config.js"
import { buildLaunchPreview, formatCommandLine } from "../../workspaces/execution-launch"
import {
@@ -69,6 +70,36 @@ function validateBinaryPath(binaryPath: string): { valid: boolean; version?: str
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 {}
@@ -185,6 +216,26 @@ function buildExecutionProfilePreview(
}
}
+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)
+ : validateBinaryPath(input.profile.binaryPath)
+
+ 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")))
@@ -261,4 +312,18 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
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/ui/src/components/settings/execution-profiles-settings-section.tsx b/packages/ui/src/components/settings/execution-profiles-settings-section.tsx
index 8acb8cfd3..fa6b88593 100644
--- a/packages/ui/src/components/settings/execution-profiles-settings-section.tsx
+++ b/packages/ui/src/components/settings/execution-profiles-settings-section.tsx
@@ -1,6 +1,6 @@
import { createEffect, createMemo, createSignal, For, Show, type Component } from "solid-js"
import { Pencil, Plus, Star, Trash2 } from "lucide-solid"
-import type { ExecutionProfile, ExecutionProfilePreviewResponse } from "../../../../server/src/api-types"
+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"
@@ -70,9 +70,12 @@ export const ExecutionProfilesSettingsSection: Component = () => {
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") },
@@ -96,7 +99,9 @@ export const ExecutionProfilesSettingsSection: Component = () => {
cwdMode()
previewWorkspacePath()
setPreviewError(null)
+ setTestError(null)
setPreviewResult(null)
+ setTestResult(null)
})
function resetForm(profile?: ExecutionProfile) {
@@ -239,6 +244,34 @@ export const ExecutionProfilesSettingsSection: Component = () => {
}
}
+ 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)
+ }
+ }
+
return (
@@ -367,16 +400,23 @@ export const ExecutionProfilesSettingsSection: Component = () => {
{previewError()}
+
+ {testError()}
+
+
resetForm()}>
{t("settings.opencode.executionProfiles.form.cancelEdit")}
-
void handlePreview()}>
+ 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()}>
+ void handleSave()}>
}>
@@ -384,6 +424,23 @@ export const ExecutionProfilesSettingsSection: Component = () => {
+
+ {(result) => (
+
+ )}
+
+
{(result) => (
}
@@ -1019,17 +871,15 @@ const FolderSelectionView: Component
= (props) => {
- }>
-
-
+
{server.name}
- {t(server.kind === "remote-server" ? "folderSelection.servers.kind.remoteServer" : "folderSelection.servers.kind.ssh")}
+ {t("folderSelection.servers.kind.remoteServer")}
- {buildConnectionProfileTarget(server)}
+ {server.baseUrl}
@@ -1246,16 +1096,6 @@ const FolderSelectionView: Component = (props) => {
{t("folderSelection.actions.connectButton")}
-
-
-
- {t("folderSelection.actions.connectSshButton")}
-
-
@@ -1510,127 +1350,6 @@ const FolderSelectionView: Component = (props) => {
-
>
)
}
diff --git a/packages/ui/src/components/settings/connection-profiles-settings-section.tsx b/packages/ui/src/components/settings/connection-profiles-settings-section.tsx
deleted file mode 100644
index a68e83c6f..000000000
--- a/packages/ui/src/components/settings/connection-profiles-settings-section.tsx
+++ /dev/null
@@ -1,289 +0,0 @@
-import { createSignal, For, Show, type Component } from "solid-js"
-import { Copy, Pencil, Plus, Trash2 } from "lucide-solid"
-import type { ConnectionProfile, SshConnectionProfile } from "../../../../server/src/api-types"
-import { useConfig } from "../../stores/preferences"
-import { useI18n } from "../../lib/i18n"
-import { serverApi } from "../../lib/api-client"
-
-const DEFAULT_SSH_REMOTE_SERVER_PORT = "9899"
-
-function createConnectionProfileId(): string {
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
- return crypto.randomUUID()
- }
- return `conn-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
-}
-
-function buildConnectionSummary(profile: ConnectionProfile): string {
- if (profile.kind === "remote-server") {
- return profile.baseUrl
- }
-
- const parts = [`${profile.username ? `${profile.username}@` : ""}${profile.host}${profile.port ? `:${profile.port}` : ""}`]
- if (profile.remotePath) parts.push(`· ${profile.remotePath}`)
- return parts.join(" ")
-}
-
-function formatConnectionTimestamp(value?: string): string | null {
- if (!value) return null
- const date = new Date(value)
- if (Number.isNaN(date.getTime())) return null
- return date.toLocaleString()
-}
-
-function duplicateConnectionProfile(profile: ConnectionProfile, nameSuffix: string): ConnectionProfile {
- const timestamp = new Date().toISOString()
- return {
- ...profile,
- id: createConnectionProfileId(),
- name: `${profile.name} ${nameSuffix}`.trim(),
- createdAt: timestamp,
- updatedAt: timestamp,
- lastConnectedAt: undefined,
- }
-}
-
-export const ConnectionProfilesSettingsSection: Component = () => {
- const { t } = useI18n()
- const { connectionProfiles, saveConnectionProfile, removeConnectionProfile } = useConfig()
-
- const [editingId, setEditingId] = createSignal(null)
- const [name, setName] = createSignal("")
- const [host, setHost] = createSignal("")
- const [port, setPort] = createSignal("")
- const [remoteServerPort, setRemoteServerPort] = createSignal(DEFAULT_SSH_REMOTE_SERVER_PORT)
- const [username, setUsername] = createSignal("")
- const [remotePath, setRemotePath] = createSignal("")
- const [bootstrapScript, setBootstrapScript] = createSignal("")
- const [saving, setSaving] = createSignal(false)
- const [formError, setFormError] = createSignal(null)
-
- function resetForm(profile?: SshConnectionProfile) {
- setEditingId(profile?.id ?? null)
- setName(profile?.name ?? "")
- setHost(profile?.host ?? "")
- setPort(profile?.port ? String(profile.port) : "")
- setRemoteServerPort(profile?.remoteServerPort ? String(profile.remoteServerPort) : DEFAULT_SSH_REMOTE_SERVER_PORT)
- setUsername(profile?.username ?? "")
- setRemotePath(profile?.remotePath ?? "")
- setBootstrapScript(profile?.bootstrapScript ?? "")
- setFormError(null)
- }
-
- async function handleSave() {
- const trimmedName = name().trim()
- const trimmedHost = host().trim()
- const nextPort = port().trim().length > 0 ? Number(port()) : undefined
- const nextRemoteServerPort = remoteServerPort().trim().length > 0 ? Number(remoteServerPort()) : Number(DEFAULT_SSH_REMOTE_SERVER_PORT)
-
- if (!trimmedName) {
- setFormError(t("settings.remoteConnections.validation.name"))
- return
- }
- if (!trimmedHost) {
- setFormError(t("settings.remoteConnections.validation.host"))
- return
- }
- if (nextPort !== undefined && (!Number.isInteger(nextPort) || nextPort <= 0 || nextPort > 65535)) {
- setFormError(t("settings.remoteConnections.validation.port"))
- return
- }
- if (!Number.isInteger(nextRemoteServerPort) || nextRemoteServerPort <= 0 || nextRemoteServerPort > 65535) {
- setFormError(t("settings.remoteConnections.validation.remoteServerPort"))
- return
- }
-
- const existing = editingId()
- ? connectionProfiles().find((profile) => profile.id === editingId() && profile.kind === "ssh") as SshConnectionProfile | undefined
- : undefined
-
- const profile: SshConnectionProfile = {
- id: existing?.id ?? createConnectionProfileId(),
- kind: "ssh",
- name: trimmedName,
- host: trimmedHost,
- port: nextPort,
- remoteServerPort: nextRemoteServerPort,
- username: username().trim() || undefined,
- remotePath: remotePath().trim() || undefined,
- bootstrapScript: bootstrapScript().trim() || undefined,
- createdAt: existing?.createdAt ?? new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- lastConnectedAt: existing?.lastConnectedAt,
- }
-
- setSaving(true)
- setFormError(null)
- try {
- await saveConnectionProfile(profile)
- resetForm()
- } catch (error) {
- setFormError(error instanceof Error ? error.message : String(error))
- } finally {
- setSaving(false)
- }
- }
-
- async function handleDelete(profile: ConnectionProfile) {
- if (profile.kind === "ssh") {
- await serverApi.disconnectSshRemote(profile.id).catch(() => undefined)
- }
- removeConnectionProfile(profile.id)
- }
-
- async function handleDuplicate(profile: ConnectionProfile) {
- setFormError(null)
- try {
- await saveConnectionProfile(duplicateConnectionProfile(profile, t("settings.common.duplicateSuffix")))
- } catch (error) {
- setFormError(error instanceof Error ? error.message : String(error))
- }
- }
-
- return (
-
-
-
-
-
-
-
-
{t("settings.remoteConnections.form.name.label")}
-
-
setName(event.currentTarget.value)} />
-
-
-
-
-
{t("settings.remoteConnections.form.host.label")}
-
-
setHost(event.currentTarget.value)} />
-
-
-
-
-
{t("settings.remoteConnections.form.port.label")}
-
-
setPort(event.currentTarget.value)} />
-
-
-
-
-
{t("settings.remoteConnections.form.remoteServerPort.label")}
-
-
setRemoteServerPort(event.currentTarget.value)} />
-
-
-
-
-
{t("settings.remoteConnections.form.username.label")}
-
-
setUsername(event.currentTarget.value)} />
-
-
-
-
-
{t("settings.remoteConnections.form.remotePath.label")}
-
-
setRemotePath(event.currentTarget.value)} />
-
-
-
-
-
- {formError()}
-
-
-
-
- resetForm()}>
- {t("settings.remoteConnections.form.cancelEdit")}
-
-
-
void handleSave()}>
-
- {editingId() ? t("settings.remoteConnections.form.update") : t("settings.remoteConnections.form.save")}
-
-
-
-
-
-
-
-
-
-
0} fallback={{t("settings.remoteConnections.list.empty")}
}>
-
- {(profile) => {
- const isSsh = () => profile.kind === "ssh"
- return (
-
-
-
- {profile.name}
- {t(`settings.remoteConnections.kind.${profile.kind}`)}
-
-
{buildConnectionSummary(profile)}
-
- {(lastConnected) => (
-
- {t("settings.remoteConnections.list.lastConnected", { time: lastConnected() })}
-
- )}
-
-
-
-
-
- resetForm(profile as SshConnectionProfile)}
- title={t("settings.remoteConnections.list.actions.edit")}
- >
-
-
-
-
void handleDuplicate(profile)}
- title={t("settings.remoteConnections.list.actions.duplicate")}
- >
-
-
-
void handleDelete(profile)}
- title={t("settings.remoteConnections.list.actions.delete")}
- >
-
-
-
-
- )
- }}
-
-
-
-
-
- )
-}
diff --git a/packages/ui/src/components/settings/remote-access-settings-section.tsx b/packages/ui/src/components/settings/remote-access-settings-section.tsx
index bf4a8c3b8..06a81aee9 100644
--- a/packages/ui/src/components/settings/remote-access-settings-section.tsx
+++ b/packages/ui/src/components/settings/remote-access-settings-section.tsx
@@ -10,7 +10,6 @@ import { showConfirmDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger"
import { useI18n } from "../../lib/i18n"
import { splitRemoteAddresses, type RemoteAddressGroups } from "../../lib/remote-access-addresses"
-import { ConnectionProfilesSettingsSection } from "./connection-profiles-settings-section"
const log = getLogger("actions")
@@ -483,8 +482,6 @@ export const RemoteAccessSettingsSection: Component = () => {
-
-
)
}
diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts
index 0b118d2d5..53e5cbc58 100644
--- a/packages/ui/src/lib/api-client.ts
+++ b/packages/ui/src/lib/api-client.ts
@@ -17,8 +17,6 @@ import type {
ServerMeta,
RemoteProxySessionCreateRequest,
RemoteProxySessionCreateResponse,
- SshConnectionBootstrapRequest,
- SshConnectionBootstrapResponse,
RemoteServerProbeRequest,
RemoteServerProbeResponse,
VoiceModeStateResponse,
@@ -272,15 +270,6 @@ export const serverApi = {
body: JSON.stringify(payload),
})
},
- connectSshRemote(payload: SshConnectionBootstrapRequest): Promise {
- return request("/api/remote-connections/ssh/connect", {
- method: "POST",
- body: JSON.stringify(payload),
- })
- },
- disconnectSshRemote(id: string): Promise {
- return request(`/api/remote-connections/ssh/${encodeURIComponent(id)}`, { method: "DELETE" })
- },
deleteRemoteProxySession(id: string): Promise {
return request(`/api/remote-proxy/sessions/${encodeURIComponent(id)}`, { method: "DELETE" })
},
diff --git a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts
index 92788ea1e..a34933338 100644
--- a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts
@@ -38,7 +38,6 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Open Folder or Connect Server",
"folderSelection.actions.subtitle": "Open local folder or connect to a CodeNomad server",
"folderSelection.actions.connectButton": "Connect CodeNomad Server",
- "folderSelection.actions.connectSshButton": "Connect SSH Host",
"folderSelection.executionProfile.label": "Execution Profile",
"folderSelection.executionProfile.subtitle": "Choose how new local workspaces launch OpenCode",
@@ -67,13 +66,12 @@ export const folderSelectionMessages = {
"folderSelection.servers.subtitle": "Open a saved remote connection from this device",
"folderSelection.servers.count": "{count} Connections",
"folderSelection.servers.empty.title": "No Saved Connections",
- "folderSelection.servers.empty.description": "Add a remote server or SSH host to reconnect quickly from this device",
+ "folderSelection.servers.empty.description": "Add a remote server to reconnect quickly from this device",
"folderSelection.servers.connectTitle": "Connect to Server",
"folderSelection.servers.connectSubtitle": "Save a remote CodeNomad server and open it in a new window",
"folderSelection.servers.connectButton": "Connect to Server",
"folderSelection.servers.remove": "Remove saved connection",
"folderSelection.servers.kind.remoteServer": "server",
- "folderSelection.servers.kind.ssh": "ssh",
"folderSelection.servers.lastConnected": "Last connected {time}",
"folderSelection.servers.skipTls": "Self-signed TLS",
"folderSelection.servers.errorTitle": "Remote Connection Failed",
@@ -91,30 +89,6 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Connecting...",
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
- "folderSelection.ssh.dialog.title": "Connect SSH Host",
- "folderSelection.ssh.dialog.description": "Save an SSH bootstrap target and optionally open it right away.",
- "folderSelection.ssh.dialog.name": "Connection name",
- "folderSelection.ssh.dialog.namePlaceholder": "Production VM",
- "folderSelection.ssh.dialog.host": "Host",
- "folderSelection.ssh.dialog.hostPlaceholder": "vm.example.com",
- "folderSelection.ssh.dialog.port": "SSH port",
- "folderSelection.ssh.dialog.portPlaceholder": "22",
- "folderSelection.ssh.dialog.remoteServerPort": "Remote server port",
- "folderSelection.ssh.dialog.remoteServerPortPlaceholder": "9899",
- "folderSelection.ssh.dialog.username": "Username",
- "folderSelection.ssh.dialog.usernamePlaceholder": "ubuntu",
- "folderSelection.ssh.dialog.remotePath": "Remote workspace path",
- "folderSelection.ssh.dialog.remotePathPlaceholder": "/srv/project",
- "folderSelection.ssh.dialog.bootstrapScript": "Bootstrap script",
- "folderSelection.ssh.dialog.bootstrapScriptHelp": "Optional script for starting a remote CodeNomad server that already has its server-side opencode-config available. The SSH flow tunnels to that server; it does not copy local plugin config to the host.",
- "folderSelection.ssh.dialog.bootstrapScriptPlaceholder": "Optional shell script run after the SSH connection is established",
- "folderSelection.ssh.dialog.cancel": "Cancel",
- "folderSelection.ssh.dialog.save": "Save",
- "folderSelection.ssh.dialog.connect": "Connect",
- "folderSelection.ssh.dialog.connecting": "Connecting...",
- "folderSelection.ssh.dialog.errorHost": "SSH host is required.",
- "folderSelection.ssh.dialog.errorPort": "SSH port must be between 1 and 65535.",
- "folderSelection.ssh.dialog.errorRemoteServerPort": "Remote server port must be between 1 and 65535.",
"folderSelection.servers.certificateInstall.title": "Install Local Certificate",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad needs to install a local certificate to open self-signed HTTPS remote windows. This certificate is only used for local desktop proxy traffic on your machine. Your operating system may show a second certificate prompt after this.",
"folderSelection.servers.certificateInstall.confirmLabel": "Continue",
diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts
index c17fccd66..ce0919d92 100644
--- a/packages/ui/src/lib/i18n/messages/en/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/en/settings.ts
@@ -109,39 +109,6 @@ export const settingsMessages = {
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
- "settings.remoteConnections.form.title": "SSH connection profiles",
- "settings.remoteConnections.form.subtitle": "Save reusable SSH bootstrap targets and scripts for remote environments.",
- "settings.remoteConnections.kind.remote-server": "Remote server",
- "settings.remoteConnections.kind.ssh": "SSH",
- "settings.remoteConnections.form.name.label": "Profile name",
- "settings.remoteConnections.form.name.placeholder": "Production VM",
- "settings.remoteConnections.form.host.label": "Host",
- "settings.remoteConnections.form.host.placeholder": "vm.example.com",
- "settings.remoteConnections.form.port.label": "Port",
- "settings.remoteConnections.form.port.placeholder": "22",
- "settings.remoteConnections.form.remoteServerPort.label": "Remote server port",
- "settings.remoteConnections.form.remoteServerPort.placeholder": "9899",
- "settings.remoteConnections.form.username.label": "Username",
- "settings.remoteConnections.form.username.placeholder": "ubuntu",
- "settings.remoteConnections.form.remotePath.label": "Remote workspace path",
- "settings.remoteConnections.form.remotePath.placeholder": "/srv/project",
- "settings.remoteConnections.form.bootstrapScript.label": "Bootstrap script",
- "settings.remoteConnections.form.bootstrapScript.help": "Use this only to start a remote CodeNomad server installation that already has its server-side opencode-config available. This SSH flow tunnels to that server and does not copy local plugin config to the host.",
- "settings.remoteConnections.form.bootstrapScript.placeholder": "Optional shell script run after connecting",
- "settings.remoteConnections.form.save": "Save connection",
- "settings.remoteConnections.form.update": "Update connection",
- "settings.remoteConnections.form.cancelEdit": "Cancel edit",
- "settings.remoteConnections.list.title": "Saved and recent connections",
- "settings.remoteConnections.list.subtitle": "Review remote server shortcuts and SSH bootstrap profiles available on this device.",
- "settings.remoteConnections.list.empty": "No connection profiles saved yet.",
- "settings.remoteConnections.list.lastConnected": "Last connected {time}",
- "settings.remoteConnections.list.actions.edit": "Edit connection",
- "settings.remoteConnections.list.actions.duplicate": "Duplicate connection",
- "settings.remoteConnections.list.actions.delete": "Delete connection",
- "settings.remoteConnections.validation.name": "Connection profile name is required.",
- "settings.remoteConnections.validation.host": "SSH host is required.",
- "settings.remoteConnections.validation.port": "Port must be between 1 and 65535.",
- "settings.remoteConnections.validation.remoteServerPort": "Remote server port must be between 1 and 65535.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
@@ -186,7 +153,7 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.form.extraDockerArgs.placeholder": "One docker argument per line",
"settings.opencode.executionProfiles.form.executable.label": "Executable",
"settings.opencode.executionProfiles.form.executable.subtitle": "Program or script to launch for this profile.",
- "settings.opencode.executionProfiles.form.executable.placeholder": "ssh",
+ "settings.opencode.executionProfiles.form.executable.placeholder": "node",
"settings.opencode.executionProfiles.form.cwdMode.label": "Working directory",
"settings.opencode.executionProfiles.form.cwdMode.subtitle": "Choose whether the command starts in the workspace directory.",
"settings.opencode.executionProfiles.form.cwdMode.workspace": "Workspace folder",
diff --git a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts
index 5fa47b258..b4a3b87b3 100644
--- a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts
@@ -38,7 +38,6 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Abrir carpeta o conectar servidor",
"folderSelection.actions.subtitle": "Abre una carpeta local o conéctate a un servidor de CodeNomad",
"folderSelection.actions.connectButton": "Conectar servidor CodeNomad",
- "folderSelection.actions.connectSshButton": "Connect SSH Host",
"folderSelection.executionProfile.label": "Perfil de ejecución",
"folderSelection.executionProfile.subtitle": "Elige cómo los nuevos espacios de trabajo locales inician OpenCode",
@@ -67,13 +66,12 @@ export const folderSelectionMessages = {
"folderSelection.servers.subtitle": "Abre una conexión remota guardada desde este dispositivo",
"folderSelection.servers.count": "{count} conexiones",
"folderSelection.servers.empty.title": "No hay conexiones guardadas",
- "folderSelection.servers.empty.description": "Añade un servidor remoto o un host SSH para reconectarte rápidamente desde este dispositivo",
+ "folderSelection.servers.empty.description": "Añade un servidor remoto para reconectarte rápidamente desde este dispositivo",
"folderSelection.servers.connectTitle": "Conectar a un servidor",
"folderSelection.servers.connectSubtitle": "Guarda un servidor remoto de CodeNomad y ábrelo en una ventana nueva",
"folderSelection.servers.connectButton": "Conectar a un servidor",
"folderSelection.servers.remove": "Eliminar conexión guardada",
"folderSelection.servers.kind.remoteServer": "servidor",
- "folderSelection.servers.kind.ssh": "ssh",
"folderSelection.servers.lastConnected": "Última conexión {time}",
"folderSelection.servers.skipTls": "TLS autofirmado",
"folderSelection.servers.errorTitle": "Falló la conexión remota",
@@ -91,30 +89,6 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Conectando...",
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
- "folderSelection.ssh.dialog.title": "Connect SSH Host",
- "folderSelection.ssh.dialog.description": "Save an SSH bootstrap target and optionally open it right away.",
- "folderSelection.ssh.dialog.name": "Connection name",
- "folderSelection.ssh.dialog.namePlaceholder": "Production VM",
- "folderSelection.ssh.dialog.host": "Host",
- "folderSelection.ssh.dialog.hostPlaceholder": "vm.example.com",
- "folderSelection.ssh.dialog.port": "SSH port",
- "folderSelection.ssh.dialog.portPlaceholder": "22",
- "folderSelection.ssh.dialog.remoteServerPort": "Remote server port",
- "folderSelection.ssh.dialog.remoteServerPortPlaceholder": "9899",
- "folderSelection.ssh.dialog.username": "Username",
- "folderSelection.ssh.dialog.usernamePlaceholder": "ubuntu",
- "folderSelection.ssh.dialog.remotePath": "Remote workspace path",
- "folderSelection.ssh.dialog.remotePathPlaceholder": "/srv/project",
- "folderSelection.ssh.dialog.bootstrapScript": "Bootstrap script",
- "folderSelection.ssh.dialog.bootstrapScriptHelp": "Optional script for starting a remote CodeNomad server that already has its server-side opencode-config available. The SSH flow tunnels to that server; it does not copy local plugin config to the host.",
- "folderSelection.ssh.dialog.bootstrapScriptPlaceholder": "Optional shell script run after the SSH connection is established",
- "folderSelection.ssh.dialog.cancel": "Cancel",
- "folderSelection.ssh.dialog.save": "Save",
- "folderSelection.ssh.dialog.connect": "Connect",
- "folderSelection.ssh.dialog.connecting": "Connecting...",
- "folderSelection.ssh.dialog.errorHost": "SSH host is required.",
- "folderSelection.ssh.dialog.errorPort": "SSH port must be between 1 and 65535.",
- "folderSelection.ssh.dialog.errorRemoteServerPort": "Remote server port must be between 1 and 65535.",
"folderSelection.servers.certificateInstall.title": "Instalar certificado local",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad necesita instalar un certificado local para abrir ventanas remotas HTTPS autofirmadas. Este certificado solo se usa para el trafico del proxy local de escritorio en tu equipo. Es posible que tu sistema operativo muestre un segundo aviso de certificado despues de esto.",
"folderSelection.servers.certificateInstall.confirmLabel": "Continuar",
diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts
index 1493d409b..edad3d8ee 100644
--- a/packages/ui/src/lib/i18n/messages/es/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/es/settings.ts
@@ -109,39 +109,6 @@ export const settingsMessages = {
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
- "settings.remoteConnections.form.title": "SSH connection profiles",
- "settings.remoteConnections.form.subtitle": "Save reusable SSH bootstrap targets and scripts for remote environments.",
- "settings.remoteConnections.kind.remote-server": "Remote server",
- "settings.remoteConnections.kind.ssh": "SSH",
- "settings.remoteConnections.form.name.label": "Profile name",
- "settings.remoteConnections.form.name.placeholder": "Production VM",
- "settings.remoteConnections.form.host.label": "Host",
- "settings.remoteConnections.form.host.placeholder": "vm.example.com",
- "settings.remoteConnections.form.port.label": "Port",
- "settings.remoteConnections.form.port.placeholder": "22",
- "settings.remoteConnections.form.remoteServerPort.label": "Remote server port",
- "settings.remoteConnections.form.remoteServerPort.placeholder": "9899",
- "settings.remoteConnections.form.username.label": "Username",
- "settings.remoteConnections.form.username.placeholder": "ubuntu",
- "settings.remoteConnections.form.remotePath.label": "Remote workspace path",
- "settings.remoteConnections.form.remotePath.placeholder": "/srv/project",
- "settings.remoteConnections.form.bootstrapScript.label": "Bootstrap script",
- "settings.remoteConnections.form.bootstrapScript.help": "Use this only to start a remote CodeNomad server installation that already has its server-side opencode-config available. This SSH flow tunnels to that server and does not copy local plugin config to the host.",
- "settings.remoteConnections.form.bootstrapScript.placeholder": "Optional shell script run after connecting",
- "settings.remoteConnections.form.save": "Save connection",
- "settings.remoteConnections.form.update": "Update connection",
- "settings.remoteConnections.form.cancelEdit": "Cancel edit",
- "settings.remoteConnections.list.title": "Saved and recent connections",
- "settings.remoteConnections.list.subtitle": "Review remote server shortcuts and SSH bootstrap profiles available on this device.",
- "settings.remoteConnections.list.empty": "No connection profiles saved yet.",
- "settings.remoteConnections.list.lastConnected": "Last connected {time}",
- "settings.remoteConnections.list.actions.edit": "Edit connection",
- "settings.remoteConnections.list.actions.duplicate": "Duplicate connection",
- "settings.remoteConnections.list.actions.delete": "Delete connection",
- "settings.remoteConnections.validation.name": "Connection profile name is required.",
- "settings.remoteConnections.validation.host": "SSH host is required.",
- "settings.remoteConnections.validation.port": "Port must be between 1 and 65535.",
- "settings.remoteConnections.validation.remoteServerPort": "Remote server port must be between 1 and 65535.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
diff --git a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts
index 3598236ad..ae432c8bf 100644
--- a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts
@@ -38,7 +38,6 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Ouvrir un dossier ou se connecter à un serveur",
"folderSelection.actions.subtitle": "Ouvrez un dossier local ou connectez-vous à un serveur CodeNomad",
"folderSelection.actions.connectButton": "Se connecter au serveur CodeNomad",
- "folderSelection.actions.connectSshButton": "Connect SSH Host",
"folderSelection.executionProfile.label": "Profil d'exécution",
"folderSelection.executionProfile.subtitle": "Choisissez comment les nouveaux espaces de travail locaux lancent OpenCode",
@@ -67,13 +66,12 @@ export const folderSelectionMessages = {
"folderSelection.servers.subtitle": "Ouvrez une connexion distante enregistrée depuis cet appareil",
"folderSelection.servers.count": "{count} connexions",
"folderSelection.servers.empty.title": "Aucune connexion enregistrée",
- "folderSelection.servers.empty.description": "Ajoutez un serveur distant ou un hôte SSH pour vous reconnecter rapidement depuis cet appareil",
+ "folderSelection.servers.empty.description": "Ajoutez un serveur distant pour vous reconnecter rapidement depuis cet appareil",
"folderSelection.servers.connectTitle": "Se connecter à un serveur",
"folderSelection.servers.connectSubtitle": "Enregistrez un serveur CodeNomad distant et ouvrez-le dans une nouvelle fenêtre",
"folderSelection.servers.connectButton": "Se connecter à un serveur",
"folderSelection.servers.remove": "Supprimer la connexion enregistrée",
"folderSelection.servers.kind.remoteServer": "serveur",
- "folderSelection.servers.kind.ssh": "ssh",
"folderSelection.servers.lastConnected": "Dernière connexion {time}",
"folderSelection.servers.skipTls": "TLS auto-signé",
"folderSelection.servers.errorTitle": "Échec de la connexion distante",
@@ -91,30 +89,6 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Connexion...",
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
- "folderSelection.ssh.dialog.title": "Connect SSH Host",
- "folderSelection.ssh.dialog.description": "Save an SSH bootstrap target and optionally open it right away.",
- "folderSelection.ssh.dialog.name": "Connection name",
- "folderSelection.ssh.dialog.namePlaceholder": "Production VM",
- "folderSelection.ssh.dialog.host": "Host",
- "folderSelection.ssh.dialog.hostPlaceholder": "vm.example.com",
- "folderSelection.ssh.dialog.port": "SSH port",
- "folderSelection.ssh.dialog.portPlaceholder": "22",
- "folderSelection.ssh.dialog.remoteServerPort": "Remote server port",
- "folderSelection.ssh.dialog.remoteServerPortPlaceholder": "9899",
- "folderSelection.ssh.dialog.username": "Username",
- "folderSelection.ssh.dialog.usernamePlaceholder": "ubuntu",
- "folderSelection.ssh.dialog.remotePath": "Remote workspace path",
- "folderSelection.ssh.dialog.remotePathPlaceholder": "/srv/project",
- "folderSelection.ssh.dialog.bootstrapScript": "Bootstrap script",
- "folderSelection.ssh.dialog.bootstrapScriptHelp": "Optional script for starting a remote CodeNomad server that already has its server-side opencode-config available. The SSH flow tunnels to that server; it does not copy local plugin config to the host.",
- "folderSelection.ssh.dialog.bootstrapScriptPlaceholder": "Optional shell script run after the SSH connection is established",
- "folderSelection.ssh.dialog.cancel": "Cancel",
- "folderSelection.ssh.dialog.save": "Save",
- "folderSelection.ssh.dialog.connect": "Connect",
- "folderSelection.ssh.dialog.connecting": "Connecting...",
- "folderSelection.ssh.dialog.errorHost": "SSH host is required.",
- "folderSelection.ssh.dialog.errorPort": "SSH port must be between 1 and 65535.",
- "folderSelection.ssh.dialog.errorRemoteServerPort": "Remote server port must be between 1 and 65535.",
"folderSelection.servers.certificateInstall.title": "Installer le certificat local",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad doit installer un certificat local pour ouvrir des fenetres distantes HTTPS auto-signees. Ce certificat est utilise uniquement pour le trafic du proxy local de bureau sur votre machine. Votre systeme d'exploitation peut afficher une seconde invite de certificat apres cela.",
"folderSelection.servers.certificateInstall.confirmLabel": "Continuer",
diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts
index e828936b6..1861b140b 100644
--- a/packages/ui/src/lib/i18n/messages/fr/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts
@@ -109,39 +109,6 @@ export const settingsMessages = {
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
- "settings.remoteConnections.form.title": "SSH connection profiles",
- "settings.remoteConnections.form.subtitle": "Save reusable SSH bootstrap targets and scripts for remote environments.",
- "settings.remoteConnections.kind.remote-server": "Remote server",
- "settings.remoteConnections.kind.ssh": "SSH",
- "settings.remoteConnections.form.name.label": "Profile name",
- "settings.remoteConnections.form.name.placeholder": "Production VM",
- "settings.remoteConnections.form.host.label": "Host",
- "settings.remoteConnections.form.host.placeholder": "vm.example.com",
- "settings.remoteConnections.form.port.label": "Port",
- "settings.remoteConnections.form.port.placeholder": "22",
- "settings.remoteConnections.form.remoteServerPort.label": "Remote server port",
- "settings.remoteConnections.form.remoteServerPort.placeholder": "9899",
- "settings.remoteConnections.form.username.label": "Username",
- "settings.remoteConnections.form.username.placeholder": "ubuntu",
- "settings.remoteConnections.form.remotePath.label": "Remote workspace path",
- "settings.remoteConnections.form.remotePath.placeholder": "/srv/project",
- "settings.remoteConnections.form.bootstrapScript.label": "Bootstrap script",
- "settings.remoteConnections.form.bootstrapScript.help": "Use this only to start a remote CodeNomad server installation that already has its server-side opencode-config available. This SSH flow tunnels to that server and does not copy local plugin config to the host.",
- "settings.remoteConnections.form.bootstrapScript.placeholder": "Optional shell script run after connecting",
- "settings.remoteConnections.form.save": "Save connection",
- "settings.remoteConnections.form.update": "Update connection",
- "settings.remoteConnections.form.cancelEdit": "Cancel edit",
- "settings.remoteConnections.list.title": "Saved and recent connections",
- "settings.remoteConnections.list.subtitle": "Review remote server shortcuts and SSH bootstrap profiles available on this device.",
- "settings.remoteConnections.list.empty": "No connection profiles saved yet.",
- "settings.remoteConnections.list.lastConnected": "Last connected {time}",
- "settings.remoteConnections.list.actions.edit": "Edit connection",
- "settings.remoteConnections.list.actions.duplicate": "Duplicate connection",
- "settings.remoteConnections.list.actions.delete": "Delete connection",
- "settings.remoteConnections.validation.name": "Connection profile name is required.",
- "settings.remoteConnections.validation.host": "SSH host is required.",
- "settings.remoteConnections.validation.port": "Port must be between 1 and 65535.",
- "settings.remoteConnections.validation.remoteServerPort": "Remote server port must be between 1 and 65535.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
diff --git a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts
index 590950d7d..8a6d8324c 100644
--- a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts
@@ -38,7 +38,6 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "פתח תיקייה או התחבר לשרת",
"folderSelection.actions.subtitle": "פתח תיקייה מקומית או התחבר לשרת CodeNomad",
"folderSelection.actions.connectButton": "התחבר לשרת CodeNomad",
- "folderSelection.actions.connectSshButton": "Connect SSH Host",
"folderSelection.executionProfile.label": "פרופיל הרצה",
"folderSelection.executionProfile.subtitle": "בחר איך להפעיל את OpenCode עבור סביבות עבודה מקומיות חדשות",
@@ -67,13 +66,12 @@ export const folderSelectionMessages = {
"folderSelection.servers.subtitle": "פתח חיבור מרוחק שמור מהמכשיר הזה",
"folderSelection.servers.count": "{count} חיבורים",
"folderSelection.servers.empty.title": "אין חיבורים שמורים",
- "folderSelection.servers.empty.description": "הוסף שרת מרוחק או מארח SSH כדי להתחבר אליו במהירות מהמכשיר הזה",
+ "folderSelection.servers.empty.description": "הוסף שרת מרוחק כדי להתחבר אליו במהירות מהמכשיר הזה",
"folderSelection.servers.connectTitle": "התחבר לשרת",
"folderSelection.servers.connectSubtitle": "שמור שרת CodeNomad מרוחק ופתח אותו בחלון חדש",
"folderSelection.servers.connectButton": "התחבר לשרת",
"folderSelection.servers.remove": "הסר חיבור שמור",
"folderSelection.servers.kind.remoteServer": "שרת",
- "folderSelection.servers.kind.ssh": "ssh",
"folderSelection.servers.lastConnected": "חיבור אחרון {time}",
"folderSelection.servers.skipTls": "TLS בחתימה עצמית",
"folderSelection.servers.errorTitle": "החיבור המרוחק נכשל",
@@ -91,30 +89,6 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "מתחבר...",
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
- "folderSelection.ssh.dialog.title": "Connect SSH Host",
- "folderSelection.ssh.dialog.description": "Save an SSH bootstrap target and optionally open it right away.",
- "folderSelection.ssh.dialog.name": "Connection name",
- "folderSelection.ssh.dialog.namePlaceholder": "Production VM",
- "folderSelection.ssh.dialog.host": "Host",
- "folderSelection.ssh.dialog.hostPlaceholder": "vm.example.com",
- "folderSelection.ssh.dialog.port": "SSH port",
- "folderSelection.ssh.dialog.portPlaceholder": "22",
- "folderSelection.ssh.dialog.remoteServerPort": "Remote server port",
- "folderSelection.ssh.dialog.remoteServerPortPlaceholder": "9899",
- "folderSelection.ssh.dialog.username": "Username",
- "folderSelection.ssh.dialog.usernamePlaceholder": "ubuntu",
- "folderSelection.ssh.dialog.remotePath": "Remote workspace path",
- "folderSelection.ssh.dialog.remotePathPlaceholder": "/srv/project",
- "folderSelection.ssh.dialog.bootstrapScript": "Bootstrap script",
- "folderSelection.ssh.dialog.bootstrapScriptHelp": "Optional script for starting a remote CodeNomad server that already has its server-side opencode-config available. The SSH flow tunnels to that server; it does not copy local plugin config to the host.",
- "folderSelection.ssh.dialog.bootstrapScriptPlaceholder": "Optional shell script run after the SSH connection is established",
- "folderSelection.ssh.dialog.cancel": "Cancel",
- "folderSelection.ssh.dialog.save": "Save",
- "folderSelection.ssh.dialog.connect": "Connect",
- "folderSelection.ssh.dialog.connecting": "Connecting...",
- "folderSelection.ssh.dialog.errorHost": "SSH host is required.",
- "folderSelection.ssh.dialog.errorPort": "SSH port must be between 1 and 65535.",
- "folderSelection.ssh.dialog.errorRemoteServerPort": "Remote server port must be between 1 and 65535.",
"folderSelection.servers.certificateInstall.title": "התקנת אישור מקומי",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad צריך להתקין אישור מקומי כדי לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית. האישור הזה משמש רק לתעבורת ה-proxy המקומי של האפליקציה במחשב שלך. ייתכן שמערכת ההפעלה תציג לאחר מכן בקשת אישור נוספת.",
"folderSelection.servers.certificateInstall.confirmLabel": "המשך",
diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts
index a09458cdc..ec13fbbcd 100644
--- a/packages/ui/src/lib/i18n/messages/he/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/he/settings.ts
@@ -108,39 +108,6 @@ export const settingsMessages = {
"settings.notifications.status.unsupported": "התראות לא נתמכות",
"settings.section.remote.title": "גישה מרוחקת",
"settings.section.remote.subtitle": "בדוק כיצד שרת זה חשוף ברשת שלך ואבטח אישורי גישה.",
- "settings.remoteConnections.form.title": "SSH connection profiles",
- "settings.remoteConnections.form.subtitle": "Save reusable SSH bootstrap targets and scripts for remote environments.",
- "settings.remoteConnections.kind.remote-server": "Remote server",
- "settings.remoteConnections.kind.ssh": "SSH",
- "settings.remoteConnections.form.name.label": "Profile name",
- "settings.remoteConnections.form.name.placeholder": "Production VM",
- "settings.remoteConnections.form.host.label": "Host",
- "settings.remoteConnections.form.host.placeholder": "vm.example.com",
- "settings.remoteConnections.form.port.label": "Port",
- "settings.remoteConnections.form.port.placeholder": "22",
- "settings.remoteConnections.form.remoteServerPort.label": "Remote server port",
- "settings.remoteConnections.form.remoteServerPort.placeholder": "9899",
- "settings.remoteConnections.form.username.label": "Username",
- "settings.remoteConnections.form.username.placeholder": "ubuntu",
- "settings.remoteConnections.form.remotePath.label": "Remote workspace path",
- "settings.remoteConnections.form.remotePath.placeholder": "/srv/project",
- "settings.remoteConnections.form.bootstrapScript.label": "Bootstrap script",
- "settings.remoteConnections.form.bootstrapScript.help": "Use this only to start a remote CodeNomad server installation that already has its server-side opencode-config available. This SSH flow tunnels to that server and does not copy local plugin config to the host.",
- "settings.remoteConnections.form.bootstrapScript.placeholder": "Optional shell script run after connecting",
- "settings.remoteConnections.form.save": "Save connection",
- "settings.remoteConnections.form.update": "Update connection",
- "settings.remoteConnections.form.cancelEdit": "Cancel edit",
- "settings.remoteConnections.list.title": "Saved and recent connections",
- "settings.remoteConnections.list.subtitle": "Review remote server shortcuts and SSH bootstrap profiles available on this device.",
- "settings.remoteConnections.list.empty": "No connection profiles saved yet.",
- "settings.remoteConnections.list.lastConnected": "Last connected {time}",
- "settings.remoteConnections.list.actions.edit": "Edit connection",
- "settings.remoteConnections.list.actions.duplicate": "Duplicate connection",
- "settings.remoteConnections.list.actions.delete": "Delete connection",
- "settings.remoteConnections.validation.name": "Connection profile name is required.",
- "settings.remoteConnections.validation.host": "SSH host is required.",
- "settings.remoteConnections.validation.port": "Port must be between 1 and 65535.",
- "settings.remoteConnections.validation.remoteServerPort": "Remote server port must be between 1 and 65535.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.",
"settings.opencode.runtime.title": "סביבת ריצה",
diff --git a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts
index a02083131..e6e568a84 100644
--- a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts
@@ -38,7 +38,6 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "フォルダを開くかサーバーに接続",
"folderSelection.actions.subtitle": "ローカルフォルダを開くか CodeNomad サーバーに接続します",
"folderSelection.actions.connectButton": "CodeNomad サーバーに接続",
- "folderSelection.actions.connectSshButton": "Connect SSH Host",
"folderSelection.executionProfile.label": "実行プロファイル",
"folderSelection.executionProfile.subtitle": "新しいローカルワークスペースで OpenCode をどう起動するか選択します",
@@ -67,13 +66,12 @@ export const folderSelectionMessages = {
"folderSelection.servers.subtitle": "この端末から保存済みのリモート接続を開きます",
"folderSelection.servers.count": "{count} 接続",
"folderSelection.servers.empty.title": "保存済み接続はありません",
- "folderSelection.servers.empty.description": "この端末からすばやく再接続できるように、リモートサーバーまたは SSH ホストを追加してください",
+ "folderSelection.servers.empty.description": "この端末からすばやく再接続できるように、リモートサーバーを追加してください",
"folderSelection.servers.connectTitle": "サーバーに接続",
"folderSelection.servers.connectSubtitle": "リモート CodeNomad サーバーを保存して新しいウィンドウで開きます",
"folderSelection.servers.connectButton": "サーバーに接続",
"folderSelection.servers.remove": "保存した接続を削除",
"folderSelection.servers.kind.remoteServer": "サーバー",
- "folderSelection.servers.kind.ssh": "ssh",
"folderSelection.servers.lastConnected": "最終接続 {time}",
"folderSelection.servers.skipTls": "自己署名 TLS",
"folderSelection.servers.errorTitle": "リモート接続に失敗しました",
@@ -91,30 +89,6 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "接続中...",
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
- "folderSelection.ssh.dialog.title": "Connect SSH Host",
- "folderSelection.ssh.dialog.description": "Save an SSH bootstrap target and optionally open it right away.",
- "folderSelection.ssh.dialog.name": "Connection name",
- "folderSelection.ssh.dialog.namePlaceholder": "Production VM",
- "folderSelection.ssh.dialog.host": "Host",
- "folderSelection.ssh.dialog.hostPlaceholder": "vm.example.com",
- "folderSelection.ssh.dialog.port": "SSH port",
- "folderSelection.ssh.dialog.portPlaceholder": "22",
- "folderSelection.ssh.dialog.remoteServerPort": "Remote server port",
- "folderSelection.ssh.dialog.remoteServerPortPlaceholder": "9899",
- "folderSelection.ssh.dialog.username": "Username",
- "folderSelection.ssh.dialog.usernamePlaceholder": "ubuntu",
- "folderSelection.ssh.dialog.remotePath": "Remote workspace path",
- "folderSelection.ssh.dialog.remotePathPlaceholder": "/srv/project",
- "folderSelection.ssh.dialog.bootstrapScript": "Bootstrap script",
- "folderSelection.ssh.dialog.bootstrapScriptHelp": "Optional script for starting a remote CodeNomad server that already has its server-side opencode-config available. The SSH flow tunnels to that server; it does not copy local plugin config to the host.",
- "folderSelection.ssh.dialog.bootstrapScriptPlaceholder": "Optional shell script run after the SSH connection is established",
- "folderSelection.ssh.dialog.cancel": "Cancel",
- "folderSelection.ssh.dialog.save": "Save",
- "folderSelection.ssh.dialog.connect": "Connect",
- "folderSelection.ssh.dialog.connecting": "Connecting...",
- "folderSelection.ssh.dialog.errorHost": "SSH host is required.",
- "folderSelection.ssh.dialog.errorPort": "SSH port must be between 1 and 65535.",
- "folderSelection.ssh.dialog.errorRemoteServerPort": "Remote server port must be between 1 and 65535.",
"folderSelection.servers.certificateInstall.title": "ローカル証明書をインストール",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad は自己署名 HTTPS のリモートウィンドウを開くために、ローカル証明書をインストールする必要があります。この証明書は、このマシン上のローカルデスクトッププロキシ通信にのみ使用されます。この後、OS が追加の証明書プロンプトを表示する場合があります。",
"folderSelection.servers.certificateInstall.confirmLabel": "続行",
diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts
index d3ce4315e..e8f7c9a4f 100644
--- a/packages/ui/src/lib/i18n/messages/ja/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts
@@ -109,39 +109,6 @@ export const settingsMessages = {
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
- "settings.remoteConnections.form.title": "SSH connection profiles",
- "settings.remoteConnections.form.subtitle": "Save reusable SSH bootstrap targets and scripts for remote environments.",
- "settings.remoteConnections.kind.remote-server": "Remote server",
- "settings.remoteConnections.kind.ssh": "SSH",
- "settings.remoteConnections.form.name.label": "Profile name",
- "settings.remoteConnections.form.name.placeholder": "Production VM",
- "settings.remoteConnections.form.host.label": "Host",
- "settings.remoteConnections.form.host.placeholder": "vm.example.com",
- "settings.remoteConnections.form.port.label": "Port",
- "settings.remoteConnections.form.port.placeholder": "22",
- "settings.remoteConnections.form.remoteServerPort.label": "Remote server port",
- "settings.remoteConnections.form.remoteServerPort.placeholder": "9899",
- "settings.remoteConnections.form.username.label": "Username",
- "settings.remoteConnections.form.username.placeholder": "ubuntu",
- "settings.remoteConnections.form.remotePath.label": "Remote workspace path",
- "settings.remoteConnections.form.remotePath.placeholder": "/srv/project",
- "settings.remoteConnections.form.bootstrapScript.label": "Bootstrap script",
- "settings.remoteConnections.form.bootstrapScript.help": "Use this only to start a remote CodeNomad server installation that already has its server-side opencode-config available. This SSH flow tunnels to that server and does not copy local plugin config to the host.",
- "settings.remoteConnections.form.bootstrapScript.placeholder": "Optional shell script run after connecting",
- "settings.remoteConnections.form.save": "Save connection",
- "settings.remoteConnections.form.update": "Update connection",
- "settings.remoteConnections.form.cancelEdit": "Cancel edit",
- "settings.remoteConnections.list.title": "Saved and recent connections",
- "settings.remoteConnections.list.subtitle": "Review remote server shortcuts and SSH bootstrap profiles available on this device.",
- "settings.remoteConnections.list.empty": "No connection profiles saved yet.",
- "settings.remoteConnections.list.lastConnected": "Last connected {time}",
- "settings.remoteConnections.list.actions.edit": "Edit connection",
- "settings.remoteConnections.list.actions.duplicate": "Duplicate connection",
- "settings.remoteConnections.list.actions.delete": "Delete connection",
- "settings.remoteConnections.validation.name": "Connection profile name is required.",
- "settings.remoteConnections.validation.host": "SSH host is required.",
- "settings.remoteConnections.validation.port": "Port must be between 1 and 65535.",
- "settings.remoteConnections.validation.remoteServerPort": "Remote server port must be between 1 and 65535.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
diff --git a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts
index da0cdbd56..7e0b0432a 100644
--- a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts
@@ -38,7 +38,6 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Открыть папку или подключить сервер",
"folderSelection.actions.subtitle": "Откройте локальную папку или подключитесь к серверу CodeNomad",
"folderSelection.actions.connectButton": "Подключить сервер CodeNomad",
- "folderSelection.actions.connectSshButton": "Connect SSH Host",
"folderSelection.executionProfile.label": "Профиль выполнения",
"folderSelection.executionProfile.subtitle": "Выберите, как OpenCode должен запускаться для новых локальных рабочих пространств",
@@ -67,13 +66,12 @@ export const folderSelectionMessages = {
"folderSelection.servers.subtitle": "Откройте сохраненное удаленное подключение с этого устройства",
"folderSelection.servers.count": "{count} подключений",
"folderSelection.servers.empty.title": "Нет сохраненных подключений",
- "folderSelection.servers.empty.description": "Добавьте удаленный сервер или SSH-хост, чтобы быстро подключаться к нему с этого устройства",
+ "folderSelection.servers.empty.description": "Добавьте удаленный сервер, чтобы быстро подключаться к нему с этого устройства",
"folderSelection.servers.connectTitle": "Подключиться к серверу",
"folderSelection.servers.connectSubtitle": "Сохраните удаленный сервер CodeNomad и откройте его в новом окне",
"folderSelection.servers.connectButton": "Подключиться к серверу",
"folderSelection.servers.remove": "Удалить сохраненное подключение",
"folderSelection.servers.kind.remoteServer": "сервер",
- "folderSelection.servers.kind.ssh": "ssh",
"folderSelection.servers.lastConnected": "Последнее подключение {time}",
"folderSelection.servers.skipTls": "Самоподписанный TLS",
"folderSelection.servers.errorTitle": "Ошибка удаленного подключения",
@@ -91,30 +89,6 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Подключение...",
"folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.",
"folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.",
- "folderSelection.ssh.dialog.title": "Connect SSH Host",
- "folderSelection.ssh.dialog.description": "Save an SSH bootstrap target and optionally open it right away.",
- "folderSelection.ssh.dialog.name": "Connection name",
- "folderSelection.ssh.dialog.namePlaceholder": "Production VM",
- "folderSelection.ssh.dialog.host": "Host",
- "folderSelection.ssh.dialog.hostPlaceholder": "vm.example.com",
- "folderSelection.ssh.dialog.port": "SSH port",
- "folderSelection.ssh.dialog.portPlaceholder": "22",
- "folderSelection.ssh.dialog.remoteServerPort": "Remote server port",
- "folderSelection.ssh.dialog.remoteServerPortPlaceholder": "9899",
- "folderSelection.ssh.dialog.username": "Username",
- "folderSelection.ssh.dialog.usernamePlaceholder": "ubuntu",
- "folderSelection.ssh.dialog.remotePath": "Remote workspace path",
- "folderSelection.ssh.dialog.remotePathPlaceholder": "/srv/project",
- "folderSelection.ssh.dialog.bootstrapScript": "Bootstrap script",
- "folderSelection.ssh.dialog.bootstrapScriptHelp": "Optional script for starting a remote CodeNomad server that already has its server-side opencode-config available. The SSH flow tunnels to that server; it does not copy local plugin config to the host.",
- "folderSelection.ssh.dialog.bootstrapScriptPlaceholder": "Optional shell script run after the SSH connection is established",
- "folderSelection.ssh.dialog.cancel": "Cancel",
- "folderSelection.ssh.dialog.save": "Save",
- "folderSelection.ssh.dialog.connect": "Connect",
- "folderSelection.ssh.dialog.connecting": "Connecting...",
- "folderSelection.ssh.dialog.errorHost": "SSH host is required.",
- "folderSelection.ssh.dialog.errorPort": "SSH port must be between 1 and 65535.",
- "folderSelection.ssh.dialog.errorRemoteServerPort": "Remote server port must be between 1 and 65535.",
"folderSelection.servers.certificateInstall.title": "Установить локальный сертификат",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad должен установить локальный сертификат, чтобы открывать удаленные HTTPS-окна с самоподписанным сертификатом. Этот сертификат используется только для трафика локального настольного прокси на вашем устройстве. После этого ваша операционная система может показать второе предупреждение о сертификате.",
"folderSelection.servers.certificateInstall.confirmLabel": "Продолжить",
diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts
index 1a3a3db9d..3c7d704e6 100644
--- a/packages/ui/src/lib/i18n/messages/ru/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts
@@ -109,39 +109,6 @@ export const settingsMessages = {
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
- "settings.remoteConnections.form.title": "SSH connection profiles",
- "settings.remoteConnections.form.subtitle": "Save reusable SSH bootstrap targets and scripts for remote environments.",
- "settings.remoteConnections.kind.remote-server": "Remote server",
- "settings.remoteConnections.kind.ssh": "SSH",
- "settings.remoteConnections.form.name.label": "Profile name",
- "settings.remoteConnections.form.name.placeholder": "Production VM",
- "settings.remoteConnections.form.host.label": "Host",
- "settings.remoteConnections.form.host.placeholder": "vm.example.com",
- "settings.remoteConnections.form.port.label": "Port",
- "settings.remoteConnections.form.port.placeholder": "22",
- "settings.remoteConnections.form.remoteServerPort.label": "Remote server port",
- "settings.remoteConnections.form.remoteServerPort.placeholder": "9899",
- "settings.remoteConnections.form.username.label": "Username",
- "settings.remoteConnections.form.username.placeholder": "ubuntu",
- "settings.remoteConnections.form.remotePath.label": "Remote workspace path",
- "settings.remoteConnections.form.remotePath.placeholder": "/srv/project",
- "settings.remoteConnections.form.bootstrapScript.label": "Bootstrap script",
- "settings.remoteConnections.form.bootstrapScript.help": "Use this only to start a remote CodeNomad server installation that already has its server-side opencode-config available. This SSH flow tunnels to that server and does not copy local plugin config to the host.",
- "settings.remoteConnections.form.bootstrapScript.placeholder": "Optional shell script run after connecting",
- "settings.remoteConnections.form.save": "Save connection",
- "settings.remoteConnections.form.update": "Update connection",
- "settings.remoteConnections.form.cancelEdit": "Cancel edit",
- "settings.remoteConnections.list.title": "Saved and recent connections",
- "settings.remoteConnections.list.subtitle": "Review remote server shortcuts and SSH bootstrap profiles available on this device.",
- "settings.remoteConnections.list.empty": "No connection profiles saved yet.",
- "settings.remoteConnections.list.lastConnected": "Last connected {time}",
- "settings.remoteConnections.list.actions.edit": "Edit connection",
- "settings.remoteConnections.list.actions.duplicate": "Duplicate connection",
- "settings.remoteConnections.list.actions.delete": "Delete connection",
- "settings.remoteConnections.validation.name": "Connection profile name is required.",
- "settings.remoteConnections.validation.host": "SSH host is required.",
- "settings.remoteConnections.validation.port": "Port must be between 1 and 65535.",
- "settings.remoteConnections.validation.remoteServerPort": "Remote server port must be between 1 and 65535.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts
index 1d35d2e35..285936c99 100644
--- a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts
@@ -38,7 +38,6 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "打开文件夹或连接服务器",
"folderSelection.actions.subtitle": "打开本地文件夹或连接到 CodeNomad 服务器",
"folderSelection.actions.connectButton": "连接 CodeNomad 服务器",
- "folderSelection.actions.connectSshButton": "Connect SSH Host",
"folderSelection.executionProfile.label": "执行配置",
"folderSelection.executionProfile.subtitle": "选择新本地工作区启动 OpenCode 的方式",
@@ -67,13 +66,12 @@ export const folderSelectionMessages = {
"folderSelection.servers.subtitle": "从此设备打开已保存的远程连接",
"folderSelection.servers.count": "{count} 个连接",
"folderSelection.servers.empty.title": "没有已保存连接",
- "folderSelection.servers.empty.description": "添加远程服务器或 SSH 主机,以便在此设备上快速重新连接",
+ "folderSelection.servers.empty.description": "添加远程服务器,以便在此设备上快速重新连接",
"folderSelection.servers.connectTitle": "连接到服务器",
"folderSelection.servers.connectSubtitle": "保存远程 CodeNomad 服务器并在新窗口中打开它",
"folderSelection.servers.connectButton": "连接到服务器",
"folderSelection.servers.remove": "删除已保存连接",
"folderSelection.servers.kind.remoteServer": "服务器",
- "folderSelection.servers.kind.ssh": "ssh",
"folderSelection.servers.lastConnected": "上次连接 {time}",
"folderSelection.servers.skipTls": "自签名 TLS",
"folderSelection.servers.errorTitle": "远程连接失败",
@@ -91,30 +89,6 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "连接中...",
"folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。",
"folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。",
- "folderSelection.ssh.dialog.title": "Connect SSH Host",
- "folderSelection.ssh.dialog.description": "Save an SSH bootstrap target and optionally open it right away.",
- "folderSelection.ssh.dialog.name": "Connection name",
- "folderSelection.ssh.dialog.namePlaceholder": "Production VM",
- "folderSelection.ssh.dialog.host": "Host",
- "folderSelection.ssh.dialog.hostPlaceholder": "vm.example.com",
- "folderSelection.ssh.dialog.port": "SSH port",
- "folderSelection.ssh.dialog.portPlaceholder": "22",
- "folderSelection.ssh.dialog.remoteServerPort": "Remote server port",
- "folderSelection.ssh.dialog.remoteServerPortPlaceholder": "9899",
- "folderSelection.ssh.dialog.username": "Username",
- "folderSelection.ssh.dialog.usernamePlaceholder": "ubuntu",
- "folderSelection.ssh.dialog.remotePath": "Remote workspace path",
- "folderSelection.ssh.dialog.remotePathPlaceholder": "/srv/project",
- "folderSelection.ssh.dialog.bootstrapScript": "Bootstrap script",
- "folderSelection.ssh.dialog.bootstrapScriptHelp": "Optional script for starting a remote CodeNomad server that already has its server-side opencode-config available. The SSH flow tunnels to that server; it does not copy local plugin config to the host.",
- "folderSelection.ssh.dialog.bootstrapScriptPlaceholder": "Optional shell script run after the SSH connection is established",
- "folderSelection.ssh.dialog.cancel": "Cancel",
- "folderSelection.ssh.dialog.save": "Save",
- "folderSelection.ssh.dialog.connect": "Connect",
- "folderSelection.ssh.dialog.connecting": "Connecting...",
- "folderSelection.ssh.dialog.errorHost": "SSH host is required.",
- "folderSelection.ssh.dialog.errorPort": "SSH port must be between 1 and 65535.",
- "folderSelection.ssh.dialog.errorRemoteServerPort": "Remote server port must be between 1 and 65535.",
"folderSelection.servers.certificateInstall.title": "安装本地证书",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad 需要安装本地证书,才能打开使用自签名 HTTPS 的远程窗口。此证书仅用于你这台设备上的本地桌面代理流量。之后你的操作系统可能还会显示第二个证书提示。",
"folderSelection.servers.certificateInstall.confirmLabel": "继续",
diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts
index 5af4107f0..873fb6f78 100644
--- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts
@@ -109,39 +109,6 @@ export const settingsMessages = {
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
- "settings.remoteConnections.form.title": "SSH connection profiles",
- "settings.remoteConnections.form.subtitle": "Save reusable SSH bootstrap targets and scripts for remote environments.",
- "settings.remoteConnections.kind.remote-server": "Remote server",
- "settings.remoteConnections.kind.ssh": "SSH",
- "settings.remoteConnections.form.name.label": "Profile name",
- "settings.remoteConnections.form.name.placeholder": "Production VM",
- "settings.remoteConnections.form.host.label": "Host",
- "settings.remoteConnections.form.host.placeholder": "vm.example.com",
- "settings.remoteConnections.form.port.label": "Port",
- "settings.remoteConnections.form.port.placeholder": "22",
- "settings.remoteConnections.form.remoteServerPort.label": "Remote server port",
- "settings.remoteConnections.form.remoteServerPort.placeholder": "9899",
- "settings.remoteConnections.form.username.label": "Username",
- "settings.remoteConnections.form.username.placeholder": "ubuntu",
- "settings.remoteConnections.form.remotePath.label": "Remote workspace path",
- "settings.remoteConnections.form.remotePath.placeholder": "/srv/project",
- "settings.remoteConnections.form.bootstrapScript.label": "Bootstrap script",
- "settings.remoteConnections.form.bootstrapScript.help": "Use this only to start a remote CodeNomad server installation that already has its server-side opencode-config available. This SSH flow tunnels to that server and does not copy local plugin config to the host.",
- "settings.remoteConnections.form.bootstrapScript.placeholder": "Optional shell script run after connecting",
- "settings.remoteConnections.form.save": "Save connection",
- "settings.remoteConnections.form.update": "Update connection",
- "settings.remoteConnections.form.cancelEdit": "Cancel edit",
- "settings.remoteConnections.list.title": "Saved and recent connections",
- "settings.remoteConnections.list.subtitle": "Review remote server shortcuts and SSH bootstrap profiles available on this device.",
- "settings.remoteConnections.list.empty": "No connection profiles saved yet.",
- "settings.remoteConnections.list.lastConnected": "Last connected {time}",
- "settings.remoteConnections.list.actions.edit": "Edit connection",
- "settings.remoteConnections.list.actions.duplicate": "Duplicate connection",
- "settings.remoteConnections.list.actions.delete": "Delete connection",
- "settings.remoteConnections.validation.name": "Connection profile name is required.",
- "settings.remoteConnections.validation.host": "SSH host is required.",
- "settings.remoteConnections.validation.port": "Port must be between 1 and 65535.",
- "settings.remoteConnections.validation.remoteServerPort": "Remote server port must be between 1 and 65535.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx
index bc594330e..c90169101 100644
--- a/packages/ui/src/stores/preferences.tsx
+++ b/packages/ui/src/stores/preferences.tsx
@@ -2,11 +2,8 @@ import { createContext, createMemo, createSignal, onMount, useContext } from "so
import type { Accessor, ParentComponent } from "solid-js"
import { storage, type OwnerBucket } from "../lib/storage"
import type {
- ConnectionProfile,
ExecutionProfile,
- RemoteServerConnectionProfile,
RemoteServerProfile,
- SshConnectionProfile,
} from "../../../server/src/api-types"
import {
ensureInstanceConfigLoaded,
@@ -116,7 +113,6 @@ interface UiStateBucket {
recentFolders?: RecentFolder[]
opencodeBinaries?: OpenCodeBinary[]
remoteServers?: RemoteServerProfile[]
- connectionProfiles?: ConnectionProfile[]
lastSelectedExecutionProfileId?: string
models?: {
recents?: ModelPreference[]
@@ -129,7 +125,6 @@ interface NormalizedUiState {
recentFolders: RecentFolder[]
opencodeBinaries: OpenCodeBinary[]
remoteServers: RemoteServerProfile[]
- connectionProfiles: ConnectionProfile[]
lastSelectedExecutionProfileId?: string
models: {
recents: ModelPreference[]
@@ -351,86 +346,6 @@ function normalizeRemoteServerProfile(value: unknown): RemoteServerProfile | nul
}
}
-function remoteServerToConnectionProfile(server: RemoteServerProfile): RemoteServerConnectionProfile {
- return {
- id: server.id,
- name: server.name,
- kind: "remote-server",
- baseUrl: server.baseUrl,
- skipTlsVerify: server.skipTlsVerify,
- createdAt: server.createdAt,
- updatedAt: server.updatedAt,
- lastConnectedAt: server.lastConnectedAt,
- }
-}
-
-function connectionProfileToRemoteServer(profile: ConnectionProfile): RemoteServerProfile | null {
- if (profile.kind !== "remote-server") return null
- return {
- id: profile.id,
- name: profile.name,
- baseUrl: profile.baseUrl,
- skipTlsVerify: profile.skipTlsVerify,
- createdAt: profile.createdAt,
- updatedAt: profile.updatedAt,
- lastConnectedAt: profile.lastConnectedAt,
- }
-}
-
-function normalizeConnectionProfiles(value: unknown, remoteServers: RemoteServerProfile[]): ConnectionProfile[] {
- const profiles: ConnectionProfile[] = []
- if (Array.isArray(value)) {
- for (const entry of value) {
- if (!entry || typeof entry !== "object") continue
- const id = typeof (entry as any).id === "string" ? (entry as any).id.trim() : ""
- const name = typeof (entry as any).name === "string" ? (entry as any).name.trim() : ""
- const kind = typeof (entry as any).kind === "string" ? (entry as any).kind.trim() : ""
- if (!id || !name) continue
-
- const createdAt = typeof (entry as any).createdAt === "string" ? (entry as any).createdAt : new Date().toISOString()
- const updatedAt = typeof (entry as any).updatedAt === "string" ? (entry as any).updatedAt : createdAt
- const lastConnectedAt = typeof (entry as any).lastConnectedAt === "string" ? (entry as any).lastConnectedAt : undefined
-
- if (kind === "remote-server") {
- const remote = normalizeRemoteServerProfile(entry)
- if (!remote) continue
- profiles.push(remoteServerToConnectionProfile(remote))
- continue
- }
-
- if (kind === "ssh") {
- const host = typeof (entry as any).host === "string" ? (entry as any).host.trim() : ""
- if (!host) continue
- const profile: SshConnectionProfile = {
- id,
- name,
- kind,
- host,
- createdAt,
- updatedAt,
- lastConnectedAt,
- port: typeof (entry as any).port === "number" ? (entry as any).port : undefined,
- remoteServerPort: typeof (entry as any).remoteServerPort === "number" ? (entry as any).remoteServerPort : undefined,
- username: typeof (entry as any).username === "string" && (entry as any).username.trim() ? (entry as any).username.trim() : undefined,
- remotePath: typeof (entry as any).remotePath === "string" && (entry as any).remotePath.trim() ? (entry as any).remotePath.trim() : undefined,
- bootstrapScript:
- typeof (entry as any).bootstrapScript === "string" && (entry as any).bootstrapScript.trim()
- ? (entry as any).bootstrapScript
- : undefined,
- }
- profiles.push(profile)
- }
- }
- }
-
- const byId = new Map(profiles.map((profile) => [profile.id, profile] as const))
- for (const server of remoteServers) {
- byId.set(server.id, remoteServerToConnectionProfile(server))
- }
-
- return sortByRecentActivity(Array.from(byId.values()))
-}
-
function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
const source = input ?? {}
const remoteServers = sortByRecentActivity(
@@ -456,7 +371,6 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
return { path: p, version, label, lastUsed }
}),
remoteServers,
- connectionProfiles: normalizeConnectionProfiles(source.connectionProfiles, remoteServers),
lastSelectedExecutionProfileId:
typeof source.lastSelectedExecutionProfileId === "string" && source.lastSelectedExecutionProfileId.trim()
? source.lastSelectedExecutionProfileId.trim()
@@ -560,11 +474,6 @@ function buildExecutionProfileList(profile: ExecutionProfile, source: ExecutionP
return [profile, ...remaining]
}
-function buildConnectionProfileList(profile: ConnectionProfile, source: ConnectionProfile[]): ConnectionProfile[] {
- const remaining = source.filter((entry) => entry.id !== profile.id)
- return sortByRecentActivity([profile, ...remaining])
-}
-
function createRandomId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID()
@@ -586,7 +495,6 @@ const preferences = uiSettings
const recentFolders = createMemo(() => uiState().recentFolders)
const opencodeBinaries = createMemo(() => uiState().opencodeBinaries)
const remoteServers = createMemo(() => uiState().remoteServers)
-const connectionProfiles = createMemo(() => uiState().connectionProfiles)
const lastSelectedExecutionProfileId = createMemo(() => uiState().lastSelectedExecutionProfileId)
const executionProfiles = createMemo(() => serverSettings().executionProfiles)
const defaultExecutionProfileId = createMemo(() => serverSettings().defaultExecutionProfileId)
@@ -735,53 +643,25 @@ function removeRecentFolder(folderPath: string): void {
async function saveRemoteServerProfile(input: RemoteServerProfileInput): Promise {
const profile = buildRemoteServerProfile(input, remoteServers())
- await saveConnectionProfile(remoteServerToConnectionProfile(profile))
+ await patchStateOwner("ui", { remoteServers: buildRemoteServerList(profile, remoteServers()) })
return profile
}
async function markRemoteServerConnected(id: string): Promise {
- const current = connectionProfiles().find((entry) => entry.id === id)
+ const current = remoteServers().find((entry) => entry.id === id)
if (!current) return
const now = new Date().toISOString()
- const updated: ConnectionProfile = {
+ const updated: RemoteServerProfile = {
...current,
updatedAt: now,
lastConnectedAt: now,
}
- await saveConnectionProfile(updated)
+ await patchStateOwner("ui", { remoteServers: buildRemoteServerList(updated, remoteServers()) })
}
function removeRemoteServerProfile(id: string): void {
- removeConnectionProfile(id)
-}
-
-async function saveConnectionProfile(profile: ConnectionProfile): Promise {
- const nextProfiles = buildConnectionProfileList(profile, connectionProfiles())
- const nextRemoteServers = sortByRecentActivity(
- nextProfiles
- .map((entry) => connectionProfileToRemoteServer(entry))
- .filter((entry): entry is RemoteServerProfile => Boolean(entry)),
- )
-
- await patchStateOwner("ui", {
- connectionProfiles: nextProfiles,
- remoteServers: nextRemoteServers,
- })
- return profile
-}
-
-function removeConnectionProfile(id: string): void {
- const nextProfiles = connectionProfiles().filter((entry) => entry.id !== id)
- const nextRemoteServers = sortByRecentActivity(
- nextProfiles
- .map((entry) => connectionProfileToRemoteServer(entry))
- .filter((entry): entry is RemoteServerProfile => Boolean(entry)),
- )
-
- void patchStateOwner("ui", {
- connectionProfiles: nextProfiles,
- remoteServers: nextRemoteServers,
- }).catch((error) => log.error("Failed to remove connection profile", error))
+ const nextRemoteServers = remoteServers().filter((entry) => entry.id !== id)
+ void patchStateOwner("ui", { remoteServers: nextRemoteServers }).catch((error) => log.error("Failed to remove remote server profile", error))
}
function setLastSelectedExecutionProfileId(profileId: string | undefined): void {
@@ -985,15 +865,12 @@ interface ConfigContextValue {
recentFolders: typeof recentFolders
opencodeBinaries: typeof opencodeBinaries
remoteServers: typeof remoteServers
- connectionProfiles: typeof connectionProfiles
lastSelectedExecutionProfileId: typeof lastSelectedExecutionProfileId
uiState: typeof uiState
addRecentFolder: typeof addRecentFolder
removeRecentFolder: typeof removeRecentFolder
addOpenCodeBinary: typeof addOpenCodeBinary
removeOpenCodeBinary: typeof removeOpenCodeBinary
- saveConnectionProfile: typeof saveConnectionProfile
- removeConnectionProfile: typeof removeConnectionProfile
setLastSelectedExecutionProfileId: typeof setLastSelectedExecutionProfileId
saveRemoteServerProfile: typeof saveRemoteServerProfile
markRemoteServerConnected: typeof markRemoteServerConnected
@@ -1048,15 +925,12 @@ const configContextValue: ConfigContextValue = {
recentFolders,
opencodeBinaries,
remoteServers,
- connectionProfiles,
lastSelectedExecutionProfileId,
uiState,
addRecentFolder,
removeRecentFolder,
addOpenCodeBinary,
removeOpenCodeBinary,
- saveConnectionProfile,
- removeConnectionProfile,
setLastSelectedExecutionProfileId,
saveRemoteServerProfile,
markRemoteServerConnected,
@@ -1130,7 +1004,6 @@ export {
defaultExecutionProfileId,
recentFolders,
opencodeBinaries,
- connectionProfiles,
lastSelectedExecutionProfileId,
themePreference,
setThemePreference,
@@ -1149,8 +1022,6 @@ export {
removeRecentFolder,
addOpenCodeBinary,
removeOpenCodeBinary,
- saveConnectionProfile,
- removeConnectionProfile,
setLastSelectedExecutionProfileId,
recordWorkspaceLaunch,
addRecentModelPreference,
From ddc295d3b2e9df5d7848af1168a71e51fa4003de Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pascal=20Andr=C3=A9?=
Date: Sat, 9 May 2026 01:33:19 +0200
Subject: [PATCH 13/17] feat(runtime): add ssh execution profiles
Adds SSH as a first-class execution profile so the current CodeNomad server can launch OpenCode on a remote host without introducing a second remote CodeNomad server lifecycle.
The launcher reserves local runtime and callback ports, starts OpenCode through ssh with forward and reverse tunnels, and rewrites callback URLs for the remote shell environment. Settings schemas, preview/test routes, UI form fields, i18n messages, and instance metadata now understand the ssh profile kind.
Validation covers resolver behavior and launch command generation for SSH alongside existing WSL, Docker, command, spawn, and clone workspace tests. Verified with server and UI typechecks plus the UI production build.
---
packages/server/src/api-types.ts | 14 ++-
packages/server/src/server/routes/settings.ts | 50 +++++++--
packages/server/src/settings/binaries.test.ts | 34 ++++++
packages/server/src/settings/binaries.ts | 32 +++++-
packages/server/src/settings/service.ts | 13 +++
.../src/workspaces/execution-launch.test.ts | 31 ++++++
.../server/src/workspaces/execution-launch.ts | 101 +++++++++++++++++-
packages/server/src/workspaces/manager.ts | 31 +++++-
.../execution-profiles-settings-section.tsx | 90 +++++++++++++++-
.../ui/src/lib/i18n/messages/en/settings.ts | 16 +++
.../ui/src/lib/i18n/messages/es/settings.ts | 16 +++
.../ui/src/lib/i18n/messages/fr/settings.ts | 16 +++
.../ui/src/lib/i18n/messages/he/settings.ts | 16 +++
.../ui/src/lib/i18n/messages/ja/settings.ts | 16 +++
.../ui/src/lib/i18n/messages/ru/settings.ts | 16 +++
.../src/lib/i18n/messages/zh-Hans/settings.ts | 16 +++
packages/ui/src/types/instance.ts | 2 +-
17 files changed, 491 insertions(+), 19 deletions(-)
diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts
index 5729b8542..e2fd2110e 100644
--- a/packages/server/src/api-types.ts
+++ b/packages/server/src/api-types.ts
@@ -14,7 +14,7 @@ import type {
export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error"
-export type ExecutionProfileKind = "local" | "wsl" | "docker" | "command"
+export type ExecutionProfileKind = "local" | "wsl" | "docker" | "command" | "ssh"
export type ExecutionProfileCwdMode = "workspace" | "inherit"
export interface ExecutionProfileBase {
@@ -50,7 +50,17 @@ export interface CommandExecutionProfile extends ExecutionProfileBase {
cwdMode?: ExecutionProfileCwdMode
}
-export type ExecutionProfile = LocalExecutionProfile | WslExecutionProfile | DockerExecutionProfile | CommandExecutionProfile
+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
diff --git a/packages/server/src/server/routes/settings.ts b/packages/server/src/server/routes/settings.ts
index 775851ac4..c1ba93d63 100644
--- a/packages/server/src/server/routes/settings.ts
+++ b/packages/server/src/server/routes/settings.ts
@@ -56,6 +56,17 @@ const ExecutionProfileSchema = z.discriminatedUnion("kind", [
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({
@@ -162,6 +173,14 @@ function buildRequestBaseUrl(request: FastifyRequest): string {
return `${request.protocol}://${host}`.replace(/\/+$/, "")
}
+function getPreviewReservedPort(profile: z.infer): number | undefined {
+ return 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 },
@@ -191,13 +210,24 @@ function buildExecutionProfilePreview(
command: input.profile.command,
extraDockerArgs: input.profile.extraDockerArgs,
}
- : {
- kind: "command" as const,
- label: input.profile.name,
- executable: input.profile.executable,
- args: input.profile.args,
- cwdMode: input.profile.cwdMode,
- }
+ : 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"
@@ -222,6 +252,8 @@ function buildExecutionProfilePreview(
workspacePath,
environment,
logLevel: readConfiguredLogLevel(options.settings),
+ reservedPort: getPreviewReservedPort(input.profile),
+ callbackPort: getPreviewCallbackPort(input.profile),
})
const redactedArgs = redactPreviewArgs(launch.args)
@@ -245,7 +277,9 @@ function testExecutionProfile(
? validateDockerImage(input.profile.image)
: input.profile.kind === "command"
? validateBinaryPath(input.profile.executable)
- : validateBinaryPath(input.profile.binaryPath, input.profile.kind === "wsl" ? { wslDistro: input.profile.distro } : {})
+ : input.profile.kind === "ssh"
+ ? validateBinaryPath("ssh")
+ : validateBinaryPath(input.profile.binaryPath, input.profile.kind === "wsl" ? { wslDistro: input.profile.distro } : {})
return {
...preview,
diff --git a/packages/server/src/settings/binaries.test.ts b/packages/server/src/settings/binaries.test.ts
index a03ca2530..ff0730ba3 100644
--- a/packages/server/src/settings/binaries.test.ts
+++ b/packages/server/src/settings/binaries.test.ts
@@ -152,6 +152,40 @@ describe("BinaryResolver", () => {
})
})
+ 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 212b9decd..00592296e 100644
--- a/packages/server/src/settings/binaries.ts
+++ b/packages/server/src/settings/binaries.ts
@@ -5,6 +5,7 @@ import type {
ExecutionProfile,
ExecutionProfileKind,
LocalExecutionProfile,
+ SshExecutionProfile,
WslExecutionProfile,
} from "../api-types"
@@ -45,7 +46,17 @@ export interface ResolvedCommandExecution extends ResolvedExecutionBase {
cwdMode?: "workspace" | "inherit"
}
-export type ResolvedBinary = ResolvedHostExecution | ResolvedDockerExecution | ResolvedCommandExecution
+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(/[\\/]/)
@@ -147,7 +158,11 @@ export class BinaryResolver {
return this.resolveDockerProfile(profile, shared)
}
- return this.resolveCommandProfile(profile, shared)
+ if (profile.kind === "command") {
+ return this.resolveCommandProfile(profile, shared)
+ }
+
+ return this.resolveSshProfile(profile, shared)
}
private resolveLocalProfile(profile: LocalExecutionProfile, shared: Omit): ResolvedHostExecution {
@@ -188,4 +203,17 @@ export class BinaryResolver {
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 01ba20ff4..b5a40bc49 100644
--- a/packages/server/src/settings/service.ts
+++ b/packages/server/src/settings/service.ts
@@ -53,11 +53,24 @@ const CommandExecutionProfileSchema = z.object({
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 {
diff --git a/packages/server/src/workspaces/execution-launch.test.ts b/packages/server/src/workspaces/execution-launch.test.ts
index 6e208c234..ff2fd24a3 100644
--- a/packages/server/src/workspaces/execution-launch.test.ts
+++ b/packages/server/src/workspaces/execution-launch.test.ts
@@ -64,6 +64,37 @@ describe("buildLaunchCommand", () => {
assert.deepEqual(result.args.slice(-6), ["serve", "--port", "0", "--print-logs", "--log-level", "INFO"])
})
+ 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.ok(result.args.some((arg) => arg.includes("opencode") && arg.includes("--port") && arg.includes("17600")))
+ assert.ok(result.args.some((arg) => arg.includes("OPENCODE_SERVER_BASE_URL='http://127.0.0.1:17601/workspaces/abc/worktrees/root/instance'")))
+ })
+
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')
})
diff --git a/packages/server/src/workspaces/execution-launch.ts b/packages/server/src/workspaces/execution-launch.ts
index 12d556e98..a61ab7ea5 100644
--- a/packages/server/src/workspaces/execution-launch.ts
+++ b/packages/server/src/workspaces/execution-launch.ts
@@ -18,10 +18,13 @@ interface BuildLaunchCommandParams {
workspacePath: string
environment: Record
logLevel: string
+ reservedPort?: number
+ callbackPort?: number
}
export function buildLaunchCommand(params: BuildLaunchCommandParams): LaunchCommandSpec {
- const openCodeArgs = ["serve", "--port", "0", "--print-logs", "--log-level", params.logLevel]
+ const openCodePort = 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") {
return buildDockerLaunchCommand(params.execution, params.workspacePath, params.environment, openCodeArgs)
@@ -36,6 +39,13 @@ export function buildLaunchCommand(params: BuildLaunchCommandParams): LaunchComm
}
}
+ 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,
@@ -45,6 +55,58 @@ export function buildLaunchCommand(params: BuildLaunchCommandParams): LaunchComm
}
}
+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 remoteCommand = [
+ "cd",
+ shellQuote(execution.remotePath),
+ "&&",
+ "env",
+ ...Object.entries(remoteEnvironment).map(([key, value]) => `${key}=${shellQuote(value)}`),
+ shellQuote(execution.binaryPath),
+ ...(execution.args ?? []).map(shellQuote),
+ ...openCodeArgs.map(shellQuote),
+ ].join(" ")
+
+ 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",
+ "-lc",
+ remoteCommand,
+ ],
+ environment: {},
+ }
+}
+
export function buildLaunchPreview(params: BuildLaunchCommandParams): LaunchCommandSpec {
const launch = buildLaunchCommand(params)
const explicitEnvironment = launch.environment ?? {}
@@ -166,6 +228,43 @@ function formatCommandToken(token: string): string {
return /[\s"'`$&|<>()[\]{};\\]/.test(token) ? JSON.stringify(token) : token
}
+function shellQuote(value: string): string {
+ if (!value) return "''"
+ return `'${value.replace(/'/g, `'"'"'`)}'`
+}
+
+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)
diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts
index ae151d2d7..4c72ddaba 100644
--- a/packages/server/src/workspaces/manager.ts
+++ b/packages/server/src/workspaces/manager.ts
@@ -1,6 +1,6 @@
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"
@@ -95,7 +95,7 @@ export class WorkspaceManager {
const id = `${Date.now().toString(36)}`
const execution = this.options.binaryResolver.resolveActive(options?.executionProfileId)
const resolvedBinaryPath = this.resolveBinaryPath(
- execution.kind === "command" ? execution.executable : execution.kind === "docker" ? "docker" : execution.path,
+ 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)
@@ -154,11 +154,15 @@ export class WorkspaceManager {
}
const logLevel = (serverConfig as any)?.logLevel
+ const reservedPort = 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
@@ -474,6 +478,29 @@ 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 delay(durationMs: number): Promise {
if (durationMs <= 0) {
return Promise.resolve()
diff --git a/packages/ui/src/components/settings/execution-profiles-settings-section.tsx b/packages/ui/src/components/settings/execution-profiles-settings-section.tsx
index af8c16ced..f8b55fb65 100644
--- a/packages/ui/src/components/settings/execution-profiles-settings-section.tsx
+++ b/packages/ui/src/components/settings/execution-profiles-settings-section.tsx
@@ -34,6 +34,8 @@ function buildProfileSummary(profile: ExecutionProfile): string {
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}`
}
}
@@ -75,6 +77,10 @@ export const ExecutionProfilesSettingsSection: Component = () => {
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)
@@ -90,6 +96,7 @@ export const ExecutionProfilesSettingsSection: Component = () => {
{ 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(() => {
@@ -105,6 +112,10 @@ export const ExecutionProfilesSettingsSection: Component = () => {
executable()
argsText()
cwdMode()
+ sshHost()
+ sshPort()
+ sshUsername()
+ sshRemotePath()
previewWorkspacePath()
setPreviewError(null)
setTestError(null)
@@ -116,7 +127,7 @@ export const ExecutionProfilesSettingsSection: Component = () => {
setEditingId(profile?.id ?? null)
setKind(profile?.kind ?? "local")
setName(profile?.name ?? "")
- setBinaryPath(profile?.kind === "local" || profile?.kind === "wsl" ? profile.binaryPath : "")
+ 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")
@@ -124,8 +135,12 @@ export const ExecutionProfilesSettingsSection: Component = () => {
setCommandText(profile?.kind === "docker" ? formatStringList(profile.command) : "")
setExtraDockerArgsText(profile?.kind === "docker" ? formatStringList(profile.extraDockerArgs) : "")
setExecutable(profile?.kind === "command" ? profile.executable : "")
- setArgsText(profile?.kind === "command" ? formatStringList(profile.args) : "")
+ 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)
}
@@ -190,6 +205,36 @@ export const ExecutionProfilesSettingsSection: Component = () => {
}
}
+ 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"))
@@ -319,7 +364,7 @@ export const ExecutionProfilesSettingsSection: Component = () => {
setName(event.currentTarget.value)} />
-
+
{t("settings.opencode.executionProfiles.form.binaryPath.label")}
@@ -329,6 +374,45 @@ export const ExecutionProfilesSettingsSection: Component = () => {
+
+
+
+
{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)} />
+
+
+
+
+
+
+
diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts
index ce0919d92..824ea9ec3 100644
--- a/packages/ui/src/lib/i18n/messages/en/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/en/settings.ts
@@ -127,6 +127,7 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.kind.wsl": "WSL",
"settings.opencode.executionProfiles.kind.docker": "Docker",
"settings.opencode.executionProfiles.kind.command": "Command",
+ "settings.opencode.executionProfiles.kind.ssh": "SSH",
"settings.opencode.executionProfiles.form.kind.label": "Profile type",
"settings.opencode.executionProfiles.form.kind.subtitle": "Choose the runtime strategy for this profile.",
"settings.opencode.executionProfiles.form.name.label": "Profile name",
@@ -160,6 +161,18 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
"settings.opencode.executionProfiles.form.args.label": "Arguments",
"settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.sshHost.label": "SSH host",
+ "settings.opencode.executionProfiles.form.sshHost.subtitle": "Remote SSH server used to run OpenCode.",
+ "settings.opencode.executionProfiles.form.sshHost.placeholder": "vm.example.com",
+ "settings.opencode.executionProfiles.form.sshPort.label": "SSH port",
+ "settings.opencode.executionProfiles.form.sshPort.subtitle": "Port used by the remote SSH server.",
+ "settings.opencode.executionProfiles.form.sshPort.placeholder": "22",
+ "settings.opencode.executionProfiles.form.sshUsername.label": "SSH username",
+ "settings.opencode.executionProfiles.form.sshUsername.subtitle": "Optional login user for the remote host.",
+ "settings.opencode.executionProfiles.form.sshUsername.placeholder": "ubuntu",
+ "settings.opencode.executionProfiles.form.sshRemotePath.label": "Remote workspace path",
+ "settings.opencode.executionProfiles.form.sshRemotePath.subtitle": "Workspace directory on the remote SSH host.",
+ "settings.opencode.executionProfiles.form.sshRemotePath.placeholder": "/srv/project",
"settings.opencode.executionProfiles.form.previewWorkspacePath.label": "Preview workspace path",
"settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle": "Optional sample workspace path used to resolve cwd and Docker mounts.",
"settings.opencode.executionProfiles.form.previewWorkspacePath.placeholder": "D:\\CodeNomad",
@@ -194,6 +207,9 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
"settings.opencode.executionProfiles.validation.docker": "Docker image and mount paths are required.",
"settings.opencode.executionProfiles.validation.executable": "Executable is required.",
+ "settings.opencode.executionProfiles.validation.sshHost": "SSH host is required.",
+ "settings.opencode.executionProfiles.validation.sshPort": "SSH port must be between 1 and 65535.",
+ "settings.opencode.executionProfiles.validation.sshRemotePath": "Remote workspace path is required.",
"settings.appearance.behavior.title": "Interaction",
diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts
index edad3d8ee..9d0b66453 100644
--- a/packages/ui/src/lib/i18n/messages/es/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/es/settings.ts
@@ -127,6 +127,7 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.kind.wsl": "WSL",
"settings.opencode.executionProfiles.kind.docker": "Docker",
"settings.opencode.executionProfiles.kind.command": "Command",
+ "settings.opencode.executionProfiles.kind.ssh": "SSH",
"settings.opencode.executionProfiles.form.kind.label": "Profile type",
"settings.opencode.executionProfiles.form.kind.subtitle": "Choose the runtime strategy for this profile.",
"settings.opencode.executionProfiles.form.name.label": "Profile name",
@@ -160,6 +161,18 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
"settings.opencode.executionProfiles.form.args.label": "Arguments",
"settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.sshHost.label": "SSH host",
+ "settings.opencode.executionProfiles.form.sshHost.subtitle": "Remote SSH server used to run OpenCode.",
+ "settings.opencode.executionProfiles.form.sshHost.placeholder": "vm.example.com",
+ "settings.opencode.executionProfiles.form.sshPort.label": "SSH port",
+ "settings.opencode.executionProfiles.form.sshPort.subtitle": "Port used by the remote SSH server.",
+ "settings.opencode.executionProfiles.form.sshPort.placeholder": "22",
+ "settings.opencode.executionProfiles.form.sshUsername.label": "SSH username",
+ "settings.opencode.executionProfiles.form.sshUsername.subtitle": "Optional login user for the remote host.",
+ "settings.opencode.executionProfiles.form.sshUsername.placeholder": "ubuntu",
+ "settings.opencode.executionProfiles.form.sshRemotePath.label": "Remote workspace path",
+ "settings.opencode.executionProfiles.form.sshRemotePath.subtitle": "Workspace directory on the remote SSH host.",
+ "settings.opencode.executionProfiles.form.sshRemotePath.placeholder": "/srv/project",
"settings.opencode.executionProfiles.form.previewWorkspacePath.label": "Preview workspace path",
"settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle": "Optional sample workspace path used to resolve cwd and Docker mounts.",
"settings.opencode.executionProfiles.form.previewWorkspacePath.placeholder": "D:\\CodeNomad",
@@ -194,6 +207,9 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
"settings.opencode.executionProfiles.validation.docker": "Docker image and mount paths are required.",
"settings.opencode.executionProfiles.validation.executable": "Executable is required.",
+ "settings.opencode.executionProfiles.validation.sshHost": "SSH host is required.",
+ "settings.opencode.executionProfiles.validation.sshPort": "SSH port must be between 1 and 65535.",
+ "settings.opencode.executionProfiles.validation.sshRemotePath": "Remote workspace path is required.",
"settings.appearance.behavior.title": "Interaccion",
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts
index 1861b140b..4206fb4e0 100644
--- a/packages/ui/src/lib/i18n/messages/fr/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts
@@ -127,6 +127,7 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.kind.wsl": "WSL",
"settings.opencode.executionProfiles.kind.docker": "Docker",
"settings.opencode.executionProfiles.kind.command": "Command",
+ "settings.opencode.executionProfiles.kind.ssh": "SSH",
"settings.opencode.executionProfiles.form.kind.label": "Profile type",
"settings.opencode.executionProfiles.form.kind.subtitle": "Choose the runtime strategy for this profile.",
"settings.opencode.executionProfiles.form.name.label": "Profile name",
@@ -160,6 +161,18 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
"settings.opencode.executionProfiles.form.args.label": "Arguments",
"settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.sshHost.label": "SSH host",
+ "settings.opencode.executionProfiles.form.sshHost.subtitle": "Remote SSH server used to run OpenCode.",
+ "settings.opencode.executionProfiles.form.sshHost.placeholder": "vm.example.com",
+ "settings.opencode.executionProfiles.form.sshPort.label": "SSH port",
+ "settings.opencode.executionProfiles.form.sshPort.subtitle": "Port used by the remote SSH server.",
+ "settings.opencode.executionProfiles.form.sshPort.placeholder": "22",
+ "settings.opencode.executionProfiles.form.sshUsername.label": "SSH username",
+ "settings.opencode.executionProfiles.form.sshUsername.subtitle": "Optional login user for the remote host.",
+ "settings.opencode.executionProfiles.form.sshUsername.placeholder": "ubuntu",
+ "settings.opencode.executionProfiles.form.sshRemotePath.label": "Remote workspace path",
+ "settings.opencode.executionProfiles.form.sshRemotePath.subtitle": "Workspace directory on the remote SSH host.",
+ "settings.opencode.executionProfiles.form.sshRemotePath.placeholder": "/srv/project",
"settings.opencode.executionProfiles.form.previewWorkspacePath.label": "Preview workspace path",
"settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle": "Optional sample workspace path used to resolve cwd and Docker mounts.",
"settings.opencode.executionProfiles.form.previewWorkspacePath.placeholder": "D:\\CodeNomad",
@@ -194,6 +207,9 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
"settings.opencode.executionProfiles.validation.docker": "Docker image and mount paths are required.",
"settings.opencode.executionProfiles.validation.executable": "Executable is required.",
+ "settings.opencode.executionProfiles.validation.sshHost": "SSH host is required.",
+ "settings.opencode.executionProfiles.validation.sshPort": "SSH port must be between 1 and 65535.",
+ "settings.opencode.executionProfiles.validation.sshRemotePath": "Remote workspace path is required.",
"settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts
index ec13fbbcd..2be76e152 100644
--- a/packages/ui/src/lib/i18n/messages/he/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/he/settings.ts
@@ -126,6 +126,7 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.kind.wsl": "WSL",
"settings.opencode.executionProfiles.kind.docker": "Docker",
"settings.opencode.executionProfiles.kind.command": "Command",
+ "settings.opencode.executionProfiles.kind.ssh": "SSH",
"settings.opencode.executionProfiles.form.kind.label": "Profile type",
"settings.opencode.executionProfiles.form.kind.subtitle": "Choose the runtime strategy for this profile.",
"settings.opencode.executionProfiles.form.name.label": "Profile name",
@@ -159,6 +160,18 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
"settings.opencode.executionProfiles.form.args.label": "Arguments",
"settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.sshHost.label": "SSH host",
+ "settings.opencode.executionProfiles.form.sshHost.subtitle": "Remote SSH server used to run OpenCode.",
+ "settings.opencode.executionProfiles.form.sshHost.placeholder": "vm.example.com",
+ "settings.opencode.executionProfiles.form.sshPort.label": "SSH port",
+ "settings.opencode.executionProfiles.form.sshPort.subtitle": "Port used by the remote SSH server.",
+ "settings.opencode.executionProfiles.form.sshPort.placeholder": "22",
+ "settings.opencode.executionProfiles.form.sshUsername.label": "SSH username",
+ "settings.opencode.executionProfiles.form.sshUsername.subtitle": "Optional login user for the remote host.",
+ "settings.opencode.executionProfiles.form.sshUsername.placeholder": "ubuntu",
+ "settings.opencode.executionProfiles.form.sshRemotePath.label": "Remote workspace path",
+ "settings.opencode.executionProfiles.form.sshRemotePath.subtitle": "Workspace directory on the remote SSH host.",
+ "settings.opencode.executionProfiles.form.sshRemotePath.placeholder": "/srv/project",
"settings.opencode.executionProfiles.form.previewWorkspacePath.label": "Preview workspace path",
"settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle": "Optional sample workspace path used to resolve cwd and Docker mounts.",
"settings.opencode.executionProfiles.form.previewWorkspacePath.placeholder": "D:\\CodeNomad",
@@ -193,6 +206,9 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
"settings.opencode.executionProfiles.validation.docker": "Docker image and mount paths are required.",
"settings.opencode.executionProfiles.validation.executable": "Executable is required.",
+ "settings.opencode.executionProfiles.validation.sshHost": "SSH host is required.",
+ "settings.opencode.executionProfiles.validation.sshPort": "SSH port must be between 1 and 65535.",
+ "settings.opencode.executionProfiles.validation.sshRemotePath": "Remote workspace path is required.",
"settings.appearance.behavior.title": "אינטראקציה",
"settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.",
diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts
index e8f7c9a4f..b691f0ab8 100644
--- a/packages/ui/src/lib/i18n/messages/ja/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts
@@ -127,6 +127,7 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.kind.wsl": "WSL",
"settings.opencode.executionProfiles.kind.docker": "Docker",
"settings.opencode.executionProfiles.kind.command": "Command",
+ "settings.opencode.executionProfiles.kind.ssh": "SSH",
"settings.opencode.executionProfiles.form.kind.label": "Profile type",
"settings.opencode.executionProfiles.form.kind.subtitle": "Choose the runtime strategy for this profile.",
"settings.opencode.executionProfiles.form.name.label": "Profile name",
@@ -160,6 +161,18 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
"settings.opencode.executionProfiles.form.args.label": "Arguments",
"settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.sshHost.label": "SSH host",
+ "settings.opencode.executionProfiles.form.sshHost.subtitle": "Remote SSH server used to run OpenCode.",
+ "settings.opencode.executionProfiles.form.sshHost.placeholder": "vm.example.com",
+ "settings.opencode.executionProfiles.form.sshPort.label": "SSH port",
+ "settings.opencode.executionProfiles.form.sshPort.subtitle": "Port used by the remote SSH server.",
+ "settings.opencode.executionProfiles.form.sshPort.placeholder": "22",
+ "settings.opencode.executionProfiles.form.sshUsername.label": "SSH username",
+ "settings.opencode.executionProfiles.form.sshUsername.subtitle": "Optional login user for the remote host.",
+ "settings.opencode.executionProfiles.form.sshUsername.placeholder": "ubuntu",
+ "settings.opencode.executionProfiles.form.sshRemotePath.label": "Remote workspace path",
+ "settings.opencode.executionProfiles.form.sshRemotePath.subtitle": "Workspace directory on the remote SSH host.",
+ "settings.opencode.executionProfiles.form.sshRemotePath.placeholder": "/srv/project",
"settings.opencode.executionProfiles.form.previewWorkspacePath.label": "Preview workspace path",
"settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle": "Optional sample workspace path used to resolve cwd and Docker mounts.",
"settings.opencode.executionProfiles.form.previewWorkspacePath.placeholder": "D:\\CodeNomad",
@@ -194,6 +207,9 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
"settings.opencode.executionProfiles.validation.docker": "Docker image and mount paths are required.",
"settings.opencode.executionProfiles.validation.executable": "Executable is required.",
+ "settings.opencode.executionProfiles.validation.sshHost": "SSH host is required.",
+ "settings.opencode.executionProfiles.validation.sshPort": "SSH port must be between 1 and 65535.",
+ "settings.opencode.executionProfiles.validation.sshRemotePath": "Remote workspace path is required.",
"settings.appearance.behavior.title": "操作",
"settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。",
diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts
index 3c7d704e6..9cadd6004 100644
--- a/packages/ui/src/lib/i18n/messages/ru/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts
@@ -127,6 +127,7 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.kind.wsl": "WSL",
"settings.opencode.executionProfiles.kind.docker": "Docker",
"settings.opencode.executionProfiles.kind.command": "Command",
+ "settings.opencode.executionProfiles.kind.ssh": "SSH",
"settings.opencode.executionProfiles.form.kind.label": "Profile type",
"settings.opencode.executionProfiles.form.kind.subtitle": "Choose the runtime strategy for this profile.",
"settings.opencode.executionProfiles.form.name.label": "Profile name",
@@ -160,6 +161,18 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
"settings.opencode.executionProfiles.form.args.label": "Arguments",
"settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.sshHost.label": "SSH host",
+ "settings.opencode.executionProfiles.form.sshHost.subtitle": "Remote SSH server used to run OpenCode.",
+ "settings.opencode.executionProfiles.form.sshHost.placeholder": "vm.example.com",
+ "settings.opencode.executionProfiles.form.sshPort.label": "SSH port",
+ "settings.opencode.executionProfiles.form.sshPort.subtitle": "Port used by the remote SSH server.",
+ "settings.opencode.executionProfiles.form.sshPort.placeholder": "22",
+ "settings.opencode.executionProfiles.form.sshUsername.label": "SSH username",
+ "settings.opencode.executionProfiles.form.sshUsername.subtitle": "Optional login user for the remote host.",
+ "settings.opencode.executionProfiles.form.sshUsername.placeholder": "ubuntu",
+ "settings.opencode.executionProfiles.form.sshRemotePath.label": "Remote workspace path",
+ "settings.opencode.executionProfiles.form.sshRemotePath.subtitle": "Workspace directory on the remote SSH host.",
+ "settings.opencode.executionProfiles.form.sshRemotePath.placeholder": "/srv/project",
"settings.opencode.executionProfiles.form.previewWorkspacePath.label": "Preview workspace path",
"settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle": "Optional sample workspace path used to resolve cwd and Docker mounts.",
"settings.opencode.executionProfiles.form.previewWorkspacePath.placeholder": "D:\\CodeNomad",
@@ -194,6 +207,9 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
"settings.opencode.executionProfiles.validation.docker": "Docker image and mount paths are required.",
"settings.opencode.executionProfiles.validation.executable": "Executable is required.",
+ "settings.opencode.executionProfiles.validation.sshHost": "SSH host is required.",
+ "settings.opencode.executionProfiles.validation.sshPort": "SSH port must be between 1 and 65535.",
+ "settings.opencode.executionProfiles.validation.sshRemotePath": "Remote workspace path is required.",
"settings.appearance.behavior.title": "Взаимодействие",
"settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.",
diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts
index 873fb6f78..b0b65c147 100644
--- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts
@@ -127,6 +127,7 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.kind.wsl": "WSL",
"settings.opencode.executionProfiles.kind.docker": "Docker",
"settings.opencode.executionProfiles.kind.command": "Command",
+ "settings.opencode.executionProfiles.kind.ssh": "SSH",
"settings.opencode.executionProfiles.form.kind.label": "Profile type",
"settings.opencode.executionProfiles.form.kind.subtitle": "Choose the runtime strategy for this profile.",
"settings.opencode.executionProfiles.form.name.label": "Profile name",
@@ -160,6 +161,18 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.form.cwdMode.inherit": "Inherit current process cwd",
"settings.opencode.executionProfiles.form.args.label": "Arguments",
"settings.opencode.executionProfiles.form.args.placeholder": "One argument per line",
+ "settings.opencode.executionProfiles.form.sshHost.label": "SSH host",
+ "settings.opencode.executionProfiles.form.sshHost.subtitle": "Remote SSH server used to run OpenCode.",
+ "settings.opencode.executionProfiles.form.sshHost.placeholder": "vm.example.com",
+ "settings.opencode.executionProfiles.form.sshPort.label": "SSH port",
+ "settings.opencode.executionProfiles.form.sshPort.subtitle": "Port used by the remote SSH server.",
+ "settings.opencode.executionProfiles.form.sshPort.placeholder": "22",
+ "settings.opencode.executionProfiles.form.sshUsername.label": "SSH username",
+ "settings.opencode.executionProfiles.form.sshUsername.subtitle": "Optional login user for the remote host.",
+ "settings.opencode.executionProfiles.form.sshUsername.placeholder": "ubuntu",
+ "settings.opencode.executionProfiles.form.sshRemotePath.label": "Remote workspace path",
+ "settings.opencode.executionProfiles.form.sshRemotePath.subtitle": "Workspace directory on the remote SSH host.",
+ "settings.opencode.executionProfiles.form.sshRemotePath.placeholder": "/srv/project",
"settings.opencode.executionProfiles.form.previewWorkspacePath.label": "Preview workspace path",
"settings.opencode.executionProfiles.form.previewWorkspacePath.subtitle": "Optional sample workspace path used to resolve cwd and Docker mounts.",
"settings.opencode.executionProfiles.form.previewWorkspacePath.placeholder": "D:\\CodeNomad",
@@ -194,6 +207,9 @@ export const settingsMessages = {
"settings.opencode.executionProfiles.validation.distro": "WSL distro is required.",
"settings.opencode.executionProfiles.validation.docker": "Docker image and mount paths are required.",
"settings.opencode.executionProfiles.validation.executable": "Executable is required.",
+ "settings.opencode.executionProfiles.validation.sshHost": "SSH host is required.",
+ "settings.opencode.executionProfiles.validation.sshPort": "SSH port must be between 1 and 65535.",
+ "settings.opencode.executionProfiles.validation.sshRemotePath": "Remote workspace path is required.",
"settings.appearance.behavior.title": "交互",
"settings.appearance.behavior.subtitle": "消息、差异与输入的默认值。",
diff --git a/packages/ui/src/types/instance.ts b/packages/ui/src/types/instance.ts
index 52bae6eea..a52047e93 100644
--- a/packages/ui/src/types/instance.ts
+++ b/packages/ui/src/types/instance.ts
@@ -45,6 +45,6 @@ export interface Instance {
binaryVersion?: string
executionProfileId?: string
executionProfileName?: string
- executionProfileKind?: "local" | "wsl" | "docker" | "command"
+ executionProfileKind?: "local" | "wsl" | "docker" | "command" | "ssh"
environmentVariables?: Record
}
From 695ac752422dca9027242fe260027d52633717bc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pascal=20Andr=C3=A9?=
Date: Sat, 9 May 2026 01:38:23 +0200
Subject: [PATCH 14/17] fix(runtime): harden forwarded execution profiles
Keeps Docker and SSH execution profiles reachable from the current CodeNomad server by reserving host ports before launch and using fixed OpenCode runtime ports for forwarded transports.
Docker profiles now publish the reserved runtime port on localhost instead of relying on an unexposed container port. SSH profiles send the remote shell script over stdin, validate environment variable names before interpolation, and avoid placing callback secrets in the local ssh argv.
The UI preferences normalizer now preserves saved SSH execution profiles so settings round-trips do not drop them. Coverage adds Docker reserved-port assertions and an unsafe SSH environment-name regression test.
---
packages/server/src/server/routes/settings.ts | 2 +-
.../src/workspaces/execution-launch.test.ts | 54 +++++++++++++++++--
.../server/src/workspaces/execution-launch.ts | 54 ++++++++++++++-----
packages/server/src/workspaces/manager.ts | 3 +-
packages/server/src/workspaces/runtime.ts | 7 ++-
packages/ui/src/stores/preferences.tsx | 21 ++++++++
6 files changed, 121 insertions(+), 20 deletions(-)
diff --git a/packages/server/src/server/routes/settings.ts b/packages/server/src/server/routes/settings.ts
index c1ba93d63..34debbf80 100644
--- a/packages/server/src/server/routes/settings.ts
+++ b/packages/server/src/server/routes/settings.ts
@@ -174,7 +174,7 @@ function buildRequestBaseUrl(request: FastifyRequest): string {
}
function getPreviewReservedPort(profile: z.infer): number | undefined {
- return profile.kind === "ssh" ? 17600 : undefined
+ return profile.kind === "docker" || profile.kind === "ssh" ? 17600 : undefined
}
function getPreviewCallbackPort(profile: z.infer): number | undefined {
diff --git a/packages/server/src/workspaces/execution-launch.test.ts b/packages/server/src/workspaces/execution-launch.test.ts
index ff2fd24a3..cb109cd49 100644
--- a/packages/server/src/workspaces/execution-launch.test.ts
+++ b/packages/server/src/workspaces/execution-launch.test.ts
@@ -48,6 +48,7 @@ describe("buildLaunchCommand", () => {
OPENCODE_SERVER_BASE_URL: "https://127.0.0.1:9898/workspaces/abc/worktrees/root/instance",
},
logLevel: "INFO",
+ reservedPort: 17600,
})
assert.equal(result.command, "docker")
@@ -55,13 +56,34 @@ describe("buildLaunchCommand", () => {
assert.ok(result.args.includes("D:/CodeNomad:/workspace"))
assert.ok(result.args.includes("C:/Users/Admin/.config/opencode:/root/.config/opencode"))
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_DIR"))
assert.ok(result.args.includes("NODE_EXTRA_CA_CERTS"))
assert.equal(result.environment?.CODENOMAD_BASE_URL, "https://host.docker.internal:9898")
assert.equal(result.environment?.OPENCODE_CONFIG_DIR, "/root/.config/opencode")
assert.equal(result.environment?.NODE_EXTRA_CA_CERTS, "/tmp/codenomad-node-extra-ca.pem")
- assert.deepEqual(result.args.slice(-6), ["serve", "--port", "0", "--print-logs", "--log-level", "INFO"])
+ assert.deepEqual(result.args.slice(-6), ["serve", "--port", "17600", "--print-logs", "--log-level", "INFO"])
+ })
+
+ 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_DIR: "C:/Users/Admin/.config/opencode" },
+ logLevel: "INFO",
+ }),
+ /Reserved local port is required/,
+ )
})
it("builds an SSH execution profile launch with forward and reverse tunnels", () => {
@@ -91,8 +113,34 @@ describe("buildLaunchCommand", () => {
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.ok(result.args.some((arg) => arg.includes("opencode") && arg.includes("--port") && arg.includes("17600")))
- assert.ok(result.args.some((arg) => arg.includes("OPENCODE_SERVER_BASE_URL='http://127.0.0.1:17601/workspaces/abc/worktrees/root/instance'")))
+ 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", () => {
diff --git a/packages/server/src/workspaces/execution-launch.ts b/packages/server/src/workspaces/execution-launch.ts
index a61ab7ea5..9b7d3c20d 100644
--- a/packages/server/src/workspaces/execution-launch.ts
+++ b/packages/server/src/workspaces/execution-launch.ts
@@ -11,6 +11,7 @@ export interface LaunchCommandSpec {
cwd?: string
environment?: Record
wslDistro?: string
+ stdin?: string
}
interface BuildLaunchCommandParams {
@@ -23,11 +24,14 @@ interface BuildLaunchCommandParams {
}
export function buildLaunchCommand(params: BuildLaunchCommandParams): LaunchCommandSpec {
- const openCodePort = params.execution.kind === "ssh" && params.reservedPort ? String(params.reservedPort) : "0"
+ 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") {
- return buildDockerLaunchCommand(params.execution, params.workspacePath, params.environment, openCodeArgs)
+ 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") {
@@ -74,16 +78,7 @@ function buildSshLaunchCommand(
const target = username ? `${username}@${host}` : host
const remoteEnvironment = rewriteSshCallbackEnvironment(environment, callbackPort)
- const remoteCommand = [
- "cd",
- shellQuote(execution.remotePath),
- "&&",
- "env",
- ...Object.entries(remoteEnvironment).map(([key, value]) => `${key}=${shellQuote(value)}`),
- shellQuote(execution.binaryPath),
- ...(execution.args ?? []).map(shellQuote),
- ...openCodeArgs.map(shellQuote),
- ].join(" ")
+ const remoteScript = buildSshRemoteScript(execution, remoteEnvironment, openCodeArgs)
return {
command: "ssh",
@@ -100,13 +95,37 @@ function buildSshLaunchCommand(
`127.0.0.1:${callbackPort}:127.0.0.1:${getUrlPort(environment.CODENOMAD_BASE_URL) ?? 9898}`,
target,
"sh",
- "-lc",
- remoteCommand,
+ "-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 ?? {}
@@ -136,6 +155,7 @@ function buildDockerLaunchCommand(
workspacePath: string,
environment: Record,
openCodeArgs: string[],
+ forwardedPort: number,
): LaunchCommandSpec {
const configDir = environment.OPENCODE_CONFIG_DIR?.trim()
if (!configDir) {
@@ -161,6 +181,8 @@ function buildDockerLaunchCommand(
execution.workspaceMountPath,
"--add-host",
`${DOCKER_HOST_ALIAS}:host-gateway`,
+ "-p",
+ `127.0.0.1:${forwardedPort}:${forwardedPort}`,
"-v",
`${workspacePath}:${execution.workspaceMountPath}`,
"-v",
@@ -233,6 +255,10 @@ function shellQuote(value: string): string {
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"]) {
diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts
index 4c72ddaba..eeeec3ca0 100644
--- a/packages/server/src/workspaces/manager.ts
+++ b/packages/server/src/workspaces/manager.ts
@@ -154,7 +154,7 @@ export class WorkspaceManager {
}
const logLevel = (serverConfig as any)?.logLevel
- const reservedPort = execution.kind === "ssh" ? await this.getAvailablePort() : undefined
+ 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,
@@ -176,6 +176,7 @@ export class WorkspaceManager {
spawnCwd: launchCommand.cwd,
environment: launchCommand.environment,
wslDistro: launchCommand.wslDistro,
+ stdin: launchCommand.stdin,
logLevel,
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})
diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts
index df71965d3..0f597d428 100644
--- a/packages/server/src/workspaces/runtime.ts
+++ b/packages/server/src/workspaces/runtime.ts
@@ -44,6 +44,7 @@ interface LaunchOptions {
spawnCwd?: string
environment?: Record
wslDistro?: string
+ stdin?: string
logLevel?: string
onExit?: (info: ProcessExitInfo) => void
}
@@ -139,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/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx
index c90169101..5c86e2168 100644
--- a/packages/ui/src/stores/preferences.tsx
+++ b/packages/ui/src/stores/preferences.tsx
@@ -272,6 +272,27 @@ function normalizeExecutionProfiles(value: unknown): ExecutionProfile[] {
args: normalizeStringList((entry as any).args),
cwdMode,
})
+ continue
+ }
+
+ if (kind === "ssh") {
+ const host = typeof (entry as any).host === "string" ? (entry as any).host.trim() : ""
+ const remotePath = typeof (entry as any).remotePath === "string" ? (entry as any).remotePath.trim() : ""
+ const binaryPath = typeof (entry as any).binaryPath === "string" ? (entry as any).binaryPath.trim() : ""
+ const port = typeof (entry as any).port === "number" && Number.isInteger((entry as any).port) ? (entry as any).port : undefined
+ const username = typeof (entry as any).username === "string" ? (entry as any).username.trim() : ""
+ if (!host || !remotePath || !binaryPath) continue
+ profiles.push({
+ id,
+ name,
+ kind,
+ host,
+ port: port && port > 0 && port <= 65535 ? port : undefined,
+ username: username || undefined,
+ remotePath,
+ binaryPath,
+ args: normalizeStringList((entry as any).args),
+ })
}
}
From c0e95083ad843b5aee47bfd07d5ed3fd9e207e2f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pascal=20Andr=C3=A9?=
Date: Sat, 9 May 2026 01:40:07 +0200
Subject: [PATCH 15/17] fix(runtime): bind docker profile runtimes externally
Docker execution profiles publish a reserved localhost port on the host, so OpenCode must bind to all container interfaces rather than the default loopback address inside the container.
Adds --hostname 0.0.0.0 only for Docker launches while leaving local, WSL, command, and SSH behavior unchanged. The launch test asserts the fixed port, publish mapping, and container bind host stay aligned.
---
packages/server/src/workspaces/execution-launch.test.ts | 2 +-
packages/server/src/workspaces/execution-launch.ts | 3 +++
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/packages/server/src/workspaces/execution-launch.test.ts b/packages/server/src/workspaces/execution-launch.test.ts
index cb109cd49..605f4db8b 100644
--- a/packages/server/src/workspaces/execution-launch.test.ts
+++ b/packages/server/src/workspaces/execution-launch.test.ts
@@ -63,7 +63,7 @@ describe("buildLaunchCommand", () => {
assert.equal(result.environment?.CODENOMAD_BASE_URL, "https://host.docker.internal:9898")
assert.equal(result.environment?.OPENCODE_CONFIG_DIR, "/root/.config/opencode")
assert.equal(result.environment?.NODE_EXTRA_CA_CERTS, "/tmp/codenomad-node-extra-ca.pem")
- assert.deepEqual(result.args.slice(-6), ["serve", "--port", "17600", "--print-logs", "--log-level", "INFO"])
+ 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", () => {
diff --git a/packages/server/src/workspaces/execution-launch.ts b/packages/server/src/workspaces/execution-launch.ts
index 9b7d3c20d..6eb47ace2 100644
--- a/packages/server/src/workspaces/execution-launch.ts
+++ b/packages/server/src/workspaces/execution-launch.ts
@@ -26,6 +26,9 @@ interface BuildLaunchCommandParams {
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) {
From a0d54eda57de3ce895bcf26a66067e6098975074 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pascal=20Andr=C3=A9?=
Date: Sat, 9 May 2026 09:54:00 +0200
Subject: [PATCH 16/17] fix(runtime): sync config for ssh profiles
SSH execution profiles run OpenCode on a different filesystem, so the server-local OpenCode config template cannot be referenced directly by OPENCODE_CONFIG_DIR.
Before launching the remote runtime, CodeNomad now archives the packaged OpenCode config template and streams it over ssh into a per-workspace directory under /tmp on the remote host. The SSH launch environment then points OPENCODE_CONFIG_DIR at that remote directory and best-effort cleanup removes it when the runtime exits or startup fails.
This keeps SSH as an execution profile for the current CodeNomad server while addressing the config locality issue raised in review. Verified with server/UI typechecks, focused runtime tests, and the UI production build.
---
packages/server/src/workspaces/manager.ts | 88 ++++++++++++++++++++++-
1 file changed, 86 insertions(+), 2 deletions(-)
diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts
index eeeec3ca0..125b1e2dd 100644
--- a/packages/server/src/workspaces/manager.ts
+++ b/packages/server/src/workspaces/manager.ts
@@ -3,7 +3,7 @@ import { spawnSync } from "child_process"
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"
@@ -35,6 +35,11 @@ interface WorkspaceManagerOptions {
interface WorkspaceRecord extends WorkspaceDescriptor {}
+function shellQuote(value: string): string {
+ if (!value) return "''"
+ return `'${value.replace(/'/g, `'"'"'`)}'`
+}
+
export class WorkspaceManager {
private readonly workspaces = new Map()
private readonly runtime: WorkspaceRuntime
@@ -153,6 +158,11 @@ export class WorkspaceManager {
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
}
+ const sshRemoteConfigDir = execution.kind === "ssh" ? await this.syncSshOpencodeConfig(execution, id) : undefined
+ if (sshRemoteConfigDir) {
+ environment.OPENCODE_CONFIG_DIR = sshRemoteConfigDir
+ }
+
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
@@ -178,7 +188,12 @@ export class WorkspaceManager {
wslDistro: launchCommand.wslDistro,
stdin: launchCommand.stdin,
logLevel,
- onExit: (info) => this.handleProcessExit(info.workspaceId, info),
+ onExit: (info) => {
+ if (execution.kind === "ssh" && sshRemoteConfigDir) {
+ this.cleanupSshOpencodeConfig(execution, sshRemoteConfigDir)
+ }
+ this.handleProcessExit(info.workspaceId, info)
+ },
})
launchedPid = pid
@@ -205,6 +220,9 @@ export class WorkspaceManager {
this.options.logger.warn({ workspaceId: id, err: stopError }, "Failed to stop workspace after startup failure")
})
}
+ if (execution.kind === "ssh" && sshRemoteConfigDir) {
+ this.cleanupSshOpencodeConfig(execution, sshRemoteConfigDir)
+ }
throw error
}
}
@@ -502,6 +520,72 @@ export class WorkspaceManager {
})
}
+ private async syncSshOpencodeConfig(execution: Extract, workspaceId: string): Promise {
+ const remoteConfigDir = `/tmp/codenomad-opencode-config-${workspaceId}`
+ const tarResult = spawnSync("tar", ["-C", this.opencodeConfigDir, "-cf", "-", "."], {
+ encoding: "buffer",
+ maxBuffer: 50 * 1024 * 1024,
+ })
+ if (tarResult.error) {
+ throw tarResult.error
+ }
+ if (tarResult.status !== 0 || !tarResult.stdout?.length) {
+ throw new Error(`Failed to archive OpenCode config for SSH profile: ${tarResult.stderr?.toString() || `tar exited with ${tarResult.status}`}`)
+ }
+
+ const sshArgs = this.buildSshCommandArgs(execution, [
+ "sh",
+ "-lc",
+ `rm -rf ${shellQuote(remoteConfigDir)} && mkdir -p ${shellQuote(remoteConfigDir)} && tar -xf - -C ${shellQuote(remoteConfigDir)}`,
+ ])
+ const sshResult = spawnSync("ssh", sshArgs, {
+ input: tarResult.stdout,
+ encoding: "buffer",
+ maxBuffer: 10 * 1024 * 1024,
+ })
+ if (sshResult.error) {
+ throw sshResult.error
+ }
+ if (sshResult.status !== 0) {
+ throw new Error(`Failed to sync OpenCode config to SSH host: ${sshResult.stderr?.toString() || `ssh exited with ${sshResult.status}`}`)
+ }
+
+ return remoteConfigDir
+ }
+
+ private cleanupSshOpencodeConfig(execution: Extract, remoteConfigDir: string): void {
+ const result = spawnSync("ssh", this.buildSshCommandArgs(execution, ["sh", "-lc", `rm -rf ${shellQuote(remoteConfigDir)}`]), {
+ 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 config directory")
+ }
+ }
+
+ private buildSshCommandArgs(execution: Extract, remoteArgs: string[]): 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 [
+ "-p",
+ String(execution.port ?? 22),
+ "-o",
+ "BatchMode=yes",
+ "-o",
+ "ExitOnForwardFailure=yes",
+ username ? `${username}@${host}` : host,
+ ...remoteArgs,
+ ]
+ }
+
private delay(durationMs: number): Promise {
if (durationMs <= 0) {
return Promise.resolve()
From a1c60afaa400285972f584d5ead29d8b8fa9a555 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pascal=20Andr=C3=A9?=
Date: Sat, 9 May 2026 10:29:49 +0200
Subject: [PATCH 17/17] fix(runtime): copy ssh config with scp
Use scp to copy the packaged OpenCode config directory to SSH execution hosts before launch instead of streaming a tar archive. This keeps SSH profiles independent from a local-only OPENCODE_CONFIG_DIR while avoiding a tar dependency on either side of the connection.
The remote temp directory is still cleaned before copy and after runtime shutdown, and the SSH target validation is shared between ssh and scp command construction. Validated with the server typecheck and focused workspace/settings tests.
---
packages/server/src/workspaces/manager.ts | 71 ++++++++++++++---------
1 file changed, 42 insertions(+), 29 deletions(-)
diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts
index 125b1e2dd..2f198a774 100644
--- a/packages/server/src/workspaces/manager.ts
+++ b/packages/server/src/workspaces/manager.ts
@@ -522,32 +522,31 @@ export class WorkspaceManager {
private async syncSshOpencodeConfig(execution: Extract, workspaceId: string): Promise {
const remoteConfigDir = `/tmp/codenomad-opencode-config-${workspaceId}`
- const tarResult = spawnSync("tar", ["-C", this.opencodeConfigDir, "-cf", "-", "."], {
- encoding: "buffer",
- maxBuffer: 50 * 1024 * 1024,
- })
- if (tarResult.error) {
- throw tarResult.error
- }
- if (tarResult.status !== 0 || !tarResult.stdout?.length) {
- throw new Error(`Failed to archive OpenCode config for SSH profile: ${tarResult.stderr?.toString() || `tar exited with ${tarResult.status}`}`)
- }
-
const sshArgs = this.buildSshCommandArgs(execution, [
"sh",
"-lc",
- `rm -rf ${shellQuote(remoteConfigDir)} && mkdir -p ${shellQuote(remoteConfigDir)} && tar -xf - -C ${shellQuote(remoteConfigDir)}`,
+ `rm -rf ${shellQuote(remoteConfigDir)}`,
])
- const sshResult = spawnSync("ssh", sshArgs, {
- input: tarResult.stdout,
- encoding: "buffer",
+ const cleanupResult = spawnSync("ssh", sshArgs, {
+ encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
})
- if (sshResult.error) {
- throw sshResult.error
+ if (cleanupResult.error) {
+ throw cleanupResult.error
}
- if (sshResult.status !== 0) {
- throw new Error(`Failed to sync OpenCode config to SSH host: ${sshResult.stderr?.toString() || `ssh exited with ${sshResult.status}`}`)
+ if (cleanupResult.status !== 0) {
+ throw new Error(`Failed to prepare SSH OpenCode config directory: ${cleanupResult.stderr || `ssh exited with ${cleanupResult.status}`}`)
+ }
+
+ const scpResult = spawnSync("scp", this.buildScpCommandArgs(execution, ["-r", this.opencodeConfigDir, `${this.buildSshTarget(execution)}:${remoteConfigDir}`]), {
+ encoding: "utf8",
+ maxBuffer: 10 * 1024 * 1024,
+ })
+ if (scpResult.error) {
+ throw scpResult.error
+ }
+ if (scpResult.status !== 0) {
+ throw new Error(`Failed to copy OpenCode config to SSH host: ${scpResult.stderr || `scp exited with ${scpResult.status}`}`)
}
return remoteConfigDir
@@ -564,6 +563,29 @@ export class WorkspaceManager {
}
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")
@@ -574,16 +596,7 @@ export class WorkspaceManager {
throw new Error("SSH username must not start with '-' or contain '@' or whitespace")
}
- return [
- "-p",
- String(execution.port ?? 22),
- "-o",
- "BatchMode=yes",
- "-o",
- "ExitOnForwardFailure=yes",
- username ? `${username}@${host}` : host,
- ...remoteArgs,
- ]
+ return username ? `${username}@${host}` : host
}
private delay(durationMs: number): Promise {