Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
79eebf4
feat(runtime): add execution and connection profile foundations
pascalandr May 8, 2026
7f60def
feat(ui): add ssh host connection flow
pascalandr May 8, 2026
951f13b
feat(ui): wire ssh connections into home screen
pascalandr May 8, 2026
a251fd8
feat(ui): preview execution profile launch commands
pascalandr May 8, 2026
0da41e7
feat(ui): add execution profile test flow
pascalandr May 8, 2026
a9e05ab
feat(ui): duplicate saved runtime profiles
pascalandr May 8, 2026
78a2794
feat(ui): surface saved connection recency
pascalandr May 8, 2026
3ac6320
feat(ui): polish saved connection labels
pascalandr May 8, 2026
819900d
fix(ui): tighten ssh connection summaries
pascalandr May 8, 2026
b2fc903
fix(runtime): harden profile review findings
pascalandr May 8, 2026
188d8eb
docs(ui): clarify ssh bootstrap config requirements
pascalandr May 8, 2026
977e8a0
merge dev into execution connection profiles
pascalandr May 8, 2026
f56f97b
refactor(runtime): scope profiles to execution modes
pascalandr May 8, 2026
ddc295d
feat(runtime): add ssh execution profiles
pascalandr May 8, 2026
695ac75
fix(runtime): harden forwarded execution profiles
pascalandr May 8, 2026
c0e9508
fix(runtime): bind docker profile runtimes externally
pascalandr May 8, 2026
a0d54ed
fix(runtime): sync config for ssh profiles
pascalandr May 9, 2026
5cfccf1
Merge branch 'dev' into feat/execution-connection-profiles
pascalandr May 9, 2026
a1c60af
fix(runtime): copy ssh config with scp
pascalandr May 9, 2026
86f9463
merge(runtime): align execution profiles with packaged plugin dev cha…
pascalandr May 17, 2026
d9c95c1
merge: TASK-058 refresh pr408 onto upstream/dev
pascalandr May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions packages/server/src/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,73 @@ import type {

export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error"

export type ExecutionProfileKind = "local" | "wsl" | "docker" | "command" | "ssh"
export type ExecutionProfileCwdMode = "workspace" | "inherit"

export interface ExecutionProfileBase {
id: string
name: string
kind: ExecutionProfileKind
}

export interface LocalExecutionProfile extends ExecutionProfileBase {
kind: "local"
binaryPath: string
}

export interface WslExecutionProfile extends ExecutionProfileBase {
kind: "wsl"
distro: string
binaryPath: string
}

export interface DockerExecutionProfile extends ExecutionProfileBase {
kind: "docker"
image: string
workspaceMountPath: string
configMountPath: string
command?: string[]
extraDockerArgs?: string[]
}

export interface CommandExecutionProfile extends ExecutionProfileBase {
kind: "command"
executable: string
args?: string[]
cwdMode?: ExecutionProfileCwdMode
}

export interface SshExecutionProfile extends ExecutionProfileBase {
kind: "ssh"
host: string
port?: number
username?: string
remotePath: string
binaryPath: string
args?: string[]
}

export type ExecutionProfile = LocalExecutionProfile | WslExecutionProfile | DockerExecutionProfile | CommandExecutionProfile | SshExecutionProfile

export interface ExecutionProfilePreviewRequest {
profile: ExecutionProfile
workspacePath?: string
}

export interface ExecutionProfilePreviewResponse {
command: string
args: string[]
commandLine: string
cwd?: string
environment: Record<string, string>
}

export interface ExecutionProfileTestResponse extends ExecutionProfilePreviewResponse {
valid: boolean
version?: string
error?: string
}

export interface WorkspaceDescriptor {
id: string
/** Absolute path on the server host. */
Expand All @@ -29,6 +96,9 @@ export interface WorkspaceDescriptor {
binaryId: string
binaryLabel: string
binaryVersion?: string
executionProfileId?: string
executionProfileName?: string
executionProfileKind?: ExecutionProfileKind
createdAt: string
updatedAt: string
/** Present when `status` is "error". */
Expand All @@ -38,6 +108,7 @@ export interface WorkspaceDescriptor {
export interface WorkspaceCreateRequest {
path: string
name?: string
executionProfileId?: string
}

export interface WorkspaceCloneRequest {
Expand Down
39 changes: 38 additions & 1 deletion packages/server/src/opencode-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"

import { buildOpencodeConfigContent } from "./opencode-plugin"
import {
buildOpencodeConfigContent,
findPackagedCodeNomadPluginReference,
rewritePackagedCodeNomadPluginReference,
} from "./opencode-plugin"

describe("buildOpencodeConfigContent", () => {
it("creates config content with the CodeNomad plugin", () => {
Expand Down Expand Up @@ -35,4 +39,37 @@ describe("buildOpencodeConfigContent", () => {

assert.deepEqual(JSON.parse(content).plugin, ["file:///plugin.tgz"])
})

it("finds the packaged CodeNomad plugin tarball reference", () => {
const reference = findPackagedCodeNomadPluginReference(`{
"plugin": [
"npm:user-plugin",
"@codenomad/codenomad-opencode-plugin@file:C:/Users/dev/AppData/Roaming/CodeNomad/codenomad-opencode-plugin.tgz"
]
}`)

assert.deepEqual(reference, {
specifier: "@codenomad/codenomad-opencode-plugin@file:C:/Users/dev/AppData/Roaming/CodeNomad/codenomad-opencode-plugin.tgz",
filePath: "C:/Users/dev/AppData/Roaming/CodeNomad/codenomad-opencode-plugin.tgz",
})
})

it("rewrites the packaged CodeNomad plugin tarball reference", () => {
const content = rewritePackagedCodeNomadPluginReference(
`{
"plugin": [
"npm:user-plugin",
"@codenomad/codenomad-opencode-plugin@file:C:/Users/dev/AppData/Roaming/CodeNomad/codenomad-opencode-plugin.tgz"
]
}`,
"/tmp/codenomad-opencode-plugin.tgz",
)

assert.deepEqual(JSON.parse(content), {
plugin: [
"npm:user-plugin",
"@codenomad/codenomad-opencode-plugin@file:/tmp/codenomad-opencode-plugin.tgz",
],
})
})
})
57 changes: 57 additions & 0 deletions packages/server/src/opencode-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,49 @@ export function resolveExistingOpencodeConfigContent(userEnvironment: Record<str
return normalizeConfigContentValue(process.env.OPENCODE_CONFIG_CONTENT)
}

export function findPackagedCodeNomadPluginReference(configContent: string | undefined): { specifier: string; filePath: string } | null {
if (!configContent?.trim()) {
return null
}

const config = parseJsoncObject(configContent)
for (const entry of normalizePluginEntries(config.plugin)) {
const reference = parsePackagedPluginSpecifier(entry)
if (reference) {
return reference
}
}

return null
}

export function rewritePackagedCodeNomadPluginReference(configContent: string, filePath: string): string {
const config = parseJsoncObject(configContent)
let changed = false
const nextPluginEntries = normalizePluginEntries(config.plugin).map((entry) => {
const reference = parsePackagedPluginSpecifier(entry)
if (!reference) {
return entry
}

changed = true
return toNpmFileSpecifier(filePath)
})

if (!changed) {
return configContent
}

return JSON.stringify(
{
...config,
plugin: typeof config.plugin === "string" ? nextPluginEntries[0] ?? toNpmFileSpecifier(filePath) : nextPluginEntries,
},
null,
2,
)
}

function toNpmFileSpecifier(filePath: string): string {
return `${pluginPackageName}@file:${filePath.replace(/\\/g, "/")}`
}
Expand All @@ -87,6 +130,20 @@ function normalizeConfigContentValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined
}

function parsePackagedPluginSpecifier(value: string): { specifier: string; filePath: string } | null {
const prefix = `${pluginPackageName}@file:`
if (!value.startsWith(prefix)) {
return null
}

const filePath = value.slice(prefix.length).trim()
if (!filePath.endsWith(".tgz")) {
return null
}

return { specifier: value, filePath }
}

function parseJsoncObject(content: string): Record<string, unknown> {
try {
const parsed = JSON.parse(stripJsonc(content))
Expand Down
17 changes: 14 additions & 3 deletions packages/server/src/server/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ interface HttpServerStartResult {
displayHost: string
}

function redactSensitivePayload(value: unknown): unknown {
if (!value || typeof value !== "object") return value
if (Array.isArray(value)) return value.map(redactSensitivePayload)
const output: Record<string, unknown> = {}
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
output[key] = /(PASSWORD|TOKEN|SECRET|API[_-]?KEY)/i.test(key) ? "[REDACTED]" : redactSensitivePayload(entry)
}
return output
}

export function createHttpServer(deps: HttpServerDeps) {
// Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS.
// We keep the runtime behavior correct and cast the instance to a generic FastifyInstance.
Expand Down Expand Up @@ -119,7 +129,8 @@ export function createHttpServer(deps: HttpServerDeps) {
}
apiLogger.debug(base, "HTTP request completed")
if (apiLogger.isLevelEnabled("trace")) {
apiLogger.trace({ ...base, params: request.params, query: request.query, body: request.body }, "HTTP request payload")
const body = redactSensitivePayload(request.body)
apiLogger.trace({ ...base, params: request.params, query: request.query, body }, "HTTP request payload")
}
done()
})
Expand Down Expand Up @@ -697,7 +708,7 @@ async function proxyWorkspaceRequest(args: {

logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
if (logger.isLevelEnabled("trace")) {
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
logger.trace({ workspaceId, targetUrl, body: redactSensitivePayload(request.body) }, "Instance proxy payload")
}

return reply.from(targetUrl, {
Expand Down Expand Up @@ -735,7 +746,7 @@ async function proxyWorkspaceRequest(args: {
worktreeSlug,
directory,
contentType: request.headers["content-type"],
body: bodyToJson(request.body),
body: redactSensitivePayload(bodyToJson(request.body)),
headers: outgoing,
},
"Proxy -> OpenCode request",
Expand Down
Loading
Loading