From 8cc940bea95dd8d296a5d03fda118525ede167ef Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:25:31 +0000 Subject: [PATCH 001/231] feat(daytona): add Daytona config, credentials, client, service and ssh config Part A of the Daytona runtime provider (ssh + daytona sub-config): - runtime schema: optional `daytona` sub-config on the ssh variant, mutually exclusive with `coder`; `daytona` runtime enablement ID (UI-only, like coder) and a shared enablement-ID -> RuntimeMode record. - shared size presets (small/medium/large) in src/common/constants/daytona.ts. - credentials in /daytona.json (0600, atomic), with DAYTONA_API_KEY / DAYTONA_API_URL fallbacks; the UI only sees a masked view. - `daytona` oRPC namespace: getConfig, setConfig, testConnection, listSandboxes, listSnapshots. - thin typed fetch client with timeouts and typed errors (no @daytona/sdk). - DaytonaService: labels with the installation identity, per-sandbox SSH token cache with refresh-then-revoke, startAndWait with bounded polling. - daytonaSshConfig: 0600 include file with one Host block per sandbox, gateway known_hosts, a managed `Host *.xum--daytona` + Include block in ~/.ssh/config (shared marker-block writer extracted from the Coder writer), and an `ssh -G` check that the token is the resolved user. - sshConfigParser (ssh2 path) now resolves Include directives. --- .../features/ChatInput/CreationControls.tsx | 21 +- .../Settings/Sections/RuntimesSection.tsx | 9 +- .../hooks/useDraftWorkspaceSettings.ts | 4 +- src/common/constants/daytona.ts | 56 +++ src/common/orpc/schemas.ts | 1 + src/common/orpc/schemas/api.ts | 1 + src/common/orpc/schemas/daytona.ts | 144 +++++++ src/common/orpc/schemas/runtime.test.ts | 20 +- src/common/orpc/schemas/runtime.ts | 44 +- src/common/schemas/ids.ts | 1 + src/common/types/runtime.ts | 14 +- .../config/daytonaCredentialsStore.test.ts | 82 ++++ src/node/config/daytonaCredentialsStore.ts | 183 ++++++++ src/node/orpc/context.ts | 2 + src/node/orpc/router.ts | 22 + src/node/runtime/daytonaSshConfig.test.ts | 136 ++++++ src/node/runtime/daytonaSshConfig.ts | 318 ++++++++++++++ src/node/runtime/muxSshConfigWriter.ts | 56 ++- src/node/runtime/sshConfigParser.test.ts | 62 +++ src/node/runtime/sshConfigParser.ts | 99 ++++- .../services/daytona/daytonaClient.test.ts | 162 +++++++ src/node/services/daytona/daytonaClient.ts | 395 +++++++++++++++++ .../services/daytona/daytonaService.test.ts | 204 +++++++++ src/node/services/daytona/daytonaService.ts | 396 ++++++++++++++++++ src/node/services/serviceContainer.test.ts | 8 +- src/node/services/serviceContainer.ts | 9 + 26 files changed, 2400 insertions(+), 49 deletions(-) create mode 100644 src/common/constants/daytona.ts create mode 100644 src/common/orpc/schemas/daytona.ts create mode 100644 src/node/config/daytonaCredentialsStore.test.ts create mode 100644 src/node/config/daytonaCredentialsStore.ts create mode 100644 src/node/runtime/daytonaSshConfig.test.ts create mode 100644 src/node/runtime/daytonaSshConfig.ts create mode 100644 src/node/services/daytona/daytonaClient.test.ts create mode 100644 src/node/services/daytona/daytonaClient.ts create mode 100644 src/node/services/daytona/daytonaService.test.ts create mode 100644 src/node/services/daytona/daytonaService.ts diff --git a/src/browser/features/ChatInput/CreationControls.tsx b/src/browser/features/ChatInput/CreationControls.tsx index 0e6adfc1fac..f16b349231f 100644 --- a/src/browser/features/ChatInput/CreationControls.tsx +++ b/src/browser/features/ChatInput/CreationControls.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect } from "react"; import * as SelectPrimitive from "@radix-ui/react-select"; import { + normalizeRuntimeEnablement, RUNTIME_MODE, type CoderWorkspaceConfig, type ParsedRuntime, @@ -1092,19 +1093,23 @@ export function CreationControls(props: CreationControlsProps) { const runtimeWorktree = props.runtimeEnablement?.worktree; const runtimeSsh = props.runtimeEnablement?.ssh; const runtimeCoder = props.runtimeEnablement?.coder; + const runtimeDaytona = props.runtimeEnablement?.daytona; const runtimeDocker = props.runtimeEnablement?.docker; const runtimeDevcontainer = props.runtimeEnablement?.devcontainer; const hasRuntimeEnablement = props.runtimeEnablement != null; const runtimeEnablement = !hasRuntimeEnablement ? undefined - : { - local: runtimeLocal ?? true, - worktree: runtimeWorktree ?? true, - ssh: runtimeSsh ?? true, - coder: runtimeCoder ?? true, - docker: runtimeDocker ?? true, - devcontainer: runtimeDevcontainer ?? true, - }; + : // Rebuilt from primitives so a fresh-but-equal prop object keeps the memoized value; + // missing keys default to enabled. + normalizeRuntimeEnablement({ + local: runtimeLocal, + worktree: runtimeWorktree, + ssh: runtimeSsh, + coder: runtimeCoder, + daytona: runtimeDaytona, + docker: runtimeDocker, + devcontainer: runtimeDevcontainer, + }); const nameState = { name: props.nameState.name, title: props.nameState.title, diff --git a/src/browser/features/Settings/Sections/RuntimesSection.tsx b/src/browser/features/Settings/Sections/RuntimesSection.tsx index 845a6c06a4d..1eeca2c6504 100644 --- a/src/browser/features/Settings/Sections/RuntimesSection.tsx +++ b/src/browser/features/Settings/Sections/RuntimesSection.tsx @@ -39,7 +39,11 @@ import { import type { CoderWorkspaceConfig } from "@/common/orpc/schemas/coder"; import { cn } from "@/common/lib/utils"; import type { ProjectConfig } from "@/common/types/project"; -import { normalizeRuntimeEnablement, RUNTIME_MODE } from "@/common/types/runtime"; +import { + normalizeRuntimeEnablement, + RUNTIME_MODE, + RUNTIME_MODE_FOR_ENABLEMENT_ID, +} from "@/common/types/runtime"; import type { RuntimeAvailabilityStatus, RuntimeEnablement, @@ -664,8 +668,7 @@ export function RuntimesSection() { const isLastEnabled = effectiveEnablement[runtime.id] && enabledRuntimeCount <= 1; const switchDisabled = rowDisabled || isLastEnabled; const optionSpec = getRuntimeOptionField(runtime.id); - const optionRuntimeMode: RuntimeMode = - runtime.id === "coder" ? RUNTIME_MODE.SSH : runtime.id; + const optionRuntimeMode = RUNTIME_MODE_FOR_ENABLEMENT_ID[runtime.id]; const switchControl = ( Runtimes). */ +export const DAYTONA_DEFAULT_API_URL = "https://app.daytona.io/api"; + +/** + * Host suffix for Daytona sandboxes (`.xum--daytona`). The `--` keeps Xum's managed + * ssh config block from colliding with user-managed entries, as the Coder suffix does. + */ +export const XUM_DAYTONA_HOST_SUFFIX = "xum--daytona"; + +/** Build the SSH alias for a Daytona sandbox (e.g. `abc123.xum--daytona`). */ +export function toXumDaytonaHost(sandboxId: string): string { + return `${sandboxId}.${XUM_DAYTONA_HOST_SUFFIX}`; +} + +export const XUM_DAYTONA_SSH_BLOCK_START = "# --- START XUM DAYTONA SSH ---"; +export const XUM_DAYTONA_SSH_BLOCK_END = "# --- END XUM DAYTONA SSH ---"; + +export type DaytonaSizePresetId = "small" | "medium" | "large"; + +export interface DaytonaSizePreset { + id: DaytonaSizePresetId; + label: string; + cpu: number; + memoryGiB: number; + diskGiB: number; +} + +/** + * The only box sizes Xum offers. There is deliberately no free-form CPU/memory input: a fixed + * list keeps cost predictable and lets validateBeforePersist reject hand-edited configs. + */ +export const DAYTONA_SIZE_PRESETS: readonly DaytonaSizePreset[] = [ + { id: "small", label: "Small", cpu: 2, memoryGiB: 4, diskGiB: 10 }, + { id: "medium", label: "Medium", cpu: 4, memoryGiB: 8, diskGiB: 20 }, + { id: "large", label: "Large", cpu: 8, memoryGiB: 16, diskGiB: 40 }, +]; + +export const DAYTONA_DEFAULT_SIZE_PRESET_ID: DaytonaSizePresetId = "small"; + +/** Find the preset whose resources match exactly; undefined when the triple is not a preset. */ +export function findDaytonaSizePreset(resources: { + cpu?: number; + memoryGiB?: number; + diskGiB?: number; +}): DaytonaSizePreset | undefined { + return DAYTONA_SIZE_PRESETS.find( + (preset) => + preset.cpu === resources.cpu && + preset.memoryGiB === resources.memoryGiB && + preset.diskGiB === resources.diskGiB + ); +} diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index fa396703872..1db47dc9d28 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -166,6 +166,7 @@ export { AWSCredentialStatusSchema, analytics, coder, + daytona, config, browser, devtools, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 1dd346ef7a8..b1f4e5dc8c8 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1460,6 +1460,7 @@ export const secrets = { // Re-export Coder schemas from dedicated file export { coder } from "./coder"; +export { daytona } from "./daytona"; // Workspace const DebugLlmRequestSnapshotSchema = z diff --git a/src/common/orpc/schemas/daytona.ts b/src/common/orpc/schemas/daytona.ts new file mode 100644 index 00000000000..505395e6f1c --- /dev/null +++ b/src/common/orpc/schemas/daytona.ts @@ -0,0 +1,144 @@ +import { z } from "zod"; + +// Daytona sandbox config - attached to the SSH runtime when Daytona is the SSH backend. +export const DaytonaSandboxConfigSchema = z.object({ + /** + * Daytona sandbox id. + * - Omitted at creation: Xum creates a sandbox and fills this in. + * - Given at creation: Xum attaches to that existing sandbox (and marks it shared). + */ + sandboxId: z.string().optional().meta({ description: "Daytona sandbox id" }), + snapshot: z + .string() + .optional() + .meta({ description: "Snapshot used to create the sandbox (empty = Daytona default)" }), + cpu: z.number().int().positive().optional().meta({ description: "vCPU count (size preset)" }), + memoryGiB: z + .number() + .int() + .positive() + .optional() + .meta({ description: "Memory in GiB (size preset)" }), + diskGiB: z + .number() + .int() + .positive() + .optional() + .meta({ description: "Disk in GiB (size preset)" }), + target: z.string().optional().meta({ description: "Daytona target (region)" }), + /** + * True when the sandbox is not owned by this workspace alone (attached existing sandbox, or a + * fork sharing its parent's sandbox). Shared sandboxes are never deleted with a workspace. + */ + shared: z.boolean().optional().meta({ + description: "True if the sandbox is shared with other workspaces or pre-existing", + }), +}); + +export type DaytonaSandboxConfig = z.infer; + +export const DaytonaApiKeySourceSchema = z.enum(["settings", "env"]); +export type DaytonaApiKeySource = z.infer; + +/** What the UI may see of the Daytona credentials: never the key itself. */ +export const DaytonaPublicConfigSchema = z.object({ + apiKeySet: z.boolean(), + apiKeySource: DaytonaApiKeySourceSchema.nullable(), + apiUrl: z.string(), + target: z.string().nullable(), +}); + +export type DaytonaPublicConfig = z.infer; + +export const DaytonaErrorKindSchema = z.enum([ + "not_configured", + "unauthorized", + "not_found", + "bad_request", + "unreachable", + "timeout", + "server", +]); + +export type DaytonaErrorKind = z.infer; + +const DaytonaFailureSchema = z.object({ + ok: z.literal(false), + kind: DaytonaErrorKindSchema, + error: z.string(), +}); + +export const DaytonaTestConnectionResultSchema = z.discriminatedUnion("ok", [ + z.object({ ok: z.literal(true) }), + DaytonaFailureSchema, +]); + +export type DaytonaTestConnectionResult = z.infer; + +/** Sandbox lifecycle collapsed to what Xum acts on; `missing` is a 404. */ +export const DaytonaSandboxStateSchema = z.enum([ + "starting", + "started", + "stopped", + "archived", + "error", + "missing", +]); + +export type DaytonaSandboxState = z.infer; + +export const DaytonaSandboxSummarySchema = z.object({ + id: z.string(), + name: z.string(), + state: DaytonaSandboxStateSchema, + cpu: z.number().optional(), + memoryGiB: z.number().optional(), + diskGiB: z.number().optional(), + snapshot: z.string().optional(), + target: z.string().optional(), +}); + +export type DaytonaSandboxSummary = z.infer; + +export const DaytonaListSandboxesResultSchema = z.discriminatedUnion("ok", [ + z.object({ ok: z.literal(true), sandboxes: z.array(DaytonaSandboxSummarySchema) }), + DaytonaFailureSchema, +]); + +export type DaytonaListSandboxesResult = z.infer; + +export const DaytonaListSnapshotsResultSchema = z.discriminatedUnion("ok", [ + z.object({ ok: z.literal(true), snapshots: z.array(z.object({ name: z.string() })) }), + DaytonaFailureSchema, +]); + +export type DaytonaListSnapshotsResult = z.infer; + +// API schemas for the daytona namespace +export const daytona = { + getConfig: { + input: z.void(), + output: DaytonaPublicConfigSchema, + }, + setConfig: { + // Each field: omitted = unchanged, empty string = cleared. + input: z.object({ + apiKey: z.string().optional(), + apiUrl: z.string().optional(), + target: z.string().optional(), + }), + output: DaytonaPublicConfigSchema, + }, + testConnection: { + input: z.void(), + output: DaytonaTestConnectionResultSchema, + }, + listSandboxes: { + input: z.void(), + output: DaytonaListSandboxesResultSchema, + }, + listSnapshots: { + input: z.void(), + output: DaytonaListSnapshotsResultSchema, + }, +}; diff --git a/src/common/orpc/schemas/runtime.test.ts b/src/common/orpc/schemas/runtime.test.ts index d61a810f390..9713219526a 100644 --- a/src/common/orpc/schemas/runtime.test.ts +++ b/src/common/orpc/schemas/runtime.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { RuntimeAvailabilityStatusSchema } from "./runtime"; +import { RuntimeAvailabilityStatusSchema, RuntimeConfigSchema } from "./runtime"; import { getDevcontainerConfigs, type RuntimeAvailabilityStatus } from "@/common/types/runtime"; describe("RuntimeAvailabilityStatusSchema", () => { @@ -82,3 +82,21 @@ describe("getDevcontainerConfigs", () => { expect(configs).toEqual([]); }); }); + +describe("RuntimeConfigSchema ssh backends", () => { + const ssh = { type: "ssh", host: "sb-1.xum--daytona", srcBaseDir: "~/xum" } as const; + + test("keeps the daytona sub-config through a persist round-trip", () => { + const daytona = { sandboxId: "sb-1", cpu: 2, memoryGiB: 4, diskGiB: 10, shared: true }; + expect(RuntimeConfigSchema.parse({ ...ssh, daytona })).toEqual({ ...ssh, daytona }); + }); + + test("rejects a config that names both Coder and Daytona", () => { + const result = RuntimeConfigSchema.safeParse({ + ...ssh, + coder: { workspaceName: "ws" }, + daytona: { sandboxId: "sb-1" }, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/common/orpc/schemas/runtime.ts b/src/common/orpc/schemas/runtime.ts index 25e74d26608..8529483c74d 100644 --- a/src/common/orpc/schemas/runtime.ts +++ b/src/common/orpc/schemas/runtime.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { CoderWorkspaceConfigSchema } from "./coder"; +import { DaytonaSandboxConfigSchema } from "./daytona"; export const RuntimeModeSchema = z.enum(["local", "worktree", "ssh", "docker", "devcontainer"]); @@ -78,24 +79,33 @@ export const RuntimeConfigSchema = z.union([ bgOutputDir: bgOutputDirField, }), // SSH runtime - z.object({ - type: z.literal("ssh"), - host: z - .string() - .meta({ description: "SSH host (can be hostname, user@host, or SSH config alias)" }), - srcBaseDir: z - .string() - .meta({ description: "Base directory on remote host where all workspaces are stored" }), - bgOutputDir: bgOutputDirField, - identityFile: z - .string() - .optional() - .meta({ description: "Path to SSH private key (if not using ~/.ssh/config or ssh-agent)" }), - port: z.number().optional().meta({ description: "SSH port (default: 22)" }), - coder: CoderWorkspaceConfigSchema.optional().meta({ - description: "Coder workspace configuration (when using Coder as SSH backend)", + z + .object({ + type: z.literal("ssh"), + host: z + .string() + .meta({ description: "SSH host (can be hostname, user@host, or SSH config alias)" }), + srcBaseDir: z + .string() + .meta({ description: "Base directory on remote host where all workspaces are stored" }), + bgOutputDir: bgOutputDirField, + identityFile: z + .string() + .optional() + .meta({ description: "Path to SSH private key (if not using ~/.ssh/config or ssh-agent)" }), + port: z.number().optional().meta({ description: "SSH port (default: 22)" }), + coder: CoderWorkspaceConfigSchema.optional().meta({ + description: "Coder workspace configuration (when using Coder as SSH backend)", + }), + daytona: DaytonaSandboxConfigSchema.optional().meta({ + description: "Daytona sandbox configuration (when using Daytona as SSH backend)", + }), + }) + // One managed SSH backend per workspace: each owns the host alias and its lifecycle hooks. + .refine((config) => config.coder == null || config.daytona == null, { + message: "SSH runtime cannot use both Coder and Daytona", + path: ["daytona"], }), - }), // Docker runtime - each workspace runs in its own container z.object({ type: z.literal("docker"), diff --git a/src/common/schemas/ids.ts b/src/common/schemas/ids.ts index e0639e0953d..67c75cd5081 100644 --- a/src/common/schemas/ids.ts +++ b/src/common/schemas/ids.ts @@ -11,6 +11,7 @@ export const RuntimeEnablementIdSchema = z.enum([ "worktree", "ssh", "coder", + "daytona", "docker", "devcontainer", ]); diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts index f8ab0ec2172..253af849a85 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -49,7 +49,7 @@ export function runtimeModeSupportsSharedTaskWorkspace(mode: RuntimeMode | undef /** * Runtime IDs that can be enabled/disabled in Settings → Runtimes. - * Note: includes "coder" which is a UI-level choice (not a RuntimeMode). + * Note: includes "coder" and "daytona", UI-level choices (both are `ssh` under the hood, not RuntimeModes). */ export const RUNTIME_ENABLEMENT_IDS = RuntimeEnablementIdSchema.options; @@ -57,11 +57,23 @@ export type RuntimeEnablementId = z.infer; export type RuntimeEnablement = Record; +/** The RuntimeMode each enablement ID runs as: Coder and Daytona are `ssh` plus a sub-config. */ +export const RUNTIME_MODE_FOR_ENABLEMENT_ID: Record = { + local: RUNTIME_MODE.LOCAL, + worktree: RUNTIME_MODE.WORKTREE, + ssh: RUNTIME_MODE.SSH, + coder: RUNTIME_MODE.SSH, + daytona: RUNTIME_MODE.SSH, + docker: RUNTIME_MODE.DOCKER, + devcontainer: RUNTIME_MODE.DEVCONTAINER, +}; + export const DEFAULT_RUNTIME_ENABLEMENT: RuntimeEnablement = { local: true, worktree: true, ssh: true, coder: true, + daytona: true, docker: true, devcontainer: true, }; diff --git a/src/node/config/daytonaCredentialsStore.test.ts b/src/node/config/daytonaCredentialsStore.test.ts new file mode 100644 index 00000000000..66eca1b879a --- /dev/null +++ b/src/node/config/daytonaCredentialsStore.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { DAYTONA_DEFAULT_API_URL } from "@/common/constants/daytona"; +import { DaytonaCredentialsStore } from "./daytonaCredentialsStore"; + +const KEY = "test-key-not-real"; + +describe("DaytonaCredentialsStore", () => { + let rootDir: string; + + beforeEach(async () => { + rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-daytona-creds-")); + }); + + afterEach(async () => { + await fs.rm(rootDir, { recursive: true, force: true }); + }); + + it("stores the key in a 0600 file and never exposes it through the public config", async () => { + const store = new DaytonaCredentialsStore(rootDir, {}); + await store.update({ apiKey: KEY, apiUrl: "https://byoc.example.com/api/", target: "eu" }); + + const stat = await fs.stat(path.join(rootDir, "daytona.json")); + expect(stat.mode & 0o777).toBe(0o600); + + const publicConfig = await store.getPublicConfig(); + expect(publicConfig).toEqual({ + apiKeySet: true, + apiKeySource: "settings", + apiUrl: "https://byoc.example.com/api", + target: "eu", + }); + expect(JSON.stringify(publicConfig)).not.toContain(KEY); + expect((await store.resolve())?.apiKey).toBe(KEY); + }); + + it("leaves omitted fields alone and clears fields set to an empty string", async () => { + const store = new DaytonaCredentialsStore(rootDir, {}); + await store.update({ apiKey: KEY, target: "us" }); + await store.update({ target: "" }); + expect(await store.read()).toEqual({ apiKey: KEY }); + await store.update({ apiKey: " " }); + expect(await store.getPublicConfig()).toEqual({ + apiKeySet: false, + apiKeySource: null, + apiUrl: DAYTONA_DEFAULT_API_URL, + target: null, + }); + }); + + it("falls back to the server environment, and a key from settings wins over it", async () => { + const env = { DAYTONA_API_KEY: "env-key", DAYTONA_API_URL: "https://env.example.com/api" }; + const store = new DaytonaCredentialsStore(rootDir, env); + expect(await store.resolve()).toEqual({ + apiKey: "env-key", + apiKeySource: "env", + apiUrl: "https://env.example.com/api", + }); + + await store.update({ apiKey: KEY }); + expect(await store.resolve()).toMatchObject({ apiKey: KEY, apiKeySource: "settings" }); + }); + + it("reads a corrupted file as empty so the user can re-enter the key", async () => { + await fs.writeFile(path.join(rootDir, "daytona.json"), "{not json", "utf-8"); + const store = new DaytonaCredentialsStore(rootDir, {}); + expect(await store.resolve()).toBeNull(); + await store.update({ apiKey: KEY }); + expect((await store.resolve())?.apiKey).toBe(KEY); + }); + + it("rejects an API URL that is not plain http(s)", async () => { + const store = new DaytonaCredentialsStore(rootDir, {}); + for (const apiUrl of ["not a url", "file:///etc/passwd", "https://user:pw@example.com/api"]) { + // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime + await expect(store.update({ apiUrl })).rejects.toThrow(/Daytona API URL/); + } + expect(await store.read()).toEqual({}); + }); +}); diff --git a/src/node/config/daytonaCredentialsStore.ts b/src/node/config/daytonaCredentialsStore.ts new file mode 100644 index 00000000000..939a9bf4a7b --- /dev/null +++ b/src/node/config/daytonaCredentialsStore.ts @@ -0,0 +1,183 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import { getXumHome } from "@/common/constants/paths"; +import { DAYTONA_DEFAULT_API_URL } from "@/common/constants/daytona"; +import type { DaytonaApiKeySource, DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; +import { getErrorMessage } from "@/common/utils/errors"; +import { log } from "@/node/services/log"; +import { ensurePrivateDir, isErrnoWithCode } from "@/node/utils/fs"; +import writeFileAtomic from "@/node/utils/writeFileAtomic"; + +/** + * Daytona credentials live in their own 0600 file, deliberately outside the other stores: + * - not providers.jsonc: that is the AI-provider list (Coder sits there only as an AI gateway); + * - not secrets.json: those secrets are injected into agent turns and terminals, and an agent + * must never hold a key that creates and deletes billed sandboxes; + * - not runtime config or chat history, which are persisted per workspace and shared freely. + */ +export const DAYTONA_CREDENTIALS_FILE = "daytona.json"; + +export interface DaytonaStoredCredentials { + apiKey?: string; + apiUrl?: string; + target?: string; +} + +/** Credentials ready for a client call. Only node code ever sees `apiKey`. */ +export interface DaytonaResolvedCredentials { + apiKey: string; + apiKeySource: DaytonaApiKeySource; + apiUrl: string; + target?: string; +} + +/** Each field: undefined = unchanged, empty (after trim) = cleared. */ +export interface DaytonaCredentialsPatch { + apiKey?: string; + apiUrl?: string; + target?: string; +} + +function nonEmpty(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeApiUrl(value: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error(`Daytona API URL is not a valid URL: ${value}`); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error("Daytona API URL must use http or https"); + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error("Daytona API URL must not contain credentials, a query or a fragment"); + } + return parsed.toString().replace(/\/+$/, ""); +} + +export class DaytonaCredentialsStore { + readonly filePath: string; + private readonly rootDir: string; + private readonly env: NodeJS.ProcessEnv; + // Serializes read-modify-write updates; writeFileAtomic alone only orders the final writes. + private updateChain: Promise = Promise.resolve(); + + constructor(rootDir?: string, env: NodeJS.ProcessEnv = process.env) { + this.rootDir = rootDir ?? getXumHome(); + this.filePath = path.join(this.rootDir, DAYTONA_CREDENTIALS_FILE); + this.env = env; + } + + /** A missing or malformed file reads as empty (self-healing): the user can re-enter the key. */ + async read(): Promise { + let raw: string; + try { + raw = await fs.readFile(this.filePath, "utf-8"); + } catch (error) { + if (!isErrnoWithCode(error, "ENOENT")) { + log.warn("Failed to read Daytona credentials file", { + filePath: this.filePath, + error: getErrorMessage(error), + }); + } + return {}; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // Never log the content: it holds the API key. + log.warn("Ignoring malformed Daytona credentials file", { filePath: this.filePath }); + return {}; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + log.warn("Ignoring malformed Daytona credentials file", { filePath: this.filePath }); + return {}; + } + const record = parsed as Record; + const stored: DaytonaStoredCredentials = {}; + const apiKey = nonEmpty(record.apiKey); + const apiUrl = nonEmpty(record.apiUrl); + const target = nonEmpty(record.target); + if (apiKey) stored.apiKey = apiKey; + if (apiUrl) stored.apiUrl = apiUrl; + if (target) stored.target = target; + return stored; + } + + update(patch: DaytonaCredentialsPatch): Promise { + const run = this.updateChain.then(() => this.applyUpdate(patch)); + // The caller gets the rejection through `run`; the chain only needs to keep going. + this.updateChain = run.catch((error: unknown) => { + log.debug("Daytona credentials update failed", { error: getErrorMessage(error) }); + }); + return run; + } + + private async applyUpdate(patch: DaytonaCredentialsPatch): Promise { + const next = await this.read(); + const fields = ["apiKey", "apiUrl", "target"] as const; + for (const field of fields) { + const value = patch[field]; + if (value === undefined) continue; + const trimmed = nonEmpty(value); + if (trimmed === undefined) { + delete next[field]; + } else { + next[field] = field === "apiUrl" ? normalizeApiUrl(trimmed) : trimmed; + } + } + await ensurePrivateDir(this.rootDir); + await writeFileAtomic(this.filePath, JSON.stringify(next, null, 2), { + encoding: "utf-8", + mode: 0o600, + }); + } + + /** + * Settings win over the Xum server's own environment (DAYTONA_API_KEY / DAYTONA_API_URL), so a + * key entered in the UI is the one used. Returns null when no API key is available. + */ + async resolve(): Promise { + const stored = await this.read(); + const envKey = nonEmpty(this.env.DAYTONA_API_KEY); + const apiKey = stored.apiKey ?? envKey; + if (!apiKey) return null; + return { + apiKey, + apiKeySource: stored.apiKey ? "settings" : "env", + apiUrl: this.resolveApiUrl(stored), + ...(stored.target ? { target: stored.target } : {}), + }; + } + + /** What the UI may see: whether a key is set and where from, never the key. */ + async getPublicConfig(): Promise { + const stored = await this.read(); + const envKeySet = nonEmpty(this.env.DAYTONA_API_KEY) !== undefined; + return { + apiKeySet: stored.apiKey !== undefined || envKeySet, + apiKeySource: stored.apiKey ? "settings" : envKeySet ? "env" : null, + apiUrl: this.resolveApiUrl(stored), + target: stored.target ?? null, + }; + } + + private resolveApiUrl(stored: DaytonaStoredCredentials): string { + if (stored.apiUrl) return stored.apiUrl; + const envUrl = nonEmpty(this.env.DAYTONA_API_URL); + if (envUrl) { + try { + return normalizeApiUrl(envUrl); + } catch (error) { + log.warn("Ignoring invalid DAYTONA_API_URL", { error: getErrorMessage(error) }); + } + } + return DAYTONA_DEFAULT_API_URL; + } +} diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 5b628fd0ebc..cd9043e0595 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -60,6 +60,7 @@ import type { DevToolsService } from "@/node/services/devToolsService"; import type { ReviewStateService } from "@/node/services/reviewStateService"; import type { DraftService } from "@/node/services/draftService"; import type { CoderService } from "@/node/services/coderService"; +import type { DaytonaService } from "@/node/services/daytona/daytonaService"; import type { ServerAuthService } from "@/node/services/serverAuthService"; import type { SshPromptService } from "@/node/services/sshPromptService"; import type { AnalyticsService } from "@/node/services/analytics/analyticsService"; @@ -135,6 +136,7 @@ export interface ORPCContext extends WithEffectContext { browserControlService: BrowserControlService; browserSessionStateHub: BrowserSessionStateHub; coderService: CoderService; + daytonaService: DaytonaService; serverAuthService: ServerAuthService; sshPromptService: SshPromptService; analyticsService: AnalyticsService; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index fb2b9152141..bd592cc21ff 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1814,6 +1814,28 @@ export const router = (authToken?: string) => { .output(schemas.coder.listWorkspaces.output) .handler(async ({ context }) => context.coderService.listWorkspaces()), }, + daytona: { + getConfig: t + .input(schemas.daytona.getConfig.input) + .output(schemas.daytona.getConfig.output) + .handler(async ({ context }) => context.daytonaService.getPublicConfig()), + setConfig: t + .input(schemas.daytona.setConfig.input) + .output(schemas.daytona.setConfig.output) + .handler(async ({ context, input }) => context.daytonaService.setConfig(input)), + testConnection: t + .input(schemas.daytona.testConnection.input) + .output(schemas.daytona.testConnection.output) + .handler(async ({ context }) => context.daytonaService.testConnection()), + listSandboxes: t + .input(schemas.daytona.listSandboxes.input) + .output(schemas.daytona.listSandboxes.output) + .handler(async ({ context }) => context.daytonaService.listSandboxes()), + listSnapshots: t + .input(schemas.daytona.listSnapshots.input) + .output(schemas.daytona.listSnapshots.output) + .handler(async ({ context }) => context.daytonaService.listSnapshots()), + }, artifacts: { list: t .input(schemas.artifacts.list.input) diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts new file mode 100644 index 00000000000..1b2d605efe4 --- /dev/null +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { XUM_DAYTONA_SSH_BLOCK_END, XUM_DAYTONA_SSH_BLOCK_START } from "@/common/constants/daytona"; +import { DaytonaSshConfigWriter, renderDaytonaKnownHosts } from "./daytonaSshConfig"; + +const GATEWAY = { + host: "ssh.daytona.test", + port: 22, + hostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGatewayKey gateway@daytona"], +}; + +const hasOpenSsh = spawnSync("ssh", ["-V"]).status === 0; + +describe("DaytonaSshConfigWriter", () => { + let tempDir: string; + let xumHome: string; + let sshConfigPath: string; + let writer: DaytonaSshConfigWriter; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-daytona-ssh-")); + xumHome = path.join(tempDir, "xum"); + sshConfigPath = path.join(tempDir, ".ssh", "config"); + writer = new DaytonaSshConfigWriter({ xumHome, sshConfigPath }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("appends a Host-scoped Include block after the user's config and writes 0600 files", async () => { + await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); + await fs.writeFile(sshConfigPath, "Host work\n User me\n", "utf8"); + + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + + expect(await fs.readFile(sshConfigPath, "utf8")).toBe( + [ + "Host work", + " User me", + XUM_DAYTONA_SSH_BLOCK_START, + "Host *.xum--daytona", + ` Include ${writer.paths.includePath}`, + XUM_DAYTONA_SSH_BLOCK_END, + "", + ].join("\n") + ); + const include = await fs.readFile(writer.paths.includePath, "utf8"); + expect(include).toContain( + [ + "Host sb-1.xum--daytona", + " HostName ssh.daytona.test", + " Port 22", + " User tokenA", + " HostKeyAlias ssh.daytona.test", + ` UserKnownHostsFile ${writer.paths.knownHostsPath}`, + " StrictHostKeyChecking yes", + ].join("\n") + ); + expect(await fs.readFile(writer.paths.knownHostsPath, "utf8")).toBe( + "ssh.daytona.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGatewayKey\n" + ); + for (const file of [writer.paths.includePath, writer.paths.knownHostsPath]) { + expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + } + }); + + it("refreshes a token by rewriting only the include file, keeping other sandboxes", async () => { + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxId: "sb-2", token: "tokenB", gateway: GATEWAY }); + const sshConfigBefore = await fs.readFile(sshConfigPath, "utf8"); + await fs.rm(writer.paths.knownHostsPath); + + await writer.updateToken({ sandboxId: "sb-1", token: "tokenA2", gateway: GATEWAY }); + + const include = await fs.readFile(writer.paths.includePath, "utf8"); + expect(include).toContain("User tokenA2"); + expect(include).not.toContain("User tokenA\n"); + expect(include).toContain("User tokenB"); + expect(await fs.readFile(sshConfigPath, "utf8")).toBe(sshConfigBefore); + expect(await Bun.file(writer.paths.knownHostsPath).exists()).toBe(false); + + await writer.removeSandbox("sb-1"); + const afterRemove = await fs.readFile(writer.paths.includePath, "utf8"); + expect(afterRemove).not.toContain("sb-1.xum--daytona"); + expect(afterRemove).toContain("Host sb-2.xum--daytona"); + }); + + it("refuses control-plane values that would inject ssh directives", async () => { + for (const input of [ + { sandboxId: "sb-1", token: "tok\n ProxyCommand touch /tmp/pwned" }, + { sandboxId: "sb-1\nHost *", token: "tok" }, + ]) { + // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime + await expect(writer.writeSandbox({ ...input, gateway: GATEWAY })).rejects.toThrow( + /Refusing to write an invalid Daytona/ + ); + } + expect(await Bun.file(sshConfigPath).exists()).toBe(false); + }); + + it("port-qualifies known_hosts entries for a gateway off port 22", () => { + expect(renderDaytonaKnownHosts({ ...GATEWAY, port: 2222 })).toBe( + [ + "ssh.daytona.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGatewayKey", + "[ssh.daytona.test]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGatewayKey", + "", + ].join("\n") + ); + }); + + it.skipIf(!hasOpenSsh)("resolves the token as the ssh user through OpenSSH", async () => { + await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); + await fs.writeFile(sshConfigPath, "Host other\n User someone\n", "utf8"); + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + + await writer.verifyResolvedUser("sb-1", "tokenA"); + + // The included Host block must not leak to unrelated hosts. + const other = spawnSync("ssh", ["-G", "-F", sshConfigPath, "unrelated.example.com"]); + expect(other.stdout.toString()).not.toContain("user tokenA"); + }); + + it.skipIf(!hasOpenSsh)("fails clearly when a user Host * User setting wins", async () => { + await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); + await fs.writeFile(sshConfigPath, "Host *\n User me\n", "utf8"); + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + + const error = await writer.verifyResolvedUser("sb-1", "tokenA").catch((e: unknown) => e); + expect((error as Error).message).toContain('"Host *"'); + expect((error as Error).message).not.toContain("tokenA"); + }); +}); diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts new file mode 100644 index 00000000000..7f6ed151745 --- /dev/null +++ b/src/node/runtime/daytonaSshConfig.ts @@ -0,0 +1,318 @@ +import { execFile } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { promisify } from "node:util"; +import { + XUM_DAYTONA_HOST_SUFFIX, + XUM_DAYTONA_SSH_BLOCK_END, + XUM_DAYTONA_SSH_BLOCK_START, + toXumDaytonaHost, +} from "@/common/constants/daytona"; +import { getErrorMessage } from "@/common/utils/errors"; +import { ensureManagedSSHConfigBlock } from "@/node/runtime/muxSshConfigWriter"; +import { log } from "@/node/services/log"; +import { ensurePrivateDir, isErrnoWithCode } from "@/node/utils/fs"; +import writeFileAtomic from "@/node/utils/writeFileAtomic"; + +const execFileAsync = promisify(execFile); + +/** + * SSH plumbing for Daytona sandboxes, reached through Daytona's SSH gateway where the access + * token is the ssh username. + * + * Tokens stay out of argv and persisted runtime config: each sandbox gets a `Host + * .xum--daytona` block in a 0600 include file under the Xum home, and `~/.ssh/config` only + * carries a managed block that includes that file. Token refresh rewrites only the include + * file; the ssh connection pool keys on the alias, so pooled masters survive a refresh. + */ + +export interface DaytonaSshGateway { + host: string; + port: number; + /** OpenSSH public key lines (` `) from Daytona's `/config`. */ + hostKeys: string[]; +} + +export interface DaytonaSshPaths { + dir: string; + includePath: string; + knownHostsPath: string; +} + +export function getDaytonaSshPaths(xumHome: string): DaytonaSshPaths { + const dir = path.join(xumHome, "ssh"); + return { + dir, + includePath: path.join(dir, "daytona.conf"), + knownHostsPath: path.join(dir, "daytona_known_hosts"), + }; +} + +export class DaytonaSshConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "DaytonaSshConfigError"; + } +} + +// Everything rendered into ssh config is validated first: a newline or quote in a control-plane +// value would let it inject arbitrary ssh directives (ProxyCommand runs local commands). +const SANDBOX_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const TOKEN_PATTERN = /^[A-Za-z0-9._~+/=-]+$/; +const HOSTNAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/; +const HOST_KEY_PATTERN = /^([A-Za-z0-9@.-]+)\s+([A-Za-z0-9+/]+={0,3})(?:\s.*)?$/; + +function assertValid(value: string, pattern: RegExp, what: string): void { + if (!pattern.test(value)) { + throw new DaytonaSshConfigError(`Refusing to write an invalid Daytona ${what} to ssh config`); + } +} + +function validateGateway(gateway: DaytonaSshGateway): void { + assertValid(gateway.host, HOSTNAME_PATTERN, "gateway host"); + if (!Number.isInteger(gateway.port) || gateway.port < 1 || gateway.port > 65535) { + throw new DaytonaSshConfigError("Refusing to write an invalid Daytona gateway port"); + } +} + +function quoteConfigPath(filePath: string): string { + if (/["\r\n]/.test(filePath)) { + throw new DaytonaSshConfigError(`Unsupported characters in path for ssh config: ${filePath}`); + } + return /\s/.test(filePath) ? `"${filePath}"` : filePath; +} + +/** + * The block in `~/.ssh/config`. OpenSSH scopes an Include to the Host/Match block above it and + * the managed block sits at the end of the file, so a bare Include would land inside the user's + * last Host block. Scoping it to `Host *.xum--daytona` keeps it from leaking to other hosts. + */ +export function renderDaytonaManagedBlock(includePath: string): string { + return [ + XUM_DAYTONA_SSH_BLOCK_START, + `Host *.${XUM_DAYTONA_HOST_SUFFIX}`, + ` Include ${quoteConfigPath(includePath)}`, + XUM_DAYTONA_SSH_BLOCK_END, + ].join("\n"); +} + +/** + * known_hosts lines for the gateway. With `HostKeyAlias` set OpenSSH looks keys up under the bare + * alias, while `[host]:port` is the form the Daytona spec documents for a non-22 port (and what + * non-alias clients look up), so a non-22 gateway gets both forms. + */ +export function renderDaytonaKnownHosts(gateway: DaytonaSshGateway): string { + validateGateway(gateway); + const hostPatterns = + gateway.port === 22 ? [gateway.host] : [gateway.host, `[${gateway.host}]:${gateway.port}`]; + const lines: string[] = []; + for (const rawKey of gateway.hostKeys) { + const match = HOST_KEY_PATTERN.exec(rawKey.trim()); + if (!match) { + log.warn("Skipping a malformed Daytona gateway host key", { gatewayHost: gateway.host }); + continue; + } + for (const hostPattern of hostPatterns) { + lines.push(`${hostPattern} ${match[1]} ${match[2]}`); + } + } + if (lines.length === 0) { + throw new DaytonaSshConfigError( + `Daytona did not provide a usable host key for its SSH gateway ${gateway.host}` + ); + } + return `${lines.join("\n")}\n`; +} + +export function renderDaytonaHostBlock(input: { + sandboxId: string; + token: string; + gateway: DaytonaSshGateway; + knownHostsPath: string; +}): string { + assertValid(input.sandboxId, SANDBOX_ID_PATTERN, "sandbox id"); + assertValid(input.token, TOKEN_PATTERN, "SSH token"); + validateGateway(input.gateway); + return [ + `Host ${toXumDaytonaHost(input.sandboxId)}`, + ` HostName ${input.gateway.host}`, + ` Port ${input.gateway.port}`, + ` User ${input.token}`, + ` HostKeyAlias ${input.gateway.host}`, + ` UserKnownHostsFile ${quoteConfigPath(input.knownHostsPath)}`, + " StrictHostKeyChecking yes", + ].join("\n"); +} + +const INCLUDE_FILE_HEADER = + "# Managed by Xum (Daytona sandboxes). Xum rewrites this file; do not edit it."; + +/** Split the include file into per-alias blocks (the file only ever holds what Xum rendered). */ +function parseIncludeBlocks(content: string): Map { + const blocks = new Map(); + let currentAlias: string | undefined; + let currentLines: string[] = []; + const flush = () => { + if (currentAlias) blocks.set(currentAlias, currentLines.join("\n").trimEnd()); + }; + for (const line of content.split("\n")) { + const hostMatch = /^Host\s+(\S+)\s*$/.exec(line); + if (hostMatch) { + flush(); + currentAlias = hostMatch[1]; + currentLines = [line]; + } else if (currentAlias) { + currentLines.push(line); + } + } + flush(); + return blocks; +} + +function renderIncludeFile(blocks: Map): string { + return `${[INCLUDE_FILE_HEADER, ...blocks.values()].join("\n\n")}\n`; +} + +// One chain per include file across writer instances: blocks of different sandboxes share it. +const includeFileLocks = new Map>(); + +function withIncludeFileLock(includePath: string, fn: () => Promise): Promise { + const previous = includeFileLocks.get(includePath) ?? Promise.resolve(); + const run = previous.then(fn); + // The caller gets the rejection through `run`; the chain only needs to keep going. + const settled: Promise = run + .then( + () => undefined, + (error: unknown) => { + log.debug("Daytona ssh include update failed", { error: getErrorMessage(error) }); + } + ) + .then(() => { + if (includeFileLocks.get(includePath) === settled) includeFileLocks.delete(includePath); + }); + includeFileLocks.set(includePath, settled); + return run; +} + +export interface DaytonaSshConfigWriterOptions { + xumHome: string; + /** Defaults to `~/.ssh/config`; tests point it at a temp file. */ + sshConfigPath?: string; + sshBinary?: string; +} + +export class DaytonaSshConfigWriter { + readonly paths: DaytonaSshPaths; + private readonly sshConfigPath?: string; + private readonly sshBinary: string; + + constructor(options: DaytonaSshConfigWriterOptions) { + this.paths = getDaytonaSshPaths(options.xumHome); + this.sshConfigPath = options.sshConfigPath; + this.sshBinary = options.sshBinary ?? "ssh"; + } + + /** First write for a sandbox: known_hosts, its include block, and the managed ssh block. */ + async writeSandbox(input: { + sandboxId: string; + token: string; + gateway: DaytonaSshGateway; + }): Promise { + await ensurePrivateDir(this.paths.dir); + await writeFileAtomic(this.paths.knownHostsPath, renderDaytonaKnownHosts(input.gateway), { + encoding: "utf-8", + mode: 0o600, + }); + await this.upsertBlock(input); + await ensureManagedSSHConfigBlock({ + markers: { + start: XUM_DAYTONA_SSH_BLOCK_START, + end: XUM_DAYTONA_SSH_BLOCK_END, + label: "Daytona", + }, + block: renderDaytonaManagedBlock(this.paths.includePath), + sshConfigPath: this.sshConfigPath, + }); + } + + /** Token refresh: rewrites only the include file, never `~/.ssh/config` or known_hosts. */ + async updateToken(input: { + sandboxId: string; + token: string; + gateway: DaytonaSshGateway; + }): Promise { + await ensurePrivateDir(this.paths.dir); + await this.upsertBlock(input); + } + + async removeSandbox(sandboxId: string): Promise { + const alias = toXumDaytonaHost(sandboxId); + await withIncludeFileLock(this.paths.includePath, async () => { + const blocks = await this.readBlocks(); + if (!blocks.delete(alias)) return; + await this.writeBlocks(blocks); + }); + } + + private upsertBlock(input: { + sandboxId: string; + token: string; + gateway: DaytonaSshGateway; + }): Promise { + const block = renderDaytonaHostBlock({ ...input, knownHostsPath: this.paths.knownHostsPath }); + const alias = toXumDaytonaHost(input.sandboxId); + return withIncludeFileLock(this.paths.includePath, async () => { + const blocks = await this.readBlocks(); + blocks.set(alias, block); + await this.writeBlocks(blocks); + }); + } + + private async readBlocks(): Promise> { + try { + return parseIncludeBlocks(await fs.readFile(this.paths.includePath, "utf-8")); + } catch (error) { + if (isErrnoWithCode(error, "ENOENT")) return new Map(); + throw error; + } + } + + private async writeBlocks(blocks: Map): Promise { + await writeFileAtomic(this.paths.includePath, renderIncludeFile(blocks), { + encoding: "utf-8", + mode: 0o600, + }); + } + + /** + * OpenSSH keeps the first value it sees, so a user `Host *` entry with `User` above Xum's + * managed block silently replaces the token. Resolve the alias with `ssh -G` and fail with a + * clear message instead of an opaque auth failure. Neither value is ever printed. + * Skipped when no OpenSSH client is installed (the ssh2 path parses the config itself). + */ + async verifyResolvedUser(sandboxId: string, token: string): Promise { + const alias = toXumDaytonaHost(sandboxId); + const args = ["-G", ...(this.sshConfigPath ? ["-F", this.sshConfigPath] : []), alias]; + let stdout: string; + try { + ({ stdout } = await execFileAsync(this.sshBinary, args, { timeout: 10_000 })); + } catch (error) { + if (isErrnoWithCode(error, "ENOENT")) { + log.debug("Skipping Daytona ssh user check: no OpenSSH client found"); + return; + } + throw new DaytonaSshConfigError( + `Could not check the ssh config for ${alias} with ssh -G (${getErrorMessage(error).split("\n")[0]})` + ); + } + const userLine = stdout.split("\n").find((line) => line.startsWith("user ")); + const resolvedUser = userLine?.slice("user ".length).trim(); + if (resolvedUser !== token) { + throw new DaytonaSshConfigError( + `Your ssh config sets User for ${alias} before Xum's Daytona block (often a "Host *" ` + + `entry with User), and OpenSSH keeps the first value. Move that User line below the ` + + `"${XUM_DAYTONA_SSH_BLOCK_START}" block in ~/.ssh/config or scope it to specific hosts.` + ); + } + } +} diff --git a/src/node/runtime/muxSshConfigWriter.ts b/src/node/runtime/muxSshConfigWriter.ts index 0e54b46601d..9a02d1ad277 100644 --- a/src/node/runtime/muxSshConfigWriter.ts +++ b/src/node/runtime/muxSshConfigWriter.ts @@ -66,12 +66,23 @@ function countOccurrences(content: string, marker: string): number { return content.split(marker).length - 1; } -function replaceMuxBlock(existingContent: string, nextMuxBlock: string): string { - const startMarkerIndex = existingContent.indexOf(MUX_CODER_SSH_BLOCK_START); - const endMarkerIndex = existingContent.indexOf(MUX_CODER_SSH_BLOCK_END, startMarkerIndex); +export interface ManagedBlockMarkers { + start: string; + end: string; + /** Names the block in corruption errors (e.g. "Coder"). */ + label: string; +} + +function replaceMuxBlock( + existingContent: string, + nextMuxBlock: string, + markers: ManagedBlockMarkers +): string { + const startMarkerIndex = existingContent.indexOf(markers.start); + const endMarkerIndex = existingContent.indexOf(markers.end, startMarkerIndex); if (startMarkerIndex === -1 || endMarkerIndex === -1 || endMarkerIndex < startMarkerIndex) { - throw new Error("Corrupted SSH config: invalid Xum Coder SSH marker order."); + throw new Error(`Corrupted SSH config: invalid Xum ${markers.label} SSH marker order.`); } const lineStartIndex = existingContent.lastIndexOf("\n", startMarkerIndex); @@ -219,9 +230,17 @@ async function writeConfigAtomically( } } -export async function ensureMuxCoderSSHConfigFile( - opts: EnsureMuxCoderSSHConfigFileOptions -): Promise { +/** + * Write (or replace in place) one Xum-managed, marker-delimited block in the user's ssh config, + * leaving every other line untouched. Shared by the Coder and Daytona providers. + */ +export async function ensureManagedSSHConfigBlock(opts: { + markers: ManagedBlockMarkers; + /** Full block text, markers included. */ + block: string; + sshConfigPath?: string; +}): Promise { + const { markers } = opts; const configuredSSHConfigPath = opts.sshConfigPath ?? path.join(os.homedir(), ".ssh", "config"); // Preserve users' symlinked ~/.ssh/config setups by writing to the symlink target, // rather than replacing the symlink path itself. @@ -233,22 +252,31 @@ export async function ensureMuxCoderSSHConfigFile( const { content: existingContent, mode: existingMode } = await loadSSHConfigContent(sshConfigPath); - const startMarkerCount = countOccurrences(existingContent, MUX_CODER_SSH_BLOCK_START); - const endMarkerCount = countOccurrences(existingContent, MUX_CODER_SSH_BLOCK_END); + const startMarkerCount = countOccurrences(existingContent, markers.start); + const endMarkerCount = countOccurrences(existingContent, markers.end); if (startMarkerCount > 1 || endMarkerCount > 1) { - throw new Error("Corrupted SSH config: duplicate Xum Coder SSH markers detected."); + throw new Error(`Corrupted SSH config: duplicate Xum ${markers.label} SSH markers detected.`); } if (startMarkerCount !== endMarkerCount) { - throw new Error("Corrupted SSH config: mismatched Xum Coder SSH markers detected."); + throw new Error(`Corrupted SSH config: mismatched Xum ${markers.label} SSH markers detected.`); } - const nextMuxBlock = renderMuxBlock(opts.coderBinaryPath); const nextContent = startMarkerCount === 1 - ? replaceMuxBlock(existingContent, nextMuxBlock) - : appendMuxBlock(existingContent, nextMuxBlock); + ? replaceMuxBlock(existingContent, opts.block, markers) + : appendMuxBlock(existingContent, opts.block); await writeConfigAtomically(sshConfigPath, nextContent, existingMode); } + +export async function ensureMuxCoderSSHConfigFile( + opts: EnsureMuxCoderSSHConfigFileOptions +): Promise { + await ensureManagedSSHConfigBlock({ + markers: { start: MUX_CODER_SSH_BLOCK_START, end: MUX_CODER_SSH_BLOCK_END, label: "Coder" }, + block: renderMuxBlock(opts.coderBinaryPath), + sshConfigPath: opts.sshConfigPath, + }); +} diff --git a/src/node/runtime/sshConfigParser.test.ts b/src/node/runtime/sshConfigParser.test.ts index ab0bfe40626..41a9cc3c4ae 100644 --- a/src/node/runtime/sshConfigParser.test.ts +++ b/src/node/runtime/sshConfigParser.test.ts @@ -1,6 +1,7 @@ import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; +import { DaytonaSshConfigWriter } from "./daytonaSshConfig"; import { resolveSSHConfig } from "./sshConfigParser"; describe("resolveSSHConfig", () => { @@ -83,4 +84,65 @@ describe("resolveSSHConfig", () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + async function withTempHome(fn: (homeDir: string) => Promise): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-ssh-config-")); + const previousUserProfile = process.env.USERPROFILE; + process.env.USERPROFILE = tempDir; + try { + await fs.mkdir(path.join(tempDir, ".ssh"), { recursive: true }); + await fn(tempDir); + } finally { + if (previousUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previousUserProfile; + } + await fs.rm(tempDir, { recursive: true, force: true }); + } + } + + test("resolves a Daytona sandbox through the managed Host-scoped Include", async () => { + await withTempHome(async (homeDir) => { + await fs.writeFile(path.join(homeDir, ".ssh", "config"), "Host work\n User me\n", "utf8"); + const writer = new DaytonaSshConfigWriter({ + xumHome: path.join(homeDir, ".xum"), + sshConfigPath: path.join(homeDir, ".ssh", "config"), + }); + await writer.writeSandbox({ + sandboxId: "sb-1", + token: "tokenA", + gateway: { host: "ssh.daytona.test", port: 2222, hostKeys: ["ssh-ed25519 AAAA"] }, + }); + + const resolved = await resolveSSHConfig("sb-1.xum--daytona"); + + expect(resolved.user).toBe("tokenA"); + expect(resolved.hostName).toBe("ssh.daytona.test"); + expect(resolved.port).toBe(2222); + expect((await resolveSSHConfig("work")).user).toBe("me"); + }); + }); + + test("resolves relative and wildcard Includes and keeps later lines in the enclosing block", async () => { + await withTempHome(async (homeDir) => { + await fs.mkdir(path.join(homeDir, ".ssh", "conf.d")); + await fs.writeFile( + path.join(homeDir, ".ssh", "conf.d", "10-box.conf"), + // Lines before the first Host apply to the enclosing block, as in OpenSSH. + "HostName 10.0.0.5\nHost other\n User otheruser\n", + "utf8" + ); + await fs.writeFile( + path.join(homeDir, ".ssh", "config"), + ["Host box", " Include conf.d/*.conf", " User boxuser", ""].join("\n"), + "utf8" + ); + + const resolved = await resolveSSHConfig("box"); + + expect(resolved.hostName).toBe("10.0.0.5"); + expect(resolved.user).toBe("boxuser"); + }); + }); }); diff --git a/src/node/runtime/sshConfigParser.ts b/src/node/runtime/sshConfigParser.ts index 5b97ff5db4e..b16451cc2f5 100644 --- a/src/node/runtime/sshConfigParser.ts +++ b/src/node/runtime/sshConfigParser.ts @@ -248,13 +248,110 @@ function toStringArray(value: ComputedConfigValue | undefined): string[] { return []; } +// OpenSSH's READCONF_MAX_DEPTH. +const MAX_INCLUDE_DEPTH = 16; +const INCLUDE_LINE_PATTERN = /^\s*include(?:\s*=\s*|\s+)(.+?)\s*$/i; +const SECTION_LINE_PATTERN = /^\s*(?:host|match)(?:\s*=\s*|\s+)\S/i; + +function splitIncludeArgs(value: string): string[] { + const args: string[] = []; + const pattern = /"([^"]*)"|(\S+)/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(value)) !== null) { + const arg = match[1] ?? match[2]; + if (arg) args.push(arg); + } + return args; +} + +async function resolveIncludePaths(value: string, homeDir: string): Promise { + const files: string[] = []; + for (const arg of splitIncludeArgs(value)) { + const expanded = expandHomePath(arg, homeDir); + // OpenSSH resolves relative user-config Include paths against ~/.ssh. + const absolute = path.isAbsolute(expanded) ? expanded : path.join(homeDir, ".ssh", expanded); + const baseName = path.basename(absolute); + if (!/[*?]/.test(baseName)) { + files.push(absolute); + continue; + } + // Wildcards are supported in the file name only (OpenSSH globs the whole path). + try { + const entries = await fs.readdir(path.dirname(absolute)); + files.push( + ...entries + .filter((entry) => glob([baseName], entry)) + .sort() + .map((entry) => path.join(path.dirname(absolute), entry)) + ); + } catch (error) { + log.debug("Failed to expand SSH config Include", { + include: absolute, + error: getErrorMessage(error), + }); + } + } + return files; +} + +/** The expanded content of each file one Include line names, in order. */ +async function readIncludedFiles(value: string, homeDir: string, depth: number): Promise { + const contents: string[] = []; + for (const file of await resolveIncludePaths(value, homeDir)) { + try { + contents.push(await expandIncludes(await fs.readFile(file, "utf8"), homeDir, depth)); + } catch (error) { + // OpenSSH skips missing include files silently. + if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { + log.debug("Failed to read SSH config Include", { + include: file, + error: getErrorMessage(error), + }); + } + } + } + return contents; +} + +/** + * Inline `Include` directives, which the ssh-config package does not resolve (Xum's Daytona + * block relies on one). An Include inside a Host/Match block is followed by that block's header + * again, so later lines of the block stay scoped to it as in OpenSSH. Approximation: Host blocks + * inside the included file are not additionally gated by the enclosing block's pattern. + */ +async function expandIncludes(content: string, homeDir: string, depth: number): Promise { + const out: string[] = []; + let sectionHeader: string | undefined; + for (const line of content.split(/\r?\n/)) { + if (SECTION_LINE_PATTERN.test(line)) { + sectionHeader = line.trim(); + out.push(line); + continue; + } + const includeMatch = INCLUDE_LINE_PATTERN.exec(line); + if (!includeMatch) { + out.push(line); + continue; + } + if (depth >= MAX_INCLUDE_DEPTH) { + log.debug("SSH config Include nesting too deep; ignoring", { include: includeMatch[1] }); + continue; + } + out.push(...(await readIncludedFiles(includeMatch[1], homeDir, depth + 1))); + if (sectionHeader) { + out.push(sectionHeader); + } + } + return out.join("\n"); +} + async function loadSSHConfig(): Promise { const homeDir = getHomeDir(); const configPath = path.join(homeDir, ".ssh", "config"); try { const content = await fs.readFile(configPath, "utf8"); - const parsed = SSHConfig.parse(content); + const parsed = SSHConfig.parse(await expandIncludes(content, homeDir, 0)); return parsed; } catch (error) { if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { diff --git a/src/node/services/daytona/daytonaClient.test.ts b/src/node/services/daytona/daytonaClient.test.ts new file mode 100644 index 00000000000..652cbbdb770 --- /dev/null +++ b/src/node/services/daytona/daytonaClient.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "bun:test"; +import { DaytonaApiError, DaytonaClient } from "./daytonaClient"; + +const API_URL = "https://daytona.test/api"; +const API_KEY = "test-api-key-not-real"; + +interface RecordedRequest { + method: string; + url: URL; + headers: Record; + body?: unknown; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(body === undefined ? "" : JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function fakeFetch(handler: (request: RecordedRequest) => Response | Promise): { + fetch: typeof fetch; + requests: RecordedRequest[]; +} { + const requests: RecordedRequest[] = []; + const impl = async (input: string | URL | Request, init?: RequestInit) => { + const request: RecordedRequest = { + method: init?.method ?? "GET", + url: new URL(input instanceof Request ? input.url : input.toString()), + headers: (init?.headers ?? {}) as Record, + body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined, + }; + requests.push(request); + return handler(request); + }; + return { fetch: impl as typeof fetch, requests }; +} + +async function captureError(promise: Promise): Promise { + try { + await promise; + } catch (error) { + if (error instanceof DaytonaApiError) return error; + throw error; + } + throw new Error("expected a DaytonaApiError"); +} + +describe("DaytonaClient", () => { + it("maps HTTP failures to typed errors with the server message, never echoing secrets", async () => { + const cases = [ + { status: 401, message: "Invalid credentials", kind: "unauthorized" }, + { status: 403, message: "Forbidden", kind: "unauthorized" }, + { status: 404, message: "Sandbox not found", kind: "not_found" }, + { status: 400, message: "Snapshot has fixed resources", kind: "bad_request" }, + { status: 503, message: "Maintenance", kind: "server" }, + ] as const; + for (const testCase of cases) { + const { fetch } = fakeFetch(() => + jsonResponse(testCase.status, { statusCode: testCase.status, message: testCase.message }) + ); + const client = new DaytonaClient({ apiUrl: API_URL, apiKey: API_KEY, fetch }); + const error = await captureError(client.revokeSshAccess("sb-1", "secret-ssh-token")); + expect(error.kind).toBe(testCase.kind); + expect(error.status).toBe(testCase.status); + expect(error.serverMessage).toBe(testCase.message); + expect(error.message).toContain(testCase.message); + expect(error.message).not.toContain(API_KEY); + expect(error.message).not.toContain("secret-ssh-token"); + } + }); + + it("fails with a timeout error instead of hanging on a stuck control plane", async () => { + const hangingFetch = ((_input: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason as Error)); + })) as typeof fetch; + const client = new DaytonaClient({ + apiUrl: API_URL, + apiKey: API_KEY, + fetch: hangingFetch, + timeoutMs: 20, + }); + const error = await captureError(client.getSandbox("sb-1")); + expect(error.kind).toBe("timeout"); + }); + + it("reports an unreachable control plane", async () => { + const failingFetch = (() => + Promise.reject(new TypeError("fetch failed: ECONNREFUSED"))) as unknown as typeof fetch; + const client = new DaytonaClient({ apiUrl: API_URL, apiKey: API_KEY, fetch: failingFetch }); + const error = await captureError(client.getSandbox("sb-1")); + expect(error.kind).toBe("unreachable"); + expect(error.message).toContain("https://daytona.test"); + }); + + it("refuses authenticated calls without a key, while /config stays public", async () => { + const { fetch, requests } = fakeFetch(() => + jsonResponse(200, { + sshGatewayHost: "ssh.daytona.test", + sshGatewayPort: 2222, + sshGatewayHostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample"], + defaultSnapshot: "daytona-small", + }) + ); + const client = new DaytonaClient({ apiUrl: `${API_URL}/`, fetch }); + expect(await client.getConfig()).toEqual({ + sshGatewayHost: "ssh.daytona.test", + sshGatewayPort: 2222, + sshGatewayHostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample"], + defaultSnapshot: "daytona-small", + }); + expect(requests[0].url.toString()).toBe("https://daytona.test/api/config"); + expect(requests[0].headers.Authorization).toBeUndefined(); + + const error = await captureError(client.listSandboxes()); + expect(error.kind).toBe("not_configured"); + expect(requests).toHaveLength(1); + }); + + it("rejects a /config without an SSH gateway", async () => { + const { fetch } = fakeFetch(() => jsonResponse(200, { defaultSnapshot: "x" })); + const client = new DaytonaClient({ apiUrl: API_URL, fetch }); + const error = await captureError(client.getConfig()); + expect(error.message).toContain("SSH gateway"); + }); + + it("follows nextCursor across sandbox pages with the label filter on every page", async () => { + const { fetch, requests } = fakeFetch((request) => { + const cursor = request.url.searchParams.get("cursor"); + if (!cursor) + return jsonResponse(200, { items: [{ id: "a" }, { id: "b" }], nextCursor: "c2" }); + if (cursor === "c2") return jsonResponse(200, { items: [{ id: "c" }], nextCursor: "c3" }); + return jsonResponse(200, { items: [], nextCursor: null }); + }); + const client = new DaytonaClient({ apiUrl: API_URL, apiKey: API_KEY, fetch }); + const sandboxes = await client.listSandboxes({ "xum.instance": "inst-1" }); + expect(sandboxes.map((sandbox) => sandbox.id)).toEqual(["a", "b", "c"]); + expect(requests).toHaveLength(3); + for (const request of requests) { + expect(request.headers.Authorization).toBe(`Bearer ${API_KEY}`); + expect(JSON.parse(request.url.searchParams.get("labels") ?? "null")).toEqual({ + "xum.instance": "inst-1", + }); + } + }); + + it("walks snapshot pages until totalPages", async () => { + const { fetch, requests } = fakeFetch((request) => { + const page = Number(request.url.searchParams.get("page")); + return jsonResponse(200, { + items: [{ name: `snap-${page}`, id: `id-${page}` }], + total: 2, + page, + totalPages: 2, + }); + }); + const client = new DaytonaClient({ apiUrl: API_URL, apiKey: API_KEY, fetch }); + expect(await client.listSnapshots()).toEqual([{ name: "snap-1" }, { name: "snap-2" }]); + expect(requests).toHaveLength(2); + }); +}); diff --git a/src/node/services/daytona/daytonaClient.ts b/src/node/services/daytona/daytonaClient.ts new file mode 100644 index 00000000000..510d18bef2b --- /dev/null +++ b/src/node/services/daytona/daytonaClient.ts @@ -0,0 +1,395 @@ +import { z } from "zod"; +import type { DaytonaErrorKind } from "@/common/orpc/schemas/daytona"; +import { getErrorMessage } from "@/common/utils/errors"; + +/** + * Thin typed fetch client for the Daytona control plane (public OpenAPI spec at + * `-json`). We deliberately do not depend on `@daytona/sdk`: it is 2 MB unpacked and + * pulls in aws-sdk, OpenTelemetry, axios and socket.io for the dozen calls Xum needs. + * + * Security: the API key and SSH tokens never appear in errors or logs. Error messages name the + * method and path only, never the query string (revoke passes the token there). + */ + +export const DAYTONA_REQUEST_TIMEOUT_MS = 15_000; + +export class DaytonaApiError extends Error { + readonly kind: DaytonaErrorKind; + readonly status?: number; + readonly serverMessage?: string; + + constructor(kind: DaytonaErrorKind, message: string, status?: number, serverMessage?: string) { + super(message); + this.name = "DaytonaApiError"; + this.kind = kind; + this.status = status; + this.serverMessage = serverMessage; + } +} + +export function isDaytonaNotFound(error: unknown): boolean { + return error instanceof DaytonaApiError && error.kind === "not_found"; +} + +const GatewayConfigSchema = z.object({ + sshGatewayHost: z.string().optional(), + sshGatewayPort: z.number().optional(), + sshGatewayHostKeys: z.array(z.string()).optional(), + defaultSnapshot: z.string().optional(), +}); + +export interface DaytonaGatewayConfig { + sshGatewayHost: string; + sshGatewayPort: number; + sshGatewayHostKeys: string[]; + defaultSnapshot?: string; +} + +const SandboxSchema = z.object({ + id: z.string(), + name: z.string().optional(), + state: z.string().optional(), + cpu: z.number().optional(), + memory: z.number().optional(), + disk: z.number().optional(), + snapshot: z.string().nullish(), + target: z.string().nullish(), + labels: z.record(z.string(), z.string()).nullish(), + lastActivityAt: z.string().nullish(), + errorReason: z.string().nullish(), +}); + +export type DaytonaSandbox = z.infer; + +const SandboxPageSchema = z.object({ + items: z.array(SandboxSchema), + nextCursor: z.string().nullish(), +}); + +const SnapshotPageSchema = z.object({ + items: z.array(z.object({ name: z.string() })), + page: z.number().optional(), + totalPages: z.number().optional(), +}); + +const SshAccessSchema = z.object({ + token: z.string(), + expiresAt: z.string(), +}); + +export interface DaytonaSshAccess { + token: string; + expiresAt: string; +} + +export interface DaytonaCreateSandboxRequest { + name?: string; + snapshot?: string; + labels?: Record; + target?: string; + cpu?: number; + /** GiB */ + memory?: number; + /** GiB */ + disk?: number; + /** Minutes; 0 disables. */ + autoStopInterval?: number; + /** Minutes. */ + autoArchiveInterval?: number; + /** Minutes; negative disables. */ + autoDeleteInterval?: number; + env?: Record; +} + +export interface DaytonaResizeRequest { + cpu?: number; + memory?: number; + disk?: number; +} + +export interface DaytonaClientOptions { + apiUrl: string; + apiKey?: string; + fetch?: typeof fetch; + timeoutMs?: number; +} + +interface RequestOptions { + query?: Record; + body?: unknown; + auth?: boolean; + signal?: AbortSignal; +} + +// Pagination guard: a control plane that keeps returning a cursor must not loop forever. +const MAX_PAGES = 50; +const PAGE_LIMIT = 100; + +function extractServerMessage(bodyText: string): string | undefined { + if (!bodyText) return undefined; + try { + const parsed: unknown = JSON.parse(bodyText); + if (parsed && typeof parsed === "object" && "message" in parsed) { + const message = (parsed as { message: unknown }).message; + if (typeof message === "string") return message; + if (Array.isArray(message)) return message.filter((m) => typeof m === "string").join("; "); + } + } catch { + // Not JSON (proxy error page etc.): keep a short plain-text excerpt instead. + return bodyText.slice(0, 200); + } + return undefined; +} + +function kindForStatus(status: number): DaytonaErrorKind { + if (status === 401 || status === 403) return "unauthorized"; + if (status === 404) return "not_found"; + if (status === 400) return "bad_request"; + return "server"; +} + +function describeStatus(kind: DaytonaErrorKind, status: number): string { + switch (kind) { + case "unauthorized": + return `Daytona rejected the API key (HTTP ${status})`; + case "not_found": + return "Daytona resource not found (HTTP 404)"; + case "bad_request": + return "Daytona rejected the request (HTTP 400)"; + default: + return `Daytona request failed (HTTP ${status})`; + } +} + +export class DaytonaClient { + private readonly apiUrl: string; + private readonly apiKey?: string; + private readonly fetchImpl: typeof fetch; + private readonly timeoutMs: number; + + constructor(options: DaytonaClientOptions) { + this.apiUrl = options.apiUrl.replace(/\/+$/, ""); + this.apiKey = options.apiKey; + this.fetchImpl = options.fetch ?? fetch; + this.timeoutMs = options.timeoutMs ?? DAYTONA_REQUEST_TIMEOUT_MS; + } + + private async request( + method: string, + pathName: string, + options: RequestOptions = {} + ): Promise { + const url = new URL(`${this.apiUrl}${pathName}`); + for (const [key, value] of Object.entries(options.query ?? {})) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + const headers: Record = { Accept: "application/json" }; + if (options.auth !== false) { + if (!this.apiKey) { + throw new DaytonaApiError("not_configured", "Daytona API key is not set"); + } + headers.Authorization = `Bearer ${this.apiKey}`; + } + if (options.body !== undefined) headers["Content-Type"] = "application/json"; + + // An explicit timer instead of AbortSignal.timeout/any: under Bun an AbortSignal.timeout + // held only by a hung request's promise chain was seen never to fire. The pending timer + // keeps the controller reachable, so a hung request is always aborted. + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, this.timeoutMs); + const abortFromCaller = () => controller.abort(); + options.signal?.addEventListener("abort", abortFromCaller, { once: true }); + const label = `${method} ${pathName}`; + + let response: Response; + let bodyText: string; + try { + if (options.signal?.aborted) controller.abort(); + response = await this.fetchImpl(url, { + method, + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + bodyText = await response.text(); + } catch (error) { + if (timedOut) { + throw new DaytonaApiError( + "timeout", + `Daytona did not answer ${label} within ${Math.round(this.timeoutMs / 1000)}s` + ); + } + if (options.signal?.aborted) { + throw new DaytonaApiError("timeout", `Daytona request ${label} was cancelled`); + } + throw new DaytonaApiError( + "unreachable", + `Could not reach Daytona at ${url.origin} (${getErrorMessage(error)})` + ); + } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", abortFromCaller); + } + + if (!response.ok) { + const kind = kindForStatus(response.status); + const serverMessage = extractServerMessage(bodyText); + const base = `${describeStatus(kind, response.status)} for ${label}`; + throw new DaytonaApiError( + kind, + serverMessage ? `${base}: ${serverMessage}` : base, + response.status, + serverMessage + ); + } + + if (!bodyText) return undefined; + try { + return JSON.parse(bodyText) as unknown; + } catch { + throw new DaytonaApiError("server", `Daytona returned invalid JSON for ${label}`); + } + } + + private parse(schema: z.ZodType, value: unknown, label: string): T { + const result = schema.safeParse(value); + if (!result.success) { + throw new DaytonaApiError("server", `Daytona returned an unexpected response for ${label}`); + } + return result.data; + } + + /** Public (no auth): SSH gateway host, port and host keys for the known_hosts file. */ + async getConfig(signal?: AbortSignal): Promise { + const raw = await this.request("GET", "/config", { auth: false, signal }); + const parsed = this.parse(GatewayConfigSchema, raw, "GET /config"); + if (!parsed.sshGatewayHost || !parsed.sshGatewayHostKeys?.length) { + throw new DaytonaApiError( + "server", + "This Daytona deployment does not advertise an SSH gateway (sshGatewayHost)" + ); + } + return { + sshGatewayHost: parsed.sshGatewayHost, + sshGatewayPort: parsed.sshGatewayPort ?? 22, + sshGatewayHostKeys: parsed.sshGatewayHostKeys, + ...(parsed.defaultSnapshot ? { defaultSnapshot: parsed.defaultSnapshot } : {}), + }; + } + + async createSandbox( + body: DaytonaCreateSandboxRequest, + signal?: AbortSignal + ): Promise { + const raw = await this.request("POST", "/sandbox", { body, signal }); + return this.parse(SandboxSchema, raw, "POST /sandbox"); + } + + async getSandbox(id: string, signal?: AbortSignal): Promise { + const raw = await this.request("GET", `/sandbox/${encodeURIComponent(id)}`, { signal }); + return this.parse(SandboxSchema, raw, "GET /sandbox/{id}"); + } + + /** Every sandbox matching `labels` (all pages); no labels lists the whole organization. */ + async listSandboxes( + labels?: Record, + signal?: AbortSignal + ): Promise { + const sandboxes: DaytonaSandbox[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGES; page++) { + const raw = await this.request("GET", "/sandbox", { + query: { + labels: labels ? JSON.stringify(labels) : undefined, + limit: PAGE_LIMIT, + cursor, + }, + signal, + }); + const parsed = this.parse(SandboxPageSchema, raw, "GET /sandbox"); + sandboxes.push(...parsed.items); + if (!parsed.nextCursor) return sandboxes; + cursor = parsed.nextCursor; + } + throw new DaytonaApiError("server", "Daytona sandbox list did not end after many pages"); + } + + /** One cheap authenticated call, for "Test connection". */ + async verifyAuth(signal?: AbortSignal): Promise { + await this.request("GET", "/sandbox", { query: { limit: 1 }, signal }); + } + + async startSandbox(id: string, signal?: AbortSignal): Promise { + await this.request("POST", `/sandbox/${encodeURIComponent(id)}/start`, { signal }); + } + + async stopSandbox(id: string, signal?: AbortSignal): Promise { + await this.request("POST", `/sandbox/${encodeURIComponent(id)}/stop`, { signal }); + } + + async archiveSandbox(id: string, signal?: AbortSignal): Promise { + await this.request("POST", `/sandbox/${encodeURIComponent(id)}/archive`, { signal }); + } + + async deleteSandbox(id: string, signal?: AbortSignal): Promise { + await this.request("DELETE", `/sandbox/${encodeURIComponent(id)}`, { signal }); + } + + /** Disk only grows; a running sandbox only grows cpu/memory (shrinking needs it stopped). */ + async resizeSandbox( + id: string, + body: DaytonaResizeRequest, + signal?: AbortSignal + ): Promise { + const raw = await this.request("POST", `/sandbox/${encodeURIComponent(id)}/resize`, { + body, + signal, + }); + return this.parse(SandboxSchema, raw, "POST /sandbox/{id}/resize"); + } + + /** SSH login is `ssh @ -p `: the token is the username. */ + async createSshAccess( + id: string, + expiresInMinutes: number, + signal?: AbortSignal + ): Promise { + const raw = await this.request("POST", `/sandbox/${encodeURIComponent(id)}/ssh-access`, { + query: { expiresInMinutes }, + signal, + }); + const parsed = this.parse(SshAccessSchema, raw, "POST /sandbox/{id}/ssh-access"); + return { token: parsed.token, expiresAt: parsed.expiresAt }; + } + + async revokeSshAccess(id: string, token: string, signal?: AbortSignal): Promise { + await this.request("DELETE", `/sandbox/${encodeURIComponent(id)}/ssh-access`, { + query: { token }, + signal, + }); + } + + /** Daytona counts only SSH and API traffic as activity; this keeps a busy box from stopping. */ + async refreshActivity(id: string, signal?: AbortSignal): Promise { + await this.request("POST", `/sandbox/${encodeURIComponent(id)}/last-activity`, { signal }); + } + + async listSnapshots(signal?: AbortSignal): Promise> { + const snapshots: Array<{ name: string }> = []; + for (let page = 1; page <= MAX_PAGES; page++) { + const raw = await this.request("GET", "/snapshots", { + query: { page, limit: PAGE_LIMIT }, + signal, + }); + const parsed = this.parse(SnapshotPageSchema, raw, "GET /snapshots"); + snapshots.push(...parsed.items.map((item) => ({ name: item.name }))); + if (parsed.items.length === 0 || parsed.totalPages === undefined) return snapshots; + if (page >= parsed.totalPages) return snapshots; + } + return snapshots; + } +} diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts new file mode 100644 index 00000000000..80d0f38967e --- /dev/null +++ b/src/node/services/daytona/daytonaService.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; +import { DaytonaApiError } from "./daytonaClient"; +import { + DAYTONA_LABEL_INSTANCE, + DAYTONA_LABEL_PROJECT, + DaytonaService, + mapDaytonaSandboxState, +} from "./daytonaService"; + +/** In-memory control plane: one sandbox whose `state` follows a script of observations. */ +class FakeControlPlane { + states: string[] = []; + exists = true; + errorReason?: string; + readonly calls: string[] = []; + private tokenCounter = 0; + readonly revoked: string[] = []; + + constructor(private readonly now: () => number) {} + + readonly fetch = ((input: string | URL | Request, init?: RequestInit) => + Promise.resolve(this.handle(input, init))) as typeof fetch; + + private handle(input: string | URL | Request, init?: RequestInit): Response { + const url = new URL(input instanceof Request ? input.url : input.toString()); + const method = init?.method ?? "GET"; + const route = `${method} ${url.pathname.replace(/^\/api/, "")}`; + this.calls.push(route); + const json = (status: number, body?: unknown) => + new Response(body === undefined ? "" : JSON.stringify(body), { status }); + + if (route === "GET /config") { + return json(200, { sshGatewayHost: "ssh.test", sshGatewayHostKeys: ["ssh-ed25519 AAAA"] }); + } + if (route === "GET /sandbox") { + if (init?.headers && (init.headers as Record).Authorization !== "Bearer k") { + return json(401, { statusCode: 401, message: "Invalid credentials" }); + } + return json(200, { items: [], nextCursor: null }); + } + if (!this.exists) return json(404, { statusCode: 404, message: "Sandbox not found" }); + if (route === "GET /sandbox/sb-1") { + const state = this.states.length > 1 ? this.states.shift() : this.states[0]; + return json(200, { id: "sb-1", name: "sb-1", state, errorReason: this.errorReason }); + } + if (route === "POST /sandbox/sb-1/start") return json(200, {}); + if (route === "POST /sandbox/sb-1/ssh-access") { + this.tokenCounter += 1; + const minutes = Number(url.searchParams.get("expiresInMinutes")); + return json(200, { + token: `tok${this.tokenCounter}`, + expiresAt: new Date(this.now() + minutes * 60_000).toISOString(), + }); + } + if (route === "DELETE /sandbox/sb-1/ssh-access") { + this.revoked.push(url.searchParams.get("token") ?? ""); + return json(200, {}); + } + return json(500, { message: `unexpected ${route}` }); + } +} + +describe("DaytonaService", () => { + let rootDir: string; + let clock: number; + let plane: FakeControlPlane; + let service: DaytonaService; + const sleeps: number[] = []; + + function makeService(env: NodeJS.ProcessEnv = { DAYTONA_API_KEY: "k" }): DaytonaService { + return new DaytonaService({ + credentialsStore: new DaytonaCredentialsStore(rootDir, env), + rootDir, + fetch: plane.fetch, + now: () => clock, + sleep: (ms) => { + sleeps.push(ms); + clock += ms; + return Promise.resolve(); + }, + }); + } + + beforeEach(async () => { + rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-daytona-service-")); + clock = Date.parse("2026-01-01T00:00:00Z"); + plane = new FakeControlPlane(() => clock); + sleeps.length = 0; + service = makeService(); + }); + + afterEach(async () => { + await fs.rm(rootDir, { recursive: true, force: true }); + }); + + it("collapses Daytona states to the ones Xum acts on", () => { + const expected: Record = { + creating: "starting", + resuming: "starting", + stopping: "starting", + archiving: "starting", + started: "started", + stopped: "stopped", + paused: "stopped", + archived: "archived", + build_failed: "error", + destroyed: "error", + some_future_state: "error", + }; + for (const [raw, state] of Object.entries(expected)) { + expect(`${raw} -> ${mapDaytonaSandboxState(raw)}`).toBe(`${raw} -> ${state}`); + } + }); + + it("starts a stopped sandbox once and waits until it reports started", async () => { + plane.states = ["stopped", "stopped", "starting", "started"]; + let startingEvents = 0; + const sandbox = await service.startAndWait("sb-1", clock + 60_000, { + onStarting: () => startingEvents++, + }); + expect(sandbox.state).toBe("started"); + expect(startingEvents).toBe(1); + expect(plane.calls.filter((call) => call === "POST /sandbox/sb-1/start")).toHaveLength(1); + }); + + it("gives up at the deadline with a timeout error", async () => { + plane.states = ["starting"]; + const deadline = clock + 7_000; + const error = await service.startAndWait("sb-1", deadline).catch((e: unknown) => e); + expect(error).toBeInstanceOf(DaytonaApiError); + expect((error as DaytonaApiError).kind).toBe("timeout"); + expect(clock).toBe(deadline); + }); + + it("fails fast on a missing sandbox or an error state instead of polling", async () => { + plane.exists = false; + const missing = await service.startAndWait("sb-1", clock + 60_000).catch((e: unknown) => e); + expect((missing as DaytonaApiError).kind).toBe("not_found"); + expect(await service.getSandboxState("sb-1")).toEqual({ state: "missing" }); + + plane.exists = true; + plane.states = ["build_failed"]; + plane.errorReason = "Dockerfile step 3 failed"; + const failed = await service.startAndWait("sb-1", clock + 60_000).catch((e: unknown) => e); + expect((failed as Error).message).toContain("Dockerfile step 3 failed"); + expect(sleeps).toEqual([]); + }); + + it("reuses an SSH token until it nears expiry, then swaps it before revoking the old one", async () => { + const first = await service.getSshToken("sb-1"); + clock += 30 * 60_000; + expect(await service.getSshToken("sb-1")).toEqual(first); + + // 50 minutes in, less than 15 minutes remain. + clock += 20 * 60_000; + const order: string[] = []; + const second = await service.getSshToken("sb-1", { + onNewToken: (access) => { + order.push(`installed ${access.token}, revoked so far: ${plane.revoked.join(",")}`); + return Promise.resolve(); + }, + }); + expect(second.token).not.toBe(first.token); + expect(order).toEqual([`installed ${second.token}, revoked so far: `]); + expect(plane.revoked).toEqual([first.token]); + }); + + it("shares one mint between concurrent token requests", async () => { + const [a, b] = await Promise.all([service.getSshToken("sb-1"), service.getSshToken("sb-1")]); + expect(a).toEqual(b); + expect(plane.calls.filter((call) => call === "POST /sandbox/sb-1/ssh-access")).toHaveLength(1); + }); + + it("reports test-connection failures as typed results", async () => { + expect(await service.testConnection()).toEqual({ ok: true }); + + const wrongKey = makeService({ DAYTONA_API_KEY: "wrong" }); + expect(await wrongKey.testConnection()).toMatchObject({ ok: false, kind: "unauthorized" }); + + const unconfigured = makeService({}); + expect(await unconfigured.testConnection()).toMatchObject({ + ok: false, + kind: "not_configured", + }); + expect(await unconfigured.checkAvailability()).toMatchObject({ available: false }); + }); + + it("labels sandboxes with an instance id that is stable across service instances", async () => { + const labels = await service.buildLabels({ + workspaceId: "ws-1", + projectPath: "/home/me/secret-dir/my-project", + }); + const again = await makeService().buildLabels({ + workspaceId: "ws-2", + projectPath: "/x/other", + }); + expect(labels[DAYTONA_LABEL_INSTANCE]).toBe(again[DAYTONA_LABEL_INSTANCE]); + expect(labels[DAYTONA_LABEL_PROJECT]).toBe("my-project"); + }); +}); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts new file mode 100644 index 00000000000..6d067154e91 --- /dev/null +++ b/src/node/services/daytona/daytonaService.ts @@ -0,0 +1,396 @@ +import * as path from "path"; +import type { + DaytonaListSandboxesResult, + DaytonaListSnapshotsResult, + DaytonaPublicConfig, + DaytonaSandboxState, + DaytonaSandboxSummary, + DaytonaTestConnectionResult, +} from "@/common/orpc/schemas/daytona"; +import { getErrorMessage } from "@/common/utils/errors"; +import type { + DaytonaCredentialsPatch, + DaytonaCredentialsStore, + DaytonaResolvedCredentials, +} from "@/node/config/daytonaCredentialsStore"; +import { loadOrCreateInstallationId } from "@/node/config/installationIdentity"; +import { log } from "@/node/services/log"; +import { + DaytonaApiError, + DaytonaClient, + isDaytonaNotFound, + type DaytonaGatewayConfig, + type DaytonaSandbox, + type DaytonaSshAccess, +} from "./daytonaClient"; + +export const DAYTONA_LABEL_INSTANCE = "xum.instance"; +export const DAYTONA_LABEL_WORKSPACE_ID = "xum.workspaceId"; +export const DAYTONA_LABEL_PROJECT = "xum.project"; + +/** Tokens are minted for an hour and replaced once less than 15 minutes remain. */ +export const DAYTONA_SSH_TOKEN_TTL_MINUTES = 60; +export const DAYTONA_SSH_TOKEN_REFRESH_MARGIN_MS = 15 * 60_000; + +export const DAYTONA_START_POLL_INTERVAL_MS = 2_000; + +type ObservedSandboxState = Exclude; + +const RAW_STATE_MAP: Record = { + creating: "starting", + restoring: "starting", + starting: "starting", + pending_build: "starting", + building_snapshot: "starting", + pulling_snapshot: "starting", + resizing: "starting", + snapshotting: "starting", + forking: "starting", + resuming: "starting", + // Going down, but not settled yet: transitional like the start states. + stopping: "starting", + archiving: "starting", + pausing: "starting", + started: "started", + stopped: "stopped", + paused: "stopped", + archived: "archived", + error: "error", + build_failed: "error", + destroyed: "error", + destroying: "error", + unknown: "error", +}; + +/** Collapse Daytona's ~20 sandbox states to the five Xum acts on; unknown values are errors. */ +export function mapDaytonaSandboxState(raw: string | undefined): ObservedSandboxState { + return (raw ? RAW_STATE_MAP[raw] : undefined) ?? "error"; +} + +// Raw states from which `start` brings the sandbox back. +const STARTABLE_RAW_STATES = new Set(["stopped", "paused", "archived"]); + +export type DaytonaAvailability = { available: true } | { available: false; reason: string }; + +export interface DaytonaServiceOptions { + credentialsStore: DaytonaCredentialsStore; + /** Xum data root; holds the installation identity used for the `xum.instance` label. */ + rootDir: string; + fetch?: typeof fetch; + now?: () => number; + sleep?: (ms: number) => Promise; +} + +export interface DaytonaStartAndWaitOptions { + /** Called when Xum asks Daytona to start the sandbox (for a "Starting sandbox" status). */ + onStarting?: () => void; + pollIntervalMs?: number; + signal?: AbortSignal; +} + +export interface DaytonaSshTokenOptions { + /** + * Runs after a fresh token is minted and before the previous one is revoked, so the caller + * can point the ssh include file at the new token first and no new connection ever uses a + * revoked token. + */ + onNewToken?: (access: DaytonaSshAccess) => Promise; +} + +function toResultError(error: unknown): { + ok: false; + kind: DaytonaApiError["kind"]; + error: string; +} { + if (error instanceof DaytonaApiError) { + return { ok: false, kind: error.kind, error: error.message }; + } + return { ok: false, kind: "server", error: getErrorMessage(error) }; +} + +function toSummary(sandbox: DaytonaSandbox): DaytonaSandboxSummary { + return { + id: sandbox.id, + name: sandbox.name ?? sandbox.id, + state: mapDaytonaSandboxState(sandbox.state), + ...(sandbox.cpu !== undefined ? { cpu: sandbox.cpu } : {}), + ...(sandbox.memory !== undefined ? { memoryGiB: sandbox.memory } : {}), + ...(sandbox.disk !== undefined ? { diskGiB: sandbox.disk } : {}), + ...(sandbox.snapshot ? { snapshot: sandbox.snapshot } : {}), + ...(sandbox.target ? { target: sandbox.target } : {}), + }; +} + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Daytona lifecycle and credential access shared by the runtime, the reconciler and the + * `daytona` oRPC namespace. One instance per Xum backend: it owns the per-sandbox SSH token + * cache, so a second instance would mint (and revoke) tokens behind the first one's back. + */ +export class DaytonaService { + private readonly credentialsStore: DaytonaCredentialsStore; + private readonly rootDir: string; + private readonly fetchImpl?: typeof fetch; + private readonly now: () => number; + private readonly sleep: (ms: number) => Promise; + private gatewayCache?: { apiUrl: string; config: DaytonaGatewayConfig }; + private readonly tokens = new Map(); + private readonly tokenRequests = new Map>(); + + constructor(options: DaytonaServiceOptions) { + this.credentialsStore = options.credentialsStore; + this.rootDir = options.rootDir; + this.fetchImpl = options.fetch; + this.now = options.now ?? Date.now; + this.sleep = options.sleep ?? defaultSleep; + } + + getPublicConfig(): Promise { + return this.credentialsStore.getPublicConfig(); + } + + async setConfig(patch: DaytonaCredentialsPatch): Promise { + await this.credentialsStore.update(patch); + // Tokens and gateway data belong to the old account or deployment. + this.gatewayCache = undefined; + this.tokens.clear(); + return this.credentialsStore.getPublicConfig(); + } + + async checkAvailability(): Promise { + const credentials = await this.credentialsStore.resolve(); + if (!credentials) { + return { + available: false, + reason: "Set a Daytona API key in Settings > Runtimes (or DAYTONA_API_KEY)", + }; + } + return { available: true }; + } + + /** Throws a `not_configured` DaytonaApiError when no API key is set. */ + async getClient(): Promise<{ client: DaytonaClient; credentials: DaytonaResolvedCredentials }> { + const credentials = await this.credentialsStore.resolve(); + if (!credentials) { + throw new DaytonaApiError( + "not_configured", + "Daytona is not configured: set an API key in Settings > Runtimes" + ); + } + return { + client: new DaytonaClient({ + apiUrl: credentials.apiUrl, + apiKey: credentials.apiKey, + fetch: this.fetchImpl, + }), + credentials, + }; + } + + async getGatewayConfig(): Promise { + const { client, credentials } = await this.getClient(); + if (this.gatewayCache?.apiUrl === credentials.apiUrl) return this.gatewayCache.config; + const config = await client.getConfig(); + this.gatewayCache = { apiUrl: credentials.apiUrl, config }; + return config; + } + + async testConnection(): Promise { + try { + const { client } = await this.getClient(); + // The public endpoint proves the URL is a Daytona control plane; the second call the key. + await client.getConfig(); + await client.verifyAuth(); + return { ok: true }; + } catch (error) { + return toResultError(error); + } + } + + async listSandboxes(): Promise { + try { + const { client } = await this.getClient(); + const sandboxes = await client.listSandboxes(); + return { + ok: true, + sandboxes: sandboxes + .filter((sandbox) => sandbox.state !== "destroyed" && sandbox.state !== "destroying") + .map(toSummary), + }; + } catch (error) { + return toResultError(error); + } + } + + async listSnapshots(): Promise { + try { + const { client } = await this.getClient(); + return { ok: true, snapshots: await client.listSnapshots() }; + } catch (error) { + return toResultError(error); + } + } + + /** Stable per Xum install, so the reconciler only ever touches this install's sandboxes. */ + getInstanceId(): Promise { + return loadOrCreateInstallationId(this.rootDir); + } + + async buildLabels(input: { + workspaceId: string; + projectPath: string; + }): Promise> { + return { + [DAYTONA_LABEL_INSTANCE]: await this.getInstanceId(), + [DAYTONA_LABEL_WORKSPACE_ID]: input.workspaceId, + // The basename only: the full path would leak local directory names to Daytona. + [DAYTONA_LABEL_PROJECT]: path.basename(input.projectPath), + }; + } + + async listInstanceSandboxes(): Promise { + const { client } = await this.getClient(); + return client.listSandboxes({ [DAYTONA_LABEL_INSTANCE]: await this.getInstanceId() }); + } + + async getSandboxState( + sandboxId: string + ): Promise<{ state: DaytonaSandboxState; sandbox?: DaytonaSandbox }> { + const { client } = await this.getClient(); + try { + const sandbox = await client.getSandbox(sandboxId); + return { state: mapDaytonaSandboxState(sandbox.state), sandbox }; + } catch (error) { + if (isDaytonaNotFound(error)) return { state: "missing" }; + throw error; + } + } + + /** + * Bring a sandbox to `started` before `deadline` (epoch ms), starting it once if it is + * stopped, paused or archived. Fails on error states, a missing sandbox, or the deadline. + */ + async startAndWait( + sandboxId: string, + deadline: number, + options: DaytonaStartAndWaitOptions = {} + ): Promise { + const { client } = await this.getClient(); + const pollIntervalMs = options.pollIntervalMs ?? DAYTONA_START_POLL_INTERVAL_MS; + let startRequested = false; + for (;;) { + if (options.signal?.aborted) { + throw new DaytonaApiError("timeout", "Waiting for the Daytona sandbox was cancelled"); + } + let sandbox: DaytonaSandbox; + try { + sandbox = await client.getSandbox(sandboxId, options.signal); + } catch (error) { + if (isDaytonaNotFound(error)) { + throw new DaytonaApiError( + "not_found", + `Daytona sandbox ${sandboxId} no longer exists (removed outside Xum?)`, + 404 + ); + } + throw error; + } + const state = mapDaytonaSandboxState(sandbox.state); + if (state === "started") return sandbox; + if (state === "error") { + const reason = sandbox.errorReason ? `: ${sandbox.errorReason}` : ""; + throw new DaytonaApiError( + "server", + `Daytona sandbox ${sandboxId} is in state ${sandbox.state ?? "unknown"}${reason}` + ); + } + // Start once: Daytona may report the old state for a poll or two after accepting it. + if (!startRequested && STARTABLE_RAW_STATES.has(sandbox.state ?? "")) { + options.onStarting?.(); + await client.startSandbox(sandboxId, options.signal); + startRequested = true; + } + const remaining = deadline - this.now(); + if (remaining <= 0) { + throw new DaytonaApiError( + "timeout", + `Daytona sandbox ${sandboxId} did not start in time (last state: ${sandbox.state ?? "unknown"})` + ); + } + await this.sleep(Math.min(pollIntervalMs, remaining)); + } + } + + tokenNeedsRefresh(sandboxId: string): boolean { + const cached = this.tokens.get(sandboxId); + if (!cached) return true; + const expiresAt = Date.parse(cached.expiresAt); + return ( + !Number.isFinite(expiresAt) || expiresAt - this.now() < DAYTONA_SSH_TOKEN_REFRESH_MARGIN_MS + ); + } + + /** + * The cached SSH token for a sandbox, minting a new one when none is cached or it expires + * within the refresh margin. Concurrent callers share one mint. The replaced token is revoked + * after `onNewToken` resolves; a failed revoke is logged, since the token expires anyway. + */ + async getSshToken( + sandboxId: string, + options: DaytonaSshTokenOptions = {} + ): Promise { + const cached = this.tokens.get(sandboxId); + if (cached && !this.tokenNeedsRefresh(sandboxId)) return cached; + const inFlight = this.tokenRequests.get(sandboxId); + if (inFlight) return inFlight; + + const request = this.mintSshToken(sandboxId, cached, options).finally(() => { + this.tokenRequests.delete(sandboxId); + }); + this.tokenRequests.set(sandboxId, request); + return request; + } + + private async mintSshToken( + sandboxId: string, + previous: DaytonaSshAccess | undefined, + options: DaytonaSshTokenOptions + ): Promise { + const { client } = await this.getClient(); + const access = await client.createSshAccess(sandboxId, DAYTONA_SSH_TOKEN_TTL_MINUTES); + await options.onNewToken?.(access); + this.tokens.set(sandboxId, access); + if (previous && previous.token !== access.token) { + await this.revokeQuietly(client, sandboxId, previous.token); + } + return access; + } + + /** Revoke and forget the cached token (workspace deleted or sandbox detached). */ + async forgetSshToken(sandboxId: string): Promise { + const cached = this.tokens.get(sandboxId); + this.tokens.delete(sandboxId); + if (!cached) return; + const { client } = await this.getClient(); + await this.revokeQuietly(client, sandboxId, cached.token); + } + + private async revokeQuietly( + client: DaytonaClient, + sandboxId: string, + token: string + ): Promise { + try { + await client.revokeSshAccess(sandboxId, token); + } catch (error) { + // Never log the token itself. + log.warn("Failed to revoke a Daytona SSH token; it expires on its own", { + sandboxId, + error: getErrorMessage(error), + }); + } + } +} diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index e7ed43c5f4d..c7a6f1bcf8d 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -106,9 +106,9 @@ import { registerInProcessWorkflowRun } from "@/node/services/workflows/workflow * Independent field → tag listing for every DI-built ORPC context field (the * production mapping lives in the Layer files); `Record` keeps it * exhaustive, so a field added to `ORPCContext` without a tag fails to compile - * here. `perfFlightRecorder`, `perfCaptures`, `perfReports`, `sessionTapes` and - * `agentResourceLimits` are container-owned (constructed directly by ServiceContainer, outside - * the DI graph) and are checked separately below. `rowTreeClient` is added by `xum server` only + * here. `perfFlightRecorder`, `perfCaptures`, `perfReports`, `sessionTapes`, + * `agentResourceLimits` and `daytonaService` are container-owned (constructed directly by + * ServiceContainer, outside the DI graph) and are checked separately below. `rowTreeClient` is added by `xum server` only * (src/cli/server.ts). */ const ORPC_FIELD_TAGS: Record< @@ -123,6 +123,7 @@ const ORPC_FIELD_TAGS: Record< | "perfReports" | "sessionTapes" | "agentResourceLimits" + | "daytonaService" >, Context.Key > = { @@ -1687,6 +1688,7 @@ describe("ServiceContainer", () => { expect(orpcContext.perfReports).toBe(services.perfReports); expect(orpcContext.sessionTapes).toBe(services.sessionTapes); expect(orpcContext.agentResourceLimits).toBe(services.agentResourceLimits); + expect(orpcContext.daytonaService).toBe(services.daytonaService); expect(services.runtime.get(IdleDispatcherTag)).toBe(services.idleDispatcher); expect(services.runtime.get(StreamManagerTag).effectRunner).toBe( services.runtime.get(EffectRunnerTag) diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 36489070e62..2babbb57883 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -59,6 +59,8 @@ import { import { createBackendCpuProfiler } from "@/node/services/perf/backendCpuProfiler"; import { PerfCaptureService } from "@/node/services/perf/perfCaptureService"; import { AgentResourceLimitsService } from "@/node/services/agentResourceLimits/agentResourceLimitsService"; +import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; +import { DaytonaService } from "@/node/services/daytona/daytonaService"; import { PerfReportService } from "@/node/services/perf/perfReportService"; import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import type { AgentPluginInstallService } from "@/node/services/agentPlugins/installService"; @@ -286,6 +288,8 @@ export class ServiceContainer { public readonly sessionTapes: SessionTapeFolderService; // Constructed directly (no DI tag): it needs only the config and holds one config listener. public readonly agentResourceLimits: AgentResourceLimitsService; + // Constructed directly (no DI tag): it needs only the data root and starts no timers. + public readonly daytonaService: DaytonaService; public readonly coderService: CoderService; public readonly serverAuthService: ServerAuthService; public readonly desktopSessionManager: DesktopSessionManager; @@ -331,6 +335,10 @@ export class ServiceContainer { this.workflowRuntimeFactory = get(QuickJSRuntimeFactoryTag); this.config = get(ConfigTag); this.agentResourceLimits = new AgentResourceLimitsService(this.config); + this.daytonaService = new DaytonaService({ + credentialsStore: new DaytonaCredentialsStore(this.config.rootDir), + rootDir: this.config.rootDir, + }); this.perfCaptures = new PerfCaptureService({ dir: getXumPerfCapturesDir(this.config.rootDir), recorder: this.perfFlightRecorder, @@ -797,6 +805,7 @@ export class ServiceContainer { browserControlService: this.browserControlService, browserSessionStateHub: this.browserSessionStateHub, coderService: this.coderService, + daytonaService: this.daytonaService, serverAuthService: this.serverAuthService, sshPromptService: this.sshPromptService, desktopSessionManager: this.desktopSessionManager, From 9403abfc63a0f7cedb102e5d40032c658622e4c7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:30:31 +0000 Subject: [PATCH 002/231] refactor(daytona): split client request, startAndWait and credential parsing into small units --- src/node/config/daytonaCredentialsStore.ts | 41 +++--- src/node/services/daytona/daytonaClient.ts | 138 +++++++++++++------- src/node/services/daytona/daytonaService.ts | 93 ++++++++----- 3 files changed, 173 insertions(+), 99 deletions(-) diff --git a/src/node/config/daytonaCredentialsStore.ts b/src/node/config/daytonaCredentialsStore.ts index 939a9bf4a7b..733c8ad6bb6 100644 --- a/src/node/config/daytonaCredentialsStore.ts +++ b/src/node/config/daytonaCredentialsStore.ts @@ -60,6 +60,26 @@ function normalizeApiUrl(value: string): string { return parsed.toString().replace(/\/+$/, ""); } +const CREDENTIAL_FIELDS = ["apiKey", "apiUrl", "target"] as const; + +/** The non-empty string fields of a credentials file, or undefined when it is not an object. */ +function parseStoredCredentials(raw: string): DaytonaStoredCredentials | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; + const record = parsed as Record; + const stored: DaytonaStoredCredentials = {}; + for (const field of CREDENTIAL_FIELDS) { + const value = nonEmpty(record[field]); + if (value) stored[field] = value; + } + return stored; +} + export class DaytonaCredentialsStore { readonly filePath: string; private readonly rootDir: string; @@ -87,26 +107,12 @@ export class DaytonaCredentialsStore { } return {}; } - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { + const stored = parseStoredCredentials(raw); + if (!stored) { // Never log the content: it holds the API key. log.warn("Ignoring malformed Daytona credentials file", { filePath: this.filePath }); return {}; } - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - log.warn("Ignoring malformed Daytona credentials file", { filePath: this.filePath }); - return {}; - } - const record = parsed as Record; - const stored: DaytonaStoredCredentials = {}; - const apiKey = nonEmpty(record.apiKey); - const apiUrl = nonEmpty(record.apiUrl); - const target = nonEmpty(record.target); - if (apiKey) stored.apiKey = apiKey; - if (apiUrl) stored.apiUrl = apiUrl; - if (target) stored.target = target; return stored; } @@ -121,8 +127,7 @@ export class DaytonaCredentialsStore { private async applyUpdate(patch: DaytonaCredentialsPatch): Promise { const next = await this.read(); - const fields = ["apiKey", "apiUrl", "target"] as const; - for (const field of fields) { + for (const field of CREDENTIAL_FIELDS) { const value = patch[field]; if (value === undefined) continue; const trimmed = nonEmpty(value); diff --git a/src/node/services/daytona/daytonaClient.ts b/src/node/services/daytona/daytonaClient.ts index 510d18bef2b..a018bf9a476 100644 --- a/src/node/services/daytona/daytonaClient.ts +++ b/src/node/services/daytona/daytonaClient.ts @@ -161,6 +161,51 @@ function describeStatus(kind: DaytonaErrorKind, status: number): string { } } +function buildUrl(apiUrl: string, pathName: string, query: RequestOptions["query"] = {}): URL { + const url = new URL(`${apiUrl}${pathName}`); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + return url; +} + +function httpError(status: number, bodyText: string, label: string): DaytonaApiError { + const kind = kindForStatus(status); + const serverMessage = extractServerMessage(bodyText); + const base = `${describeStatus(kind, status)} for ${label}`; + return new DaytonaApiError( + kind, + serverMessage ? `${base}: ${serverMessage}` : base, + status, + serverMessage + ); +} + +function transportError( + error: unknown, + context: { + label: string; + origin: string; + timeoutMs: number; + timedOut: boolean; + cancelled: boolean; + } +): DaytonaApiError { + if (context.timedOut) { + return new DaytonaApiError( + "timeout", + `Daytona did not answer ${context.label} within ${Math.round(context.timeoutMs / 1000)}s` + ); + } + if (context.cancelled) { + return new DaytonaApiError("timeout", `Daytona request ${context.label} was cancelled`); + } + return new DaytonaApiError( + "unreachable", + `Could not reach Daytona at ${context.origin} (${getErrorMessage(error)})` + ); +} + export class DaytonaClient { private readonly apiUrl: string; private readonly apiKey?: string; @@ -179,10 +224,24 @@ export class DaytonaClient { pathName: string, options: RequestOptions = {} ): Promise { - const url = new URL(`${this.apiUrl}${pathName}`); - for (const [key, value] of Object.entries(options.query ?? {})) { - if (value !== undefined) url.searchParams.set(key, String(value)); + const label = `${method} ${pathName}`; + const url = buildUrl(this.apiUrl, pathName, options.query); + const { response, bodyText } = await this.send(url, label, { + method, + headers: this.buildHeaders(options), + body: options.body === undefined ? undefined : JSON.stringify(options.body), + callerSignal: options.signal, + }); + if (!response.ok) throw httpError(response.status, bodyText, label); + if (!bodyText) return undefined; + try { + return JSON.parse(bodyText) as unknown; + } catch { + throw new DaytonaApiError("server", `Daytona returned invalid JSON for ${label}`); } + } + + private buildHeaders(options: RequestOptions): Record { const headers: Record = { Accept: "application/json" }; if (options.auth !== false) { if (!this.apiKey) { @@ -191,7 +250,20 @@ export class DaytonaClient { headers.Authorization = `Bearer ${this.apiKey}`; } if (options.body !== undefined) headers["Content-Type"] = "application/json"; + return headers; + } + /** fetch with this client's timeout and the caller's signal; transport failures are typed. */ + private async send( + url: URL, + label: string, + init: { + method: string; + headers: Record; + body?: string; + callerSignal?: AbortSignal; + } + ): Promise<{ response: Response; bodyText: string }> { // An explicit timer instead of AbortSignal.timeout/any: under Bun an AbortSignal.timeout // held only by a hung request's promise chain was seen never to fire. The pending timer // keeps the controller reachable, so a hung request is always aborted. @@ -201,57 +273,29 @@ export class DaytonaClient { timedOut = true; controller.abort(); }, this.timeoutMs); + const callerSignal = init.callerSignal; const abortFromCaller = () => controller.abort(); - options.signal?.addEventListener("abort", abortFromCaller, { once: true }); - const label = `${method} ${pathName}`; - - let response: Response; - let bodyText: string; + callerSignal?.addEventListener("abort", abortFromCaller, { once: true }); try { - if (options.signal?.aborted) controller.abort(); - response = await this.fetchImpl(url, { - method, - headers, - body: options.body === undefined ? undefined : JSON.stringify(options.body), + if (callerSignal?.aborted) controller.abort(); + const response = await this.fetchImpl(url, { + method: init.method, + headers: init.headers, + body: init.body, signal: controller.signal, }); - bodyText = await response.text(); + return { response, bodyText: await response.text() }; } catch (error) { - if (timedOut) { - throw new DaytonaApiError( - "timeout", - `Daytona did not answer ${label} within ${Math.round(this.timeoutMs / 1000)}s` - ); - } - if (options.signal?.aborted) { - throw new DaytonaApiError("timeout", `Daytona request ${label} was cancelled`); - } - throw new DaytonaApiError( - "unreachable", - `Could not reach Daytona at ${url.origin} (${getErrorMessage(error)})` - ); + throw transportError(error, { + label, + origin: url.origin, + timeoutMs: this.timeoutMs, + timedOut, + cancelled: callerSignal?.aborted === true, + }); } finally { clearTimeout(timer); - options.signal?.removeEventListener("abort", abortFromCaller); - } - - if (!response.ok) { - const kind = kindForStatus(response.status); - const serverMessage = extractServerMessage(bodyText); - const base = `${describeStatus(kind, response.status)} for ${label}`; - throw new DaytonaApiError( - kind, - serverMessage ? `${base}: ${serverMessage}` : base, - response.status, - serverMessage - ); - } - - if (!bodyText) return undefined; - try { - return JSON.parse(bodyText) as unknown; - } catch { - throw new DaytonaApiError("server", `Daytona returned invalid JSON for ${label}`); + callerSignal?.removeEventListener("abort", abortFromCaller); } } diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 6d067154e91..ea289ee3727 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -67,8 +67,40 @@ export function mapDaytonaSandboxState(raw: string | undefined): ObservedSandbox return (raw ? RAW_STATE_MAP[raw] : undefined) ?? "error"; } -// Raw states from which `start` brings the sandbox back. -const STARTABLE_RAW_STATES = new Set(["stopped", "paused", "archived"]); +// States from which `start` brings the sandbox back (stopped includes paused). +const STARTABLE_STATES = new Set(["stopped", "archived"]); + +function sandboxStateError(sandboxId: string, sandbox: DaytonaSandbox): DaytonaApiError { + const reason = sandbox.errorReason ? `: ${sandbox.errorReason}` : ""; + return new DaytonaApiError( + "server", + `Daytona sandbox ${sandboxId} is in state ${sandbox.state ?? "unknown"}${reason}` + ); +} + +function startTimeoutError(sandboxId: string, sandbox: DaytonaSandbox): DaytonaApiError { + return new DaytonaApiError( + "timeout", + `Daytona sandbox ${sandboxId} did not start in time (last state: ${sandbox.state ?? "unknown"})` + ); +} + +/** + * One startAndWait step for an observed sandbox: done once started, start it (once) when it is + * stopped or archived, otherwise keep waiting. Error states throw. + */ +function nextStartStep( + sandboxId: string, + sandbox: DaytonaSandbox, + startRequested: boolean +): "done" | "start" | "wait" { + const state = mapDaytonaSandboxState(sandbox.state); + if (state === "started") return "done"; + if (state === "error") throw sandboxStateError(sandboxId, sandbox); + // Start once: Daytona may report the old state for a poll or two after accepting it. + if (!startRequested && STARTABLE_STATES.has(state)) return "start"; + return "wait"; +} export type DaytonaAvailability = { available: true } | { available: false; reason: string }; @@ -279,48 +311,41 @@ export class DaytonaService { options: DaytonaStartAndWaitOptions = {} ): Promise { const { client } = await this.getClient(); - const pollIntervalMs = options.pollIntervalMs ?? DAYTONA_START_POLL_INTERVAL_MS; let startRequested = false; for (;;) { - if (options.signal?.aborted) { - throw new DaytonaApiError("timeout", "Waiting for the Daytona sandbox was cancelled"); - } - let sandbox: DaytonaSandbox; - try { - sandbox = await client.getSandbox(sandboxId, options.signal); - } catch (error) { - if (isDaytonaNotFound(error)) { - throw new DaytonaApiError( - "not_found", - `Daytona sandbox ${sandboxId} no longer exists (removed outside Xum?)`, - 404 - ); - } - throw error; - } - const state = mapDaytonaSandboxState(sandbox.state); - if (state === "started") return sandbox; - if (state === "error") { - const reason = sandbox.errorReason ? `: ${sandbox.errorReason}` : ""; - throw new DaytonaApiError( - "server", - `Daytona sandbox ${sandboxId} is in state ${sandbox.state ?? "unknown"}${reason}` - ); - } - // Start once: Daytona may report the old state for a poll or two after accepting it. - if (!startRequested && STARTABLE_RAW_STATES.has(sandbox.state ?? "")) { + options.signal?.throwIfAborted(); + const sandbox = await this.getSandboxForStart(client, sandboxId, options.signal); + const step = nextStartStep(sandboxId, sandbox, startRequested); + if (step === "done") return sandbox; + if (step === "start") { options.onStarting?.(); await client.startSandbox(sandboxId, options.signal); startRequested = true; } const remaining = deadline - this.now(); - if (remaining <= 0) { + if (remaining <= 0) throw startTimeoutError(sandboxId, sandbox); + await this.sleep( + Math.min(options.pollIntervalMs ?? DAYTONA_START_POLL_INTERVAL_MS, remaining) + ); + } + } + + private async getSandboxForStart( + client: DaytonaClient, + sandboxId: string, + signal?: AbortSignal + ): Promise { + try { + return await client.getSandbox(sandboxId, signal); + } catch (error) { + if (isDaytonaNotFound(error)) { throw new DaytonaApiError( - "timeout", - `Daytona sandbox ${sandboxId} did not start in time (last state: ${sandbox.state ?? "unknown"})` + "not_found", + `Daytona sandbox ${sandboxId} no longer exists (removed outside Xum?)`, + 404 ); } - await this.sleep(Math.min(pollIntervalMs, remaining)); + throw error; } } From 4869c28843e74d6a4dcf7379115b5cd9c74181b2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:15:04 +0000 Subject: [PATCH 003/231] feat(daytona): add DaytonaSSHRuntime, reconciler, agent-home seeding and wiring Part B of the Daytona runtime provider (ssh + daytona sub-config): - DaytonaSSHRuntime (mirrors CoderSSHRuntime): finalizeConfig names a new sandbox before persist (Daytona takes a name wherever it takes an id) or attaches a given one as shared; validateBeforePersist checks credentials, preset size and name/attach existence; postCreateSetup creates with preset resources and lifecycle intervals, falls back to create-then-resize on HTTP 400, waits, writes ssh access, seeds the agent home and deletes the sandbox it created on failure; ensureReady caches a ready result for 60 s per sandbox, shares one in-flight start, reports "Starting sandbox" and maps a missing sandbox to runtime_not_ready; forkWorkspace marks both shared; deleteWorkspace starts a stopped sandbox so the dirty/unpushed checks run. - DaytonaService: create/resize/stop/delete/refreshActivity, single-flight ensureSandboxReady, ssh include ownership with token refresh, ready cache. - Agent-home seeding of tool_env/tool_pre/tool_post and global skills to the paths remote lookups read (~/.mux, ~/.agents/skills), never for attached boxes. - runtimeFactory selects DaytonaSSHRuntime; checkRuntimeAvailability reports Daytona availability; bash AI proxy forwards are not restored at startup. - Archive reuses the Coder keep/stop/delete behavior for dedicated sandboxes. - DaytonaReconciler (service container, 60 s, never blocks startup): keepalive for active workspaces, token refresh, stop-only orphan sweep with archived workspaces counted as references, per-call timeouts. - Gated TEST_DAYTONA=1 real test (skipped by default). --- src/common/orpc/schemas/runtime.ts | 2 + src/common/types/runtime.ts | 13 + src/node/runtime/DaytonaSSHRuntime.test.ts | 321 +++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 614 ++++++++++++++++++ src/node/runtime/daytonaAgentHomeSeed.test.ts | 74 +++ src/node/runtime/daytonaAgentHomeSeed.ts | 189 ++++++ src/node/runtime/runtimeFactory.test.ts | 28 + src/node/runtime/runtimeFactory.ts | 136 +++- .../bashAiProxy/sshForwardTarget.test.ts | 27 +- .../services/bashAiProxy/sshForwardTarget.ts | 4 +- .../services/daytona/daytona.testHarness.ts | 279 ++++++++ .../daytona/daytonaLifecycleHooks.test.ts | 59 ++ .../services/daytona/daytonaLifecycleHooks.ts | 83 +++ .../daytona/daytonaReconciler.test.ts | 110 ++++ .../services/daytona/daytonaReconciler.ts | 192 ++++++ src/node/services/daytona/daytonaService.ts | 183 ++++++ src/node/services/serviceContainer.ts | 42 ++ src/node/services/workspaceService.ts | 7 +- tests/runtime/daytonaRuntime.test.ts | 165 +++++ 19 files changed, 2496 insertions(+), 32 deletions(-) create mode 100644 src/node/runtime/DaytonaSSHRuntime.test.ts create mode 100644 src/node/runtime/DaytonaSSHRuntime.ts create mode 100644 src/node/runtime/daytonaAgentHomeSeed.test.ts create mode 100644 src/node/runtime/daytonaAgentHomeSeed.ts create mode 100644 src/node/services/daytona/daytona.testHarness.ts create mode 100644 src/node/services/daytona/daytonaLifecycleHooks.test.ts create mode 100644 src/node/services/daytona/daytonaLifecycleHooks.ts create mode 100644 src/node/services/daytona/daytonaReconciler.test.ts create mode 100644 src/node/services/daytona/daytonaReconciler.ts create mode 100644 tests/runtime/daytonaRuntime.test.ts diff --git a/src/common/orpc/schemas/runtime.ts b/src/common/orpc/schemas/runtime.ts index 8529483c74d..9f44ce96973 100644 --- a/src/common/orpc/schemas/runtime.ts +++ b/src/common/orpc/schemas/runtime.ts @@ -55,6 +55,8 @@ export const RuntimeAvailabilitySchema = z.object({ ssh: RuntimeAvailabilityStatusSchema, docker: RuntimeAvailabilityStatusSchema, devcontainer: RuntimeAvailabilityStatusSchema, + // Daytona is `ssh` + a sub-config, not a RuntimeMode; optional so mode-keyed maps still fit. + daytona: RuntimeAvailabilityStatusSchema.optional(), }); export const RuntimeConfigSchema = z.union([ // Legacy local with srcBaseDir (treated as worktree) diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts index 253af849a85..1bf09f4df10 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -265,6 +265,19 @@ export function isSSHRuntime( return config?.type === "ssh"; } +/** + * A Daytona sandbox Xum created for this workspace alone: not an attached existing sandbox + * and not one shared with a fork. Only these may be stopped or deleted with the workspace. + */ +export function isDedicatedDaytonaSandbox(config: RuntimeConfig | undefined): boolean { + return ( + isSSHRuntime(config) && + config.daytona != null && + config.daytona.shared !== true && + (config.daytona.sandboxId?.trim() ?? "") !== "" + ); +} + /** * Type guard to check if a runtime config is Docker */ diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts new file mode 100644 index 00000000000..cffc71fdc44 --- /dev/null +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -0,0 +1,321 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn, type Mock } from "bun:test"; +import * as fs from "fs/promises"; +import * as path from "path"; +import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; +import type { RuntimeConfig } from "@/common/types/runtime"; +import { + createDaytonaTestRig, + type DaytonaTestRig, +} from "@/node/services/daytona/daytona.testHarness"; +import { DAYTONA_READY_CACHE_MS } from "@/node/services/daytona/daytonaService"; +import * as runtimeHelpers from "@/node/utils/runtime/helpers"; +import { DaytonaSSHRuntime } from "./DaytonaSSHRuntime"; +import type { InitLogger, RuntimeStatusEvent, WorkspaceInitParams } from "./Runtime"; +import { SSHRuntime } from "./SSHRuntime"; +import { createSSHTransport } from "./transports"; + +let rig: DaytonaTestRig; +let execSpy: Mock; + +beforeEach(async () => { + rig = await createDaytonaTestRig(); + execSpy = spyOn(runtimeHelpers, "execBuffered").mockResolvedValue({ + stdout: "", + stderr: "", + exitCode: 0, + duration: 0, + }); +}); + +afterEach(async () => { + execSpy.mockRestore(); + await rig.cleanup(); +}); + +function makeRuntime(daytona: DaytonaSandboxConfig, testRig = rig): DaytonaSSHRuntime { + const config = { host: "placeholder.xum--daytona", srcBaseDir: "~/src", daytona }; + return new DaytonaSSHRuntime(config, createSSHTransport(config, false), testRig.service, { + workspaceId: "ws-1", + // Empty by default: tests that seed write files here. + agentHomeSeedSources: { + xumHome: path.join(testRig.rootDir, "host-xum"), + home: path.join(testRig.rootDir, "host-home"), + }, + }); +} + +function sshDaytonaConfig(daytona: DaytonaSandboxConfig): RuntimeConfig { + return { type: "ssh", host: "placeholder.xum--daytona", srcBaseDir: "~/src", daytona }; +} + +function initParams(): WorkspaceInitParams & { log: string[] } { + const logLines: string[] = []; + const initLogger: InitLogger = { + logStep: (line) => logLines.push(line), + logStdout: (line) => logLines.push(line), + logStderr: (line) => logLines.push(line), + logComplete: () => undefined, + }; + return { + projectPath: "/home/me/project", + branchName: "feature", + trunkBranch: "main", + workspacePath: "~/src/project/feature", + initLogger, + log: logLines, + }; +} + +const SMALL = { cpu: 2, memoryGiB: 4, diskGiB: 10 }; + +describe("DaytonaSSHRuntime.ensureReady", () => { + it("trusts a ready result for 60 s across runtime instances, then checks again", async () => { + rig.plane.addSandbox({ id: "sb-ready" }); + expect(await makeRuntime({ sandboxId: "sb-ready" }).ensureReady()).toEqual({ ready: true }); + const getsAfterFirst = rig.plane.countCalls("GET /sandbox/sb-ready"); + + // A stream start builds a fresh runtime; it must not hit the control plane again. + expect(await makeRuntime({ sandboxId: "sb-ready" }).ensureReady()).toEqual({ ready: true }); + expect(rig.plane.countCalls("GET /sandbox/sb-ready")).toBe(getsAfterFirst); + + rig.clock.now += DAYTONA_READY_CACHE_MS; + await makeRuntime({ sandboxId: "sb-ready" }).ensureReady(); + expect(rig.plane.countCalls("GET /sandbox/sb-ready")).toBeGreaterThan(getsAfterFirst); + }); + + it("starts a stopped sandbox, reports it in the status sink, and writes the ssh token", async () => { + rig.plane.addSandbox({ id: "sb-stopped", state: "stopped" }); + const events: RuntimeStatusEvent[] = []; + + const result = await makeRuntime({ sandboxId: "sb-stopped" }).ensureReady({ + statusSink: (event) => events.push(event), + }); + + expect(result).toEqual({ ready: true }); + expect(rig.plane.countCalls("POST /sandbox/sb-stopped/start")).toBe(1); + expect(events.map((event) => event.phase)).toContain("starting"); + expect(events.at(-1)?.phase).toBe("ready"); + expect(rig.writer.tokens.get("sb-stopped")).toBeDefined(); + }); + + it("shares one start between concurrent checks", async () => { + rig.plane.addSandbox({ id: "sb-archived", state: "archived" }); + const results = await Promise.all([ + makeRuntime({ sandboxId: "sb-archived" }).ensureReady(), + makeRuntime({ sandboxId: "sb-archived" }).ensureReady(), + ]); + expect(results).toEqual([{ ready: true }, { ready: true }]); + expect(rig.plane.countCalls("POST /sandbox/sb-archived/start")).toBe(1); + expect(rig.plane.countCalls("POST /sandbox/sb-archived/ssh-access")).toBe(1); + }); + + it("reports a sandbox removed outside Xum as runtime_not_ready and never recreates it", async () => { + const result = await makeRuntime({ sandboxId: "sb-gone" }).ensureReady(); + + expect(result.ready).toBe(false); + if (!result.ready) { + expect(result.errorType).toBe("runtime_not_ready"); + expect(result.error).toContain("no longer exists"); + } + expect(rig.plane.countCalls("POST /sandbox")).toBe(0); + }); +}); + +describe("DaytonaSSHRuntime.finalizeConfig and validateBeforePersist", () => { + it("names a new sandbox before persist and fills the default size", async () => { + const runtime = makeRuntime({}); + const result = await runtime.finalizeConfig("Feature_X", sshDaytonaConfig({})); + expect(result.success).toBe(true); + if (!result.success || result.data.type !== "ssh") throw new Error("expected ssh config"); + const sandboxId = result.data.daytona?.sandboxId ?? ""; + expect(sandboxId).toMatch(/^xum-feature-x-[0-9a-f]{6}$/); + expect(result.data.host).toBe(`${sandboxId}.xum--daytona`); + expect(result.data.daytona).toMatchObject({ shared: false, ...SMALL }); + expect(await runtime.validateBeforePersist("Feature_X", result.data)).toEqual({ + success: true, + data: undefined, + }); + }); + + it("attaches to a given sandbox as shared", async () => { + rig.plane.addSandbox({ id: "sb-mine" }); + const runtime = makeRuntime({}); + const result = await runtime.finalizeConfig("b", sshDaytonaConfig({ sandboxId: "sb-mine" })); + if (!result.success || result.data.type !== "ssh") throw new Error("expected ssh config"); + expect(result.data.daytona).toMatchObject({ sandboxId: "sb-mine", shared: true }); + expect((await runtime.validateBeforePersist("b", result.data)).success).toBe(true); + }); + + it("rejects missing credentials, non-preset sizes and taken names", async () => { + const noKey = await createDaytonaTestRig({ apiKey: null }); + try { + const result = await makeRuntime({}, noKey).finalizeConfig("b", sshDaytonaConfig({})); + expect(result.success).toBe(false); + } finally { + await noKey.cleanup(); + } + + const runtime = makeRuntime({}); + const odd = await runtime.finalizeConfig("b", sshDaytonaConfig({ cpu: 3 })); + expect(odd.success).toBe(false); + const handEdited = sshDaytonaConfig({ sandboxId: "xum-b-1", shared: false, cpu: 64 }); + expect((await runtime.validateBeforePersist("b", handEdited)).success).toBe(false); + + rig.plane.addSandbox({ id: "sb-9", name: "xum-b-taken" }); + const taken = sshDaytonaConfig({ sandboxId: "xum-b-taken", shared: false, ...SMALL }); + expect((await runtime.validateBeforePersist("b", taken)).success).toBe(false); + }); +}); + +describe("DaytonaSSHRuntime.postCreateSetup", () => { + it("creates the sandbox with preset resources, labels and lifecycle intervals", async () => { + await makeRuntime({ sandboxId: "xum-new", shared: false, ...SMALL }).postCreateSetup( + initParams() + ); + + expect(rig.plane.createBodies).toHaveLength(1); + expect(rig.plane.createBodies[0]).toMatchObject({ + name: "xum-new", + cpu: 2, + memory: 4, + disk: 10, + autoStopInterval: 30, + autoArchiveInterval: 1440, + autoDeleteInterval: -1, + labels: { "xum.workspaceId": "ws-1", "xum.project": "project" }, + }); + expect(rig.plane.find("xum-new")?.state).toBe("started"); + expect(rig.writer.tokens.get("xum-new")).toBeDefined(); + }); + + it("falls back to create-then-resize when the snapshot has fixed resources", async () => { + rig.plane.rejectCreateResources = true; + const medium = { cpu: 4, memoryGiB: 8, diskGiB: 20 }; + + await makeRuntime({ + sandboxId: "xum-fixed", + shared: false, + snapshot: "fixed-snap", + ...medium, + }).postCreateSetup(initParams()); + + expect(rig.plane.createBodies).toHaveLength(2); + expect(rig.plane.createBodies[1].cpu).toBeUndefined(); + expect(rig.plane.find("xum-fixed")).toMatchObject({ + state: "started", + cpu: 4, + memory: 8, + disk: 20, + }); + }); + + it("deletes the sandbox it created when setup fails afterwards", async () => { + rig.writer.verifyError = new Error("Your ssh config sets User"); + const params = initParams(); + + const failure = await makeRuntime({ sandboxId: "xum-doomed", shared: false, ...SMALL }) + .postCreateSetup(params) + .then( + () => undefined, + (error: unknown) => error + ); + + expect(failure).toBeInstanceOf(Error); + expect(String(failure)).toContain("Your ssh config sets User"); + + expect(rig.plane.countCalls("POST /sandbox")).toBe(1); + expect(rig.plane.find("xum-doomed")).toBeUndefined(); + }); + + it("seeds the agent home of a created sandbox but never of an attached one", async () => { + const hostXum = path.join(rig.rootDir, "host-xum"); + await fs.mkdir(hostXum, { recursive: true }); + await fs.writeFile(path.join(hostXum, "tool_env"), "export JOBS=4\n"); + const seedCalls = () => execSpy.mock.calls.filter(([, command]) => command === "sh -s"); + + await makeRuntime({ sandboxId: "xum-seeded", shared: false, ...SMALL }).postCreateSetup( + initParams() + ); + expect(seedCalls()).toHaveLength(1); + expect(seedCalls()[0][2].stdin).toContain('"$HOME/.mux/tool_env"'); + + rig.plane.addSandbox({ id: "sb-users", state: "stopped" }); + await makeRuntime({ sandboxId: "sb-users", shared: true }).postCreateSetup(initParams()); + expect(seedCalls()).toHaveLength(1); + expect(rig.plane.countCalls("POST /sandbox")).toBe(1); + expect(rig.plane.find("sb-users")?.state).toBe("started"); + }); +}); + +describe("DaytonaSSHRuntime.deleteWorkspace", () => { + let sshDeleteSpy: Mock; + + beforeEach(() => { + sshDeleteSpy = spyOn(SSHRuntime.prototype, "deleteWorkspace").mockResolvedValue({ + success: true, + deletedPath: "/path", + }); + }); + + afterEach(() => { + sshDeleteSpy.mockRestore(); + }); + + it("starts a stopped sandbox to run the dirty check and keeps a dirty workspace", async () => { + rig.plane.addSandbox({ id: "sb-dirty", state: "stopped" }); + sshDeleteSpy.mockResolvedValue({ success: false, error: "uncommitted changes" }); + + const result = await makeRuntime({ sandboxId: "sb-dirty", shared: false }).deleteWorkspace( + "/home/me/project", + "feature", + false + ); + + expect(result).toEqual({ success: false, error: "uncommitted changes" }); + expect(rig.plane.countCalls("POST /sandbox/sb-dirty/start")).toBe(1); + expect(sshDeleteSpy).toHaveBeenCalledTimes(1); + expect(rig.plane.find("sb-dirty")).toBeDefined(); + }); + + it("deletes an owned sandbox after a clean check and drops its ssh access", async () => { + rig.plane.addSandbox({ id: "sb-clean", state: "stopped" }); + + const result = await makeRuntime({ sandboxId: "sb-clean", shared: false }).deleteWorkspace( + "/home/me/project", + "feature", + false + ); + + expect(result.success).toBe(true); + expect(rig.plane.find("sb-clean")).toBeUndefined(); + expect(rig.writer.removed).toContain("sb-clean"); + expect(rig.plane.revokedTokens).toHaveLength(1); + }); + + it("never deletes a sandbox shared with a fork", async () => { + rig.plane.addSandbox({ id: "sb-parent" }); + const forkSpy = spyOn(SSHRuntime.prototype, "forkWorkspace").mockResolvedValue({ + success: true, + workspacePath: "/fork", + }); + try { + const runtime = makeRuntime({ sandboxId: "sb-parent", shared: false }); + const fork = await runtime.forkWorkspace({ + projectPath: "/home/me/project", + sourceWorkspaceName: "feature", + newWorkspaceName: "feature-fork", + initLogger: initParams().initLogger, + }); + for (const config of [fork.forkedRuntimeConfig, fork.sourceRuntimeConfig]) { + expect(config?.type === "ssh" ? config.daytona?.shared : undefined).toBe(true); + } + + expect((await runtime.deleteWorkspace("/home/me/project", "feature", false)).success).toBe( + true + ); + expect(rig.plane.find("sb-parent")).toBeDefined(); + } finally { + forkSpy.mockRestore(); + } + }); +}); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts new file mode 100644 index 00000000000..c94d2fcd1af --- /dev/null +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -0,0 +1,614 @@ +/** + * DaytonaSSHRuntime - SSH runtime for Daytona Cloud sandboxes, reached through Daytona's SSH + * gateway. Mirrors CoderSSHRuntime: the sandbox is provisioned in postCreateSetup (after the + * workspace metadata is persisted, so progress streams to the init log), and ensureReady starts + * a stopped or archived sandbox on demand. + * + * Sandbox identity: Daytona accepts a sandbox name wherever it takes an id, so for a sandbox + * Xum creates, finalizeConfig picks a unique name and stores it as `daytona.sandboxId`. The + * host alias (`.xum--daytona`) is then known before persist, like Coder's. + */ + +import * as os from "os"; +import * as path from "path"; +import { randomBytes } from "crypto"; +import type { + EnsureReadyOptions, + EnsureReadyResult, + RuntimeCreateFlags, + RuntimeStatusEvent, + WorkspaceCreationParams, + WorkspaceCreationResult, + WorkspaceForkParams, + WorkspaceForkResult, + WorkspaceInitParams, +} from "./Runtime"; +import { SSHRuntime, type SSHRuntimeConfig } from "./SSHRuntime"; +import type { SSHTransport } from "./transports"; +import { + DAYTONA_DEFAULT_SIZE_PRESET_ID, + DAYTONA_SIZE_PRESETS, + findDaytonaSizePreset, + toXumDaytonaHost, + type DaytonaSizePreset, +} from "@/common/constants/daytona"; +import { getXumHome } from "@/common/constants/paths"; +import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; +import type { RuntimeConfig } from "@/common/types/runtime"; +import { isSSHRuntime } from "@/common/types/runtime"; +import type { Result } from "@/common/types/result"; +import { Err, Ok } from "@/common/types/result"; +import { getErrorMessage } from "@/common/utils/errors"; +import { DaytonaApiError, type DaytonaSandbox } from "@/node/services/daytona/daytonaClient"; +import { + DAYTONA_START_TIMEOUT_MS, + type DaytonaService, +} from "@/node/services/daytona/daytonaService"; +import { log } from "@/node/services/log"; +import { execBuffered } from "@/node/utils/runtime/helpers"; +import { seedAgentHome, type AgentHomeSeedSources } from "./daytonaAgentHomeSeed"; +import { expandTildeForSSH } from "./tildeExpansion"; + +/** The DaytonaService surface this runtime uses (tests pass an in-memory fake). */ +export type DaytonaRuntimeService = Pick< + DaytonaService, + | "checkAvailability" + | "getSandboxState" + | "buildLabels" + | "getDefaultTarget" + | "createSandbox" + | "resizeSandbox" + | "stopAndWait" + | "startAndWait" + | "deleteSandbox" + | "ensureSandboxReady" + | "isSandboxReady" + | "markSandboxReady" +>; + +export interface DaytonaSSHRuntimeConfig extends SSHRuntimeConfig { + daytona: DaytonaSandboxConfig; +} + +export interface DaytonaSSHRuntimeOptions { + projectPath?: string; + workspaceName?: string; + workspacePath?: string; + /** Labels the created sandbox, so the reconciler can tell which workspace it belongs to. */ + workspaceId?: string; + /** Host directories the agent home is seeded from (default: the Xum home and user home). */ + agentHomeSeedSources?: AgentHomeSeedSources; +} + +/** Idle sandboxes stop after 30 minutes and archive after a day stopped; never auto-delete. */ +const DAYTONA_AUTO_STOP_MINUTES = 30; +const DAYTONA_AUTO_ARCHIVE_MINUTES = 1440; +const DAYTONA_AUTO_DELETE_DISABLED = -1; + +const SANDBOX_NAME_MAX_BRANCH_LENGTH = 40; + +/** `xum--`: unique per creation, and readable in the Daytona dashboard. */ +export function buildDaytonaSandboxName(branchName: string): string { + const slug = branchName + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, SANDBOX_NAME_MAX_BRANCH_LENGTH) + .replace(/-+$/g, ""); + const suffix = randomBytes(3).toString("hex"); + return slug ? `xum-${slug}-${suffix}` : `xum-${suffix}`; +} + +/** No size set means the default preset; anything else must be exactly one preset. */ +function resolveSizePreset(config: DaytonaSandboxConfig): DaytonaSizePreset | undefined { + if (config.cpu === undefined && config.memoryGiB === undefined && config.diskGiB === undefined) { + return DAYTONA_SIZE_PRESETS.find((preset) => preset.id === DAYTONA_DEFAULT_SIZE_PRESET_ID); + } + return findDaytonaSizePreset(config); +} + +function invalidSizeError(): string { + const sizes = DAYTONA_SIZE_PRESETS.map( + (preset) => `${preset.label} (${preset.cpu} vCPU, ${preset.memoryGiB} GiB)` + ).join(", "); + return `Daytona sandbox size must be one of: ${sizes}`; +} + +function isBadRequest(error: unknown): boolean { + return error instanceof DaytonaApiError && error.kind === "bad_request"; +} + +/** Resolve with `promise`, or reject as soon as `signal` aborts (the shared work keeps going). */ +function raceAbort(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return promise; + if (signal.aborted) return Promise.reject(new Error("Aborted")); + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error("Aborted")); + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error instanceof Error ? error : new Error(getErrorMessage(error))); + } + ); + }); +} + +/** + * IMPORTANT: extends SSHRuntime (rather than delegating) so code that checks + * `runtime instanceof SSHRuntime` (PTY, tools, MCP, bash AI proxy) treats Daytona as SSH. + */ +export class DaytonaSSHRuntime extends SSHRuntime { + private daytonaConfig: DaytonaSandboxConfig; + private readonly daytonaService: DaytonaRuntimeService; + private readonly workspaceId?: string; + private readonly agentHomeSeedSources?: AgentHomeSeedSources; + + /** As Coder: the sandbox does not exist yet at create time, so skip host access. */ + readonly createFlags: RuntimeCreateFlags = { + deferredRuntimeAccess: true, + configLevelCollisionDetection: true, + }; + + constructor( + config: DaytonaSSHRuntimeConfig, + transport: SSHTransport, + daytonaService: DaytonaRuntimeService, + options?: DaytonaSSHRuntimeOptions + ) { + const sandboxId = config.daytona.sandboxId; + super( + { + host: sandboxId ? toXumDaytonaHost(sandboxId) : config.host, + srcBaseDir: config.srcBaseDir, + bgOutputDir: config.bgOutputDir, + identityFile: config.identityFile, + port: config.port, + }, + transport, + options + ); + this.daytonaConfig = config.daytona; + this.daytonaService = daytonaService; + this.workspaceId = options?.workspaceId; + this.agentHomeSeedSources = options?.agentHomeSeedSources; + } + + private sandboxMissing(sandboxId: string): EnsureReadyResult { + return { + ready: false, + error: + `Daytona sandbox "${sandboxId}" no longer exists (it was deleted outside Xum). ` + + `Xum does not recreate it; delete this workspace or attach it to another sandbox.`, + errorType: "runtime_not_ready", + }; + } + + /** Keep cleanup hooks from starting a stopped (billed) sandbox through SSH. */ + async isRunningWithoutStart(_abortSignal: AbortSignal): Promise { + const sandboxId = this.daytonaConfig.sandboxId; + if (!sandboxId) return false; + try { + const { state } = await this.daytonaService.getSandboxState(sandboxId); + return state === "started"; + } catch (error) { + log.warn("Could not read Daytona sandbox state; treating it as not running", { + sandboxId, + error: getErrorMessage(error), + }); + return false; + } + } + + /** + * Ready means started and reachable over ssh with a fresh token. A ready result is cached + * per sandbox for 60 s (in the service: runtimes are recreated per stream), and concurrent + * checks share one control-plane request. A sandbox removed outside Xum is reported, never + * silently recreated. + */ + override async ensureReady(options?: EnsureReadyOptions): Promise { + const sandboxId = this.daytonaConfig.sandboxId; + if (!sandboxId) { + return { + ready: false, + error: "Daytona sandbox id not set", + errorType: "runtime_not_ready", + }; + } + if (this.daytonaService.isSandboxReady(sandboxId)) { + return { ready: true }; + } + + const emitStatus = (phase: RuntimeStatusEvent["phase"], detail?: string) => { + options?.statusSink?.({ phase, runtimeType: "ssh", detail }); + }; + emitStatus("checking"); + + try { + await raceAbort( + this.daytonaService.ensureSandboxReady(sandboxId, { + onStarting: () => emitStatus("starting", "Starting sandbox..."), + }), + options?.signal + ); + } catch (error) { + emitStatus("error"); + return this.ensureReadyFailure(sandboxId, error, options?.signal); + } + + const repoCheck = await this.checkWorkspaceRepo(options); + if (repoCheck && !repoCheck.ready) { + emitStatus("error", repoCheck.error); + return repoCheck; + } + this.daytonaService.markSandboxReady(sandboxId); + emitStatus("ready"); + return { ready: true }; + } + + private ensureReadyFailure( + sandboxId: string, + error: unknown, + signal: AbortSignal | undefined + ): EnsureReadyResult { + if (signal?.aborted) { + return { ready: false, error: "Aborted", errorType: "runtime_start_failed" }; + } + if (error instanceof DaytonaApiError) { + if (error.kind === "not_found") return this.sandboxMissing(sandboxId); + if (error.kind === "not_configured" || error.kind === "unauthorized") { + return { ready: false, error: error.message, errorType: "runtime_not_ready" }; + } + } + return { + ready: false, + error: `Failed to start Daytona sandbox: ${getErrorMessage(error)}`, + errorType: "runtime_start_failed", + }; + } + + /** + * Fill in the sandbox identity after collision handling: a given `sandboxId` attaches to that + * existing sandbox (shared, never deleted by Xum); none means Xum creates one with a fresh + * name and the default size when none was chosen. + */ + async finalizeConfig( + finalBranchName: string, + config: RuntimeConfig + ): Promise> { + if (!isSSHRuntime(config) || !config.daytona) { + return Ok(config); + } + const availability = await this.daytonaService.checkAvailability(); + if (!availability.available) { + return Err(availability.reason); + } + + const daytona = config.daytona; + const existingId = daytona.sandboxId?.trim(); + if (existingId) { + return Ok({ + ...config, + host: toXumDaytonaHost(existingId), + daytona: { ...daytona, sandboxId: existingId, shared: true }, + }); + } + + const preset = resolveSizePreset(daytona); + if (!preset) { + return Err(invalidSizeError()); + } + const sandboxId = buildDaytonaSandboxName(finalBranchName); + return Ok({ + ...config, + host: toXumDaytonaHost(sandboxId), + daytona: { + ...daytona, + sandboxId, + shared: false, + cpu: preset.cpu, + memoryGiB: preset.memoryGiB, + diskGiB: preset.diskGiB, + }, + }); + } + + /** + * Check before persisting: credentials, a preset size for new sandboxes (hand-edited + * configs included), and that the sandbox to attach exists or the name to create is free. + */ + async validateBeforePersist( + _finalBranchName: string, + config: RuntimeConfig + ): Promise> { + if (!isSSHRuntime(config) || !config.daytona) { + return Ok(undefined); + } + const availability = await this.daytonaService.checkAvailability(); + if (!availability.available) { + return Err(availability.reason); + } + const daytona = config.daytona; + const sandboxId = daytona.sandboxId; + if (!sandboxId) { + return Err("Daytona sandbox id is required (should be set by finalizeConfig)"); + } + if (daytona.shared !== true && !findDaytonaSizePreset(daytona)) { + return Err(invalidSizeError()); + } + + let state; + try { + ({ state } = await this.daytonaService.getSandboxState(sandboxId)); + } catch (error) { + return Err(`Could not reach Daytona: ${getErrorMessage(error)}`); + } + if (daytona.shared === true && state === "missing") { + return Err(`Daytona sandbox "${sandboxId}" was not found`); + } + if (daytona.shared !== true && state !== "missing") { + return Err(`A Daytona sandbox named "${sandboxId}" already exists`); + } + return Ok(undefined); + } + + /** The sandbox may not exist yet, so only compute the path (as Coder does). */ + override createWorkspace(params: WorkspaceCreationParams): Promise { + const workspacePath = this.getWorkspacePath(params.projectPath, params.directoryName); + params.initLogger.logStep("Workspace path computed (Daytona provisioning will follow)"); + return Promise.resolve({ success: true, workspacePath }); + } + + /** + * Provision the sandbox (unless attaching), wait until it runs, configure ssh, seed the + * agent home and prepare the workspace directory. initWorkspace then runs the normal SSH + * sync, worktree creation and init hook. If anything fails after the create request was + * sent, the sandbox this call created is deleted again so it cannot linger and bill. + */ + async postCreateSetup(params: WorkspaceInitParams): Promise { + const { initLogger, abortSignal } = params; + const sandboxId = this.daytonaConfig.sandboxId; + if (!sandboxId) { + throw new Error("Daytona sandbox id is required (should be set by finalizeConfig)"); + } + const creates = this.daytonaConfig.shared !== true; + + let createRequested = false; + try { + if (creates) { + initLogger.logStep(`Creating Daytona sandbox "${sandboxId}"...`); + createRequested = true; + await this.createSandbox(sandboxId, params); + initLogger.logStep("Daytona sandbox created"); + } + + initLogger.logStep("Waiting for the sandbox to start and configuring SSH..."); + await raceAbort( + this.daytonaService.ensureSandboxReady(sandboxId, { + onStarting: () => initLogger.logStep("Starting sandbox..."), + }), + abortSignal + ); + + if (creates) { + initLogger.logStep("Copying agent hooks and global skills into the sandbox..."); + const count = await seedAgentHome( + this, + this.agentHomeSeedSources ?? { xumHome: getXumHome(), home: os.homedir() }, + abortSignal + ); + initLogger.logStep(`Copied ${count} agent file(s)`); + } + + initLogger.logStep("Preparing workspace directory..."); + const parentDir = path.posix.dirname(params.workspacePath); + const mkdirResult = await execBuffered(this, `mkdir -p ${expandTildeForSSH(parentDir)}`, { + cwd: "/tmp", + timeout: 10, + abortSignal, + }); + if (mkdirResult.exitCode !== 0) { + const errorMsg = mkdirResult.stderr || mkdirResult.stdout || "Unknown error"; + throw new Error(`Failed to prepare workspace directory: ${errorMsg}`); + } + } catch (error) { + const errorMsg = getErrorMessage(error); + log.error("Daytona sandbox setup failed", { sandboxId, error: errorMsg }); + initLogger.logStderr(`Daytona sandbox setup failed: ${errorMsg}`); + if (createRequested) { + await this.deleteCreatedSandbox(sandboxId, initLogger); + } + throw new Error(`Daytona sandbox setup failed: ${errorMsg}`); + } + } + + private async deleteCreatedSandbox( + sandboxId: string, + initLogger: WorkspaceInitParams["initLogger"] + ): Promise { + try { + initLogger.logStep(`Deleting the sandbox "${sandboxId}" this setup created...`); + await this.daytonaService.deleteSandbox(sandboxId); + } catch (error) { + // The reconciler stops labeled, unreferenced sandboxes, so a leak here is bounded. + const message = getErrorMessage(error); + log.error("Failed to delete a Daytona sandbox after setup failed", { + sandboxId, + error: message, + }); + initLogger.logStderr(`Failed to delete Daytona sandbox "${sandboxId}": ${message}`); + } + } + + /** + * Create with the preset resources. Daytona rejects resources (HTTP 400) for snapshots with + * fixed resources: then create with the snapshot's own size and resize to the preset. + */ + private async createSandbox(sandboxId: string, params: WorkspaceInitParams): Promise { + const preset = resolveSizePreset(this.daytonaConfig); + if (!preset) throw new Error(invalidSizeError()); + const base = { + name: sandboxId, + ...(this.daytonaConfig.snapshot ? { snapshot: this.daytonaConfig.snapshot } : {}), + labels: await this.daytonaService.buildLabels({ + workspaceId: this.workspaceId ?? sandboxId, + projectPath: params.projectPath, + }), + ...(await this.resolveTarget()), + autoStopInterval: DAYTONA_AUTO_STOP_MINUTES, + autoArchiveInterval: DAYTONA_AUTO_ARCHIVE_MINUTES, + autoDeleteInterval: DAYTONA_AUTO_DELETE_DISABLED, + }; + try { + await this.daytonaService.createSandbox({ + ...base, + cpu: preset.cpu, + memory: preset.memoryGiB, + disk: preset.diskGiB, + }); + return; + } catch (error) { + if (!isBadRequest(error)) throw error; + params.initLogger.logStep( + `Daytona refused the size for this snapshot (${getErrorMessage(error)}); ` + + `creating with the snapshot's size and resizing to ${preset.label}...` + ); + } + await this.daytonaService.createSandbox(base); + await this.resizeToPreset(sandboxId, preset); + } + + private async resolveTarget(): Promise<{ target?: string }> { + const target = this.daytonaConfig.target ?? (await this.daytonaService.getDefaultTarget()); + return target ? { target } : {}; + } + + /** + * A running sandbox can only grow cpu and memory; shrinking them or growing the disk needs a + * stopped one. Disk never shrinks, so a larger snapshot disk is kept. + */ + private async resizeToPreset(sandboxId: string, preset: DaytonaSizePreset): Promise { + const deadline = () => Date.now() + DAYTONA_START_TIMEOUT_MS; + const sandbox: DaytonaSandbox = await this.daytonaService.startAndWait(sandboxId, deadline()); + const growsDisk = sandbox.disk === undefined || sandbox.disk < preset.diskGiB; + const shrinks = (sandbox.cpu ?? 0) > preset.cpu || (sandbox.memory ?? 0) > preset.memoryGiB; + const request = { + cpu: preset.cpu, + memory: preset.memoryGiB, + ...(growsDisk ? { disk: preset.diskGiB } : {}), + }; + if (growsDisk || shrinks) { + await this.daytonaService.stopAndWait(sandboxId, deadline()); + await this.daytonaService.resizeSandbox(sandboxId, request); + } else if (sandbox.cpu !== preset.cpu || sandbox.memory !== preset.memoryGiB) { + await this.daytonaService.resizeSandbox(sandboxId, request); + } + await this.daytonaService.startAndWait(sandboxId, deadline()); + } + + /** + * Keep the inherited worktree fork (the child shares the parent's sandbox) and mark both + * workspaces shared, so deleting either never deletes the sandbox under the other. + */ + override async forkWorkspace(params: WorkspaceForkParams): Promise { + const result = await super.forkWorkspace(params); + // Sub-agents must share the parent's sandbox: never fall back to creating a new one. + if (!result.success) return { ...result, failureIsFatal: true }; + + const sharedDaytonaConfig = { ...this.daytonaConfig, shared: true }; + // So a postCreateSetup on this instance (for the fork) skips creating a sandbox. + this.daytonaConfig = sharedDaytonaConfig; + const sharedRuntimeConfig = { + type: "ssh" as const, + ...this.getConfig(), + daytona: sharedDaytonaConfig, + }; + return { + ...result, + forkedRuntimeConfig: sharedRuntimeConfig, + sourceRuntimeConfig: sharedRuntimeConfig, + }; + } + + /** + * Delete the workspace; delete the sandbox too unless it is shared. The SSH dirty and + * unpushed checks always run first, so a stopped or archived sandbox is started for them: + * unlike Coder, never delete unchecked work to avoid a start. Force skips the checks. + */ + override async deleteWorkspace( + projectPath: string, + workspaceName: string, + force: boolean, + abortSignal?: AbortSignal, + trusted?: boolean, + options?: { keepBranch?: boolean } + ): Promise<{ success: true; deletedPath: string } | { success: false; error: string }> { + const sandboxId = this.daytonaConfig.sandboxId; + const shared = this.daytonaConfig.shared === true; + const deletedPath = this.getWorkspacePath(projectPath, workspaceName); + const sshDelete = () => + super.deleteWorkspace(projectPath, workspaceName, force, abortSignal, trusted, options); + + if (!sandboxId) { + log.warn("Daytona sandbox id not set, falling back to SSH-only deletion"); + return sshDelete(); + } + + // Force ("cancel creation" or an explicit force delete): the sandbox may not be reachable + // yet, so delete it without SSH. A shared sandbox only loses this workspace's checkout. + if (force && !shared) { + return this.deleteOwnedSandbox(sandboxId, deletedPath, undefined); + } + + try { + await raceAbort(this.daytonaService.ensureSandboxReady(sandboxId), abortSignal); + } catch (error) { + if (error instanceof DaytonaApiError && error.kind === "not_found") { + log.debug("Daytona sandbox already gone, skipping SSH cleanup", { sandboxId }); + // Still drop the token and include block of a sandbox this workspace owned. + return shared + ? { success: true, deletedPath } + : this.deleteOwnedSandbox(sandboxId, deletedPath, undefined); + } + if (force) return sshDelete(); + return { + success: false, + error: + `Could not start Daytona sandbox "${sandboxId}" to check for uncommitted work: ` + + `${getErrorMessage(error)}`, + }; + } + + const sshResult = await sshDelete(); + // A failed check (dirty or unpushed work) keeps the workspace and its sandbox. + if (!sshResult.success || shared) { + return sshResult; + } + return this.deleteOwnedSandbox(sandboxId, sshResult.deletedPath, "SSH delete succeeded, but "); + } + + private async deleteOwnedSandbox( + sandboxId: string, + deletedPath: string, + errorPrefix: string | undefined + ): Promise<{ success: true; deletedPath: string } | { success: false; error: string }> { + try { + await this.daytonaService.deleteSandbox(sandboxId); + return { success: true, deletedPath }; + } catch (error) { + const message = getErrorMessage(error); + log.error("Failed to delete Daytona sandbox", { sandboxId, error: message }); + return { + success: false, + error: errorPrefix + ? `${errorPrefix}failed to delete Daytona sandbox: ${message}` + : `Failed to delete Daytona sandbox: ${message}`, + }; + } + } +} diff --git a/src/node/runtime/daytonaAgentHomeSeed.test.ts b/src/node/runtime/daytonaAgentHomeSeed.test.ts new file mode 100644 index 00000000000..ff156dfe5ac --- /dev/null +++ b/src/node/runtime/daytonaAgentHomeSeed.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { execFile } from "child_process"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { promisify } from "util"; +import { buildAgentHomeSeedScript, collectAgentHomeSeedFiles } from "./daytonaAgentHomeSeed"; + +const execFileAsync = promisify(execFile); + +let root: string; +let hostXum: string; +let hostHome: string; +let remoteHome: string; + +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "xum-seed-test-")); + hostXum = path.join(root, "host", ".xum"); + hostHome = path.join(root, "host"); + remoteHome = path.join(root, "remote"); + await fs.mkdir(hostXum, { recursive: true }); + await fs.mkdir(remoteHome, { recursive: true }); +}); + +afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); +}); + +/** Run the generated script with sh as the remote would, with HOME at the fake remote home. */ +async function runSeedScript(script: string): Promise { + const child = execFileAsync("sh", ["-s"], { env: { ...process.env, HOME: remoteHome } }); + child.child.stdin?.end(script); + await child; +} + +describe("agent-home seeding", () => { + it("recreates hooks and global skills where the remote lookups read them", async () => { + await fs.writeFile(path.join(hostXum, "tool_env"), "export JOBS=4\n"); + await fs.writeFile(path.join(hostXum, "tool_pre"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + await fs.mkdir(path.join(hostXum, "skills", "deploy", "scripts"), { recursive: true }); + await fs.writeFile( + path.join(hostXum, "skills", "deploy", "SKILL.md"), + "# deploy 'quoted' $x\n" + ); + await fs.writeFile(path.join(hostXum, "skills", "deploy", "scripts", "run.sh"), "echo hi\n", { + mode: 0o755, + }); + await fs.mkdir(path.join(hostHome, ".agents", "skills", "lint"), { recursive: true }); + await fs.writeFile(path.join(hostHome, ".agents", "skills", "lint", "SKILL.md"), "# lint\n"); + // A broken symlink must be skipped, not fail the seed. + await fs.symlink(path.join(root, "nowhere"), path.join(hostXum, "skills", "broken")); + + const files = await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux"); + await runSeedScript(buildAgentHomeSeedScript(files)); + + const read = (rel: string) => fs.readFile(path.join(remoteHome, rel), "utf-8"); + expect(await read(".mux/tool_env")).toBe("export JOBS=4\n"); + expect(await read(".mux/skills/deploy/SKILL.md")).toBe("# deploy 'quoted' $x\n"); + expect(await read(".agents/skills/lint/SKILL.md")).toBe("# lint\n"); + const toolPreMode = (await fs.stat(path.join(remoteHome, ".mux/tool_pre"))).mode; + const scriptMode = (await fs.stat(path.join(remoteHome, ".mux/skills/deploy/scripts/run.sh"))) + .mode; + const envMode = (await fs.stat(path.join(remoteHome, ".mux/tool_env"))).mode; + expect(toolPreMode & 0o111).not.toBe(0); + expect(scriptMode & 0o111).not.toBe(0); + expect(envMode & 0o111).toBe(0); + }); + + it("collects nothing when the host has no agent files", async () => { + expect(await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux")).toEqual( + [] + ); + }); +}); diff --git a/src/node/runtime/daytonaAgentHomeSeed.ts b/src/node/runtime/daytonaAgentHomeSeed.ts new file mode 100644 index 00000000000..dcdded203f9 --- /dev/null +++ b/src/node/runtime/daytonaAgentHomeSeed.ts @@ -0,0 +1,189 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import { getErrorMessage } from "@/common/utils/errors"; +import { log } from "@/node/services/log"; +import { isErrnoWithCode } from "@/node/utils/fs"; +import { execBuffered } from "@/node/utils/runtime/helpers"; +import type { Runtime } from "./Runtime"; +import { expandTildeForSSH } from "./tildeExpansion"; + +/** + * Seeds a Xum-created Daytona sandbox with the host's user-global agent files, so hooks and + * global skills behave as they do locally. Destinations are the paths the remote lookups read: + * hooks.ts and agentSkillsService resolve user-global files under `runtime.getXumHome()` + * (`~/.mux` on SSH remotes) and universal skills under `~/.agents/skills`. + * + * Xum owns the box, so existing files are overwritten. Attached (shared) sandboxes are never + * seeded: they belong to the user. + */ + +const SEEDED_HOOK_FILES = ["tool_env", "tool_pre", "tool_post"] as const; + +/** A box seed is a convenience: past this size the rest is skipped with a warning. */ +const MAX_SEED_BYTES = 16 * 1024 * 1024; +const MAX_SKILL_DIR_DEPTH = 8; +const SEED_TIMEOUT_SECONDS = 120; +const HEREDOC_MARKER = "XUM_DAYTONA_SEED_EOF"; + +export interface AgentHomeSeedFile { + /** Remote path, `~/`-relative. */ + remotePath: string; + content: Buffer; + executable: boolean; +} + +export interface AgentHomeSeedSources { + /** Host Xum home (holds tool_env, tool_pre, tool_post and skills/). */ + xumHome: string; + /** Host user home (holds .agents/skills). */ + home: string; +} + +interface SeedBudget { + remainingBytes: number; + exhausted: boolean; +} + +async function readRegularFile( + filePath: string +): Promise<{ content: Buffer; mode: number } | null> { + try { + // stat follows symlinks: plugin installs link skills into the skills directory. + const stat = await fs.stat(filePath); + if (!stat.isFile()) return null; + return { content: await fs.readFile(filePath), mode: stat.mode }; + } catch (error) { + if (isErrnoWithCode(error, "ENOENT")) return null; + log.warn("Skipping an unreadable file while seeding a Daytona sandbox", { + filePath, + error: getErrorMessage(error), + }); + return null; + } +} + +function addFile( + files: AgentHomeSeedFile[], + budget: SeedBudget, + remotePath: string, + file: { content: Buffer; mode: number } +): void { + if (file.content.length > budget.remainingBytes) { + budget.exhausted = true; + return; + } + budget.remainingBytes -= file.content.length; + files.push({ remotePath, content: file.content, executable: (file.mode & 0o111) !== 0 }); +} + +async function collectDirectory( + files: AgentHomeSeedFile[], + budget: SeedBudget, + hostDir: string, + remoteDir: string, + depth: number +): Promise { + if (depth > MAX_SKILL_DIR_DEPTH || budget.exhausted) return; + let entries: string[]; + try { + entries = await fs.readdir(hostDir); + } catch (error) { + if (!isErrnoWithCode(error, "ENOENT") && !isErrnoWithCode(error, "ENOTDIR")) { + log.warn("Skipping an unreadable directory while seeding a Daytona sandbox", { + hostDir, + error: getErrorMessage(error), + }); + } + return; + } + for (const entry of entries.sort()) { + const hostPath = path.join(hostDir, entry); + const remotePath = path.posix.join(remoteDir, entry); + let stat: Awaited>; + try { + stat = await fs.stat(hostPath); + } catch (error) { + // Broken symlinks are common in skill directories; skip them. + log.debug("Skipping a missing path while seeding a Daytona sandbox", { + hostPath, + error: getErrorMessage(error), + }); + continue; + } + if (stat.isDirectory()) { + await collectDirectory(files, budget, hostPath, remotePath, depth + 1); + } else if (stat.isFile()) { + const file = await readRegularFile(hostPath); + if (file) addFile(files, budget, remotePath, file); + } + } +} + +/** The host files to copy; missing files and directories are skipped. */ +export async function collectAgentHomeSeedFiles( + sources: AgentHomeSeedSources, + remoteXumHome: string +): Promise { + const files: AgentHomeSeedFile[] = []; + const budget: SeedBudget = { remainingBytes: MAX_SEED_BYTES, exhausted: false }; + for (const name of SEEDED_HOOK_FILES) { + const file = await readRegularFile(path.join(sources.xumHome, name)); + if (file) addFile(files, budget, path.posix.join(remoteXumHome, name), file); + } + await collectDirectory( + files, + budget, + path.join(sources.xumHome, "skills"), + path.posix.join(remoteXumHome, "skills"), + 0 + ); + await collectDirectory( + files, + budget, + path.join(sources.home, ".agents", "skills"), + "~/.agents/skills", + 0 + ); + if (budget.exhausted) { + log.warn("Daytona agent-home seed hit its size limit; some files were not copied", { + limitBytes: MAX_SEED_BYTES, + }); + } + return files; +} + +/** One POSIX sh script that writes every file (sent on stdin: no argv size limit applies). */ +export function buildAgentHomeSeedScript(files: readonly AgentHomeSeedFile[]): string { + const lines = ["set -e"]; + for (const file of files) { + const target = expandTildeForSSH(file.remotePath); + lines.push(`mkdir -p ${expandTildeForSSH(path.posix.dirname(file.remotePath))}`); + lines.push(`base64 -d > ${target} <<'${HEREDOC_MARKER}'`); + // Line-wrapped base64 never contains the marker. + lines.push(...(file.content.toString("base64").match(/.{1,76}/g) ?? [])); + lines.push(HEREDOC_MARKER); + lines.push(`chmod ${file.executable ? "755" : "644"} ${target}`); + } + return `${lines.join("\n")}\n`; +} + +export async function seedAgentHome( + runtime: Runtime, + sources: AgentHomeSeedSources, + abortSignal?: AbortSignal +): Promise { + const files = await collectAgentHomeSeedFiles(sources, runtime.getXumHome()); + if (files.length === 0) return 0; + const result = await execBuffered(runtime, "sh -s", { + cwd: "/tmp", + timeout: SEED_TIMEOUT_SECONDS, + abortSignal, + stdin: buildAgentHomeSeedScript(files), + }); + if (result.exitCode !== 0) { + throw new Error( + `Failed to copy agent files into the sandbox: ${result.stderr.trim() || `exit ${result.exitCode}`}` + ); + } + return files.length; +} diff --git a/src/node/runtime/runtimeFactory.test.ts b/src/node/runtime/runtimeFactory.test.ts index 399ad9727f4..740953a56a9 100644 --- a/src/node/runtime/runtimeFactory.test.ts +++ b/src/node/runtime/runtimeFactory.test.ts @@ -18,6 +18,8 @@ import { import { LocalRuntime } from "./LocalRuntime"; import { WorktreeRuntime } from "./WorktreeRuntime"; import { CoderSSHRuntime } from "./CoderSSHRuntime"; +import { DaytonaSSHRuntime } from "./DaytonaSSHRuntime"; +import { createDaytonaTestRig } from "@/node/services/daytona/daytona.testHarness"; import type { CoderService } from "@/node/services/coderService"; describe("isIncompatibleRuntimeConfig", () => { @@ -205,3 +207,29 @@ describe("createRuntime - Coder host normalization", () => { expect(sshRuntime.transport.getConfig().host).toBe("legacy.mux--coder"); }); }); + +describe("createRuntime - Daytona", () => { + it("selects DaytonaSSHRuntime and points the transport at the sandbox alias", async () => { + const rig = await createDaytonaTestRig(); + try { + const config: RuntimeConfig = { + type: "ssh", + host: "placeholder", + srcBaseDir: "~/src", + daytona: { sandboxId: "sb-1", shared: false }, + }; + + const runtime = createRuntime(config, { daytonaService: rig.service }); + + expect(runtime).toBeInstanceOf(DaytonaSSHRuntime); + const sshRuntime = runtime as unknown as { + getConfig(): { host: string }; + transport: { getConfig(): { host: string } }; + }; + expect(sshRuntime.getConfig().host).toBe("sb-1.xum--daytona"); + expect(sshRuntime.transport.getConfig().host).toBe("sb-1.xum--daytona"); + } finally { + await rig.cleanup(); + } + }); +}); diff --git a/src/node/runtime/runtimeFactory.ts b/src/node/runtime/runtimeFactory.ts index e31b368b4de..0116e36e508 100644 --- a/src/node/runtime/runtimeFactory.ts +++ b/src/node/runtime/runtimeFactory.ts @@ -6,6 +6,7 @@ import { LocalRuntime } from "./LocalRuntime"; import { WorktreeRuntime } from "./WorktreeRuntime"; import { SSHRuntime } from "./SSHRuntime"; import { CoderSSHRuntime } from "./CoderSSHRuntime"; +import { DaytonaSSHRuntime, type DaytonaRuntimeService } from "./DaytonaSSHRuntime"; import { createSSHTransport } from "./transports"; import { DockerRuntime, getContainerName } from "./DockerRuntime"; import { DevcontainerRuntime } from "./DevcontainerRuntime"; @@ -21,6 +22,7 @@ import { Config } from "@/node/config"; import { checkDevcontainerCliVersion } from "./devcontainerCli"; import { buildDevcontainerConfigInfo, scanDevcontainerConfigs } from "./devcontainerConfigs"; import { resolveCoderSSHHost } from "@/constants/coder"; +import { toXumDaytonaHost } from "@/common/constants/daytona"; import { getErrorMessage } from "@/common/utils/errors"; // Global CoderService singleton - set during app init so all createRuntime calls can use it @@ -35,6 +37,13 @@ export function setGlobalCoderService(service: CoderService): void { globalCoderService = service; } +// Global DaytonaService singleton, set by the service container (same pattern as Coder). +let globalDaytonaService: DaytonaRuntimeService | undefined; + +export function setGlobalDaytonaService(service: DaytonaRuntimeService): void { + globalDaytonaService = service; +} + /** * Run the full init sequence: postCreateSetup (if present) then initWorkspace. * Use this everywhere instead of calling initWorkspace directly to ensure @@ -171,6 +180,8 @@ export interface CreateRuntimeOptions { * When provided and config has coder field, returns a Coder SSH runtime (SSH/SSH2). */ coderService?: CoderService; + /** Daytona service for SSH runtimes with a `daytona` sub-config (defaults to the global one). */ + daytonaService?: DaytonaRuntimeService; } /** @@ -218,35 +229,33 @@ export function createRuntime(config: RuntimeConfig, options?: CreateRuntimeOpti return new WorktreeRuntime(config.srcBaseDir, runtimeIdentity); case "ssh": { - // Normalize Coder host before transport creation so both transport - // and runtime use the canonical *.mux--coder hostname from the start. - const sshHost = resolveCoderSSHHost(config.host, config.coder?.workspaceName); + // Normalize the host before transport creation so both transport and runtime use the + // canonical managed alias (*.mux--coder, *.xum--daytona) from the start. const sshConfig = { - host: sshHost, + host: resolveSSHRuntimeHost(config), srcBaseDir: config.srcBaseDir, bgOutputDir: config.bgOutputDir, identityFile: config.identityFile, port: config.port, }; + const transport = createSSHTransport(sshConfig, shouldUseSSH2Runtime()); - const useSSH2 = shouldUseSSH2Runtime(); - const transport = createSSHTransport(sshConfig, useSSH2); - - // Use a Coder SSH runtime for SSH+Coder when coderService is available (explicit or global) - const coderService = options?.coderService ?? globalCoderService; - + if (config.daytona) { + return createDaytonaRuntime( + { ...sshConfig, daytona: config.daytona }, + transport, + options, + runtimeIdentity + ); + } if (config.coder) { - if (!coderService) { - throw new Error("Coder runtime requested but CoderService is not initialized"); - } - return new CoderSSHRuntime( + return createCoderRuntime( { ...sshConfig, coder: config.coder }, transport, - coderService, + options, runtimeIdentity ); } - return new SSHRuntime(sshConfig, transport, runtimeIdentity); } @@ -297,6 +306,47 @@ export function createRuntime(config: RuntimeConfig, options?: CreateRuntimeOpti } } +function resolveSSHRuntimeHost(config: Extract): string { + const daytonaSandboxId = config.daytona?.sandboxId; + return daytonaSandboxId + ? toXumDaytonaHost(daytonaSandboxId) + : resolveCoderSSHHost(config.host, config.coder?.workspaceName); +} + +type RuntimeIdentity = ConstructorParameters[2]; + +/** SSH + Coder, using the explicit CoderService or the global one. */ +function createCoderRuntime( + config: ConstructorParameters[0], + transport: ReturnType, + options: CreateRuntimeOptions | undefined, + runtimeIdentity: RuntimeIdentity +): CoderSSHRuntime { + const coderService = options?.coderService ?? globalCoderService; + if (!coderService) { + throw new Error("Coder runtime requested but CoderService is not initialized"); + } + return new CoderSSHRuntime(config, transport, coderService, runtimeIdentity); +} + +/** SSH + Daytona, using the explicit DaytonaService or the global one. */ +function createDaytonaRuntime( + config: ConstructorParameters[0], + transport: ReturnType, + options: CreateRuntimeOptions | undefined, + runtimeIdentity: RuntimeIdentity +): DaytonaSSHRuntime { + const daytonaService = options?.daytonaService ?? globalDaytonaService; + if (!daytonaService) { + throw new Error("Daytona runtime requested but DaytonaService is not initialized"); + } + // The workspace id labels a sandbox Xum creates, for the reconciler. + return new DaytonaSSHRuntime(config, transport, daytonaService, { + ...runtimeIdentity, + workspaceId: options?.workspaceId, + }); +} + /** * Check if a project has a .git directory (is a git repository). */ @@ -311,26 +361,37 @@ async function isGitRepository(projectPath: string): Promise { } } -type RuntimeAvailabilityMap = Record; +/** One entry per RuntimeMode, plus Daytona (an `ssh` sub-config that needs credentials). */ +type RuntimeAvailabilityMap = Record & { + daytona: RuntimeAvailabilityStatus; +}; /** * Check availability of all runtime types for a given project. * Returns a record of runtime mode to availability status. */ export async function checkRuntimeAvailability( - projectPath: string + projectPath: string, + options: { daytonaService?: DaytonaRuntimeService } = {} ): Promise { - const [isGit, containerEngine, dockerResponsive, devcontainerCliInfo, devcontainerConfigs] = - await Promise.all([ - isGitRepository(projectPath), - detectContainerEngine(), - // The devcontainer CLI shells out to the literal `docker` binary regardless - // of which engine the Docker runtime resolved (e.g. an XUM_CONTAINER_CLI - // override), so Dev Container availability needs its own docker probe. - isEngineResponsive("docker"), - checkDevcontainerCliVersion(), - scanDevcontainerConfigs(projectPath), - ]); + const [ + isGit, + containerEngine, + dockerResponsive, + devcontainerCliInfo, + devcontainerConfigs, + daytonaAvailability, + ] = await Promise.all([ + isGitRepository(projectPath), + detectContainerEngine(), + // The devcontainer CLI shells out to the literal `docker` binary regardless + // of which engine the Docker runtime resolved (e.g. an XUM_CONTAINER_CLI + // override), so Dev Container availability needs its own docker probe. + isEngineResponsive("docker"), + checkDevcontainerCliVersion(), + scanDevcontainerConfigs(projectPath), + checkDaytonaAvailability(options.daytonaService), + ]); const devcontainerConfigInfo = buildDevcontainerConfigInfo(devcontainerConfigs); @@ -370,5 +431,22 @@ export async function checkRuntimeAvailability( ? { available: false, reason: containerEngine.reason } : { available: true }, devcontainer: devcontainerAvailability, + // Like ssh, Daytona syncs a git checkout to the box. + daytona: isGit ? daytonaAvailability : { available: false, reason: gitRequiredReason }, }; } + +async function checkDaytonaAvailability( + explicitService: DaytonaRuntimeService | undefined +): Promise { + const daytonaService = explicitService ?? globalDaytonaService; + if (!daytonaService) { + return { available: false, reason: "Daytona is not initialized" }; + } + try { + return await daytonaService.checkAvailability(); + } catch (error) { + // Availability must never fail the whole runtime list. + return { available: false, reason: `Daytona settings unreadable: ${getErrorMessage(error)}` }; + } +} diff --git a/src/node/services/bashAiProxy/sshForwardTarget.test.ts b/src/node/services/bashAiProxy/sshForwardTarget.test.ts index 7dae4b22ac3..c06e7c0f123 100644 --- a/src/node/services/bashAiProxy/sshForwardTarget.test.ts +++ b/src/node/services/bashAiProxy/sshForwardTarget.test.ts @@ -3,7 +3,32 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { isXumRootShared } from "./sshForwardTarget"; +import { DaytonaSSHRuntime } from "@/node/runtime/DaytonaSSHRuntime"; +import { SSHRuntime } from "@/node/runtime/SSHRuntime"; +import { createSSHTransport } from "@/node/runtime/transports"; +import { createDaytonaTestRig } from "@/node/services/daytona/daytona.testHarness"; + +import { createSshForwardTarget, isXumRootShared } from "./sshForwardTarget"; + +describe("createSshForwardTarget", () => { + test("never restores a forward at startup for a Daytona sandbox (it would start a billed box)", async () => { + const rig = await createDaytonaTestRig(); + try { + const plain = { host: "box", srcBaseDir: "~/src" }; + const daytona = { ...plain, host: "sb-1.xum--daytona", daytona: { sandboxId: "sb-1" } }; + const sshTarget = createSshForwardTarget( + new SSHRuntime(plain, createSSHTransport(plain, false)) + ); + const daytonaTarget = createSshForwardTarget( + new DaytonaSSHRuntime(daytona, createSSHTransport(daytona, false), rig.service) + ); + expect(sshTarget?.restoreAtStartup).toBe(true); + expect(daytonaTarget?.restoreAtStartup).toBe(false); + } finally { + await rig.cleanup(); + } + }); +}); describe("isXumRootShared", () => { let rootDir: string; diff --git a/src/node/services/bashAiProxy/sshForwardTarget.ts b/src/node/services/bashAiProxy/sshForwardTarget.ts index 4a3137bb250..30722842cfb 100644 --- a/src/node/services/bashAiProxy/sshForwardTarget.ts +++ b/src/node/services/bashAiProxy/sshForwardTarget.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; import { CoderSSHRuntime } from "@/node/runtime/CoderSSHRuntime"; +import { DaytonaSSHRuntime } from "@/node/runtime/DaytonaSSHRuntime"; import type { Runtime } from "@/node/runtime/Runtime"; import { SSHRuntime } from "@/node/runtime/SSHRuntime"; import { ServerLockfile } from "@/node/services/serverLockfile"; @@ -31,7 +32,8 @@ export function createSshForwardTarget(runtime: Runtime): ForwardTarget | undefi const config = runtime.getConfig(); return { hostKey: [config.host, config.port ?? 22, config.identityFile ?? ""].join("|"), - restoreAtStartup: !(runtime instanceof CoderSSHRuntime), + // Managed boxes: restoring a forward at startup would start a stopped (billed) box. + restoreAtStartup: !(runtime instanceof CoderSSHRuntime || runtime instanceof DaytonaSSHRuntime), openReverseForward: (remotePort, localPort) => runtime.openReverseForward(remotePort, localPort), remoteHealth: async (remotePort, nonce) => { diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts new file mode 100644 index 00000000000..eaf6405cd4b --- /dev/null +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -0,0 +1,279 @@ +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; +import type { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; +import { DaytonaService } from "./daytonaService"; + +/** + * In-memory Daytona control plane for unit tests: enough of the REST API (by id or name, as + * Daytona accepts) to drive the runtime, the reconciler and the archive hook end to end + * through the real DaytonaService and DaytonaClient. + */ + +function requestBody(init: RequestInit | undefined): string { + return typeof init?.body === "string" ? init.body : "{}"; +} + +export interface FakeSandbox { + id: string; + name: string; + state: string; + cpu: number; + memory: number; + disk: number; + labels: Record; + /** GETs left before a transitional state settles (creating/starting -> started). */ + pendingPolls: number; +} + +export class FakeDaytonaControlPlane { + readonly sandboxes = new Map(); + /** `METHOD /path` for every request (query strings omitted). */ + readonly calls: string[] = []; + readonly createBodies: Array> = []; + readonly revokedTokens: string[] = []; + /** Answer POST /sandbox with 400 when it carries cpu/memory/disk (fixed-resource snapshot). */ + rejectCreateResources = false; + /** Resources a sandbox gets when created without any. */ + snapshotResources = { cpu: 1, memory: 1, disk: 3 }; + /** Routes (`METHOD /path`) that never answer, for timeout tests. */ + readonly hangingRoutes = new Set(); + private nextId = 1; + private nextToken = 1; + + constructor(private readonly now: () => number) {} + + readonly fetch = ((input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + const route = `${init?.method ?? "GET"} ${url.pathname.replace(/^\/api/, "")}`; + this.calls.push(route); + if (this.hangingRoutes.has(route)) { + return new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted"))); + }); + } + return Promise.resolve(this.handle(route, url, init)); + }) as typeof fetch; + + addSandbox(partial: Partial & { id: string }): FakeSandbox { + const sandbox: FakeSandbox = { + name: partial.id, + state: "started", + cpu: 2, + memory: 4, + disk: 10, + labels: {}, + pendingPolls: 0, + ...partial, + }; + this.sandboxes.set(sandbox.id, sandbox); + return sandbox; + } + + find(idOrName: string): FakeSandbox | undefined { + return ( + this.sandboxes.get(idOrName) ?? [...this.sandboxes.values()].find((s) => s.name === idOrName) + ); + } + + countCalls(route: string): number { + return this.calls.filter((call) => call === route).length; + } + + private handle(route: string, url: URL, init?: RequestInit): Response { + const json = (status: number, body?: unknown) => + new Response(body === undefined ? "" : JSON.stringify(body), { status }); + const fail = (status: number, message: string) => json(status, { statusCode: status, message }); + + if (route === "GET /config") { + return json(200, { + sshGatewayHost: "ssh.daytona.test", + sshGatewayPort: 22, + sshGatewayHostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey"], + }); + } + if (route === "GET /sandbox") { + const labels = url.searchParams.get("labels"); + const wanted = labels ? (JSON.parse(labels) as Record) : {}; + const items = [...this.sandboxes.values()] + .filter((sb) => Object.entries(wanted).every(([key, value]) => sb.labels[key] === value)) + .map((sb) => this.view(sb)); + return json(200, { items, nextCursor: null }); + } + if (route === "POST /sandbox") { + const body = JSON.parse(requestBody(init)) as Record; + this.createBodies.push(body); + const hasResources = body.cpu != null || body.memory != null || body.disk != null; + if (this.rejectCreateResources && hasResources) { + return fail(400, "Cannot specify Sandbox resources when using a snapshot"); + } + const id = `sb-${this.nextId++}`; + const sandbox = this.addSandbox({ + id, + name: typeof body.name === "string" ? body.name : id, + state: "creating", + pendingPolls: 1, + cpu: typeof body.cpu === "number" ? body.cpu : this.snapshotResources.cpu, + memory: typeof body.memory === "number" ? body.memory : this.snapshotResources.memory, + disk: typeof body.disk === "number" ? body.disk : this.snapshotResources.disk, + labels: (body.labels as Record | undefined) ?? {}, + }); + return json(200, this.view(sandbox)); + } + + const match = /^(GET|POST|DELETE) \/sandbox\/([^/]+)(?:\/(.+))?$/.exec(route); + if (!match) return fail(500, `unexpected ${route}`); + const [, method, rawId, action] = match; + const idOrName = decodeURIComponent(rawId); + // last-activity takes the real id only (no name lookup), as in Daytona's API. + const sandbox = action === "last-activity" ? this.sandboxes.get(idOrName) : this.find(idOrName); + if (!sandbox) return fail(404, "Sandbox not found"); + + if (method === "GET" && !action) { + if (sandbox.pendingPolls > 0) { + sandbox.pendingPolls -= 1; + } else if (sandbox.state === "creating" || sandbox.state === "starting") { + sandbox.state = "started"; + } + return json(200, this.view(sandbox)); + } + if (method === "DELETE" && !action) { + this.sandboxes.delete(sandbox.id); + return json(200, {}); + } + switch (`${method} ${action}`) { + case "POST start": + sandbox.state = "starting"; + return json(200, {}); + case "POST stop": + sandbox.state = "stopped"; + return json(200, {}); + case "POST resize": + return this.resize(sandbox, JSON.parse(requestBody(init)) as Record); + case "POST ssh-access": { + const minutes = Number(url.searchParams.get("expiresInMinutes")); + return json(200, { + token: `tok${this.nextToken++}`, + expiresAt: new Date(this.now() + minutes * 60_000).toISOString(), + }); + } + case "DELETE ssh-access": + this.revokedTokens.push(url.searchParams.get("token") ?? ""); + return json(200, {}); + case "POST last-activity": + return json(200, {}); + default: + return fail(500, `unexpected ${route}`); + } + } + + /** Daytona's rules: disk only grows and needs a stopped box; running boxes only grow. */ + private resize(sandbox: FakeSandbox, body: Record): Response { + const running = sandbox.state === "started"; + const shrinks = + (body.cpu ?? sandbox.cpu) < sandbox.cpu || (body.memory ?? sandbox.memory) < sandbox.memory; + if (body.disk !== undefined && (body.disk < sandbox.disk || running)) { + return new Response(JSON.stringify({ message: "disk resize needs a stopped sandbox" }), { + status: 400, + }); + } + if (running && shrinks) { + return new Response(JSON.stringify({ message: "cannot shrink a running sandbox" }), { + status: 400, + }); + } + sandbox.cpu = body.cpu ?? sandbox.cpu; + sandbox.memory = body.memory ?? sandbox.memory; + sandbox.disk = body.disk ?? sandbox.disk; + return new Response(JSON.stringify(this.view(sandbox)), { status: 200 }); + } + + private view(sandbox: FakeSandbox) { + return { + id: sandbox.id, + name: sandbox.name, + state: sandbox.state, + cpu: sandbox.cpu, + memory: sandbox.memory, + disk: sandbox.disk, + labels: sandbox.labels, + }; + } +} + +/** Records ssh config writes instead of touching `~/.ssh/config`. */ +export class FakeDaytonaSshConfigWriter implements Pick< + DaytonaSshConfigWriter, + "writeSandbox" | "updateToken" | "removeSandbox" | "verifyResolvedUser" +> { + /** Current token per sandbox alias, as the include file would hold it. */ + readonly tokens = new Map(); + readonly fullWrites: string[] = []; + readonly removed: string[] = []; + verifyError?: Error; + + writeSandbox(input: { sandboxId: string; token: string }): Promise { + this.fullWrites.push(input.sandboxId); + this.tokens.set(input.sandboxId, input.token); + return Promise.resolve(); + } + + updateToken(input: { sandboxId: string; token: string }): Promise { + this.tokens.set(input.sandboxId, input.token); + return Promise.resolve(); + } + + removeSandbox(sandboxId: string): Promise { + this.removed.push(sandboxId); + this.tokens.delete(sandboxId); + return Promise.resolve(); + } + + verifyResolvedUser(): Promise { + return this.verifyError ? Promise.reject(this.verifyError) : Promise.resolve(); + } +} + +export interface DaytonaTestRig { + rootDir: string; + plane: FakeDaytonaControlPlane; + writer: FakeDaytonaSshConfigWriter; + service: DaytonaService; + clock: { now: number }; + cleanup: () => Promise; +} + +/** + * A real DaytonaService over the fake control plane, with a test clock whose sleeps advance + * time instantly. `apiKey: null` builds one without credentials. + */ +export async function createDaytonaTestRig( + options: { apiKey?: string | null } = {} +): Promise { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-daytona-test-")); + const clock = { now: Date.parse("2026-01-01T00:00:00Z") }; + const plane = new FakeDaytonaControlPlane(() => clock.now); + const writer = new FakeDaytonaSshConfigWriter(); + const env: NodeJS.ProcessEnv = + options.apiKey === null ? {} : { DAYTONA_API_KEY: options.apiKey ?? "test-key" }; + const service = new DaytonaService({ + credentialsStore: new DaytonaCredentialsStore(rootDir, env), + rootDir, + sshConfigWriter: writer, + fetch: plane.fetch, + now: () => clock.now, + sleep: (ms) => { + clock.now += ms; + return Promise.resolve(); + }, + }); + return { + rootDir, + plane, + writer, + service, + clock, + cleanup: () => fs.rm(rootDir, { recursive: true, force: true }), + }; +} diff --git a/src/node/services/daytona/daytonaLifecycleHooks.test.ts b/src/node/services/daytona/daytonaLifecycleHooks.test.ts new file mode 100644 index 00000000000..3865dc64396 --- /dev/null +++ b/src/node/services/daytona/daytonaLifecycleHooks.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; +import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; +import type { WorkspaceMetadata } from "@/common/types/workspace"; +import { createDaytonaTestRig, type DaytonaTestRig } from "./daytona.testHarness"; +import { createDaytonaArchiveHook } from "./daytonaLifecycleHooks"; + +let rig: DaytonaTestRig; + +beforeEach(async () => { + rig = await createDaytonaTestRig(); +}); + +afterEach(async () => { + await rig.cleanup(); +}); + +function metadata(daytona: DaytonaSandboxConfig): WorkspaceMetadata { + return { + id: "ws", + name: "ws", + projectName: "proj", + projectPath: "/tmp/proj", + runtimeConfig: { type: "ssh", host: "x.xum--daytona", srcBaseDir: "~/src", daytona }, + }; +} + +function archive(daytona: DaytonaSandboxConfig, behavior: CoderWorkspaceArchiveBehavior) { + const hook = createDaytonaArchiveHook({ + daytonaService: rig.service, + getArchiveBehavior: () => behavior, + }); + return hook({ workspaceId: "ws", workspaceMetadata: metadata(daytona) }); +} + +describe("createDaytonaArchiveHook", () => { + it("applies the archive behavior to a sandbox Xum created", async () => { + rig.plane.addSandbox({ id: "sb-keep" }); + rig.plane.addSandbox({ id: "sb-stop" }); + rig.plane.addSandbox({ id: "sb-delete" }); + + expect((await archive({ sandboxId: "sb-keep", shared: false }, "keep")).success).toBe(true); + expect((await archive({ sandboxId: "sb-stop", shared: false }, "stop")).success).toBe(true); + expect((await archive({ sandboxId: "sb-delete", shared: false }, "delete")).success).toBe(true); + + expect(rig.plane.find("sb-keep")?.state).toBe("started"); + expect(rig.plane.find("sb-stop")?.state).toBe("stopped"); + expect(rig.plane.find("sb-delete")).toBeUndefined(); + }); + + it("never stops or deletes an attached or shared sandbox", async () => { + rig.plane.addSandbox({ id: "sb-users" }); + + expect((await archive({ sandboxId: "sb-users", shared: true }, "delete")).success).toBe(true); + expect((await archive({ sandboxId: "sb-users", shared: true }, "stop")).success).toBe(true); + + expect(rig.plane.find("sb-users")?.state).toBe("started"); + }); +}); diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts new file mode 100644 index 00000000000..650cccac78a --- /dev/null +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -0,0 +1,83 @@ +import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; +import { Err, Ok, type Result } from "@/common/types/result"; +import { isDedicatedDaytonaSandbox, isSSHRuntime } from "@/common/types/runtime"; +import type { WorkspaceMetadata } from "@/common/types/workspace"; +import { getErrorMessage } from "@/common/utils/errors"; +import { log } from "@/node/services/log"; +import type { BeforeArchiveHook } from "@/node/services/workspaceLifecycleHooks"; +import type { DaytonaService } from "./daytonaService"; + +const DAYTONA_STOP_TIMEOUT_MS = 60_000; + +/** + * Archive reuses the Coder archive behavior setting (keep, stop, delete) for sandboxes Xum + * created; attached and shared sandboxes are never touched. No unarchive hook: ensureReady + * starts a stopped sandbox on the next use. + */ +export function createDaytonaArchiveHook(options: { + daytonaService: Pick; + getArchiveBehavior: () => CoderWorkspaceArchiveBehavior; + /** As for Coder: model-driven archives must not stop a box under a surviving detached job. */ + hasUnsettledRemoteBackgroundJobs?: ( + workspaceMetadata: WorkspaceMetadata + ) => Promise>; + now?: () => number; +}): BeforeArchiveHook { + const now = options.now ?? Date.now; + return async ({ + workspaceMetadata, + coderWorkspaceArchiveBehavior, + refuseStopUnderUnverifiedRemoteJobs, + }): Promise> => { + const runtimeConfig = workspaceMetadata.runtimeConfig; + if (!isDedicatedDaytonaSandbox(runtimeConfig) || !isSSHRuntime(runtimeConfig)) { + return Ok(undefined); + } + const sandboxId = runtimeConfig.daytona?.sandboxId; + if (!sandboxId) return Ok(undefined); + + // The archive operation's policy snapshot wins (see the Coder hook). + const behavior = coderWorkspaceArchiveBehavior ?? options.getArchiveBehavior(); + if (behavior === "keep") return Ok(undefined); + + try { + if (behavior === "delete") { + await options.daytonaService.deleteSandbox(sandboxId); + return Ok(undefined); + } + + const { state } = await options.daytonaService.getSandboxState(sandboxId); + if (state === "stopped" || state === "archived" || state === "missing") { + return Ok(undefined); + } + if (refuseStopUnderUnverifiedRemoteJobs === true) { + const refusal = await probeRemoteJobs(options, workspaceMetadata, sandboxId); + if (refusal) return Err(refusal); + } + log.debug("Stopping Daytona sandbox before archive", { sandboxId, state }); + await options.daytonaService.stopAndWait(sandboxId, now() + DAYTONA_STOP_TIMEOUT_MS); + return Ok(undefined); + } catch (error) { + return Err(`Failed to ${behavior} Daytona sandbox "${sandboxId}": ${getErrorMessage(error)}`); + } + }; +} + +async function probeRemoteJobs( + options: Parameters[0], + workspaceMetadata: WorkspaceMetadata, + sandboxId: string +): Promise { + const manual = "Ask the user to archive this workspace manually."; + if (options.hasUnsettledRemoteBackgroundJobs == null) { + return `Cannot verify that no background process is still running in Daytona sandbox "${sandboxId}"; stopping it could terminate a surviving job. ${manual}`; + } + const probe = await options.hasUnsettledRemoteBackgroundJobs(workspaceMetadata); + if (!probe.success) { + return `Cannot verify that no background process is still running in Daytona sandbox "${sandboxId}" (${probe.error}); stopping it could terminate a surviving job. ${manual}`; + } + if (probe.data) { + return `A background process from a previous session may still be running in Daytona sandbox "${sandboxId}"; stopping it would terminate that job. Terminate the process (or wait for it to finish) or ask the user to archive this workspace manually.`; + } + return undefined; +} diff --git a/src/node/services/daytona/daytonaReconciler.test.ts b/src/node/services/daytona/daytonaReconciler.test.ts new file mode 100644 index 00000000000..bdbef0af54b --- /dev/null +++ b/src/node/services/daytona/daytonaReconciler.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { RuntimeConfig } from "@/common/types/runtime"; +import { createDaytonaTestRig, type DaytonaTestRig } from "./daytona.testHarness"; +import { DaytonaReconciler, listDaytonaWorkspaceRefs } from "./daytonaReconciler"; +import { DAYTONA_LABEL_INSTANCE } from "./daytonaService"; + +let rig: DaytonaTestRig; +let instanceLabels: Record; + +beforeEach(async () => { + rig = await createDaytonaTestRig(); + instanceLabels = { [DAYTONA_LABEL_INSTANCE]: await rig.service.getInstanceId() }; +}); + +afterEach(async () => { + await rig.cleanup(); +}); + +function daytonaConfig(sandboxId: string): RuntimeConfig { + return { + type: "ssh", + host: `${sandboxId}.xum--daytona`, + srcBaseDir: "~/src", + daytona: { sandboxId }, + }; +} + +function makeReconciler( + workspaces: Array<{ id: string; runtimeConfig: RuntimeConfig; archivedAt?: string }>, + activeIds: string[], + callTimeoutMs?: number +): DaytonaReconciler { + const config = { projects: new Map([["/home/me/project", { workspaces }]]) }; + return new DaytonaReconciler({ + daytonaService: rig.service, + listWorkspaces: () => listDaytonaWorkspaceRefs(config), + isWorkspaceActive: (workspaceId) => activeIds.includes(workspaceId), + callTimeoutMs, + }); +} + +describe("DaytonaReconciler.sweep", () => { + it("refreshes activity only for sandboxes of active workspaces", async () => { + // Xum stores the name of a sandbox it created; last-activity needs the real id. + rig.plane.addSandbox({ id: "sb-1", name: "xum-busy-aaaaaa", labels: instanceLabels }); + rig.plane.addSandbox({ id: "sb-2", name: "xum-idle-bbbbbb", labels: instanceLabels }); + + await makeReconciler( + [ + { id: "ws-busy", runtimeConfig: daytonaConfig("xum-busy-aaaaaa") }, + { id: "ws-idle", runtimeConfig: daytonaConfig("xum-idle-bbbbbb") }, + ], + ["ws-busy"] + ).sweep(); + + expect(rig.plane.countCalls("POST /sandbox/sb-1/last-activity")).toBe(1); + expect(rig.plane.countCalls("POST /sandbox/sb-2/last-activity")).toBe(0); + }); + + it("stops only unreferenced running sandboxes of this install, archived workspaces included", async () => { + rig.plane.addSandbox({ id: "sb-orphan", labels: instanceLabels }); + rig.plane.addSandbox({ id: "sb-archived-ws", labels: instanceLabels }); + rig.plane.addSandbox({ id: "sb-creating", state: "creating", labels: instanceLabels }); + rig.plane.addSandbox({ id: "sb-other-install", labels: { [DAYTONA_LABEL_INSTANCE]: "x" } }); + + await makeReconciler( + [ + { + id: "ws-archived", + runtimeConfig: daytonaConfig("sb-archived-ws"), + archivedAt: "2026-01-01T00:00:00Z", + }, + ], + [] + ).sweep(); + + expect(rig.plane.find("sb-orphan")?.state).toBe("stopped"); + expect(rig.plane.find("sb-archived-ws")?.state).toBe("started"); + expect(rig.plane.find("sb-creating")?.state).toBe("creating"); + expect(rig.plane.find("sb-other-install")?.state).toBe("started"); + // Never deletes: an orphan may still hold unpushed work. + expect(rig.plane.calls.some((call) => call.startsWith("DELETE /sandbox/"))).toBe(false); + }); + + it("moves on past a hung control-plane call", async () => { + rig.plane.addSandbox({ id: "sb-hung", labels: instanceLabels }); + rig.plane.addSandbox({ id: "sb-orphan", labels: instanceLabels }); + rig.plane.hangingRoutes.add("POST /sandbox/sb-hung/last-activity"); + + await makeReconciler( + [{ id: "ws", runtimeConfig: daytonaConfig("sb-hung") }], + ["ws"], + 20 + ).sweep(); + + expect(rig.plane.find("sb-orphan")?.state).toBe("stopped"); + }); + + it("refreshes an active workspace's ssh token before it expires", async () => { + rig.plane.addSandbox({ id: "sb-1", labels: instanceLabels }); + await rig.service.ensureSshAccess("sb-1"); + const firstToken = rig.writer.tokens.get("sb-1"); + rig.clock.now += 50 * 60_000; + + await makeReconciler([{ id: "ws", runtimeConfig: daytonaConfig("sb-1") }], ["ws"]).sweep(); + + expect(rig.writer.tokens.get("sb-1")).not.toBe(firstToken); + expect(rig.plane.revokedTokens).toEqual([firstToken ?? ""]); + }); +}); diff --git a/src/node/services/daytona/daytonaReconciler.ts b/src/node/services/daytona/daytonaReconciler.ts new file mode 100644 index 00000000000..0ca686fecfa --- /dev/null +++ b/src/node/services/daytona/daytonaReconciler.ts @@ -0,0 +1,192 @@ +import type { Workspace } from "@/common/types/project"; +import { isSSHRuntime, type RuntimeConfig } from "@/common/types/runtime"; +import { getErrorMessage } from "@/common/utils/errors"; +import { log } from "@/node/services/log"; +import type { DaytonaSandbox } from "./daytonaClient"; +import { mapDaytonaSandboxState, type DaytonaService } from "./daytonaService"; + +export const DAYTONA_RECONCILE_INTERVAL_MS = 60_000; +/** Each control-plane call gets this long; a hung call is logged and the sweep moves on. */ +export const DAYTONA_RECONCILE_CALL_TIMEOUT_MS = 20_000; + +export interface DaytonaWorkspaceRef { + workspaceId: string; + runtimeConfig: RuntimeConfig | undefined; +} + +export interface DaytonaReconcilerOptions { + daytonaService: Pick< + DaytonaService, + | "checkAvailability" + | "listInstanceSandboxes" + | "refreshActivity" + | "stopSandbox" + | "hasExpiringSshToken" + | "ensureSshAccess" + >; + /** Every workspace Xum knows, archived ones included (they still own their sandbox). */ + listWorkspaces: () => DaytonaWorkspaceRef[]; + /** Streaming, or running a background job: Daytona counts only SSH and API traffic. */ + isWorkspaceActive: (workspaceId: string) => boolean; + intervalMs?: number; + callTimeoutMs?: number; +} + +/** + * Every workspace in the config, archived ones included: an archived workspace still owns its + * (stopped) sandbox and unarchiving must find it, so the orphan sweep must not touch it. + */ +export function listDaytonaWorkspaceRefs(config: { + projects: ReadonlyMap< + string, + { workspaces: ReadonlyArray> } + >; +}): DaytonaWorkspaceRef[] { + const refs: DaytonaWorkspaceRef[] = []; + for (const project of config.projects.values()) { + for (const workspace of project.workspaces) { + if (workspace.id) + refs.push({ workspaceId: workspace.id, runtimeConfig: workspace.runtimeConfig }); + } + } + return refs; +} + +function withTimeout(promise: Promise, timeoutMs: number, what: string): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${what} timed out after ${timeoutMs} ms`)), + timeoutMs + ); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +/** + * One sweep a minute, safe to repeat, that keeps Daytona sandboxes in line with Xum: + * - keepalive: refresh activity for sandboxes whose workspace is streaming or running a + * background job, so Daytona's 30-minute auto-stop does not cut off long agent work; + * - token refresh: replace an active workspace's ssh token before it expires; + * - orphans: STOP (never delete) started sandboxes labeled with this install's `xum.instance` + * that no workspace references. Deleting would lose work without a dirty check, and a + * stopped sandbox costs only storage. + * The first sweep runs one interval after start: startup never waits on the control plane. + */ +export class DaytonaReconciler { + private readonly options: DaytonaReconcilerOptions; + private timer: ReturnType | undefined; + private sweeping: Promise | undefined; + + constructor(options: DaytonaReconcilerOptions) { + this.options = options; + } + + start(): void { + if (this.timer) return; + this.timer = setInterval(() => { + if (this.sweeping) return; + this.sweeping = this.sweep() + .catch((error: unknown) => { + log.warn("Daytona reconcile sweep failed", { error: getErrorMessage(error) }); + }) + .finally(() => { + this.sweeping = undefined; + }); + }, this.options.intervalMs ?? DAYTONA_RECONCILE_INTERVAL_MS); + this.timer.unref?.(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = undefined; + } + + async sweep(): Promise { + const service = this.options.daytonaService; + // No credentials: no Daytona workspaces can be reached, and nothing to reconcile. + if (!(await service.checkAvailability()).available) return; + + const refs = this.collectReferences(); + let sandboxes: DaytonaSandbox[] | undefined; + try { + sandboxes = await this.call(service.listInstanceSandboxes(), "listing Daytona sandboxes"); + } catch (error) { + // Keepalive still runs from config alone; orphans wait for the next sweep. + log.warn("Daytona reconcile: could not list sandboxes", { error: getErrorMessage(error) }); + } + + for (const ref of refs) { + if (!this.options.isWorkspaceActive(ref.workspaceId)) continue; + await this.keepAlive(ref.sandboxId, sandboxes); + } + if (sandboxes) { + await this.stopOrphans(sandboxes, refs); + } + } + + private collectReferences(): Array<{ workspaceId: string; sandboxId: string }> { + const refs: Array<{ workspaceId: string; sandboxId: string }> = []; + for (const workspace of this.options.listWorkspaces()) { + const config = workspace.runtimeConfig; + const sandboxId = isSSHRuntime(config) ? config.daytona?.sandboxId?.trim() : undefined; + if (sandboxId) refs.push({ workspaceId: workspace.workspaceId, sandboxId }); + } + return refs; + } + + private async keepAlive(sandboxId: string, sandboxes: DaytonaSandbox[] | undefined) { + const service = this.options.daytonaService; + // Xum stores a name for sandboxes it created; last-activity only takes the real id. + const listed = sandboxes?.find((sb) => sb.id === sandboxId || sb.name === sandboxId); + try { + await this.call(service.refreshActivity(listed?.id ?? sandboxId), "refreshing activity"); + } catch (error) { + log.warn("Daytona reconcile: activity refresh failed", { + sandboxId, + error: getErrorMessage(error), + }); + } + if (!service.hasExpiringSshToken(sandboxId)) return; + try { + await this.call(service.ensureSshAccess(sandboxId), "refreshing the ssh token"); + } catch (error) { + log.warn("Daytona reconcile: ssh token refresh failed", { + sandboxId, + error: getErrorMessage(error), + }); + } + } + + private async stopOrphans( + sandboxes: DaytonaSandbox[], + refs: Array<{ sandboxId: string }> + ): Promise { + const referenced = new Set(refs.map((ref) => ref.sandboxId)); + for (const sandbox of sandboxes) { + if (referenced.has(sandbox.id) || (sandbox.name && referenced.has(sandbox.name))) continue; + // Only settled, running boxes: a starting one may belong to a creation in flight. + if (mapDaytonaSandboxState(sandbox.state) !== "started") continue; + log.info("Daytona reconcile: stopping an unreferenced sandbox", { sandboxId: sandbox.id }); + try { + await this.call( + this.options.daytonaService.stopSandbox(sandbox.id), + "stopping an orphaned sandbox" + ); + } catch (error) { + log.warn("Daytona reconcile: could not stop an orphaned sandbox", { + sandboxId: sandbox.id, + error: getErrorMessage(error), + }); + } + } + } + + private call(promise: Promise, what: string): Promise { + return withTimeout( + promise, + this.options.callTimeoutMs ?? DAYTONA_RECONCILE_CALL_TIMEOUT_MS, + what + ); + } +} diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index ea289ee3727..5e4769ffe27 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -14,12 +14,15 @@ import type { DaytonaResolvedCredentials, } from "@/node/config/daytonaCredentialsStore"; import { loadOrCreateInstallationId } from "@/node/config/installationIdentity"; +import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; import { log } from "@/node/services/log"; import { DaytonaApiError, DaytonaClient, isDaytonaNotFound, + type DaytonaCreateSandboxRequest, type DaytonaGatewayConfig, + type DaytonaResizeRequest, type DaytonaSandbox, type DaytonaSshAccess, } from "./daytonaClient"; @@ -34,6 +37,15 @@ export const DAYTONA_SSH_TOKEN_REFRESH_MARGIN_MS = 15 * 60_000; export const DAYTONA_START_POLL_INTERVAL_MS = 2_000; +/** Upper bound for bringing a stopped or archived sandbox back (archived restores are slow). */ +export const DAYTONA_START_TIMEOUT_MS = 180_000; + +/** + * How long a successful readiness check is trusted. Runtimes are recreated per stream start, so + * the cache lives here, keyed by sandbox, rather than on a runtime instance. + */ +export const DAYTONA_READY_CACHE_MS = 60_000; + type ObservedSandboxState = Exclude; const RAW_STATE_MAP: Record = { @@ -108,6 +120,11 @@ export interface DaytonaServiceOptions { credentialsStore: DaytonaCredentialsStore; /** Xum data root; holds the installation identity used for the `xum.instance` label. */ rootDir: string; + /** Defaults to a writer for the real `~/.ssh/config`; tests pass one aimed at a temp dir. */ + sshConfigWriter?: Pick< + DaytonaSshConfigWriter, + "writeSandbox" | "updateToken" | "removeSandbox" | "verifyResolvedUser" + >; fetch?: typeof fetch; now?: () => number; sleep?: (ms: number) => Promise; @@ -120,6 +137,17 @@ export interface DaytonaStartAndWaitOptions { signal?: AbortSignal; } +export interface DaytonaEnsureReadyOptions { + /** Called when the sandbox has to be started (it was stopped or archived). */ + onStarting?: () => void; +} + +interface SandboxReadyRequest { + promise: Promise; + onStarting: Set<() => void>; + starting: boolean; +} + export interface DaytonaSshTokenOptions { /** * Runs after a fresh token is minted and before the previous one is revoked, so the caller @@ -171,10 +199,17 @@ export class DaytonaService { private gatewayCache?: { apiUrl: string; config: DaytonaGatewayConfig }; private readonly tokens = new Map(); private readonly tokenRequests = new Map>(); + private readonly sshConfigWriter: NonNullable; + /** Sandboxes whose include block and `~/.ssh/config` block this process has written. */ + private readonly sshConfigured = new Set(); + private readonly readyAt = new Map(); + private readonly readyRequests = new Map(); constructor(options: DaytonaServiceOptions) { this.credentialsStore = options.credentialsStore; this.rootDir = options.rootDir; + this.sshConfigWriter = + options.sshConfigWriter ?? new DaytonaSshConfigWriter({ xumHome: options.rootDir }); this.fetchImpl = options.fetch; this.now = options.now ?? Date.now; this.sleep = options.sleep ?? defaultSleep; @@ -189,6 +224,8 @@ export class DaytonaService { // Tokens and gateway data belong to the old account or deployment. this.gatewayCache = undefined; this.tokens.clear(); + this.sshConfigured.clear(); + this.readyAt.clear(); return this.credentialsStore.getPublicConfig(); } @@ -349,6 +386,152 @@ export class DaytonaService { } } + /** The default target (region) from Settings > Runtimes, if one is set. */ + async getDefaultTarget(): Promise { + return (await this.credentialsStore.resolve())?.target; + } + + async createSandbox(request: DaytonaCreateSandboxRequest): Promise { + const { client } = await this.getClient(); + return client.createSandbox(request); + } + + async resizeSandbox(sandboxId: string, request: DaytonaResizeRequest): Promise { + const { client } = await this.getClient(); + this.readyAt.delete(sandboxId); + await client.resizeSandbox(sandboxId, request); + } + + async stopSandbox(sandboxId: string): Promise { + const { client } = await this.getClient(); + this.readyAt.delete(sandboxId); + await client.stopSandbox(sandboxId); + } + + /** Stop and wait until Daytona reports the sandbox stopped (a resize of disk needs that). */ + async stopAndWait(sandboxId: string, deadline: number): Promise { + await this.stopSandbox(sandboxId); + const { client } = await this.getClient(); + for (;;) { + const sandbox = await this.getSandboxForStart(client, sandboxId); + const state = mapDaytonaSandboxState(sandbox.state); + if (state === "stopped") return; + if (state === "error") throw sandboxStateError(sandboxId, sandbox); + const remaining = deadline - this.now(); + if (remaining <= 0) { + throw new DaytonaApiError( + "timeout", + `Daytona sandbox ${sandboxId} did not stop in time (last state: ${sandbox.state ?? "unknown"})` + ); + } + await this.sleep(Math.min(DAYTONA_START_POLL_INTERVAL_MS, remaining)); + } + } + + /** + * Delete the sandbox and drop Xum's SSH access to it (token and include block). A sandbox + * that is already gone counts as deleted. + */ + async deleteSandbox(sandboxId: string): Promise { + const { client } = await this.getClient(); + // Revoke first: Daytona refuses to revoke a token of a sandbox that no longer exists. + await this.releaseSshAccess(sandboxId); + try { + await client.deleteSandbox(sandboxId); + } catch (error) { + if (!isDaytonaNotFound(error)) throw error; + } + } + + async refreshActivity(sandboxId: string): Promise { + const { client } = await this.getClient(); + await client.refreshActivity(sandboxId); + } + + /** Whether a readiness check for this sandbox passed within DAYTONA_READY_CACHE_MS. */ + isSandboxReady(sandboxId: string): boolean { + const at = this.readyAt.get(sandboxId); + return at !== undefined && this.now() - at < DAYTONA_READY_CACHE_MS; + } + + markSandboxReady(sandboxId: string): void { + this.readyAt.set(sandboxId, this.now()); + } + + /** + * Bring the sandbox to `started` and make sure Xum can ssh into it (fresh token, include + * block). Concurrent callers for one sandbox share a single request; each still gets its own + * `onStarting` call. Throws a DaytonaApiError (`not_found` when the sandbox is gone). + */ + ensureSandboxReady(sandboxId: string, options: DaytonaEnsureReadyOptions = {}): Promise { + const existing = this.readyRequests.get(sandboxId); + if (existing) { + if (options.onStarting) { + existing.onStarting.add(options.onStarting); + if (existing.starting) options.onStarting(); + } + return existing.promise; + } + + const request: SandboxReadyRequest = { + promise: Promise.resolve(), + onStarting: new Set(options.onStarting ? [options.onStarting] : []), + starting: false, + }; + request.promise = (async () => { + await this.startAndWait(sandboxId, this.now() + DAYTONA_START_TIMEOUT_MS, { + onStarting: () => { + request.starting = true; + for (const listener of request.onStarting) listener(); + }, + }); + await this.ensureSshAccess(sandboxId); + })().finally(() => { + this.readyRequests.delete(sandboxId); + }); + this.readyRequests.set(sandboxId, request); + return request.promise; + } + + /** + * A valid token in the include file for this sandbox. The first write in this process also + * writes known_hosts and the `~/.ssh/config` block and checks that ssh resolves the token as + * the user; a refresh rewrites only the include file. + */ + async ensureSshAccess(sandboxId: string): Promise { + const gatewayConfig = await this.getGatewayConfig(); + const gateway = { + host: gatewayConfig.sshGatewayHost, + port: gatewayConfig.sshGatewayPort, + hostKeys: gatewayConfig.sshGatewayHostKeys, + }; + // A cached token was written by the mint that produced it, so only a mint writes files. + await this.getSshToken(sandboxId, { + onNewToken: async ({ token }) => { + if (this.sshConfigured.has(sandboxId)) { + await this.sshConfigWriter.updateToken({ sandboxId, token, gateway }); + return; + } + await this.sshConfigWriter.writeSandbox({ sandboxId, token, gateway }); + await this.sshConfigWriter.verifyResolvedUser(sandboxId, token); + this.sshConfigured.add(sandboxId); + }, + }); + } + + /** Whether this process holds a token for the sandbox that is close to expiry. */ + hasExpiringSshToken(sandboxId: string): boolean { + return this.tokens.has(sandboxId) && this.tokenNeedsRefresh(sandboxId); + } + + /** Revoke the token and remove the sandbox's include block. */ + async releaseSshAccess(sandboxId: string): Promise { + this.sshConfigured.delete(sandboxId); + this.readyAt.delete(sandboxId); + await this.forgetSshToken(sandboxId); + await this.sshConfigWriter.removeSandbox(sandboxId); + } + tokenNeedsRefresh(sandboxId: string): boolean { const cached = this.tokens.get(sandboxId); if (!cached) return true; diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 2babbb57883..a42a8447a80 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -61,6 +61,14 @@ import { PerfCaptureService } from "@/node/services/perf/perfCaptureService"; import { AgentResourceLimitsService } from "@/node/services/agentResourceLimits/agentResourceLimitsService"; import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; import { DaytonaService } from "@/node/services/daytona/daytonaService"; +import { + DaytonaReconciler, + listDaytonaWorkspaceRefs, +} from "@/node/services/daytona/daytonaReconciler"; +import { createDaytonaArchiveHook } from "@/node/services/daytona/daytonaLifecycleHooks"; +import { DEFAULT_CODER_ARCHIVE_BEHAVIOR } from "@/common/config/coderArchiveBehavior"; +import { setGlobalDaytonaService } from "@/node/runtime/runtimeFactory"; +import { createRuntimeForWorkspace } from "@/node/runtime/runtimeHelpers"; import { PerfReportService } from "@/node/services/perf/perfReportService"; import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import type { AgentPluginInstallService } from "@/node/services/agentPlugins/installService"; @@ -165,8 +173,10 @@ import { WorkspaceGoal, WorkspaceMcpOverrides, WorkspaceTurnManagerTag, + WorkspaceLifecycleHooksTag, type AppTags, } from "@/node/services/di/tags"; +import type { WorkspaceLifecycleHooks } from "@/node/services/workspaceLifecycleHooks"; /** * A startup step of `ServiceContainer.initializeCore()` did not settle within @@ -290,6 +300,7 @@ export class ServiceContainer { public readonly agentResourceLimits: AgentResourceLimitsService; // Constructed directly (no DI tag): it needs only the data root and starts no timers. public readonly daytonaService: DaytonaService; + private readonly daytonaReconciler: DaytonaReconciler; public readonly coderService: CoderService; public readonly serverAuthService: ServerAuthService; public readonly desktopSessionManager: DesktopSessionManager; @@ -422,12 +433,38 @@ export class ServiceContainer { this.heartbeatService = get(Heartbeat); this.agentStatusService = get(AgentStatus); this.bashAiProxy = get(BashAiProxy); + this.daytonaReconciler = this.wireDaytona(get(WorkspaceLifecycleHooksTag)); assert( new Set(this.startupCoreSteps.map((step) => step.name)).size === this.startupCoreSteps.length, "startupCoreSteps names must be unique (they key stepDurationsMs)" ); } + /** Daytona runtimes, archive hook and reconciler (DaytonaService is built without DI). */ + private wireDaytona(lifecycleHooks: WorkspaceLifecycleHooks): DaytonaReconciler { + setGlobalDaytonaService(this.daytonaService); + lifecycleHooks.registerBeforeArchive( + createDaytonaArchiveHook({ + daytonaService: this.daytonaService, + getArchiveBehavior: () => + this.config.loadConfigOrDefault().coderWorkspaceArchiveBehavior ?? + DEFAULT_CODER_ARCHIVE_BEHAVIOR, + hasUnsettledRemoteBackgroundJobs: (workspaceMetadata) => + this.backgroundProcessManager.hasUnsettledRemoteSpawnRecords( + createRuntimeForWorkspace(workspaceMetadata), + workspaceMetadata.id + ), + }) + ); + return new DaytonaReconciler({ + daytonaService: this.daytonaService, + listWorkspaces: () => listDaytonaWorkspaceRefs(this.config.loadConfigOrDefault()), + isWorkspaceActive: (workspaceId) => + this.aiService.isStreaming(workspaceId) || + this.backgroundProcessManager.hasRunningBackgroundProcesses(workspaceId), + }); + } + async initialize(): Promise { await this.initializeCore(); await this.runStartupHousekeeping(); @@ -666,6 +703,9 @@ export class ServiceContainer { this.heartbeatService.start(); this.startupStepDurationsMs["heartbeatService.start"] = Date.now() - heartbeatStartedAt; + // Timer only: the first sweep runs one interval later and never blocks startup. + this.daytonaReconciler.start(); + const agentStatusStartedAt = Date.now(); this.agentStatusService.start(); this.startupStepDurationsMs["agentStatusService.start"] = Date.now() - agentStatusStartedAt; @@ -826,6 +866,7 @@ export class ServiceContainer { await this.desktopBridgeServer.stop(); this.desktopTokenManager.dispose(); this.heartbeatService.stop(); + this.daytonaReconciler.stop(); this.agentStatusService.stop(); this.perfFlightRecorder.stop(); await this.perfCaptures.dispose(); @@ -993,6 +1034,7 @@ export class ServiceContainer { // generateWorkspaceStatus against services that are about to be torn // down below. shutdownStep("agentStatusService.stop", () => this.agentStatusService.stop()); + shutdownStep("daytonaReconciler.stop", () => this.daytonaReconciler.stop()); shutdownStep("perfFlightRecorder.stop", () => this.perfFlightRecorder.stop()); await shutdownStep("perfCaptures.dispose", () => this.perfCaptures.dispose()); shutdownStep("agentResourceLimits.dispose", () => this.agentResourceLimits.dispose()); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5f823f0e312..e033bf81b05 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -353,6 +353,7 @@ import { hasSrcBaseDir, getSrcBaseDir, isSSHRuntime, + isDedicatedDaytonaSandbox, isDockerRuntime, isDevcontainerRuntime, } from "@/common/types/runtime"; @@ -1075,8 +1076,12 @@ const ARCHIVE_OWN_ACTIVITY_POLICY = { backgroundProcesses: "allow", } as const; -/** A Coder workspace Xum created for this workspace (not an existing one it connected to). */ +/** + * A remote box Xum created for this workspace (not an existing one it connected to): a Coder + * workspace, or a Daytona sandbox, which reuses the Coder archive behavior (keep, stop, delete). + */ function isDedicatedCoderRuntime(runtimeConfig: RuntimeConfig | undefined): boolean { + if (isDedicatedDaytonaSandbox(runtimeConfig)) return true; return ( runtimeConfig != null && isSSHRuntime(runtimeConfig) && diff --git a/tests/runtime/daytonaRuntime.test.ts b/tests/runtime/daytonaRuntime.test.ts new file mode 100644 index 00000000000..d3f9ad18397 --- /dev/null +++ b/tests/runtime/daytonaRuntime.test.ts @@ -0,0 +1,165 @@ +/** + * Real Daytona Cloud test (costs money): creates a sandbox through Daytona's SSH gateway and + * drives create, exec, file read/write, fork, archive and delete through DaytonaSSHRuntime. + * + * Skipped unless TEST_DAYTONA=1 and DAYTONA_API_KEY are set (DAYTONA_API_URL optional). HOME + * points at a temp dir and the ssh2 transport is used, so the real ~/.ssh/config and Xum home + * are never written. The sandbox is always deleted in afterAll. + */ + +import { execFileSync } from "child_process"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import type { RuntimeConfig } from "@/common/types/runtime"; +import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; +import { DaytonaSSHRuntime } from "@/node/runtime/DaytonaSSHRuntime"; +import type { InitLogger } from "@/node/runtime/Runtime"; +import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; +import { createSSHTransport } from "@/node/runtime/transports"; +import { createDaytonaArchiveHook } from "@/node/services/daytona/daytonaLifecycleHooks"; +import { DaytonaService } from "@/node/services/daytona/daytonaService"; +import { execBuffered, readFileString, writeFileString } from "@/node/utils/runtime/helpers"; + +const shouldRun = process.env.TEST_DAYTONA === "1" && Boolean(process.env.DAYTONA_API_KEY); +const describeDaytona = shouldRun ? describe : describe.skip; + +const logger: InitLogger = { + logStep: (line) => console.log(`[daytona] ${line}`), + logStdout: (line) => console.log(`[daytona] ${line}`), + logStderr: (line) => console.error(`[daytona] ${line}`), + logComplete: () => undefined, +}; + +describeDaytona("Daytona runtime (real control plane)", () => { + let tempRoot: string; + let originalHome: string | undefined; + let service: DaytonaService; + let projectPath: string; + let sandboxId: string | undefined; + + function makeRuntime(config: Extract, workspaceName: string) { + if (!config.daytona) throw new Error("expected a daytona config"); + const sshConfig = { ...config, daytona: config.daytona }; + return new DaytonaSSHRuntime(sshConfig, createSSHTransport(sshConfig, true), service, { + projectPath, + workspaceName, + workspaceId: `daytona-test-${workspaceName}`, + agentHomeSeedSources: { xumHome: path.join(tempRoot, "xum"), home: tempRoot }, + }); + } + + beforeAll(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "xum-daytona-real-")); + originalHome = process.env.HOME; + process.env.HOME = tempRoot; + const xumHome = path.join(tempRoot, "xum"); + await fs.mkdir(xumHome, { recursive: true }); + await fs.writeFile(path.join(xumHome, "tool_env"), "export XUM_DAYTONA_TEST=1\n"); + service = new DaytonaService({ + credentialsStore: new DaytonaCredentialsStore(xumHome), + rootDir: xumHome, + sshConfigWriter: new DaytonaSshConfigWriter({ + xumHome, + sshConfigPath: path.join(tempRoot, ".ssh", "config"), + }), + }); + + projectPath = path.join(tempRoot, "project"); + await fs.mkdir(projectPath, { recursive: true }); + await fs.writeFile(path.join(projectPath, "README.md"), "hello\n"); + const git = (...args: string[]) => execFileSync("git", args, { cwd: projectPath }); + git("init", "-b", "main"); + git("-c", "user.email=t@t", "-c", "user.name=t", "add", "."); + git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"); + }, 60_000); + + afterAll(async () => { + if (sandboxId) await service.deleteSandbox(sandboxId); + process.env.HOME = originalHome; + await fs.rm(tempRoot, { recursive: true, force: true }); + }, 120_000); + + test("create, exec, files, fork, archive, delete", async () => { + const draft: RuntimeConfig = { + type: "ssh", + host: "pending.xum--daytona", + srcBaseDir: "~/src", + daytona: {}, + }; + const creator = makeRuntime(draft, "feature"); + const finalized = await creator.finalizeConfig("feature", draft); + if (!finalized.success || finalized.data.type !== "ssh") throw new Error("finalize failed"); + sandboxId = finalized.data.daytona?.sandboxId; + expect((await creator.validateBeforePersist("feature", finalized.data)).success).toBe(true); + + const runtime = makeRuntime(finalized.data, "feature"); + const created = await runtime.createWorkspace({ + projectPath, + branchName: "feature", + trunkBranch: "main", + directoryName: "feature", + initLogger: logger, + }); + if (!created.success || !created.workspacePath) throw new Error("createWorkspace failed"); + const initParams = { + projectPath, + branchName: "feature", + trunkBranch: "main", + workspacePath: created.workspacePath, + initLogger: logger, + trusted: true, + }; + await runtime.postCreateSetup(initParams); + expect((await runtime.initWorkspace(initParams)).success).toBe(true); + expect(await runtime.ensureReady()).toEqual({ ready: true }); + + const echo = await execBuffered(runtime, "cat ~/.mux/tool_env && echo ok", { + cwd: created.workspacePath, + timeout: 60, + }); + expect(echo.stdout).toContain("XUM_DAYTONA_TEST=1"); + expect(echo.stdout).toContain("ok"); + + const filePath = `${created.workspacePath}/note.txt`; + await writeFileString(runtime, filePath, "from xum\n"); + expect(await readFileString(runtime, filePath)).toBe("from xum\n"); + + const fork = await runtime.forkWorkspace({ + projectPath, + sourceWorkspaceName: "feature", + newWorkspaceName: "feature-fork", + initLogger: logger, + trusted: true, + }); + expect(fork.success).toBe(true); + const forkConfig = fork.forkedRuntimeConfig; + if (forkConfig?.type !== "ssh") throw new Error("fork lost its ssh config"); + expect(forkConfig.daytona?.shared).toBe(true); + + // Archive with "stop" stops a dedicated sandbox (this one is shared after the fork, so use + // the pre-fork config to exercise the stop path). + const archiveHook = createDaytonaArchiveHook({ + daytonaService: service, + getArchiveBehavior: () => "stop", + }); + const archived = await archiveHook({ + workspaceId: "daytona-test-feature", + workspaceMetadata: { + id: "daytona-test-feature", + name: "feature", + projectName: "project", + projectPath, + runtimeConfig: finalized.data, + }, + }); + expect(archived.success).toBe(true); + expect((await service.getSandboxState(sandboxId ?? "")).state).toBe("stopped"); + + // Delete starts the stopped sandbox for the dirty check; the shared sandbox survives. + const forkRuntime = makeRuntime(forkConfig, "feature-fork"); + const deleted = await forkRuntime.deleteWorkspace(projectPath, "feature-fork", false); + expect(deleted.success).toBe(true); + expect((await service.getSandboxState(sandboxId ?? "")).state).toBe("started"); + }, 900_000); +}); From 71726bd70508813c49f5aea0787a031dfd3646c8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:19:11 +0000 Subject: [PATCH 004/231] refactor(daytona): split runtime, seed and archive-hook functions under the CRAP limit --- src/node/runtime/DaytonaSSHRuntime.ts | 226 ++++++++++++------ src/node/runtime/daytonaAgentHomeSeed.ts | 75 +++--- .../services/daytona/daytonaLifecycleHooks.ts | 63 ++--- 3 files changed, 234 insertions(+), 130 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index c94d2fcd1af..314883cde74 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -139,6 +139,27 @@ function raceAbort(promise: Promise, signal: AbortSignal | undefined): Pro }); } +type DeleteResult = { success: true; deletedPath: string } | { success: false; error: string }; + +type StatusEmitter = (phase: RuntimeStatusEvent["phase"], detail?: string) => void; + +function makeStatusEmitter(statusSink: EnsureReadyOptions["statusSink"]): StatusEmitter { + return (phase, detail) => statusSink?.({ phase, runtimeType: "ssh", detail }); +} + +function growsDisk(sandbox: DaytonaSandbox, preset: DaytonaSizePreset): boolean { + return sandbox.disk === undefined || sandbox.disk < preset.diskGiB; +} + +/** A running sandbox can only grow cpu and memory. */ +function shrinksCompute(sandbox: DaytonaSandbox, preset: DaytonaSizePreset): boolean { + return (sandbox.cpu ?? 0) > preset.cpu || (sandbox.memory ?? 0) > preset.memoryGiB; +} + +function changesCompute(sandbox: DaytonaSandbox, preset: DaytonaSizePreset): boolean { + return sandbox.cpu !== preset.cpu || sandbox.memory !== preset.memoryGiB; +} + /** * IMPORTANT: extends SSHRuntime (rather than delegating) so code that checks * `runtime instanceof SSHRuntime` (PTY, tools, MCP, bash AI proxy) treats Daytona as SSH. @@ -224,23 +245,38 @@ export class DaytonaSSHRuntime extends SSHRuntime { return { ready: true }; } - const emitStatus = (phase: RuntimeStatusEvent["phase"], detail?: string) => { - options?.statusSink?.({ phase, runtimeType: "ssh", detail }); - }; + const emitStatus = makeStatusEmitter(options?.statusSink); emitStatus("checking"); + const failure = await this.bringSandboxUp(sandboxId, emitStatus, options?.signal); + if (failure) return failure; + return this.markReadyIfRepoPresent(sandboxId, options, emitStatus); + } + /** Start the sandbox if needed and refresh ssh access; a failure result, or undefined. */ + private async bringSandboxUp( + sandboxId: string, + emitStatus: StatusEmitter, + signal: AbortSignal | undefined + ): Promise { try { await raceAbort( this.daytonaService.ensureSandboxReady(sandboxId, { onStarting: () => emitStatus("starting", "Starting sandbox..."), }), - options?.signal + signal ); + return undefined; } catch (error) { emitStatus("error"); - return this.ensureReadyFailure(sandboxId, error, options?.signal); + return this.ensureReadyFailure(sandboxId, error, signal); } + } + private async markReadyIfRepoPresent( + sandboxId: string, + options: EnsureReadyOptions | undefined, + emitStatus: StatusEmitter + ): Promise { const repoCheck = await this.checkWorkspaceRepo(options); if (repoCheck && !repoCheck.ready) { emitStatus("error", repoCheck.error); @@ -338,23 +374,30 @@ export class DaytonaSSHRuntime extends SSHRuntime { if (!sandboxId) { return Err("Daytona sandbox id is required (should be set by finalizeConfig)"); } - if (daytona.shared !== true && !findDaytonaSizePreset(daytona)) { + const attaching = daytona.shared === true; + if (!attaching && !findDaytonaSizePreset(daytona)) { return Err(invalidSizeError()); } + return this.checkSandboxIdentity(sandboxId, attaching); + } - let state; + /** The sandbox to attach must exist; the name for a new one must be free. */ + private async checkSandboxIdentity( + sandboxId: string, + attaching: boolean + ): Promise> { + let exists: boolean; try { - ({ state } = await this.daytonaService.getSandboxState(sandboxId)); + exists = (await this.daytonaService.getSandboxState(sandboxId)).state !== "missing"; } catch (error) { return Err(`Could not reach Daytona: ${getErrorMessage(error)}`); } - if (daytona.shared === true && state === "missing") { - return Err(`Daytona sandbox "${sandboxId}" was not found`); - } - if (daytona.shared !== true && state !== "missing") { - return Err(`A Daytona sandbox named "${sandboxId}" already exists`); - } - return Ok(undefined); + if (attaching === exists) return Ok(undefined); + return Err( + attaching + ? `Daytona sandbox "${sandboxId}" was not found` + : `A Daytona sandbox named "${sandboxId}" already exists` + ); } /** The sandbox may not exist yet, so only compute the path (as Coder does). */ @@ -371,7 +414,6 @@ export class DaytonaSSHRuntime extends SSHRuntime { * sent, the sandbox this call created is deleted again so it cannot linger and bill. */ async postCreateSetup(params: WorkspaceInitParams): Promise { - const { initLogger, abortSignal } = params; const sandboxId = this.daytonaConfig.sandboxId; if (!sandboxId) { throw new Error("Daytona sandbox id is required (should be set by finalizeConfig)"); @@ -381,52 +423,63 @@ export class DaytonaSSHRuntime extends SSHRuntime { let createRequested = false; try { if (creates) { - initLogger.logStep(`Creating Daytona sandbox "${sandboxId}"...`); + params.initLogger.logStep(`Creating Daytona sandbox "${sandboxId}"...`); createRequested = true; await this.createSandbox(sandboxId, params); - initLogger.logStep("Daytona sandbox created"); - } - - initLogger.logStep("Waiting for the sandbox to start and configuring SSH..."); - await raceAbort( - this.daytonaService.ensureSandboxReady(sandboxId, { - onStarting: () => initLogger.logStep("Starting sandbox..."), - }), - abortSignal - ); - - if (creates) { - initLogger.logStep("Copying agent hooks and global skills into the sandbox..."); - const count = await seedAgentHome( - this, - this.agentHomeSeedSources ?? { xumHome: getXumHome(), home: os.homedir() }, - abortSignal - ); - initLogger.logStep(`Copied ${count} agent file(s)`); - } - - initLogger.logStep("Preparing workspace directory..."); - const parentDir = path.posix.dirname(params.workspacePath); - const mkdirResult = await execBuffered(this, `mkdir -p ${expandTildeForSSH(parentDir)}`, { - cwd: "/tmp", - timeout: 10, - abortSignal, - }); - if (mkdirResult.exitCode !== 0) { - const errorMsg = mkdirResult.stderr || mkdirResult.stdout || "Unknown error"; - throw new Error(`Failed to prepare workspace directory: ${errorMsg}`); + params.initLogger.logStep("Daytona sandbox created"); } + await this.connectAndSeed(sandboxId, creates, params); + await this.prepareWorkspaceDirectory(params); } catch (error) { const errorMsg = getErrorMessage(error); log.error("Daytona sandbox setup failed", { sandboxId, error: errorMsg }); - initLogger.logStderr(`Daytona sandbox setup failed: ${errorMsg}`); + params.initLogger.logStderr(`Daytona sandbox setup failed: ${errorMsg}`); if (createRequested) { - await this.deleteCreatedSandbox(sandboxId, initLogger); + await this.deleteCreatedSandbox(sandboxId, params.initLogger); } throw new Error(`Daytona sandbox setup failed: ${errorMsg}`); } } + /** Wait until the sandbox runs, write ssh access, and seed the home of a box Xum created. */ + private async connectAndSeed( + sandboxId: string, + seed: boolean, + params: WorkspaceInitParams + ): Promise { + const { initLogger, abortSignal } = params; + initLogger.logStep("Waiting for the sandbox to start and configuring SSH..."); + await raceAbort( + this.daytonaService.ensureSandboxReady(sandboxId, { + onStarting: () => initLogger.logStep("Starting sandbox..."), + }), + abortSignal + ); + if (!seed) return; + initLogger.logStep("Copying agent hooks and global skills into the sandbox..."); + const count = await seedAgentHome( + this, + this.agentHomeSeedSources ?? { xumHome: getXumHome(), home: os.homedir() }, + abortSignal + ); + initLogger.logStep(`Copied ${count} agent file(s)`); + } + + /** git clone does not create the parent directory of the workspace. */ + private async prepareWorkspaceDirectory(params: WorkspaceInitParams): Promise { + params.initLogger.logStep("Preparing workspace directory..."); + const parentDir = path.posix.dirname(params.workspacePath); + const mkdirResult = await execBuffered(this, `mkdir -p ${expandTildeForSSH(parentDir)}`, { + cwd: "/tmp", + timeout: 10, + abortSignal: params.abortSignal, + }); + if (mkdirResult.exitCode !== 0) { + const errorMsg = mkdirResult.stderr || mkdirResult.stdout || "Unknown error"; + throw new Error(`Failed to prepare workspace directory: ${errorMsg}`); + } + } + private async deleteCreatedSandbox( sandboxId: string, initLogger: WorkspaceInitParams["initLogger"] @@ -494,18 +547,17 @@ export class DaytonaSSHRuntime extends SSHRuntime { */ private async resizeToPreset(sandboxId: string, preset: DaytonaSizePreset): Promise { const deadline = () => Date.now() + DAYTONA_START_TIMEOUT_MS; - const sandbox: DaytonaSandbox = await this.daytonaService.startAndWait(sandboxId, deadline()); - const growsDisk = sandbox.disk === undefined || sandbox.disk < preset.diskGiB; - const shrinks = (sandbox.cpu ?? 0) > preset.cpu || (sandbox.memory ?? 0) > preset.memoryGiB; + const sandbox = await this.daytonaService.startAndWait(sandboxId, deadline()); + const disk = growsDisk(sandbox, preset); const request = { cpu: preset.cpu, memory: preset.memoryGiB, - ...(growsDisk ? { disk: preset.diskGiB } : {}), + ...(disk ? { disk: preset.diskGiB } : {}), }; - if (growsDisk || shrinks) { + if (disk || shrinksCompute(sandbox, preset)) { await this.daytonaService.stopAndWait(sandboxId, deadline()); await this.daytonaService.resizeSandbox(sandboxId, request); - } else if (sandbox.cpu !== preset.cpu || sandbox.memory !== preset.memoryGiB) { + } else if (changesCompute(sandbox, preset)) { await this.daytonaService.resizeSandbox(sandboxId, request); } await this.daytonaService.startAndWait(sandboxId, deadline()); @@ -547,7 +599,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { abortSignal?: AbortSignal, trusted?: boolean, options?: { keepBranch?: boolean } - ): Promise<{ success: true; deletedPath: string } | { success: false; error: string }> { + ): Promise { const sandboxId = this.daytonaConfig.sandboxId; const shared = this.daytonaConfig.shared === true; const deletedPath = this.getWorkspacePath(projectPath, workspaceName); @@ -565,38 +617,64 @@ export class DaytonaSSHRuntime extends SSHRuntime { return this.deleteOwnedSandbox(sandboxId, deletedPath, undefined); } + const prepared = await this.startForDeleteChecks(sandboxId, abortSignal); + if (prepared === "missing") { + return this.forgetMissingSandbox(sandboxId, deletedPath, shared); + } + // A forced delete still tries the SSH cleanup; a normal one never skips the checks. + if (prepared !== "ready" && !force) { + return { success: false, error: prepared.error }; + } + return this.deleteAfterChecks(sandboxId, await sshDelete(), shared); + } + + private forgetMissingSandbox( + sandboxId: string, + deletedPath: string, + shared: boolean + ): Promise { + log.debug("Daytona sandbox already gone, skipping SSH cleanup", { sandboxId }); + // Still drop the token and include block of a sandbox this workspace owned. + return shared + ? Promise.resolve({ success: true, deletedPath }) + : this.deleteOwnedSandbox(sandboxId, deletedPath, undefined); + } + + /** A failed check (dirty or unpushed work) keeps the workspace and its sandbox. */ + private deleteAfterChecks( + sandboxId: string, + sshResult: DeleteResult, + shared: boolean + ): Promise { + if (!sshResult.success || shared) { + return Promise.resolve(sshResult); + } + return this.deleteOwnedSandbox(sandboxId, sshResult.deletedPath, "SSH delete succeeded, but "); + } + + /** Start the sandbox (if stopped or archived) so the dirty and unpushed checks can run. */ + private async startForDeleteChecks( + sandboxId: string, + abortSignal: AbortSignal | undefined + ): Promise<"ready" | "missing" | { error: string }> { try { await raceAbort(this.daytonaService.ensureSandboxReady(sandboxId), abortSignal); + return "ready"; } catch (error) { - if (error instanceof DaytonaApiError && error.kind === "not_found") { - log.debug("Daytona sandbox already gone, skipping SSH cleanup", { sandboxId }); - // Still drop the token and include block of a sandbox this workspace owned. - return shared - ? { success: true, deletedPath } - : this.deleteOwnedSandbox(sandboxId, deletedPath, undefined); - } - if (force) return sshDelete(); + if (error instanceof DaytonaApiError && error.kind === "not_found") return "missing"; return { - success: false, error: `Could not start Daytona sandbox "${sandboxId}" to check for uncommitted work: ` + `${getErrorMessage(error)}`, }; } - - const sshResult = await sshDelete(); - // A failed check (dirty or unpushed work) keeps the workspace and its sandbox. - if (!sshResult.success || shared) { - return sshResult; - } - return this.deleteOwnedSandbox(sandboxId, sshResult.deletedPath, "SSH delete succeeded, but "); } private async deleteOwnedSandbox( sandboxId: string, deletedPath: string, errorPrefix: string | undefined - ): Promise<{ success: true; deletedPath: string } | { success: false; error: string }> { + ): Promise { try { await this.daytonaService.deleteSandbox(sandboxId); return { success: true, deletedPath }; diff --git a/src/node/runtime/daytonaAgentHomeSeed.ts b/src/node/runtime/daytonaAgentHomeSeed.ts index dcdded203f9..e5a413548cc 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.ts @@ -76,6 +76,21 @@ function addFile( files.push({ remotePath, content: file.content, executable: (file.mode & 0o111) !== 0 }); } +/** Directory entries, sorted; null when the directory is missing or unreadable. */ +async function readDirectory(hostDir: string): Promise { + try { + return (await fs.readdir(hostDir)).sort(); + } catch (error) { + if (!isErrnoWithCode(error, "ENOENT") && !isErrnoWithCode(error, "ENOTDIR")) { + log.warn("Skipping an unreadable directory while seeding a Daytona sandbox", { + hostDir, + error: getErrorMessage(error), + }); + } + return null; + } +} + async function collectDirectory( files: AgentHomeSeedFile[], budget: SeedBudget, @@ -84,39 +99,43 @@ async function collectDirectory( depth: number ): Promise { if (depth > MAX_SKILL_DIR_DEPTH || budget.exhausted) return; - let entries: string[]; + const entries = await readDirectory(hostDir); + if (!entries) return; + for (const entry of entries) { + await collectEntry( + files, + budget, + path.join(hostDir, entry), + path.posix.join(remoteDir, entry), + depth + ); + } +} + +async function collectEntry( + files: AgentHomeSeedFile[], + budget: SeedBudget, + hostPath: string, + remotePath: string, + depth: number +): Promise { + let stat: Awaited>; try { - entries = await fs.readdir(hostDir); + stat = await fs.stat(hostPath); } catch (error) { - if (!isErrnoWithCode(error, "ENOENT") && !isErrnoWithCode(error, "ENOTDIR")) { - log.warn("Skipping an unreadable directory while seeding a Daytona sandbox", { - hostDir, - error: getErrorMessage(error), - }); - } + // Broken symlinks are common in skill directories; skip them. + log.debug("Skipping a missing path while seeding a Daytona sandbox", { + hostPath, + error: getErrorMessage(error), + }); return; } - for (const entry of entries.sort()) { - const hostPath = path.join(hostDir, entry); - const remotePath = path.posix.join(remoteDir, entry); - let stat: Awaited>; - try { - stat = await fs.stat(hostPath); - } catch (error) { - // Broken symlinks are common in skill directories; skip them. - log.debug("Skipping a missing path while seeding a Daytona sandbox", { - hostPath, - error: getErrorMessage(error), - }); - continue; - } - if (stat.isDirectory()) { - await collectDirectory(files, budget, hostPath, remotePath, depth + 1); - } else if (stat.isFile()) { - const file = await readRegularFile(hostPath); - if (file) addFile(files, budget, remotePath, file); - } + if (stat.isDirectory()) { + await collectDirectory(files, budget, hostPath, remotePath, depth + 1); + return; } + const file = stat.isFile() ? await readRegularFile(hostPath) : null; + if (file) addFile(files, budget, remotePath, file); } /** The host files to copy; missing files and directories are skipped. */ diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index 650cccac78a..b6cdb71c556 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -14,7 +14,7 @@ const DAYTONA_STOP_TIMEOUT_MS = 60_000; * created; attached and shared sandboxes are never touched. No unarchive hook: ensureReady * starts a stopped sandbox on the next use. */ -export function createDaytonaArchiveHook(options: { +export interface DaytonaArchiveHookOptions { daytonaService: Pick; getArchiveBehavior: () => CoderWorkspaceArchiveBehavior; /** As for Coder: model-driven archives must not stop a box under a surviving detached job. */ @@ -22,49 +22,56 @@ export function createDaytonaArchiveHook(options: { workspaceMetadata: WorkspaceMetadata ) => Promise>; now?: () => number; -}): BeforeArchiveHook { - const now = options.now ?? Date.now; - return async ({ - workspaceMetadata, - coderWorkspaceArchiveBehavior, - refuseStopUnderUnverifiedRemoteJobs, - }): Promise> => { - const runtimeConfig = workspaceMetadata.runtimeConfig; - if (!isDedicatedDaytonaSandbox(runtimeConfig) || !isSSHRuntime(runtimeConfig)) { - return Ok(undefined); - } - const sandboxId = runtimeConfig.daytona?.sandboxId; +} + +export function createDaytonaArchiveHook(options: DaytonaArchiveHookOptions): BeforeArchiveHook { + return async (args): Promise> => { + const sandboxId = dedicatedSandboxId(args.workspaceMetadata); if (!sandboxId) return Ok(undefined); // The archive operation's policy snapshot wins (see the Coder hook). - const behavior = coderWorkspaceArchiveBehavior ?? options.getArchiveBehavior(); + const behavior = args.coderWorkspaceArchiveBehavior ?? options.getArchiveBehavior(); if (behavior === "keep") return Ok(undefined); - try { if (behavior === "delete") { await options.daytonaService.deleteSandbox(sandboxId); return Ok(undefined); } - - const { state } = await options.daytonaService.getSandboxState(sandboxId); - if (state === "stopped" || state === "archived" || state === "missing") { - return Ok(undefined); - } - if (refuseStopUnderUnverifiedRemoteJobs === true) { - const refusal = await probeRemoteJobs(options, workspaceMetadata, sandboxId); - if (refusal) return Err(refusal); - } - log.debug("Stopping Daytona sandbox before archive", { sandboxId, state }); - await options.daytonaService.stopAndWait(sandboxId, now() + DAYTONA_STOP_TIMEOUT_MS); - return Ok(undefined); + return await stopForArchive(options, args, sandboxId); } catch (error) { return Err(`Failed to ${behavior} Daytona sandbox "${sandboxId}": ${getErrorMessage(error)}`); } }; } +/** Only sandboxes Xum created for this workspace; attached and shared ones are never touched. */ +function dedicatedSandboxId(workspaceMetadata: WorkspaceMetadata): string | undefined { + const runtimeConfig = workspaceMetadata.runtimeConfig; + if (!isDedicatedDaytonaSandbox(runtimeConfig) || !isSSHRuntime(runtimeConfig)) return undefined; + return runtimeConfig.daytona?.sandboxId; +} + +async function stopForArchive( + options: DaytonaArchiveHookOptions, + args: Parameters[0], + sandboxId: string +): Promise> { + const { state } = await options.daytonaService.getSandboxState(sandboxId); + if (state === "stopped" || state === "archived" || state === "missing") { + return Ok(undefined); + } + if (args.refuseStopUnderUnverifiedRemoteJobs === true) { + const refusal = await probeRemoteJobs(options, args.workspaceMetadata, sandboxId); + if (refusal) return Err(refusal); + } + log.debug("Stopping Daytona sandbox before archive", { sandboxId, state }); + const now = options.now ?? Date.now; + await options.daytonaService.stopAndWait(sandboxId, now() + DAYTONA_STOP_TIMEOUT_MS); + return Ok(undefined); +} + async function probeRemoteJobs( - options: Parameters[0], + options: DaytonaArchiveHookOptions, workspaceMetadata: WorkspaceMetadata, sandboxId: string ): Promise { From abebb1715a9b1ee1130af993be6d3c80816c71e3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:41:19 +0000 Subject: [PATCH 005/231] fix(daytona): check dirty work before archive-delete, name the runtime in archive refusals, report sandbox states Archive with the delete behavior now runs the same dirty and unpushed checks as deleteWorkspace on a dedicated Daytona sandbox (starting a stopped one for them). When they fail, the sandbox is stopped instead and the archive result carries a warning the sidebar and menu bar show. Archive refusals name the Daytona sandbox instead of a Coder workspace, and getRuntimeStatuses reports Daytona sandbox states (starting, running, stopped, archived, error). --- .../ProjectSidebar/ProjectSidebar.tsx | 8 + .../WorkspaceMenuBar/WorkspaceMenuBar.tsx | 8 + .../Settings/Sections/GeneralSection.tsx | 7 +- .../hooks/useArchiveWorkspaceConfirmation.ts | 4 + src/browser/stores/RuntimeStatusStore.ts | 21 +- src/common/orpc/schemas/api.ts | 9 +- src/common/orpc/schemas/runtime.ts | 15 + src/node/runtime/DaytonaSSHRuntime.ts | 24 ++ src/node/runtime/SSHRuntime.ts | 266 ++++++++++-------- src/node/runtime/runtimeFactory.ts | 5 + .../daytona/daytonaLifecycleHooks.test.ts | 54 +++- .../services/daytona/daytonaLifecycleHooks.ts | 50 +++- src/node/services/serviceContainer.ts | 17 ++ src/node/services/workspaceLifecycleHooks.ts | 5 + src/node/services/workspaceService.ts | 62 +++- 15 files changed, 406 insertions(+), 149 deletions(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index 234bc84fb8c..698f94710ad 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -969,6 +969,8 @@ const ProjectSidebarInner: React.FC = ({ const [removingWorkspaceIds, setRemovingWorkspaceIds] = useState>(new Set()); const workspaceArchiveError = usePopoverError(); + // Kept until dismissed: it reports a kept remote box the user may want to clean up. + const workspaceArchiveWarning = usePopoverError(null); const workspaceForkError = usePopoverError(); const workspaceStopRuntimeError = usePopoverError(); const workspaceRemoveError = usePopoverError(); @@ -1147,6 +1149,7 @@ const ProjectSidebarInner: React.FC = ({ // left-sidebar row that may be far from their current focus. Use the shared toast fallback // position so archive errors match other top-right UI error surfaces. showError: (workspaceId, error) => workspaceArchiveError.showError(workspaceId, error), + showWarning: (workspaceId, warning) => workspaceArchiveWarning.showError(workspaceId, warning), }); const handleArchiveWorkspace = archiveFlow.requestArchive; @@ -3775,6 +3778,11 @@ const ProjectSidebarInner: React.FC = ({ prefix="Failed to archive workspace" onDismiss={workspaceArchiveError.clearError} /> + = ({ const [moreMenuOpen, setMoreMenuOpen] = useState(false); const titleEdit = useHeaderTitleEdit(workspaceId, moreActionsButtonRef); const archiveError = usePopoverError(); + const archiveWarning = usePopoverError(null); const forkError = usePopoverError(); const stopRuntimeError = usePopoverError(); @@ -531,6 +532,8 @@ export const WorkspaceMenuBar: React.FC = ({ rect ? { top: rect.top + window.scrollY, left: rect.right + 10 } : undefined ); }, + showWarning: (warningWorkspaceId, warning) => + archiveWarning.showError(warningWorkspaceId, warning), }); // The menu bar is reused across workspaces. Render-time adjustment (not an effect): a // confirmation for another workspace, including one opened by a preflight that resolved after @@ -1113,6 +1116,11 @@ export const WorkspaceMenuBar: React.FC = ({ prefix="Failed to archive workspace" onDismiss={archiveError.clearError} /> + ); }; diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 5ce360ef773..bc5c92c650d 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -1150,10 +1150,11 @@ export function GeneralSection() {
-
Coder workspace on archive
+
Remote box on archive
- Action to take on dedicated Coder workspaces when archiving a chat. Delete is - permanent. + Action to take on dedicated Coder workspaces and Daytona sandboxes when archiving a + chat. Delete is permanent; a Daytona sandbox with uncommitted or unpushed work is + stopped instead.
+ props.onConfigChange({ + ...props.config, + snapshot: value === DEFAULT_SNAPSHOT_VALUE ? undefined : value, + }) + } + disabled={props.disabled} + > + + + + + Daytona default + {options.map((name) => ( + + {name} + + ))} + + + )} +
+ {snapshotsError && ( +

+ Could not list snapshots: {snapshotsError} +

+ )} + + ); +} + +function SizeRow(props: { + config: DaytonaSandboxConfig; + onConfigChange: (config: DaytonaSandboxConfig) => void; + disabled?: boolean; +}) { + return ( +
+ + +
+ ); +} + +function ExistingSandboxRow(props: { + config: DaytonaSandboxConfig; + onConfigChange: (config: DaytonaSandboxConfig) => void; + resources: DaytonaResources; + disabled?: boolean; +}) { + const { sandboxes, sandboxesError, loadingSandboxes } = props.resources; + const placeholder = sandboxesError + ? "Error loading sandboxes" + : sandboxes.length === 0 + ? "No sandboxes found" + : "Select sandbox..."; + return ( + <> +
+ + {loadingSandboxes ? ( + + ) : ( + + )} +
+ {sandboxesError && ( +

+ Could not list sandboxes: {sandboxesError} +

+ )} + + ); +} + +export interface DaytonaSandboxFormProps { + config: DaytonaSandboxConfig; + onConfigChange: (config: DaytonaSandboxConfig) => void; + resources: DaytonaResources; + /** Creation only: attach to an existing sandbox. Project defaults always describe a new one. */ + allowExisting: boolean; + disabled?: boolean; + hasError?: boolean; + className?: string; +} + +export function DaytonaSandboxForm(props: DaytonaSandboxFormProps) { + const mode = props.allowExisting ? getMode(props.config) : "new"; + const ready = props.resources.config?.apiKeySet === true; + + const handleModeChange = (next: DaytonaMode) => { + if (next === mode) return; + // Existing starts unselected; New keeps nothing from the attached sandbox. + props.onConfigChange(next === "existing" ? { shared: true } : {}); + }; + + return ( +
+ {!ready ? ( + + ) : ( + <> + {props.allowExisting && ( +
+ + +
+ )} + {mode === "new" ? ( + <> + + + + ) : ( + + )} + + )} +
+ ); +} diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.stories.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.stories.tsx new file mode 100644 index 00000000000..8aaa0345420 --- /dev/null +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { lightweightMeta, PIXEL_DISABLED } from "@/browser/stories/meta.js"; +import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; +import type { DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; +import { DaytonaAccessForm, DaytonaDefaultsForm } from "./DaytonaSettingsForms"; +import { SettingsSectionStory } from "./settingsStoryUtils"; + +const PROJECT_PATH = "/Users/test/my-app"; + +const NO_KEY: DaytonaPublicConfig = { + apiKeySet: false, + apiKeySource: null, + apiUrl: "https://app.daytona.io/api", + target: null, +}; + +const PHONE_VIEWPORT = { + phone390: { + name: "Phone 390", + styles: { width: "390px", height: "844px" }, + type: "mobile", + }, +}; + +const meta: Meta = { + ...lightweightMeta, + title: "Settings/Sections/Daytona Settings", + parameters: { + ...lightweightMeta.parameters, + viewport: { options: PHONE_VIEWPORT }, + // Interaction contracts only: the Pixel snapshot budget has no headroom, and the phone + // stories assert their own no-overflow width instead of relying on a capture. + pixel: PIXEL_DISABLED, + }, +}; + +export default meta; +type Story = StoryObj; + +function renderForms(options: { + daytonaConfig: DaytonaPublicConfig; + testError?: string; + /** Phone stories force the width: the test-runner plays at desktop size. */ + width?: number; +}) { + return ( + + createMockORPCClient({ + daytonaConfig: options.daytonaConfig, + daytonaTestConnectionResult: options.testError + ? { ok: false, kind: "unauthorized", error: options.testError } + : { ok: true }, + daytonaSnapshotsResult: { ok: true, snapshots: [{ name: "daytonaio/sandbox:0.4.3" }] }, + }) + } + > +
+ + +
+
+ ); +} + +/** First setup: paste a key, save it, then test the connection. */ +export const SaveKeyAndTest: Story = { + globals: { viewport: { value: "desktop", isRotated: false } }, + render: () => renderForms({ daytonaConfig: NO_KEY }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("Not set"); + await userEvent.type(canvas.getByLabelText(/API key/), "dtn-story-key"); + await userEvent.click(canvas.getByRole("button", { name: "Save" })); + await canvas.findByText("Key set (settings)"); + await expect(canvas.getByLabelText(/API key/)).toHaveValue(""); + await userEvent.click(canvas.getByRole("button", { name: "Test connection" })); + await canvas.findByText(/Connected/); + }, +}; + +/** A key from the environment: no Remove button, and a failed test shows the server's reason. */ +export const EnvKeyTestFailsPhone: Story = { + globals: { viewport: { value: "phone390", isRotated: false } }, + render: () => + renderForms({ + daytonaConfig: { ...NO_KEY, apiKeySet: true, apiKeySource: "env" }, + testError: "Daytona rejected the API key (401: Invalid credentials)", + width: 390, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("Key set (env)"); + await expect(canvas.queryByRole("button", { name: "Remove key" })).toBeNull(); + await userEvent.click(canvas.getByRole("button", { name: "Test connection" })); + await canvas.findByText(/401: Invalid credentials/); + const form = canvas.getByTestId("daytona-access-form"); + await expect(form.scrollWidth).toBeLessThanOrEqual(form.clientWidth); + }, +}; diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx new file mode 100644 index 00000000000..14207bd7751 --- /dev/null +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx @@ -0,0 +1,223 @@ +import { useEffect, useState } from "react"; +import { CheckCircle2, Loader2, XCircle } from "lucide-react"; +import { Button } from "@/browser/components/Button/Button"; +import { Input } from "@/browser/components/Input/Input"; +import { useAPI } from "@/browser/contexts/API"; +import { DAYTONA_DEFAULT_API_URL } from "@/common/constants/daytona"; +import type { DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; +import { getErrorMessage } from "@/common/utils/errors"; +import { DaytonaSandboxForm } from "@/browser/features/Runtime/DaytonaControls"; +import { useDaytonaResources } from "@/browser/hooks/useDaytonaResources"; +import { + readDaytonaOptionDefaults, + updateRuntimeOptionDefaults, + useRuntimeOptionDefaults, + writeDaytonaOptionDefaults, +} from "@/browser/utils/runtimeOptionDefaults"; + +type TestState = + | { status: "idle" } + | { status: "testing" } + | { status: "ok" } + | { status: "error"; message: string }; + +function describeKey(config: DaytonaPublicConfig | null): string { + if (!config) return "Loading..."; + if (!config.apiKeySet) return "Not set"; + return config.apiKeySource === "env" ? "Key set (env)" : "Key set (settings)"; +} + +/** + * Daytona access for this Xum install (global scope). The key is write-only: the backend + * never returns it, so the field only ever holds a new key the user is typing. Stored in its + * own 0600 file, never in Secrets, which agent turns and terminals receive. + */ +export function DaytonaAccessForm() { + const { api } = useAPI(); + const [config, setConfig] = useState(null); + const [apiKeyDraft, setApiKeyDraft] = useState(""); + // null = untouched: the inputs show the saved values. + const [apiUrlDraft, setApiUrlDraft] = useState(null); + const [targetDraft, setTargetDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [test, setTest] = useState({ status: "idle" }); + + useEffect(() => { + if (!api) return; + let active = true; + api.daytona + .getConfig() + .then((next) => { + if (active) setConfig(next); + }) + .catch((loadError: unknown) => { + if (active) setError(`Could not load Daytona settings: ${getErrorMessage(loadError)}`); + }); + return () => { + active = false; + }; + }, [api]); + + const savedUrl = config?.apiUrl ?? DAYTONA_DEFAULT_API_URL; + const savedTarget = config?.target ?? ""; + const apiUrlValue = apiUrlDraft ?? savedUrl; + const targetValue = targetDraft ?? savedTarget; + const isDirty = + apiKeyDraft.trim() !== "" || + apiUrlValue.trim() !== savedUrl || + targetValue.trim() !== savedTarget; + + const save = async (input: { apiKey?: string; apiUrl?: string; target?: string }) => { + if (!api) return; + setSaving(true); + setError(null); + setTest({ status: "idle" }); + try { + setConfig(await api.daytona.setConfig(input)); + setApiKeyDraft(""); + setApiUrlDraft(null); + setTargetDraft(null); + } catch (saveError) { + setError(`Could not save Daytona settings: ${getErrorMessage(saveError)}`); + } finally { + setSaving(false); + } + }; + + const handleSave = () => { + void save({ + ...(apiKeyDraft.trim() ? { apiKey: apiKeyDraft.trim() } : {}), + apiUrl: apiUrlValue.trim(), + target: targetValue.trim(), + }); + }; + + const handleTest = async () => { + if (!api) return; + setTest({ status: "testing" }); + try { + const result = await api.daytona.testConnection(); + setTest(result.ok ? { status: "ok" } : { status: "error", message: result.error }); + } catch (testError) { + setTest({ status: "error", message: getErrorMessage(testError) }); + } + }; + + const disabled = !api || config === null || saving; + + return ( +
+
+ + setApiKeyDraft(event.target.value)} + placeholder={ + config?.apiKeySet ? "Enter a new key to replace it" : "Paste a Daytona API key" + } + disabled={disabled} + className="min-w-0" + /> +
+
+ + setApiUrlDraft(event.target.value)} + placeholder={DAYTONA_DEFAULT_API_URL} + disabled={disabled} + className="min-w-0" + /> +
+
+ + setTargetDraft(event.target.value)} + placeholder="Daytona default" + disabled={disabled} + className="min-w-0" + /> +
+
+ + + {config?.apiKeySource === "settings" && ( + + )} +
+ {test.status === "ok" && ( +

+ + Connected: the API key works. +

+ )} + {test.status === "error" && ( +

+ + {test.message} +

+ )} + {error && ( +

+ {error} +

+ )} +

+ Without a key here, Xum uses DAYTONA_API_KEY and DAYTONA_API_URL from its own environment. +

+
+ ); +} + +/** + * Project defaults for new Daytona workspaces (snapshot and size), saved with the project's + * other runtime option defaults so the creation controls start from them. + */ +export function DaytonaDefaultsForm(props: { projectPath: string; disabled?: boolean }) { + const resources = useDaytonaResources({ enabled: true, loadSandboxes: false }); + const defaults = readDaytonaOptionDefaults(useRuntimeOptionDefaults(props.projectPath)); + return ( + + updateRuntimeOptionDefaults(props.projectPath, (prev) => + writeDaytonaOptionDefaults(prev, next) + ) + } + resources={resources} + allowExisting={false} + disabled={props.disabled} + /> + ); +} diff --git a/src/browser/features/Settings/Sections/RuntimesSection.tsx b/src/browser/features/Settings/Sections/RuntimesSection.tsx index 1eeca2c6504..b8089589d31 100644 --- a/src/browser/features/Settings/Sections/RuntimesSection.tsx +++ b/src/browser/features/Settings/Sections/RuntimesSection.tsx @@ -10,6 +10,7 @@ import { resolveCoderAvailability, } from "@/browser/features/Runtime/CoderControls"; import { RuntimeConfigInput } from "@/browser/components/RuntimeConfigInput/RuntimeConfigInput"; +import { DaytonaAccessForm, DaytonaDefaultsForm } from "./DaytonaSettingsForms"; import { Select, SelectContent, @@ -75,6 +76,7 @@ const RUNTIME_ROWS: RuntimeRow[] = [ { id: "worktree", ...RUNTIME_CHOICE_UI.worktree }, { id: "ssh", ...RUNTIME_CHOICE_UI.ssh }, { id: "coder", ...RUNTIME_CHOICE_UI.coder }, + { id: "daytona", ...RUNTIME_CHOICE_UI.daytona }, { id: "docker", ...RUNTIME_CHOICE_UI.docker }, { id: "devcontainer", ...RUNTIME_CHOICE_UI.devcontainer }, ]; @@ -771,6 +773,16 @@ export function RuntimesSection() { disabled={rowDisabled || coderAvailability.state !== "available"} /> ) : null} + {runtime.id === "daytona" && !selectedProjectPath ? : null} + {runtime.id === "daytona" && + selectedProjectPath && + effectiveEnablement.daytona ? ( + + ) : null}
diff --git a/src/browser/hooks/useDaytonaResources.ts b/src/browser/hooks/useDaytonaResources.ts new file mode 100644 index 00000000000..c86d173a4de --- /dev/null +++ b/src/browser/hooks/useDaytonaResources.ts @@ -0,0 +1,103 @@ +/** + * Daytona data for the creation controls and Settings: the public credential view (never the + * key), snapshots for the snapshot select, and sandboxes for the existing-sandbox picker. + * The runtime config itself is owned by the caller (selectedRuntime.daytona or project defaults). + */ +import { useEffect, useState } from "react"; +import { useAPI } from "@/browser/contexts/API"; +import type { DaytonaPublicConfig, DaytonaSandboxSummary } from "@/common/orpc/schemas/daytona"; +import { getErrorMessage } from "@/common/utils/errors"; + +export interface DaytonaResources { + /** Null while loading or when the API is not connected. */ + config: DaytonaPublicConfig | null; + /** Set when the Daytona settings could not be read at all. */ + configError: string | null; + snapshots: string[]; + snapshotsError: string | null; + loadingSnapshots: boolean; + sandboxes: DaytonaSandboxSummary[]; + sandboxesError: string | null; + loadingSandboxes: boolean; +} + +export const EMPTY_DAYTONA_RESOURCES: DaytonaResources = { + config: null, + configError: null, + snapshots: [], + snapshotsError: null, + loadingSnapshots: false, + sandboxes: [], + sandboxesError: null, + loadingSandboxes: false, +}; + +interface UseDaytonaResourcesOptions { + /** Fetch only while a Daytona form is visible: each list is a billed-account API call. */ + enabled: boolean; + /** The existing-sandbox picker is creation-only; Settings defaults never need the list. */ + loadSandboxes: boolean; +} + +export function useDaytonaResources(options: UseDaytonaResourcesOptions): DaytonaResources { + const { api } = useAPI(); + // One state object so consumers get a stable identity until something actually loads. + const [resources, setResources] = useState(EMPTY_DAYTONA_RESOURCES); + const { enabled, loadSandboxes } = options; + + useEffect(() => { + if (!api || !enabled) return; + let active = true; + const update = (patch: Partial) => { + if (active) setResources((prev) => ({ ...prev, ...patch })); + }; + + const load = async () => { + const config = await api.daytona.getConfig(); + update({ config, configError: null }); + if (!config.apiKeySet) return; + update({ loadingSnapshots: true, loadingSandboxes: loadSandboxes }); + await Promise.all([ + api.daytona.listSnapshots().then((result) => + update( + result.ok + ? { + snapshots: result.snapshots.map((snapshot) => snapshot.name), + snapshotsError: null, + loadingSnapshots: false, + } + : { snapshots: [], snapshotsError: result.error, loadingSnapshots: false } + ) + ), + loadSandboxes + ? api.daytona + .listSandboxes() + .then((result) => + update( + result.ok + ? { sandboxes: result.sandboxes, sandboxesError: null, loadingSandboxes: false } + : { sandboxes: [], sandboxesError: result.error, loadingSandboxes: false } + ) + ) + : Promise.resolve(), + ]); + }; + + load().catch((error: unknown) => { + const message = getErrorMessage(error); + update({ + configError: message, + snapshotsError: message, + sandboxesError: message, + loadingSnapshots: false, + loadingSandboxes: false, + }); + }); + + return () => { + active = false; + }; + }, [api, enabled, loadSandboxes]); + + return resources; +} diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index f92182b7ed7..5c38276135c 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -10,9 +10,12 @@ import { buildRuntimeString, RUNTIME_MODE, CODER_RUNTIME_PLACEHOLDER, + DAYTONA_RUNTIME_PLACEHOLDER, } from "@/common/types/runtime"; +import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; import type { RuntimeChoice } from "@/browser/utils/runtimeUi"; import { + readDaytonaOptionDefaults, readOptionField, readSshOptionDefaults, type RuntimeOptionDefaults, @@ -50,6 +53,7 @@ export interface DraftWorkspaceSettings { interface SshRuntimeConfig { host: string; coder?: CoderWorkspaceConfig; + daytona?: DaytonaSandboxConfig; } interface SshRuntimeState { @@ -89,6 +93,9 @@ const buildRuntimeForMode = ( const effectiveHost = sshConfig.coder && !sshConfig.host.trim() ? CODER_RUNTIME_PLACEHOLDER : sshConfig.host; + if (sshConfig.daytona) { + return { mode: "ssh", host: DAYTONA_RUNTIME_PLACEHOLDER, daytona: sshConfig.daytona }; + } return { mode: "ssh", host: effectiveHost, @@ -126,8 +133,12 @@ const mergeRememberedRuntimeConfig = ( !nextRuntime.host.trim() && remembered.ssh.host.trim() ? remembered.ssh.host : nextRuntime.host, + // A Daytona selection must never pick up a remembered Coder config: one managed + // backend per workspace (the runtime schema rejects both). coder: - nextRuntime.coder === undefined && remembered.ssh.coder != null + nextRuntime.coder === undefined && + nextRuntime.daytona == null && + remembered.ssh.coder != null ? remembered.ssh.coder : nextRuntime.coder, }; @@ -163,6 +174,7 @@ const mergeRememberedRuntimeConfig = ( const normalizeRuntimeChoice = (value: unknown): RuntimeChoice | null => { if ( value === "coder" || + value === "daytona" || value === RUNTIME_MODE.LOCAL || value === RUNTIME_MODE.WORKTREE || value === RUNTIME_MODE.SSH || @@ -179,6 +191,8 @@ const buildRuntimeFromChoice = (choice: RuntimeChoice): ParsedRuntime => { switch (choice) { case "coder": return { mode: RUNTIME_MODE.SSH, host: CODER_RUNTIME_PLACEHOLDER }; + case "daytona": + return { mode: RUNTIME_MODE.SSH, host: DAYTONA_RUNTIME_PLACEHOLDER }; case RUNTIME_MODE.LOCAL: return { mode: RUNTIME_MODE.LOCAL }; case RUNTIME_MODE.WORKTREE: @@ -222,6 +236,8 @@ export function useDraftWorkspaceSettings( coderConfigFallback: CoderWorkspaceConfig; /** Preserves the last SSH host when leaving Coder so the input stays populated. */ sshHostFallback: string; + /** Project Daytona defaults (Settings > Runtimes) that start a Daytona selection. */ + daytonaConfigFallback: DaytonaSandboxConfig; /** Set the currently selected runtime (discriminated union) */ setSelectedRuntime: (runtime: ParsedRuntime) => void; setTrunkBranch: (branch: string) => void; @@ -350,9 +366,11 @@ export function useDraftWorkspaceSettings( const coderDefaultFromString = parsedDefault?.mode === RUNTIME_MODE.SSH && parsedDefault.host === CODER_RUNTIME_PLACEHOLDER; + const daytonaDefault = settingsDefaultRuntime === "daytona"; // Defaults must stay explicit and sticky; last-used SSH state should only seed inputs. - const defaultRuntimeChoice: RuntimeChoice = - defaultRuntimeMode === RUNTIME_MODE.SSH && coderDefaultFromString + const defaultRuntimeChoice: RuntimeChoice = daytonaDefault + ? "daytona" + : defaultRuntimeMode === RUNTIME_MODE.SSH && coderDefaultFromString ? "coder" : defaultRuntimeMode; @@ -415,9 +433,15 @@ export function useDraftWorkspaceSettings( // `setSelectedRuntime` changed on every keystroke and re-rendered CreationControls (#5937). // useDraftWorkspaceSettings.compiler.test.tsx guards their identity. // Uses discriminated union: SSH has host, Docker has image. + // Parsed into a fresh object, so it too must follow the last hook to keep its identity. + const daytonaConfigFallback = readDaytonaOptionDefaults(lastRuntimeConfigs); const defaultRuntime = buildRuntimeForMode( defaultRuntimeMode, - { host: defaultSshHost, coder: defaultSshCoder }, + { + host: defaultSshHost, + coder: defaultSshCoder, + daytona: daytonaDefault ? daytonaConfigFallback : undefined, + }, defaultDockerImage, lastShareCredentials, defaultDevcontainerConfigPath, @@ -491,6 +515,7 @@ export function useDraftWorkspaceSettings( }, coderConfigFallback, sshHostFallback, + daytonaConfigFallback, setSelectedRuntime, setTrunkBranch, getRuntimeString, diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index cfc3a340c34..66313aa0c29 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -94,6 +94,12 @@ import type { CoderTemplate, CoderWorkspace, } from "@/common/orpc/schemas/coder"; +import type { + DaytonaListSandboxesResult, + DaytonaListSnapshotsResult, + DaytonaPublicConfig, + DaytonaTestConnectionResult, +} from "@/common/orpc/schemas/daytona"; import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; import type { WorktreeArchiveBehavior } from "@/common/config/worktreeArchiveBehavior"; import { @@ -421,6 +427,11 @@ export interface MockORPCClientOptions { coderPresetsResult?: Map; /** Override Coder workspace list result (including error states) */ coderWorkspacesResult?: CoderListWorkspacesResult; + /** Daytona access as Settings > Runtimes sees it (default: no key). */ + daytonaConfig?: DaytonaPublicConfig; + daytonaTestConnectionResult?: DaytonaTestConnectionResult; + daytonaSnapshotsResult?: DaytonaListSnapshotsResult; + daytonaSandboxesResult?: DaytonaListSandboxesResult; /** Available agent skills (descriptors) */ agentSkills?: AgentSkillDescriptor[]; /** Agent skills that were discovered but couldn't be loaded (SKILL.md parse errors, etc.) */ @@ -586,6 +597,15 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl coderTemplatesResult, coderPresetsResult = new Map(), coderWorkspacesResult, + daytonaConfig: initialDaytonaConfig = { + apiKeySet: false, + apiKeySource: null, + apiUrl: "https://app.daytona.io/api", + target: null, + }, + daytonaTestConnectionResult = { ok: true }, + daytonaSnapshotsResult = { ok: true, snapshots: [] }, + daytonaSandboxesResult = { ok: true, sandboxes: [] }, layoutPresets: initialLayoutPresets, agentSkills = [], invalidAgentSkills = [], @@ -732,11 +752,13 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl let toolSearchEnabled = true; let bashAiProxyEnabled = false; let agentHeartbeatsEnabled = false; + let daytonaConfig = initialDaytonaConfig; let runtimeEnablement: Record = initialRuntimeEnablement ?? { local: true, worktree: true, ssh: true, coder: true, + daytona: true, docker: true, devcontainer: true, }; @@ -2431,6 +2453,23 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl listWorkspaces: () => Promise.resolve(coderWorkspacesResult ?? { ok: true, workspaces: coderWorkspaces }), }, + daytona: { + getConfig: () => Promise.resolve(daytonaConfig), + setConfig: (input: { apiKey?: string; apiUrl?: string; target?: string }) => { + const keyCleared = input.apiKey === ""; + const keySet = input.apiKey !== undefined && !keyCleared; + daytonaConfig = { + apiKeySet: keySet || (!keyCleared && daytonaConfig.apiKeySet), + apiKeySource: keySet ? "settings" : keyCleared ? null : daytonaConfig.apiKeySource, + apiUrl: input.apiUrl?.trim() ? input.apiUrl.trim() : daytonaConfig.apiUrl, + target: input.target === undefined ? daytonaConfig.target : input.target.trim() || null, + }; + return Promise.resolve(daytonaConfig); + }, + testConnection: () => Promise.resolve(daytonaTestConnectionResult), + listSnapshots: () => Promise.resolve(daytonaSnapshotsResult), + listSandboxes: () => Promise.resolve(daytonaSandboxesResult), + }, nameGeneration: { generate: () => { if (nameGenerationResult) { diff --git a/src/browser/utils/runtimeOptionDefaults.ts b/src/browser/utils/runtimeOptionDefaults.ts index 3a3fe451d16..345c0ff340a 100644 --- a/src/browser/utils/runtimeOptionDefaults.ts +++ b/src/browser/utils/runtimeOptionDefaults.ts @@ -5,7 +5,11 @@ import { } from "@/browser/stores/AppConfigStore"; import type { UserPreferences } from "@/common/config/schemas/userPreferences"; import type { CoderWorkspaceConfig } from "@/common/orpc/schemas/coder"; -import { CODER_RUNTIME_PLACEHOLDER, RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime"; +import { + DaytonaSandboxConfigSchema, + type DaytonaSandboxConfig, +} from "@/common/orpc/schemas/daytona"; +import { isManagedSshPlaceholder, RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime"; export type RuntimeOptionDefaults = Partial>; @@ -142,7 +146,7 @@ export function writeSshOptionDefaults( ): RuntimeOptionDefaults { let updated = writeOptionValue(prev, RUNTIME_MODE.SSH, "coderEnabled", next.coderEnabled); - if (next.host.trim().length > 0 && next.host !== CODER_RUNTIME_PLACEHOLDER) { + if (next.host.trim().length > 0 && !isManagedSshPlaceholder(next.host)) { updated = writeOptionField(updated, RUNTIME_MODE.SSH, "host", next.host); } @@ -169,3 +173,30 @@ export function writeSshCoderDefaultsPreservingMode( coderConfig: nextCoderConfig, }); } + +/** + * Project Daytona defaults (snapshot and size preset), set in Settings > Runtimes and used to + * prefill new Daytona workspaces. Only the creation-time fields: an id or shared flag would + * attach every new workspace to one sandbox. + */ +export function readDaytonaOptionDefaults(defaults: RuntimeOptionDefaults): DaytonaSandboxConfig { + const parsed = DaytonaSandboxConfigSchema.safeParse( + readOptionValue(defaults, RUNTIME_MODE.SSH, "daytona") + ); + if (!parsed.success) return {}; + const { snapshot, cpu, memoryGiB, diskGiB } = parsed.data; + return { snapshot, cpu, memoryGiB, diskGiB }; +} + +export function writeDaytonaOptionDefaults( + prev: RuntimeOptionDefaults, + next: DaytonaSandboxConfig +): RuntimeOptionDefaults { + const { snapshot, cpu, memoryGiB, diskGiB } = next; + return writeOptionValue(prev, RUNTIME_MODE.SSH, "daytona", { + snapshot, + cpu, + memoryGiB, + diskGiB, + }); +} diff --git a/src/browser/utils/runtimeUi.ts b/src/browser/utils/runtimeUi.ts index cbcdff95446..0572f87976c 100644 --- a/src/browser/utils/runtimeUi.ts +++ b/src/browser/utils/runtimeUi.ts @@ -8,6 +8,7 @@ import { DockerIcon, DevcontainerIcon, CoderIcon, + DaytonaIcon, } from "@/browser/components/icons/RuntimeIcons/RuntimeIcons"; export interface RuntimeIconProps { @@ -79,7 +80,7 @@ export interface RuntimeUiSpec { }; } -export type RuntimeChoice = RuntimeMode | "coder"; +export type RuntimeChoice = RuntimeMode | "coder" | "daytona"; export const RUNTIME_UI = { local: { @@ -211,9 +212,19 @@ const CODER_RUNTIME_UI: RuntimeUiSpec = { Icon: CoderIcon, }; +const DAYTONA_RUNTIME_UI: RuntimeUiSpec = { + ...RUNTIME_UI.ssh, + label: "Daytona", + description: "Managed cloud sandbox from Daytona", + options: "Snapshot and box size", + docsPath: "/runtime/daytona", + Icon: DaytonaIcon, +}; + export const RUNTIME_CHOICE_UI = { ...RUNTIME_UI, coder: CODER_RUNTIME_UI, + daytona: DAYTONA_RUNTIME_UI, } satisfies Record; export const RUNTIME_BADGE_UI = { @@ -225,6 +236,10 @@ export const RUNTIME_BADGE_UI = { Icon: CODER_RUNTIME_UI.Icon, badge: CODER_RUNTIME_UI.badge, }, + daytona: { + Icon: DAYTONA_RUNTIME_UI.Icon, + badge: DAYTONA_RUNTIME_UI.badge, + }, worktree: { Icon: RUNTIME_UI.worktree.Icon, badge: RUNTIME_UI.worktree.badge, @@ -243,11 +258,16 @@ export const RUNTIME_BADGE_UI = { }, } satisfies Record>; -export interface DevcontainerStatusChip { +export interface RuntimeStatusChip { label: string; className: string; } +// Theme success tokens: raw emerald text was below WCAG AA in the light themes (#6882). +const RUNNING_CHIP_CLASS = "bg-success/10 text-success-text"; +// The /50 muted tint left even foreground text at 4.17:1 in flexoki-dark (#6544). +const STOPPED_CHIP_CLASS = "bg-muted/15 text-foreground"; + /** * Returns the titlebar chip presentation for a devcontainer runtime status, * or null when the status is indeterminate and no chip should be shown. @@ -256,21 +276,40 @@ export interface DevcontainerStatusChip { */ export function getDevcontainerStatusChip( status: RuntimeStatus | null -): DevcontainerStatusChip | null { +): RuntimeStatusChip | null { switch (status) { case "running": return { label: "Devcontainer Running", - // Theme success tokens: raw emerald text was below WCAG AA in the light themes (#6882). - className: "bg-success/10 text-success-text", + className: RUNNING_CHIP_CLASS, }; case "stopped": return { label: "Devcontainer Stopped", - // The /50 muted tint left even foreground text at 4.17:1 in flexoki-dark (#6544). - className: "bg-muted/15 text-foreground", + className: STOPPED_CHIP_CLASS, }; default: return null; } } + +/** + * Titlebar chip for a Daytona sandbox status, or null while unknown. Daytona auto-stops idle + * sandboxes, so stopped and archived are normal states (the next turn starts the sandbox). + */ +export function getDaytonaStatusChip(status: RuntimeStatus | null): RuntimeStatusChip | null { + switch (status) { + case "running": + return { label: "Sandbox Running", className: RUNNING_CHIP_CLASS }; + case "starting": + return { label: "Sandbox Starting", className: "bg-warning/10 text-warning" }; + case "stopped": + return { label: "Sandbox Stopped", className: STOPPED_CHIP_CLASS }; + case "archived": + return { label: "Sandbox Archived", className: STOPPED_CHIP_CLASS }; + case "error": + return { label: "Sandbox Error", className: "bg-destructive/10 text-destructive" }; + default: + return null; + } +} diff --git a/src/common/orpc/schemas/drafts.ts b/src/common/orpc/schemas/drafts.ts index 74e3fc03166..91e0371a730 100644 --- a/src/common/orpc/schemas/drafts.ts +++ b/src/common/orpc/schemas/drafts.ts @@ -4,6 +4,7 @@ import { ServiceTierSchema } from "@/common/config/schemas/providersConfig"; import { OpenAIReasoningModeSchema, ThinkingLevelSchema } from "@/common/types/thinking"; import { AgentDefinitionScopeSchema } from "./agentDefinition"; import { CoderWorkspaceConfigSchema } from "./coder"; +import { DaytonaSandboxConfigSchema } from "./daytona"; import { SendIdSchema, SendMessageOptionsSchema } from "./stream"; /** @@ -257,6 +258,7 @@ const ParsedRuntimeSchema = z.discriminatedUnion("mode", [ mode: z.literal("ssh"), host: z.string(), coder: CoderWorkspaceConfigSchema.optional(), + daytona: DaytonaSandboxConfigSchema.optional(), }), z.object({ mode: z.literal("docker"), diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts index 1bf09f4df10..68d417ce5d6 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -6,6 +6,7 @@ import type { z } from "zod"; import type { RuntimeConfigSchema } from "../orpc/schemas"; import { RuntimeEnablementIdSchema, RuntimeModeSchema } from "../orpc/schemas/runtime"; import type { CoderWorkspaceConfig } from "../orpc/schemas/coder"; +import type { DaytonaSandboxConfig } from "../orpc/schemas/daytona"; // Re-export CoderWorkspaceConfig type from schema (single source of truth) export type { CoderWorkspaceConfig }; @@ -111,6 +112,14 @@ export const DEVCONTAINER_RUNTIME_PREFIX = "devcontainer "; /** Placeholder host for Coder SSH runtimes (where host is derived from Coder config) */ export const CODER_RUNTIME_PLACEHOLDER = "coder://"; +/** Placeholder host for Daytona SSH runtimes (finalizeConfig derives the real alias) */ +export const DAYTONA_RUNTIME_PLACEHOLDER = "daytona://"; + +/** Hosts the creation UI fills in for managed SSH backends; never a user's SSH host. */ +export function isManagedSshPlaceholder(host: string): boolean { + return host === CODER_RUNTIME_PLACEHOLDER || host === DAYTONA_RUNTIME_PLACEHOLDER; +} + export type RuntimeConfig = z.infer; /** @@ -120,7 +129,7 @@ export type RuntimeConfig = z.infer; export type ParsedRuntime = | { mode: "local" } | { mode: "worktree" } - | { mode: "ssh"; host: string; coder?: CoderWorkspaceConfig } + | { mode: "ssh"; host: string; coder?: CoderWorkspaceConfig; daytona?: DaytonaSandboxConfig } | { mode: "docker"; image: string; shareCredentials?: boolean } | { mode: "devcontainer"; configPath: string; shareCredentials?: boolean }; @@ -235,6 +244,7 @@ export function buildRuntimeConfig(parsed: ParsedRuntime): RuntimeConfig | undef host: parsed.host.trim(), srcBaseDir: "~/mux", // Default remote base directory (tilde resolved by backend) coder: parsed.coder, + daytona: parsed.daytona, }; case RUNTIME_MODE.DOCKER: return { From 3be8da4c6cf2eb2c94a44a9f4bab376ad1f42410 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:09:42 +0000 Subject: [PATCH 007/231] fix(daytona): never stop or delete the parent's sandbox when archiving a shared-checkout child Side chats and isolation:none sub-agents copy the parent's runtime config, sandbox ID included, without the shared flag, so archiving one stopped or deleted the parent's sandbox. --- .../daytona/daytonaLifecycleHooks.test.ts | 21 +++++++++++++++++++ .../services/daytona/daytonaLifecycleHooks.ts | 3 +++ 2 files changed, 24 insertions(+) diff --git a/src/node/services/daytona/daytonaLifecycleHooks.test.ts b/src/node/services/daytona/daytonaLifecycleHooks.test.ts index 8ab828a3f5e..837232381dd 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.test.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.test.ts @@ -77,6 +77,27 @@ describe("createDaytonaArchiveHook", () => { expect(rig.plane.find("sb-users")?.state).toBe("started"); }); + it("leaves the parent's sandbox alone when a shared-checkout sub-agent is archived", async () => { + rig.plane.addSandbox({ id: "sb-parent" }); + const hook = createDaytonaArchiveHook({ + daytonaService: rig.service, + getArchiveBehavior: () => "delete", + checkSandboxDeletable: cleanCheck, + }); + + // An isolation:none child copies the parent's runtime config, sandbox ID included. + const result = await hook({ + workspaceId: "child", + workspaceMetadata: { + ...metadata({ sandboxId: "sb-parent", shared: false }), + taskIsolation: "none", + }, + }); + + expect(result.success).toBe(true); + expect(rig.plane.find("sb-parent")?.state).toBe("started"); + }); + it("keeps a dirty sandbox on archive delete: starts it for the check, then stops it and warns", async () => { rig.plane.addSandbox({ id: "sb-dirty", state: "stopped" }); const sshCheck = spyOn( diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index b3ac8a79d0c..36968a59784 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -50,6 +50,9 @@ export function createDaytonaArchiveHook(options: DaytonaArchiveHookOptions): Be /** Only sandboxes Xum created for this workspace; attached and shared ones are never touched. */ function dedicatedSandboxId(workspaceMetadata: WorkspaceMetadata): string | undefined { + // A shared-checkout sub-agent copies its parent's runtime config, sandbox ID included, + // without marking it shared: the sandbox belongs to the parent. + if (workspaceMetadata.taskIsolation === "none") return undefined; const runtimeConfig = workspaceMetadata.runtimeConfig; if (!isDedicatedDaytonaSandbox(runtimeConfig) || !isSSHRuntime(runtimeConfig)) return undefined; return runtimeConfig.daytona?.sandboxId; From a5da99bc590731d0f6fe8a139aff521cd8814c65 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:11:25 +0000 Subject: [PATCH 008/231] docs(daytona): add the Daytona runtime page and fix the remote user-hook path The hooks page said remote user-level hooks live in ~/.xum; SSH remotes read ~/.mux and Docker reads /var/mux (RemoteRuntime/DockerRuntime getXumHome). --- docs/docs.json | 1 + docs/hooks/tools.mdx | 2 +- docs/runtime/daytona.mdx | 79 +++++++++++++++++ docs/runtime/index.mdx | 1 + .../builtInSkillContent.generated.ts | 87 ++++++++++++++++++- 5 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 docs/runtime/daytona.mdx diff --git a/docs/docs.json b/docs/docs.json index 5f07aff5cc7..fcf0bc13df5 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -68,6 +68,7 @@ "runtime/worktree", "runtime/ssh", "runtime/coder", + "runtime/daytona", "runtime/docker", "runtime/devcontainer" ] diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index 7601274c295..26a08271285 100644 --- a/docs/hooks/tools.mdx +++ b/docs/hooks/tools.mdx @@ -182,7 +182,7 @@ Xum searches for each hook file in this order: This applies to `tool_pre`, `tool_post`, and `tool_env`. -For SSH workspaces, hooks execute **on the remote machine**. +For SSH workspaces (including Coder and Daytona), hooks execute **on the remote machine**, and the user-level location there is `~/.mux/`, not `~/.xum/`. Docker workspaces read user-level hooks from `/var/mux/` inside the container. Xum copies your local user-level hooks into sandboxes it creates for [Daytona](/runtime/daytona) workspaces. diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx new file mode 100644 index 00000000000..bd608dfa08f --- /dev/null +++ b/docs/runtime/daytona.mdx @@ -0,0 +1,79 @@ +--- +title: Daytona Runtime +description: Run agents in Daytona Cloud sandboxes +--- + +Xum can run workspaces in [Daytona](https://www.daytona.io/docs) sandboxes. Each new Daytona workspace gets its own sandbox, and Xum connects to it over SSH through Daytona's SSH gateway. Builds, tests, and other agent commands use the sandbox's CPU and memory, not your machine's. Under the hood a Daytona workspace is an [SSH workspace](/runtime/ssh), so `XUM_RUNTIME` is `ssh`. + +## Requirements + +- A Daytona account and an API key from the Daytona dashboard +- `ssh` installed on the machine running Xum +- A snapshot (or Daytona's default) with git and your toolchain + +## Set up access + +1. Open **Settings > Runtimes** and find the **Daytona** row. +2. Paste your API key and click **Save**. Change **API URL** only for a self-hosted (BYOC) Daytona install. **Target** picks a region; leave it empty for Daytona's default. +3. Click **Test connection**. + +Xum stores the key in `~/.xum/daytona.json` (under `XUM_ROOT` when it is set) with file mode `0600`. The settings page never shows a saved key again: to change it, enter a new one, or click **Remove key**. + +Without a saved key, Xum uses `DAYTONA_API_KEY` and `DAYTONA_API_URL` from its own environment. A saved key wins over the environment. + + + Do not put your Daytona API key in **Secrets**. Xum passes secrets to agent turns and terminals, + so an agent could read the key and create or delete sandboxes in your organization. + + +## Project defaults + +In **Settings > Runtimes**, change the scope from **All** to a project and turn on **Override project settings** to set the project's default **Snapshot** and **Size** for new Daytona workspaces. The new-workspace controls start from these values. + +| Size | vCPU | Memory | Disk | +| ------ | ---- | ------ | ------ | +| Small | 2 | 4 GiB | 10 GiB | +| Medium | 4 | 8 GiB | 20 GiB | +| Large | 8 | 16 GiB | 40 GiB | + +Small is the default. Xum offers only these sizes, which keeps costs predictable. + +## Create a workspace + +1. In the new-workspace controls, choose **Daytona** as the runtime. +2. Select **New**, then pick a snapshot and a size. +3. Xum creates the sandbox, waits until it starts, copies your user-level [tool hooks](/hooks/tools) and skills into it, clones the project, and runs the [init hook](/hooks/init). + +Xum labels each sandbox it creates with `xum.instance`, `xum.workspaceId`, and `xum.project`. If Daytona rejects the size for a snapshot, Xum creates the sandbox at the snapshot's size and then resizes it. + +## Use an existing sandbox + +Select **Existing** and pick a sandbox from your organization. Xum treats an attached sandbox as yours: it never copies files into its home directory, stops it, or deletes it. It does start the sandbox when a workspace needs it. Several Xum workspaces can share one sandbox; each gets its own directory. + +## Lifecycle + +| Xum event | Daytona action | +| ----------------------------------- | --------------------------------------------------------------------------------------------- | +| A turn starts | Start the sandbox if it is stopped or archived (waits up to 3 minutes) | +| An agent is streaming or a job runs | Report activity every minute, so Daytona does not stop the sandbox | +| Idle | Daytona stops the sandbox after 30 minutes and archives it after 1 day stopped | +| Sub-agent with a forked workspace | A new worktree in the same sandbox, not a second sandbox | +| Archive | Follows **Settings > General > Remote box on archive** (default: stop) | +| Delete | Check for uncommitted or unpushed work, then delete the sandbox (attached sandboxes are kept) | +| Sandbox deleted outside Xum | The workspace reports that the sandbox no longer exists; Xum does not recreate it | + +With **Remote box on archive** set to delete, Xum first checks the workspace for uncommitted or unpushed work. If it finds some, or cannot run the check, it stops the sandbox instead and shows a warning. + +Once a minute, Xum also stops running sandboxes that carry this install's label but that no workspace uses. It never deletes them. + +## SSH setup + +Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. + +OpenSSH uses the first `User` value that matches a host. If your `~/.ssh/config` has a `Host *` entry with a `User` line above Xum's block, connections to sandboxes fail. Xum checks for this and reports it; move the `User` line into a narrower `Host` entry, or below Xum's block. + +## Notes + +- **Costs:** Daytona bills running sandboxes by size and time. Archive or delete workspaces you no longer need, and pick the smallest size that works. +- **CPU count:** `nproc` inside a sandbox reports the host's CPUs, not the sandbox's, so builds may start too many parallel jobs. Set job counts (for example `MAKEFLAGS=-j4`) in your snapshot or in the project's [`.xum/tool_env`](/hooks/tools). +- User-level hooks and skills in a sandbox live under `~/.mux`, the same as on other SSH hosts. diff --git a/docs/runtime/index.mdx b/docs/runtime/index.mdx index 27522402b1e..89d106d50b9 100644 --- a/docs/runtime/index.mdx +++ b/docs/runtime/index.mdx @@ -10,6 +10,7 @@ Runtimes determine where and how Xum executes agent workspaces. | **[Local](/runtime/local)** | All workspaces share the project directory | Quick edits to your working copy | | **[Worktree](/runtime/worktree)** | Each workspace gets its own directory | Running multiple agents in parallel | | **[SSH](/runtime/ssh)** | Remote execution over SSH | Security, performance, heavy parallelism | +| **[Daytona](/runtime/daytona)** | Cloud sandbox per workspace, over SSH | Remote compute without managing hosts | | **[Docker](/runtime/docker)** | Isolated container per workspace | Full isolation, reproducible environments | | **[Dev Container](/runtime/devcontainer)** | Container from devcontainer.json | Projects with existing devcontainer configs | diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 69677620598..b071477e427 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -6311,6 +6311,7 @@ export const BUILTIN_SKILL_FILES: Record> = { ' "runtime/worktree",', ' "runtime/ssh",', ' "runtime/coder",', + ' "runtime/daytona",', ' "runtime/docker",', ' "runtime/devcontainer"', " ]", @@ -7329,7 +7330,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "This applies to `tool_pre`, `tool_post`, and `tool_env`.", "", - "For SSH workspaces, hooks execute **on the remote machine**.", + "For SSH workspaces (including Coder and Daytona), hooks execute **on the remote machine**, and the user-level location there is `~/.mux/`, not `~/.xum/`. Docker workspaces read user-level hooks from `/var/mux/` inside the container. Xum copies your local user-level hooks into sandboxes it creates for [Daytona](/runtime/daytona) workspaces.", "", "", "", @@ -9998,6 +9999,88 @@ export const BUILTIN_SKILL_FILES: Record> = { "- By default, archiving a **New** (Xum-created) Coder-backed workspace will also stop the underlying Coder workspace, and unarchiving will attempt to start it again. This does **not** apply when using an **Existing** Coder workspace. To disable this behavior, set `stopCoderWorkspaceOnArchive` to `false` in `~/.xum/config.json`.", "", ].join("\n"), + "references/docs/runtime/daytona.mdx": [ + "---", + "title: Daytona Runtime", + "description: Run agents in Daytona Cloud sandboxes", + "---", + "", + "Xum can run workspaces in [Daytona](https://www.daytona.io/docs) sandboxes. Each new Daytona workspace gets its own sandbox, and Xum connects to it over SSH through Daytona's SSH gateway. Builds, tests, and other agent commands use the sandbox's CPU and memory, not your machine's. Under the hood a Daytona workspace is an [SSH workspace](/runtime/ssh), so `XUM_RUNTIME` is `ssh`.", + "", + "## Requirements", + "", + "- A Daytona account and an API key from the Daytona dashboard", + "- `ssh` installed on the machine running Xum", + "- A snapshot (or Daytona's default) with git and your toolchain", + "", + "## Set up access", + "", + "1. Open **Settings > Runtimes** and find the **Daytona** row.", + "2. Paste your API key and click **Save**. Change **API URL** only for a self-hosted (BYOC) Daytona install. **Target** picks a region; leave it empty for Daytona's default.", + "3. Click **Test connection**.", + "", + "Xum stores the key in `~/.xum/daytona.json` (under `XUM_ROOT` when it is set) with file mode `0600`. The settings page never shows a saved key again: to change it, enter a new one, or click **Remove key**.", + "", + "Without a saved key, Xum uses `DAYTONA_API_KEY` and `DAYTONA_API_URL` from its own environment. A saved key wins over the environment.", + "", + "", + " Do not put your Daytona API key in **Secrets**. Xum passes secrets to agent turns and terminals,", + " so an agent could read the key and create or delete sandboxes in your organization.", + "", + "", + "## Project defaults", + "", + "In **Settings > Runtimes**, change the scope from **All** to a project and turn on **Override project settings** to set the project's default **Snapshot** and **Size** for new Daytona workspaces. The new-workspace controls start from these values.", + "", + "| Size | vCPU | Memory | Disk |", + "| ------ | ---- | ------ | ------ |", + "| Small | 2 | 4 GiB | 10 GiB |", + "| Medium | 4 | 8 GiB | 20 GiB |", + "| Large | 8 | 16 GiB | 40 GiB |", + "", + "Small is the default. Xum offers only these sizes, which keeps costs predictable.", + "", + "## Create a workspace", + "", + "1. In the new-workspace controls, choose **Daytona** as the runtime.", + "2. Select **New**, then pick a snapshot and a size.", + "3. Xum creates the sandbox, waits until it starts, copies your user-level [tool hooks](/hooks/tools) and skills into it, clones the project, and runs the [init hook](/hooks/init).", + "", + "Xum labels each sandbox it creates with `xum.instance`, `xum.workspaceId`, and `xum.project`. If Daytona rejects the size for a snapshot, Xum creates the sandbox at the snapshot's size and then resizes it.", + "", + "## Use an existing sandbox", + "", + "Select **Existing** and pick a sandbox from your organization. Xum treats an attached sandbox as yours: it never copies files into its home directory, stops it, or deletes it. It does start the sandbox when a workspace needs it. Several Xum workspaces can share one sandbox; each gets its own directory.", + "", + "## Lifecycle", + "", + "| Xum event | Daytona action |", + "| ----------------------------------- | --------------------------------------------------------------------------------------------- |", + "| A turn starts | Start the sandbox if it is stopped or archived (waits up to 3 minutes) |", + "| An agent is streaming or a job runs | Report activity every minute, so Daytona does not stop the sandbox |", + "| Idle | Daytona stops the sandbox after 30 minutes and archives it after 1 day stopped |", + "| Sub-agent with a forked workspace | A new worktree in the same sandbox, not a second sandbox |", + "| Archive | Follows **Settings > General > Remote box on archive** (default: stop) |", + "| Delete | Check for uncommitted or unpushed work, then delete the sandbox (attached sandboxes are kept) |", + "| Sandbox deleted outside Xum | The workspace reports that the sandbox no longer exists; Xum does not recreate it |", + "", + "With **Remote box on archive** set to delete, Xum first checks the workspace for uncommitted or unpushed work. If it finds some, or cannot run the check, it stops the sandbox instead and shows a warning.", + "", + "Once a minute, Xum also stops running sandboxes that carry this install's label but that no workspace uses. It never deletes them.", + "", + "## SSH setup", + "", + "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires.", + "", + "OpenSSH uses the first `User` value that matches a host. If your `~/.ssh/config` has a `Host *` entry with a `User` line above Xum's block, connections to sandboxes fail. Xum checks for this and reports it; move the `User` line into a narrower `Host` entry, or below Xum's block.", + "", + "## Notes", + "", + "- **Costs:** Daytona bills running sandboxes by size and time. Archive or delete workspaces you no longer need, and pick the smallest size that works.", + "- **CPU count:** `nproc` inside a sandbox reports the host's CPUs, not the sandbox's, so builds may start too many parallel jobs. Set job counts (for example `MAKEFLAGS=-j4`) in your snapshot or in the project's [`.xum/tool_env`](/hooks/tools).", + "- User-level hooks and skills in a sandbox live under `~/.mux`, the same as on other SSH hosts.", + "", + ].join("\n"), "references/docs/runtime/devcontainer.mdx": [ "---", "title: Dev Container Runtime", @@ -10108,6 +10191,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "| **[Local](/runtime/local)** | All workspaces share the project directory | Quick edits to your working copy |", "| **[Worktree](/runtime/worktree)** | Each workspace gets its own directory | Running multiple agents in parallel |", "| **[SSH](/runtime/ssh)** | Remote execution over SSH | Security, performance, heavy parallelism |", + "| **[Daytona](/runtime/daytona)** | Cloud sandbox per workspace, over SSH | Remote compute without managing hosts |", "| **[Docker](/runtime/docker)** | Isolated container per workspace | Full isolation, reproducible environments |", "| **[Dev Container](/runtime/devcontainer)** | Container from devcontainer.json | Projects with existing devcontainer configs |", "", @@ -11124,6 +11208,7 @@ export const BUILTIN_SKILL_FILES: Record> = { " - Worktree Runtime (`/runtime/worktree`) → `references/docs/runtime/worktree.mdx`: Isolated git worktree environments for parallel agent work", " - SSH Runtime (`/runtime/ssh`) → `references/docs/runtime/ssh.mdx`: Run agents on remote hosts over SSH for security and performance", " - Coder Runtime (`/runtime/coder`) → `references/docs/runtime/coder.mdx`: Run agents on Coder workspaces", + " - Daytona Runtime (`/runtime/daytona`) → `references/docs/runtime/daytona.mdx`: Run agents in Daytona Cloud sandboxes", " - Docker Runtime (`/runtime/docker`) → `references/docs/runtime/docker.mdx`: Run agents in isolated Docker containers", " - Dev Container Runtime (`/runtime/devcontainer`) → `references/docs/runtime/devcontainer.mdx`: Run agents in containers defined by devcontainer.json", " - **Hooks**", From 137ac977c59c0863bbc36ce4df506cfafae98ff8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:16:59 +0000 Subject: [PATCH 009/231] fix(daytona): satisfy lint and format checks for the runtime controls and docs index Rebuild the runtime-choice context inside the auto-switch effect so it depends on its fields, not a per-render object; format; sync the xum-docs skill index. --- .../features/ChatInput/CreationControls.tsx | 21 ++++++++++++++++--- .../Settings/Sections/RuntimesSection.tsx | 4 +++- src/browser/stores/RuntimeStatusStore.ts | 3 +-- src/browser/utils/runtimeUi.ts | 4 +--- src/node/builtinSkills/xum-docs.md | 1 + 5 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/browser/features/ChatInput/CreationControls.tsx b/src/browser/features/ChatInput/CreationControls.tsx index 77f42199aca..dede679992f 100644 --- a/src/browser/features/ChatInput/CreationControls.tsx +++ b/src/browser/features/ChatInput/CreationControls.tsx @@ -511,7 +511,10 @@ interface RuntimeChoiceContext { } /** The runtime a workspace-type pick switches to, keeping what was entered for that type. */ -function buildRuntimeForChoice(choice: RuntimeChoice, context: RuntimeChoiceContext): ParsedRuntime { +function buildRuntimeForChoice( + choice: RuntimeChoice, + context: RuntimeChoiceContext +): ParsedRuntime { const current = context.selectedRuntime; switch (choice) { case "coder": @@ -660,16 +663,28 @@ function CreationControlsContent(props: CreationControlsProps) { } // User request: auto-switch away from Settings-disabled runtimes. - onSelectedRuntimeChange(buildRuntimeForChoice(firstEnabled, runtimeChoiceContext)); + // The context is rebuilt here so the effect depends on its fields, not a per-render object. + onSelectedRuntimeChange( + buildRuntimeForChoice(firstEnabled, { + selectedRuntime, + sshHostFallback: props.sshHostFallback, + coderConfigFallback: props.coderConfigFallback, + daytonaConfigFallback: props.daytonaConfigFallback, + runtimeAvailabilityState, + }) + ); }, [ isDevcontainerMissing, isNonGitRepo, onSelectedRuntimeChange, props.coderProps, props.runtimeEnablement, + props.sshHostFallback, + props.coderConfigFallback, + props.daytonaConfigFallback, + runtimeAvailabilityState, availabilityMap, runtimeChoice, - runtimeChoiceContext, selectedRuntime, isCoderAvailable, ]); diff --git a/src/browser/features/Settings/Sections/RuntimesSection.tsx b/src/browser/features/Settings/Sections/RuntimesSection.tsx index b8089589d31..6a24454576e 100644 --- a/src/browser/features/Settings/Sections/RuntimesSection.tsx +++ b/src/browser/features/Settings/Sections/RuntimesSection.tsx @@ -773,7 +773,9 @@ export function RuntimesSection() { disabled={rowDisabled || coderAvailability.state !== "available"} /> ) : null} - {runtime.id === "daytona" && !selectedProjectPath ? : null} + {runtime.id === "daytona" && !selectedProjectPath ? ( + + ) : null} {runtime.id === "daytona" && selectedProjectPath && effectiveEnablement.daytona ? ( diff --git a/src/browser/stores/RuntimeStatusStore.ts b/src/browser/stores/RuntimeStatusStore.ts index 1190095742c..10b390ca417 100644 --- a/src/browser/stores/RuntimeStatusStore.ts +++ b/src/browser/stores/RuntimeStatusStore.ts @@ -153,8 +153,7 @@ export class RuntimeStatusStore { const workspaceIds = Array.from(this.workspaceMetadata.values()) .filter((workspace) => { return ( - hasRuntimeStatus(workspace.runtimeConfig) && - this.statuses.hasKeySubscribers(workspace.id) + hasRuntimeStatus(workspace.runtimeConfig) && this.statuses.hasKeySubscribers(workspace.id) ); }) .map((workspace) => workspace.id); diff --git a/src/browser/utils/runtimeUi.ts b/src/browser/utils/runtimeUi.ts index 0572f87976c..9426187dde5 100644 --- a/src/browser/utils/runtimeUi.ts +++ b/src/browser/utils/runtimeUi.ts @@ -274,9 +274,7 @@ const STOPPED_CHIP_CLASS = "bg-muted/15 text-foreground"; * Only `running` and `stopped` produce a chip; `unknown`, `unsupported`, * and null (not yet loaded) suppress the chip to avoid misreporting. */ -export function getDevcontainerStatusChip( - status: RuntimeStatus | null -): RuntimeStatusChip | null { +export function getDevcontainerStatusChip(status: RuntimeStatus | null): RuntimeStatusChip | null { switch (status) { case "running": return { diff --git a/src/node/builtinSkills/xum-docs.md b/src/node/builtinSkills/xum-docs.md index 9693706ec19..1b5ea97a0a8 100644 --- a/src/node/builtinSkills/xum-docs.md +++ b/src/node/builtinSkills/xum-docs.md @@ -74,6 +74,7 @@ Use this index to find a page's: - Worktree Runtime (`/runtime/worktree`) → `references/docs/runtime/worktree.mdx`: Isolated git worktree environments for parallel agent work - SSH Runtime (`/runtime/ssh`) → `references/docs/runtime/ssh.mdx`: Run agents on remote hosts over SSH for security and performance - Coder Runtime (`/runtime/coder`) → `references/docs/runtime/coder.mdx`: Run agents on Coder workspaces + - Daytona Runtime (`/runtime/daytona`) → `references/docs/runtime/daytona.mdx`: Run agents in Daytona Cloud sandboxes - Docker Runtime (`/runtime/docker`) → `references/docs/runtime/docker.mdx`: Run agents in isolated Docker containers - Dev Container Runtime (`/runtime/devcontainer`) → `references/docs/runtime/devcontainer.mdx`: Run agents in containers defined by devcontainer.json - **Hooks** From 436ecd9a1a9f9c5d11a8c225feb5fc3808e14558 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:14:06 +0000 Subject: [PATCH 010/231] fix(daytona): keep the draft default runtime stable under React Compiler Normalizing the project's default runtime in render broke the compiler's memoization, so selectedRuntime changed identity on every keystroke (useDraftWorkspaceSettings.compiler.test.tsx). The config type already admits daytona, so the normalization was unnecessary. --- src/browser/hooks/useDraftWorkspaceSettings.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index 5c38276135c..0d47de94a3c 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -303,9 +303,7 @@ export function useDraftWorkspaceSettings( Boolean(projectConfig?.runtimeEnablement) || projectConfig?.defaultRuntime !== undefined; const settingsDefaultRuntime: RuntimeChoice = hasProjectRuntimeOverrides - ? (normalizeRuntimeChoice(projectConfig?.defaultRuntime) ?? - globalDefaultRuntime ?? - RUNTIME_MODE.WORKTREE) + ? (projectConfig?.defaultRuntime ?? globalDefaultRuntime ?? RUNTIME_MODE.WORKTREE) : (globalDefaultRuntime ?? RUNTIME_MODE.WORKTREE); const parsedDefault = buildRuntimeFromChoice(settingsDefaultRuntime); From fae3a1a6c64c07e5cbb72dc981e55d9ed1c63e74 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:25:28 +0000 Subject: [PATCH 011/231] refactor(daytona): split the Daytona UI and archive paths under the CRAP limits Extract the SSH badge, runtime-choice table, Coder/Daytona creation validation, Daytona access hooks and components, archive result helpers, the remembered-SSH merge, the delete-check exit-code mapping and the Daytona status probe. The creation form now loads Daytona lists itself while mounted. Covers the archive warning popover path with a test. --- .../components/RuntimeBadge/RuntimeBadge.tsx | 39 +- .../features/ChatInput/CreationControls.tsx | 117 +++--- src/browser/features/ChatInput/index.tsx | 9 - src/browser/features/ChatInput/utils.ts | 65 ++-- .../features/Runtime/DaytonaControls.tsx | 67 ++-- .../Sections/DaytonaSettingsForms.tsx | 353 ++++++++++++------ .../Settings/Sections/RuntimesSection.tsx | 13 +- .../useArchiveWorkspaceConfirmation.test.tsx | 23 ++ .../hooks/useArchiveWorkspaceConfirmation.ts | 42 ++- .../hooks/useDraftWorkspaceSettings.ts | 56 +-- src/node/runtime/SSHRuntime.ts | 66 ++-- src/node/services/workspaceService.ts | 56 +-- 12 files changed, 552 insertions(+), 354 deletions(-) diff --git a/src/browser/components/RuntimeBadge/RuntimeBadge.tsx b/src/browser/components/RuntimeBadge/RuntimeBadge.tsx index 6bbbfd9c480..ab00be7ebb1 100644 --- a/src/browser/components/RuntimeBadge/RuntimeBadge.tsx +++ b/src/browser/components/RuntimeBadge/RuntimeBadge.tsx @@ -70,26 +70,33 @@ function TooltipRow({ type RuntimeType = keyof typeof RUNTIME_BADGE_UI; +/** SSH runtimes split by their managed backend (Coder, Daytona) or plain host. */ +function getSshRuntimeInfo(runtimeConfig: Extract): { + type: RuntimeType; + label: string; +} { + if (runtimeConfig.coder) { + const coderWorkspaceName = runtimeConfig.coder.workspaceName; + return { + type: "coder", + label: `Coder Workspace: ${coderWorkspaceName ?? runtimeConfig.host}`, + }; + } + if (runtimeConfig.daytona) { + return { + type: "daytona", + label: `Daytona sandbox: ${runtimeConfig.daytona.sandboxId ?? runtimeConfig.host}`, + }; + } + const hostname = extractSshHostname(runtimeConfig); + return { type: "ssh", label: `SSH: ${hostname ?? runtimeConfig.host}` }; +} + function getRuntimeInfo( runtimeConfig?: RuntimeConfig ): { type: RuntimeType; label: string } | null { if (isSSHRuntime(runtimeConfig)) { - // Coder-backed SSH runtime gets special treatment - if (runtimeConfig.coder) { - const coderWorkspaceName = runtimeConfig.coder.workspaceName; - return { - type: "coder", - label: `Coder Workspace: ${coderWorkspaceName ?? runtimeConfig.host}`, - }; - } - if (runtimeConfig.daytona) { - return { - type: "daytona", - label: `Daytona sandbox: ${runtimeConfig.daytona.sandboxId ?? runtimeConfig.host}`, - }; - } - const hostname = extractSshHostname(runtimeConfig); - return { type: "ssh", label: `SSH: ${hostname ?? runtimeConfig.host}` }; + return getSshRuntimeInfo(runtimeConfig); } if (isWorktreeRuntime(runtimeConfig)) { return { type: "worktree", label: "Worktree: isolated git worktree" }; diff --git a/src/browser/features/ChatInput/CreationControls.tsx b/src/browser/features/ChatInput/CreationControls.tsx index dede679992f..7a67aeec944 100644 --- a/src/browser/features/ChatInput/CreationControls.tsx +++ b/src/browser/features/ChatInput/CreationControls.tsx @@ -11,8 +11,7 @@ import { isManagedSshPlaceholder, } from "@/common/types/runtime"; import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; -import type { DaytonaResources } from "@/browser/hooks/useDaytonaResources"; -import { DaytonaSandboxForm } from "../Runtime/DaytonaControls"; +import { DaytonaCreationForm } from "../Runtime/DaytonaControls"; import type { RuntimeAvailabilityMap, RuntimeAvailabilityState } from "./useCreationWorkspace"; import { resolveDevcontainerSelection, @@ -149,8 +148,6 @@ interface CreationControlsProps { coderInfo?: CoderInfo | null; /** Coder workspace controls props (optional - only rendered when provided) */ coderProps?: Omit; - /** Daytona snapshots, sandboxes and access state (loaded while Daytona is selected). */ - daytonaResources?: DaytonaResources; } /** Runtime type button group with icons and colors */ @@ -510,50 +507,59 @@ interface RuntimeChoiceContext { runtimeAvailabilityState: RuntimeAvailabilityState; } -/** The runtime a workspace-type pick switches to, keeping what was entered for that type. */ -function buildRuntimeForChoice( - choice: RuntimeChoice, - context: RuntimeChoiceContext -): ParsedRuntime { - const current = context.selectedRuntime; - switch (choice) { - case "coder": - // Switch to SSH mode with the last known Coder config so prior selections restore. - return { mode: "ssh", host: CODER_RUNTIME_PLACEHOLDER, coder: context.coderConfigFallback }; - case "daytona": - return { - mode: "ssh", - host: DAYTONA_RUNTIME_PLACEHOLDER, - daytona: context.daytonaConfigFallback, - }; - case RUNTIME_MODE.SSH: - return { - mode: "ssh", - host: - current.mode === "ssh" && !isManagedSshPlaceholder(current.host) - ? current.host - : context.sshHostFallback, - }; - case RUNTIME_MODE.DOCKER: - return { mode: "docker", image: current.mode === "docker" ? current.image : "" }; - case RUNTIME_MODE.DEVCONTAINER: { +/** The runtime each workspace-type pick switches to, keeping what was entered for that type. */ +const RUNTIME_FOR_CHOICE: Record ParsedRuntime> = + { + // Switch to SSH mode with the last known Coder config so prior selections restore. + coder: (context) => ({ + mode: "ssh", + host: CODER_RUNTIME_PLACEHOLDER, + coder: context.coderConfigFallback, + }), + daytona: (context) => ({ + mode: "ssh", + host: DAYTONA_RUNTIME_PLACEHOLDER, + daytona: context.daytonaConfigFallback, + }), + [RUNTIME_MODE.SSH]: ({ selectedRuntime: current, sshHostFallback }) => ({ + mode: "ssh", + host: + current.mode === "ssh" && !isManagedSshPlaceholder(current.host) + ? current.host + : sshHostFallback, + }), + [RUNTIME_MODE.DOCKER]: ({ selectedRuntime: current }) => ({ + mode: "docker", + image: current.mode === "docker" ? current.image : "", + }), + [RUNTIME_MODE.DEVCONTAINER]: ({ selectedRuntime: current, runtimeAvailabilityState }) => { + if (current.mode === "devcontainer") { + return { + mode: "devcontainer", + configPath: current.configPath, + shareCredentials: current.shareCredentials, + }; + } // Use resolver to get initial config path (prefers first available config) const initialSelection = resolveDevcontainerSelection({ selectedRuntime: { mode: "devcontainer", configPath: "" }, - availabilityState: context.runtimeAvailabilityState, + availabilityState: runtimeAvailabilityState, }); return { mode: "devcontainer", - configPath: - current.mode === "devcontainer" ? current.configPath : initialSelection.configPath, - shareCredentials: current.mode === "devcontainer" ? current.shareCredentials : false, + configPath: initialSelection.configPath, + shareCredentials: false, }; - } - case RUNTIME_MODE.LOCAL: - return { mode: "local" }; - case RUNTIME_MODE.WORKTREE: - return { mode: "worktree" }; - } + }, + [RUNTIME_MODE.LOCAL]: () => ({ mode: "local" }), + [RUNTIME_MODE.WORKTREE]: () => ({ mode: "worktree" }), + }; + +function buildRuntimeForChoice( + choice: RuntimeChoice, + context: RuntimeChoiceContext +): ParsedRuntime { + return RUNTIME_FOR_CHOICE[choice](context); } function CreationControlsContent(props: CreationControlsProps) { @@ -1009,22 +1015,18 @@ function CreationControlsContent(props: CreationControlsProps) { {availabilityMap?.daytona?.available === false && (

{availabilityMap.daytona.reason}

)} - {props.daytonaResources && ( - - onSelectedRuntimeChange({ - mode: "ssh", - host: DAYTONA_RUNTIME_PLACEHOLDER, - daytona, - }) - } - resources={props.daytonaResources} - allowExisting - disabled={props.disabled} - hasError={props.runtimeFieldError === "ssh"} - /> - )} + + onSelectedRuntimeChange({ + mode: "ssh", + host: DAYTONA_RUNTIME_PLACEHOLDER, + daytona, + }) + } + disabled={props.disabled} + hasError={props.runtimeFieldError === "ssh"} + />
)} @@ -1156,7 +1158,6 @@ export function CreationControls(props: CreationControlsProps) { runtimeFieldError={props.runtimeFieldError} coderInfo={props.coderInfo} coderProps={coderProps} - daytonaResources={props.daytonaResources} /> ); } diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 10afb49830f..7df14719c8f 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -168,7 +168,6 @@ import { useCreationWorkspace } from "./useCreationWorkspace"; import { useCoderConfigChangeHandler } from "./useCoderConfigChangeHandler"; import { useCreationProjectChangeHandler } from "./useCreationProjectChangeHandler"; import { useCoderWorkspace } from "@/browser/hooks/useCoderWorkspace"; -import { useDaytonaResources } from "@/browser/hooks/useDaytonaResources"; import { useTutorial } from "@/browser/contexts/TutorialContext"; import { useContextMenuPosition } from "@/browser/hooks/useContextMenuPosition"; import { usePowerMode } from "@/browser/contexts/PowerModeContext"; @@ -914,13 +913,6 @@ const ChatInputInner: React.FC = (props) => { coderInfoRefreshPolicy: variant === "creation" ? "mount-and-focus" : "mount-only", }); - // Daytona lists are account API calls, so they load only while Daytona is the pick. - const daytonaResources = useDaytonaResources({ - enabled: - variant === "creation" && currentRuntime.mode === "ssh" && currentRuntime.daytona != null, - loadSandboxes: true, - }); - const creationRuntimeError = variant === "creation" && props.kind !== "scratch" ? validateCreationRuntime(creationState.selectedRuntime, coderState.presets.length) @@ -940,7 +932,6 @@ const ChatInputInner: React.FC = (props) => { coderConfigFallback: creationState.coderConfigFallback, sshHostFallback: creationState.sshHostFallback, daytonaConfigFallback: creationState.daytonaConfigFallback, - daytonaResources, defaultRuntimeMode: creationState.defaultRuntimeMode, onSelectedRuntimeChange: creationState.setSelectedRuntime, disabled: isSendInFlight, diff --git a/src/browser/features/ChatInput/utils.ts b/src/browser/features/ChatInput/utils.ts index 800954e8246..e38a69aa668 100644 --- a/src/browser/features/ChatInput/utils.ts +++ b/src/browser/features/ChatInput/utils.ts @@ -18,6 +18,8 @@ import { isMcpPromptCommandKey, } from "@/common/utils/tools/mcpPromptCommandKey"; import type { ParsedRuntime } from "@/common/types/runtime"; +import type { CoderWorkspaceConfig } from "@/common/orpc/schemas/coder"; +import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; import { buildAgentSkillMetadata, dedupeAgentSkillRefs, @@ -448,6 +450,40 @@ export function hasProjectScopedSkillRef(refs: AgentSkillReference[]): boolean { return refs.some((ref) => ref.scope === "project"); } +function isBlank(value: string | undefined): boolean { + return !(value ?? "").trim(); +} + +/** Existing mode needs the workspace name; New mode needs a template (and a preset when 2+ exist). */ +function validateCoderRuntime( + coder: CoderWorkspaceConfig, + coderPresetCount: number +): CreationRuntimeValidationError | null { + if (coder.existingWorkspace) { + return isBlank(coder.workspaceName) ? { mode: "ssh", kind: "missingCoderWorkspace" } : null; + } + if (isBlank(coder.template)) { + return { mode: "ssh", kind: "missingCoderTemplate" }; + } + const requiresPreset = coderPresetCount >= 2; + if (requiresPreset && isBlank(coder.preset)) { + return { mode: "ssh", kind: "missingCoderPreset" }; + } + return null; +} + +/** + * A new sandbox needs nothing more (snapshot and size have defaults); attaching (shared) + * needs the picked sandbox. + */ +function validateDaytonaRuntime( + daytona: DaytonaSandboxConfig +): CreationRuntimeValidationError | null { + return daytona.shared === true && isBlank(daytona.sandboxId) + ? { mode: "ssh", kind: "missingDaytonaSandbox" } + : null; +} + export function validateCreationRuntime( runtime: ParsedRuntime, coderPresetCount: number @@ -457,33 +493,8 @@ export function validateCreationRuntime( } if (runtime.mode === "ssh") { - // Daytona: a new sandbox needs nothing more (snapshot and size have defaults); attaching - // (shared) needs the picked sandbox. - if (runtime.daytona) { - return runtime.daytona.shared === true && !(runtime.daytona.sandboxId ?? "").trim() - ? { mode: "ssh", kind: "missingDaytonaSandbox" } - : null; - } - if (runtime.coder) { - if (runtime.coder.existingWorkspace) { - // Existing mode: workspace name is required - if (!(runtime.coder.workspaceName ?? "").trim()) { - return { mode: "ssh", kind: "missingCoderWorkspace" }; - } - } else { - // New mode: template is required - if (!(runtime.coder.template ?? "").trim()) { - return { mode: "ssh", kind: "missingCoderTemplate" }; - } - // Preset required when 2+ presets exist - const requiresPreset = coderPresetCount >= 2; - if (requiresPreset && !(runtime.coder.preset ?? "").trim()) { - return { mode: "ssh", kind: "missingCoderPreset" }; - } - } - return null; - } - + if (runtime.daytona) return validateDaytonaRuntime(runtime.daytona); + if (runtime.coder) return validateCoderRuntime(runtime.coder, coderPresetCount); return runtime.host.trim() ? null : { mode: "ssh", kind: "missingHost" }; } diff --git a/src/browser/features/Runtime/DaytonaControls.tsx b/src/browser/features/Runtime/DaytonaControls.tsx index 0287e9402a0..be76e5adf1d 100644 --- a/src/browser/features/Runtime/DaytonaControls.tsx +++ b/src/browser/features/Runtime/DaytonaControls.tsx @@ -13,7 +13,7 @@ import { type DaytonaSizePresetId, } from "@/common/constants/daytona"; import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; -import type { DaytonaResources } from "@/browser/hooks/useDaytonaResources"; +import { useDaytonaResources, type DaytonaResources } from "@/browser/hooks/useDaytonaResources"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/Tooltip/Tooltip"; import { Select, @@ -256,15 +256,40 @@ export interface DaytonaSandboxFormProps { className?: string; } -export function DaytonaSandboxForm(props: DaytonaSandboxFormProps) { - const mode = props.allowExisting ? getMode(props.config) : "new"; - const ready = props.resources.config?.apiKeySet === true; - +function DaytonaModeToggle(props: { + config: DaytonaSandboxConfig; + onConfigChange: (config: DaytonaSandboxConfig) => void; + disabled?: boolean; +}) { + const mode = getMode(props.config); const handleModeChange = (next: DaytonaMode) => { if (next === mode) return; // Existing starts unselected; New keeps nothing from the attached sandbox. props.onConfigChange(next === "existing" ? { shared: true } : {}); }; + return ( +
+ + +
+ ); +} + +export function DaytonaSandboxForm(props: DaytonaSandboxFormProps) { + const mode = props.allowExisting ? getMode(props.config) : "new"; + const ready = props.resources.config?.apiKeySet === true; return (
{props.allowExisting && ( -
- - -
+ )} {mode === "new" ? ( <> @@ -324,3 +338,14 @@ export function DaytonaSandboxForm(props: DaytonaSandboxFormProps) {
); } + +/** + * The workspace-creation form. Mounted only while Daytona is the picked runtime, so the + * snapshot and sandbox lists (account API calls) load only then. + */ +export function DaytonaCreationForm( + props: Omit +) { + const resources = useDaytonaResources({ enabled: true, loadSandboxes: true }); + return ; +} diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx index 14207bd7751..80b10f53535 100644 --- a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { CheckCircle2, Loader2, XCircle } from "lucide-react"; import { Button } from "@/browser/components/Button/Button"; import { Input } from "@/browser/components/Input/Input"; @@ -21,28 +21,20 @@ type TestState = | { status: "ok" } | { status: "error"; message: string }; +type DaytonaAccessInput = { apiKey?: string; apiUrl?: string; target?: string }; + function describeKey(config: DaytonaPublicConfig | null): string { if (!config) return "Loading..."; if (!config.apiKeySet) return "Not set"; return config.apiKeySource === "env" ? "Key set (env)" : "Key set (settings)"; } -/** - * Daytona access for this Xum install (global scope). The key is write-only: the backend - * never returns it, so the field only ever holds a new key the user is typing. Stored in its - * own 0600 file, never in Secrets, which agent turns and terminals receive. - */ -export function DaytonaAccessForm() { - const { api } = useAPI(); - const [config, setConfig] = useState(null); - const [apiKeyDraft, setApiKeyDraft] = useState(""); - // null = untouched: the inputs show the saved values. - const [apiUrlDraft, setApiUrlDraft] = useState(null); - const [targetDraft, setTargetDraft] = useState(null); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [test, setTest] = useState({ status: "idle" }); +type DaytonaApi = NonNullable["api"]>; +/** The saved public config (never the key itself), loaded once per API connection. */ +function useDaytonaPublicConfig(api: DaytonaApi | null) { + const [config, setConfig] = useState(null); + const [loadError, setLoadError] = useState(null); useEffect(() => { if (!api) return; let active = true; @@ -51,147 +43,245 @@ export function DaytonaAccessForm() { .then((next) => { if (active) setConfig(next); }) - .catch((loadError: unknown) => { - if (active) setError(`Could not load Daytona settings: ${getErrorMessage(loadError)}`); + .catch((error: unknown) => { + if (active) setLoadError(`Could not load Daytona settings: ${getErrorMessage(error)}`); }); return () => { active = false; }; }, [api]); + return { config, setConfig, loadError }; +} - const savedUrl = config?.apiUrl ?? DAYTONA_DEFAULT_API_URL; - const savedTarget = config?.target ?? ""; - const apiUrlValue = apiUrlDraft ?? savedUrl; - const targetValue = targetDraft ?? savedTarget; - const isDirty = - apiKeyDraft.trim() !== "" || - apiUrlValue.trim() !== savedUrl || - targetValue.trim() !== savedTarget; - - const save = async (input: { apiKey?: string; apiUrl?: string; target?: string }) => { +function useDaytonaConnectionTest(api: DaytonaApi | null) { + const [test, setTest] = useState({ status: "idle" }); + const runTest = async () => { if (!api) return; + setTest({ status: "testing" }); + try { + const result = await api.daytona.testConnection(); + setTest(result.ok ? { status: "ok" } : { status: "error", message: result.error }); + } catch (testError) { + setTest({ status: "error", message: getErrorMessage(testError) }); + } + }; + return { test, runTest, resetTest: () => setTest({ status: "idle" }) }; +} + +/** The saved config plus the save and test-connection calls. */ +function useDaytonaAccess() { + const { api } = useAPI(); + const { config, setConfig, loadError } = useDaytonaPublicConfig(api); + const { test, runTest, resetTest } = useDaytonaConnectionTest(api); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + /** Resolves true when the backend accepted the change. */ + const save = async (input: DaytonaAccessInput): Promise => { + if (!api) return false; setSaving(true); - setError(null); - setTest({ status: "idle" }); + setSaveError(null); + resetTest(); try { setConfig(await api.daytona.setConfig(input)); - setApiKeyDraft(""); - setApiUrlDraft(null); - setTargetDraft(null); - } catch (saveError) { - setError(`Could not save Daytona settings: ${getErrorMessage(saveError)}`); + return true; + } catch (error) { + setSaveError(`Could not save Daytona settings: ${getErrorMessage(error)}`); + return false; } finally { setSaving(false); } }; - const handleSave = () => { - void save({ - ...(apiKeyDraft.trim() ? { apiKey: apiKeyDraft.trim() } : {}), - apiUrl: apiUrlValue.trim(), - target: targetValue.trim(), - }); + return { + loaded: api != null && config !== null, + config, + saving, + error: saveError ?? loadError, + test, + save, + runTest, }; +} - const handleTest = async () => { - if (!api) return; - setTest({ status: "testing" }); - try { - const result = await api.daytona.testConnection(); - setTest(result.ok ? { status: "ok" } : { status: "error", message: result.error }); - } catch (testError) { - setTest({ status: "error", message: getErrorMessage(testError) }); - } +/** What the URL and target inputs show while untouched. */ +function savedAccessValues(config: DaytonaPublicConfig | null) { + return { apiUrl: config?.apiUrl ?? DAYTONA_DEFAULT_API_URL, target: config?.target ?? "" }; +} + +/** Unsaved edits. null = untouched: the URL and target inputs show the saved values. */ +function useDaytonaAccessDrafts(config: DaytonaPublicConfig | null) { + const [apiKey, setApiKey] = useState(""); + const [apiUrlDraft, setApiUrl] = useState(null); + const [targetDraft, setTarget] = useState(null); + const saved = savedAccessValues(config); + const apiUrl = apiUrlDraft ?? saved.apiUrl; + const target = targetDraft ?? saved.target; + const isDirty = + apiKey.trim() !== "" || apiUrl.trim() !== saved.apiUrl || target.trim() !== saved.target; + const reset = () => { + setApiKey(""); + setApiUrl(null); + setTarget(null); }; + return { apiKey, apiUrl, target, setApiKey, setApiUrl, setTarget, isDirty, reset }; +} - const disabled = !api || config === null || saving; +/** A blank key field means "keep the saved key", so it is left out of the update. */ +function toAccessInput(drafts: { apiKey: string; apiUrl: string; target: string }) { + const apiKey = drafts.apiKey.trim(); + return { + ...(apiKey ? { apiKey } : {}), + apiUrl: drafts.apiUrl.trim(), + target: drafts.target.trim(), + }; +} +function AccessField(props: { + id: string; + label: ReactNode; + value: string; + onChange: (value: string) => void; + placeholder: string; + disabled: boolean; + type?: "password"; +}) { return ( -
-
- - setApiKeyDraft(event.target.value)} - placeholder={ - config?.apiKeySet ? "Enter a new key to replace it" : "Paste a Daytona API key" - } - disabled={disabled} - className="min-w-0" - /> -
-
- - setApiUrlDraft(event.target.value)} - placeholder={DAYTONA_DEFAULT_API_URL} - disabled={disabled} - className="min-w-0" - /> -
-
- - setTargetDraft(event.target.value)} - placeholder="Daytona default" - disabled={disabled} - className="min-w-0" - /> -
-
- - + + {props.canRemoveKey && ( + - {config?.apiKeySource === "settings" && ( - - )} -
- {test.status === "ok" && ( + )} +
+ ); +} + +function AccessMessages(props: { test: TestState; error: string | null }) { + return ( + <> + {props.test.status === "ok" && (

Connected: the API key works.

)} - {test.status === "error" && ( + {props.test.status === "error" && (

- {test.message} + {props.test.message}

)} - {error && ( + {props.error && (

- {error} + {props.error}

)} + + ); +} + +/** + * Daytona access for this Xum install (global scope). The key is write-only: the backend + * never returns it, so the field only ever holds a new key the user is typing. Stored in its + * own 0600 file, never in Secrets, which agent turns and terminals receive. + */ +export function DaytonaAccessForm() { + const access = useDaytonaAccess(); + const drafts = useDaytonaAccessDrafts(access.config); + const disabled = !access.loaded || access.saving; + const keySet = access.config?.apiKeySet === true; + + const save = async (input: DaytonaAccessInput) => { + if (await access.save(input)) drafts.reset(); + }; + + return ( +
+ + API key {describeKey(access.config)} + + } + value={drafts.apiKey} + onChange={drafts.setApiKey} + placeholder={keySet ? "Enter a new key to replace it" : "Paste a Daytona API key"} + disabled={disabled} + /> + + + void save(toAccessInput(drafts))} + onTest={() => void access.runTest()} + onRemoveKey={() => void save({ apiKey: "" })} + /> +

Without a key here, Xum uses DAYTONA_API_KEY and DAYTONA_API_URL from its own environment.

@@ -221,3 +311,20 @@ export function DaytonaDefaultsForm(props: { projectPath: string; disabled?: boo /> ); } + +/** The Daytona row's body: access in the global scope, defaults in a project scope. */ +export function DaytonaRuntimeSettings(props: { + projectPath: string | null; + enabled: boolean; + disabled: boolean; +}) { + if (!props.projectPath) return ; + if (!props.enabled) return null; + return ( + + ); +} diff --git a/src/browser/features/Settings/Sections/RuntimesSection.tsx b/src/browser/features/Settings/Sections/RuntimesSection.tsx index 6a24454576e..9a3a603a7a9 100644 --- a/src/browser/features/Settings/Sections/RuntimesSection.tsx +++ b/src/browser/features/Settings/Sections/RuntimesSection.tsx @@ -10,7 +10,7 @@ import { resolveCoderAvailability, } from "@/browser/features/Runtime/CoderControls"; import { RuntimeConfigInput } from "@/browser/components/RuntimeConfigInput/RuntimeConfigInput"; -import { DaytonaAccessForm, DaytonaDefaultsForm } from "./DaytonaSettingsForms"; +import { DaytonaRuntimeSettings } from "./DaytonaSettingsForms"; import { Select, SelectContent, @@ -773,15 +773,10 @@ export function RuntimesSection() { disabled={rowDisabled || coderAvailability.state !== "available"} /> ) : null} - {runtime.id === "daytona" && !selectedProjectPath ? ( - - ) : null} - {runtime.id === "daytona" && - selectedProjectPath && - effectiveEnablement.daytona ? ( - ) : null} diff --git a/src/browser/hooks/useArchiveWorkspaceConfirmation.test.tsx b/src/browser/hooks/useArchiveWorkspaceConfirmation.test.tsx index 696411d769c..26bb592262d 100644 --- a/src/browser/hooks/useArchiveWorkspaceConfirmation.test.tsx +++ b/src/browser/hooks/useArchiveWorkspaceConfirmation.test.tsx @@ -40,6 +40,7 @@ function setup(overrides: Partial = {}) isStreaming: () => false, getDisplayTitle: () => "Feature work", showError, + showWarning: () => undefined, ...overrides, }; const view = renderHook(() => useArchiveWorkspaceConfirmation(options)); @@ -79,6 +80,28 @@ describe("useArchiveWorkspaceConfirmation", () => { expect(view.result.current.modalProps.isOpen).toBe(false); }); + test("shows the warnings of an archive that succeeded, and nothing for a clean one", async () => { + const showWarning = mock( + (_workspaceId: string, _warning: string, _anchorEl?: HTMLElement) => undefined + ); + const anchor = document.createElement("button"); + const warned = setup({ + showWarning, + archiveWorkspace: () => + Promise.resolve({ + success: true, + data: { kind: "archived" as const, warnings: ["Sandbox kept", "Stopped instead"] }, + }), + }); + + await warned.request(anchor); + await setup({ showWarning }).request(); + + expect(showWarning.mock.calls).toEqual([ + [WORKSPACE_ID, "Sandbox kept\nStopped instead", anchor], + ]); + }); + test("does nothing while an archive for the workspace is already in flight", async () => { const { request, preflightArchiveWorkspace, archiveWorkspace } = setup({ isArchiving: () => true, diff --git a/src/browser/hooks/useArchiveWorkspaceConfirmation.ts b/src/browser/hooks/useArchiveWorkspaceConfirmation.ts index 26ad5089c6d..1a7c090a95f 100644 --- a/src/browser/hooks/useArchiveWorkspaceConfirmation.ts +++ b/src/browser/hooks/useArchiveWorkspaceConfirmation.ts @@ -1,6 +1,7 @@ import { useState, type ComponentProps } from "react"; import type { ConfirmationModal } from "@/browser/components/ConfirmationModal/ConfirmationModal"; import type { WorkspaceContext } from "@/browser/contexts/WorkspaceContext"; +import type { ArchivePreflightResult, ArchiveWorkspaceResult } from "@/common/orpc/schemas/api"; import { buildArchiveConfirmDescription, buildArchiveConfirmWarning, @@ -27,7 +28,24 @@ export interface UseArchiveWorkspaceConfirmationOptions { /** `anchorEl` is the control that started the archive, when there is one. */ showError: (workspaceId: string, error: string, anchorEl?: HTMLElement) => void; /** An archive that succeeded with something the user should know (e.g. a sandbox kept). */ - showWarning?: (workspaceId: string, warning: string, anchorEl?: HTMLElement) => void; + showWarning: (workspaceId: string, warning: string, anchorEl?: HTMLElement) => void; +} + +type ArchiveCallResult = { + success: boolean; + data?: ArchivePreflightResult | ArchiveWorkspaceResult; +}; + +/** The untracked paths a successful preflight or archive asks the user to confirm losing. */ +function lossyUntrackedPaths(result: ArchiveCallResult): string[] | undefined { + return result.success && result.data?.kind === "confirm-lossy-untracked-files" + ? result.data.paths + : undefined; +} + +function archiveWarningText(result: ArchiveCallResult): string | null { + const warnings = result.data?.kind === "archived" ? (result.data.warnings ?? []) : []; + return warnings.length > 0 ? warnings.join("\n") : null; } function didUntrackedPathSetChange(acknowledged: string[], latest: string[]): boolean { @@ -56,11 +74,12 @@ export function useArchiveWorkspaceConfirmation(options: UseArchiveWorkspaceConf workspaceId, acknowledgedUntrackedPaths ? { acknowledgedUntrackedPaths } : undefined ); - if (result.success && result.data?.kind === "confirm-lossy-untracked-files") { + const confirmPaths = lossyUntrackedPaths(result); + if (confirmPaths) { setConfirmation({ workspaceId, displayTitle: options.getDisplayTitle(workspaceId), - untrackedPaths: result.data.paths, + untrackedPaths: confirmPaths, // The retry path already handled any earlier streaming warning. Only surface the // interruption warning again when the archive attempt has not yet been confirmed. isStreaming: acknowledgedUntrackedPaths == null ? options.isStreaming(workspaceId) : false, @@ -68,24 +87,20 @@ export function useArchiveWorkspaceConfirmation(options: UseArchiveWorkspaceConf return; } if (result.success) { - const warnings = result.data?.kind === "archived" ? (result.data.warnings ?? []) : []; - if (warnings.length > 0) options.showWarning?.(workspaceId, warnings.join("\n"), anchorEl); + const warning = archiveWarningText(result); + if (warning) options.showWarning(workspaceId, warning, anchorEl); return; } if (acknowledgedUntrackedPaths != null) { // Archive may fail if new untracked files appear between confirmation and capture. // Re-run preflight so we can reopen the modal with the latest paths. - const preflight = await options.preflightArchiveWorkspace(workspaceId); - if ( - preflight.success && - preflight.data?.kind === "confirm-lossy-untracked-files" && - didUntrackedPathSetChange(acknowledgedUntrackedPaths, preflight.data.paths) - ) { + const latestPaths = lossyUntrackedPaths(await options.preflightArchiveWorkspace(workspaceId)); + if (latestPaths && didUntrackedPathSetChange(acknowledgedUntrackedPaths, latestPaths)) { setConfirmation({ workspaceId, displayTitle: options.getDisplayTitle(workspaceId), - untrackedPaths: preflight.data.paths, + untrackedPaths: latestPaths, isStreaming: options.isStreaming(workspaceId), }); return; @@ -112,8 +127,7 @@ export function useArchiveWorkspaceConfirmation(options: UseArchiveWorkspaceConf return; } - const untrackedPaths = - preflight.data?.kind === "confirm-lossy-untracked-files" ? preflight.data.paths : undefined; + const untrackedPaths = lossyUntrackedPaths(preflight); if (isStreaming || untrackedPaths) { // Show a single combined confirmation dialog for streaming + untracked-file warnings. setConfirmation({ diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index 0d47de94a3c..ca10df1d944 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -116,6 +116,18 @@ const buildRuntimeForMode = ( } }; +const mergeRememberedSsh = ( + nextRuntime: Extract, + remembered: RememberedRuntimeValues["ssh"] +): ParsedRuntime => ({ + ...nextRuntime, + host: !nextRuntime.host.trim() && remembered.host.trim() ? remembered.host : nextRuntime.host, + coder: + nextRuntime.coder === undefined && remembered.coder != null + ? remembered.coder + : nextRuntime.coder, +}); + const mergeRememberedRuntimeConfig = ( nextRuntime: ParsedRuntime, previousMode: RuntimeMode, @@ -127,21 +139,9 @@ const mergeRememberedRuntimeConfig = ( switch (nextRuntime.mode) { case RUNTIME_MODE.SSH: - return { - ...nextRuntime, - host: - !nextRuntime.host.trim() && remembered.ssh.host.trim() - ? remembered.ssh.host - : nextRuntime.host, - // A Daytona selection must never pick up a remembered Coder config: one managed - // backend per workspace (the runtime schema rejects both). - coder: - nextRuntime.coder === undefined && - nextRuntime.daytona == null && - remembered.ssh.coder != null - ? remembered.ssh.coder - : nextRuntime.coder, - }; + // A Daytona selection takes no remembered host or Coder config: its host is a + // placeholder, and one managed backend per workspace (the runtime schema rejects both). + return nextRuntime.daytona ? nextRuntime : mergeRememberedSsh(nextRuntime, remembered.ssh); case RUNTIME_MODE.DOCKER: return { ...nextRuntime, @@ -171,6 +171,18 @@ const mergeRememberedRuntimeConfig = ( } }; +/** The default runtime-type button: Coder and Daytona are SSH sub-choices. */ +const resolveDefaultRuntimeChoice = ( + settingsDefaultRuntime: RuntimeChoice, + defaultRuntimeMode: RuntimeMode, + coderDefaultFromString: boolean +): RuntimeChoice => { + if (settingsDefaultRuntime === "daytona") return "daytona"; + return defaultRuntimeMode === RUNTIME_MODE.SSH && coderDefaultFromString + ? "coder" + : defaultRuntimeMode; +}; + const normalizeRuntimeChoice = (value: unknown): RuntimeChoice | null => { if ( value === "coder" || @@ -365,13 +377,6 @@ export function useDraftWorkspaceSettings( const coderDefaultFromString = parsedDefault?.mode === RUNTIME_MODE.SSH && parsedDefault.host === CODER_RUNTIME_PLACEHOLDER; const daytonaDefault = settingsDefaultRuntime === "daytona"; - // Defaults must stay explicit and sticky; last-used SSH state should only seed inputs. - const defaultRuntimeChoice: RuntimeChoice = daytonaDefault - ? "daytona" - : defaultRuntimeMode === RUNTIME_MODE.SSH && coderDefaultFromString - ? "coder" - : defaultRuntimeMode; - const setLastRuntimeConfig = useCallback( // eslint-disable-next-line local/no-object-parameters -- grandfathered when the rule was introduced; fix the underlying type instead of copying this pattern (mode: RuntimeMode, field: string, value: string | boolean | object | null) => { @@ -508,7 +513,12 @@ export function useDraftWorkspaceSettings( reasoningMode, agentId, selectedRuntime, - defaultRuntimeMode: defaultRuntimeChoice, + // Defaults must stay explicit and sticky; last-used SSH state should only seed inputs. + defaultRuntimeMode: resolveDefaultRuntimeChoice( + settingsDefaultRuntime, + defaultRuntimeMode, + coderDefaultFromString + ), trunkBranch, }, coderConfigFallback, diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index d497ba11e3d..3fe4d9298aa 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -509,6 +509,39 @@ async function fastReadGitHeadsRefs(projectPath: string): Promise /** Trunk-like names SSH cleanups never `branch -D`, even when they look orphaned. */ const PROTECTED_BRANCHES = ["main", "master", "trunk", "develop", "default"]; +/** Maps the exit code of checkWorkspaceDeletable's script; stderr is read only when needed. */ +async function interpretDeletableCheck( + checkExitCode: number, + readStderr: () => Promise +): Promise<"ok" | "missing" | { error: string }> { + if (checkExitCode === 3) { + return "missing"; + } + + if (checkExitCode === 1) { + return { error: "Workspace contains uncommitted changes. Use force flag to delete anyway." }; + } + + if (checkExitCode === 2) { + // stderr holds the unpushed commits output + const commitList = (await readStderr()).trim(); + const errorMsg = commitList + ? `Workspace contains unpushed commits:\n\n${commitList}` + : "Workspace contains unpushed commits. Use force flag to delete anyway."; + + return { error: errorMsg }; + } + + if (checkExitCode !== 0) { + // Unexpected error + const stderr = await readStderr(); + return { + error: `Failed to check workspace state: ${stderr.trim() || `exit code ${checkExitCode}`}`, + }; + } + return "ok"; +} + export class SSHRuntime extends RemoteRuntime { private readonly config: SSHRuntimeConfig; private readonly transport: SSHTransport; @@ -3316,36 +3349,9 @@ export class SSHRuntime extends RemoteRuntime { // Command doesn't use stdin - abort to close immediately without waiting await checkStream.stdin.abort(); - const checkExitCode = await checkStream.exitCode; - - // Handle check results - if (checkExitCode === 3) { - return "missing"; - } - - if (checkExitCode === 1) { - return { error: "Workspace contains uncommitted changes. Use force flag to delete anyway." }; - } - - if (checkExitCode === 2) { - // Read stderr which contains the unpushed commits output - const stderr = await streamToString(checkStream.stderr); - const commitList = stderr.trim(); - const errorMsg = commitList - ? `Workspace contains unpushed commits:\n\n${commitList}` - : "Workspace contains unpushed commits. Use force flag to delete anyway."; - - return { error: errorMsg }; - } - - if (checkExitCode !== 0) { - // Unexpected error - const stderr = await streamToString(checkStream.stderr); - return { - error: `Failed to check workspace state: ${stderr.trim() || `exit code ${checkExitCode}`}`, - }; - } - return "ok"; + return interpretDeletableCheck(await checkStream.exitCode, () => + streamToString(checkStream.stderr) + ); } async deleteWorkspace( diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index f2ca53ac89c..d2c99a68a91 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1062,6 +1062,22 @@ const DAYTONA_RUNTIME_STATUS: Record +): Promise> { + const statuses: Record = {}; + await Promise.all( + workspaceIds.map(async (workspaceId) => { + const runtimeConfig = metadataById.get(workspaceId)?.runtimeConfig; + const sandboxId = isSSHRuntime(runtimeConfig) ? runtimeConfig.daytona?.sandboxId : undefined; + if (sandboxId) statuses[workspaceId] = await probeDaytonaRuntimeStatus(sandboxId); + }) + ); + return statuses; +} + /** Passive Daytona probe: reads the sandbox state, never starts it. */ async function probeDaytonaRuntimeStatus(sandboxId: string): Promise { const service = getGlobalDaytonaService(); @@ -1103,17 +1119,23 @@ const ARCHIVE_OWN_ACTIVITY_POLICY = { backgroundProcesses: "allow", } as const; -/** - * A remote box Xum created for this workspace (not an existing one it connected to): a Coder - * workspace, or a Daytona sandbox, which reuses the Coder archive behavior (keep, stop, delete). - */ /** What archive refusals call the dedicated remote box, so Daytona users are not told "Coder". */ -function dedicatedRemoteBoxLabel(runtimeConfig: RuntimeConfig | undefined): string { +function dedicatedRemoteBoxLabel(metadata: WorkspaceMetadata | undefined): string { + const runtimeConfig = metadata?.runtimeConfig; return isSSHRuntime(runtimeConfig) && runtimeConfig.daytona != null ? "Daytona sandbox" : "Coder workspace"; } +/** The archive success value; warnings are outcomes that did not fail it (a sandbox kept). */ +function archivedResult(warnings: string[]): { kind: "archived"; warnings?: string[] } { + return warnings.length > 0 ? { kind: "archived", warnings } : { kind: "archived" }; +} + +/** + * A remote box Xum created for this workspace (not an existing one it connected to): a Coder + * workspace, or a Daytona sandbox, which reuses the Coder archive behavior (keep, stop, delete). + */ function isDedicatedCoderRuntime(runtimeConfig: RuntimeConfig | undefined): boolean { if (isDedicatedDaytonaSandbox(runtimeConfig)) return true; return ( @@ -13041,7 +13063,7 @@ export class WorkspaceService ); if (options?.forbidCoderWorkspaceDeletion === true) { if (isDedicatedCoderWorkspace && coderWorkspaceArchiveBehavior === "delete") { - const boxLabel = dedicatedRemoteBoxLabel(beforeArchiveMetadata?.runtimeConfig); + const boxLabel = dedicatedRemoteBoxLabel(beforeArchiveMetadata); return Err( `${boxLabel} archive behavior is set to "Delete", which would permanently delete the dedicated remote ${boxLabel} without user confirmation (unarchive cannot recreate it). Ask the user to archive this workspace manually or change the ${boxLabel} archive behavior.` ); @@ -13074,7 +13096,7 @@ export class WorkspaceService (await this.hasUntrackableExternalAppOpen(workspaceId)) ) { return Err( - `A native terminal or external editor was opened for this workspace and its lifetime cannot be tracked; the archive policy would remove the checkout or stop the dedicated remote ${dedicatedRemoteBoxLabel(beforeArchiveMetadata?.runtimeConfig)} under it. Ask the user to archive this workspace manually.` + `A native terminal or external editor was opened for this workspace and its lifetime cannot be tracked; the archive policy would remove the checkout or stop the dedicated remote ${dedicatedRemoteBoxLabel(beforeArchiveMetadata)} under it. Ask the user to archive this workspace manually.` ); } @@ -13364,10 +13386,7 @@ export class WorkspaceService subProjectPath: beforeArchiveMetadata?.subProjectPath, }); eventSpine.emit("workspace.archived", { workspaceId }); - return Ok({ - kind: "archived" as const, - ...(archiveWarnings.length > 0 ? { warnings: archiveWarnings } : {}), - }); + return Ok(archivedResult(archiveWarnings)); } catch (error) { const message = getErrorMessage(error); return Err(`Failed to archive workspace: ${message}`); @@ -13805,7 +13824,6 @@ export class WorkspaceService const metadataById = new Map(allMetadata.map((metadata) => [metadata.id, metadata])); const devcontainerWorkspaces: Array<{ workspaceId: string; hostWorkspacePath: string }> = []; - const daytonaWorkspaces: Array<{ workspaceId: string; sandboxId: string }> = []; for (const workspaceId of workspaceIds) { const metadata = metadataById.get(workspaceId); @@ -13813,13 +13831,6 @@ export class WorkspaceService continue; } - const daytonaSandboxId = isSSHRuntime(metadata.runtimeConfig) - ? metadata.runtimeConfig.daytona?.sandboxId - : undefined; - if (daytonaSandboxId) { - daytonaWorkspaces.push({ workspaceId, sandboxId: daytonaSandboxId }); - continue; - } if (metadata.runtimeConfig.type !== "devcontainer") { statuses[workspaceId] = "unsupported"; continue; @@ -13838,11 +13849,8 @@ export class WorkspaceService } } - await Promise.all( - daytonaWorkspaces.map(async (workspace) => { - statuses[workspace.workspaceId] = await probeDaytonaRuntimeStatus(workspace.sandboxId); - }) - ); + // Daytona workspaces were marked "unsupported" with the other SSH ones above. + Object.assign(statuses, await probeDaytonaRuntimeStatuses(workspaceIds, metadataById)); // Passive status probes must not call ensureReady(); Docker labels are enough to tell // whether devcontainers are already running. Probe all requested paths with one docker ps. From b218f5a8f5fa9d75f92700bd93349a5515cbe6de Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:13:47 +0000 Subject: [PATCH 012/231] fix(daytona): use interfaces for two object types (lint) --- .../features/Settings/Sections/DaytonaSettingsForms.tsx | 6 +++++- src/browser/hooks/useArchiveWorkspaceConfirmation.ts | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx index 80b10f53535..e2b6f6ec2e0 100644 --- a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx @@ -21,7 +21,11 @@ type TestState = | { status: "ok" } | { status: "error"; message: string }; -type DaytonaAccessInput = { apiKey?: string; apiUrl?: string; target?: string }; +interface DaytonaAccessInput { + apiKey?: string; + apiUrl?: string; + target?: string; +} function describeKey(config: DaytonaPublicConfig | null): string { if (!config) return "Loading..."; diff --git a/src/browser/hooks/useArchiveWorkspaceConfirmation.ts b/src/browser/hooks/useArchiveWorkspaceConfirmation.ts index 1a7c090a95f..1d25a89011c 100644 --- a/src/browser/hooks/useArchiveWorkspaceConfirmation.ts +++ b/src/browser/hooks/useArchiveWorkspaceConfirmation.ts @@ -31,10 +31,10 @@ export interface UseArchiveWorkspaceConfirmationOptions { showWarning: (workspaceId: string, warning: string, anchorEl?: HTMLElement) => void; } -type ArchiveCallResult = { +interface ArchiveCallResult { success: boolean; data?: ArchivePreflightResult | ArchiveWorkspaceResult; -}; +} /** The untracked paths a successful preflight or archive asks the user to confirm losing. */ function lossyUntrackedPaths(result: ArchiveCallResult): string[] | undefined { From c96c3de87d40df9f9edc08444bbc29d0adbcee7b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:27:43 +0000 Subject: [PATCH 013/231] fix(daytona): keep the runtime badge current and stop UI status checks from starting sandboxes - Background git and PR status wait for a Daytona sandbox to run, as for devcontainers, so opening a workspace no longer starts a stopped sandbox. - RuntimeStatusStore refreshes when a turn starts a stopped runtime and when that start ends (runtime-status events from the workspace chat stream). - The API key status sits beside its label instead of inside it. --- .../components/AppLoader/AppLoader.tsx | 3 ++ .../Sections/DaytonaSettingsForms.stories.tsx | 8 ++- .../Sections/DaytonaSettingsForms.tsx | 26 ++++++---- src/browser/stores/GitStatusStore.test.ts | 50 ++++++++++++------- src/browser/stores/RuntimeStatusStore.test.ts | 45 +++++++++++++++++ src/browser/stores/RuntimeStatusStore.ts | 46 ++++++++++++++++- src/browser/stores/WorkspaceStore.ts | 10 ++++ src/browser/utils/runtimeExecutionPolicy.ts | 21 +++++--- 8 files changed, 171 insertions(+), 38 deletions(-) diff --git a/src/browser/components/AppLoader/AppLoader.tsx b/src/browser/components/AppLoader/AppLoader.tsx index a5bbf45ac09..9bc812c77d1 100644 --- a/src/browser/components/AppLoader/AppLoader.tsx +++ b/src/browser/components/AppLoader/AppLoader.tsx @@ -236,6 +236,9 @@ function AppLoaderInner() { gitStatusStore.subscribeToFileModifications((listener) => workspaceStore.subscribeFileModifyingTool(listener) ); + runtimeStatusStore.subscribeToRuntimeStatusEvents((listener) => + workspaceStoreInstance.subscribeRuntimeStatusEvents(listener) + ); setStoresSynced(true); } else { diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.stories.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.stories.tsx index 8aaa0345420..0da38edebbb 100644 --- a/src/browser/features/Settings/Sections/DaytonaSettingsForms.stories.tsx +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.stories.tsx @@ -74,10 +74,14 @@ export const SaveKeyAndTest: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await canvas.findByText("Not set"); - await userEvent.type(canvas.getByLabelText(/API key/), "dtn-story-key"); + await userEvent.type(canvas.getByLabelText("API key"), "dtn-story-key"); await userEvent.click(canvas.getByRole("button", { name: "Save" })); await canvas.findByText("Key set (settings)"); - await expect(canvas.getByLabelText(/API key/)).toHaveValue(""); + // The status sits beside the label: the field's name stays "API key". + await expect(canvas.getByLabelText("API key")).toHaveAccessibleDescription( + "Key set (settings)" + ); + await expect(canvas.getByLabelText("API key")).toHaveValue(""); await userEvent.click(canvas.getByRole("button", { name: "Test connection" })); await canvas.findByText(/Connected/); }, diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx index e2b6f6ec2e0..8b1df237ecc 100644 --- a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx @@ -143,20 +143,31 @@ function toAccessInput(drafts: { apiKey: string; apiUrl: string; target: string function AccessField(props: { id: string; - label: ReactNode; + label: string; + /** Shown beside the label, not inside it, so the field's name stays the label alone. */ + status?: ReactNode; value: string; onChange: (value: string) => void; placeholder: string; disabled: boolean; type?: "password"; }) { + const statusId = `${props.id}-status`; return (
- +
+ + {props.status != null && ( + + {props.status} + + )} +
- API key {describeKey(access.config)} - - } + label="API key" + status={describeKey(access.config)} value={drafts.apiKey} onChange={drafts.setApiKey} placeholder={keySet ? "Enter a new key to replace it" : "Paste a Daytona API key"} diff --git a/src/browser/stores/GitStatusStore.test.ts b/src/browser/stores/GitStatusStore.test.ts index a1533d7f6cf..bad6ddf9498 100644 --- a/src/browser/stores/GitStatusStore.test.ts +++ b/src/browser/stores/GitStatusStore.test.ts @@ -35,6 +35,13 @@ const DEVCONTAINER_RUNTIME = { configPath: ".devcontainer/devcontainer.json", }; +const DAYTONA_RUNTIME = { + type: "ssh" as const, + host: "sb-1.xum--daytona", + srcBaseDir: "~/mux", + daytona: { sandboxId: "sb-1" }, +}; + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function waitUntil(condition: () => boolean, timeoutMs = 1000): Promise { @@ -536,29 +543,36 @@ describe("GitStatusStore", () => { }); describe("passive fetch runtime gating", () => { - it("skips passive fetch and status checks for devcontainer with unresolved runtime status", async () => { - store.dispose(); - const workspaceId = "dc-unresolved"; - const runtimeStatusStore = createRuntimeStatusStoreMock(null); - store = createStore(runtimeStatusStore.runtimeStatusStore); - store.syncWorkspaces( - new Map([[workspaceId, createWorkspaceMetadata(workspaceId, DEVCONTAINER_RUNTIME)]]) - ); - const unsubscribe = store.subscribeKey(workspaceId, jest.fn()); + it.each([ + ["devcontainer", DEVCONTAINER_RUNTIME], + // A Daytona sandbox starts on the first command: opening the workspace must not start it. + ["Daytona", DAYTONA_RUNTIME], + ] as const)( + "skips passive fetch and status checks for a %s with unresolved runtime status", + async (_label, runtimeConfig) => { + store.dispose(); + const workspaceId = "dc-unresolved"; + const runtimeStatusStore = createRuntimeStatusStoreMock(null); + store = createStore(runtimeStatusStore.runtimeStatusStore); + store.syncWorkspaces( + new Map([[workspaceId, createWorkspaceMetadata(workspaceId, runtimeConfig)]]) + ); + const unsubscribe = store.subscribeKey(workspaceId, jest.fn()); - await waitUntil(() => runtimeStatusStore.getListenerCount(workspaceId) > 0); - expect(getFetchCallCount()).toBe(0); + await waitUntil(() => runtimeStatusStore.getListenerCount(workspaceId) > 0); + expect(getFetchCallCount()).toBe(0); - mockExecuteBash.mockClear(); + mockExecuteBash.mockClear(); - // @ts-expect-error - Accessing private method for passive fetch coverage - await store.updateGitStatus(); + // @ts-expect-error - Accessing private method for passive fetch coverage + await store.updateGitStatus(); - expect(getFetchCallCount()).toBe(0); - expect(mockExecuteBash).not.toHaveBeenCalled(); + expect(getFetchCallCount()).toBe(0); + expect(mockExecuteBash).not.toHaveBeenCalled(); - unsubscribe(); - }); + unsubscribe(); + } + ); it("retries passive fetch when devcontainer runtime transitions from null to running", async () => { store.dispose(); diff --git a/src/browser/stores/RuntimeStatusStore.test.ts b/src/browser/stores/RuntimeStatusStore.test.ts index a1912be6b6c..05055abb096 100644 --- a/src/browser/stores/RuntimeStatusStore.test.ts +++ b/src/browser/stores/RuntimeStatusStore.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; import { createTestApiClient } from "@/browser/testUtils"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { RuntimeStatusEvent } from "@/common/types/stream"; import { RuntimeStatusStore, type RuntimeStatus } from "./RuntimeStatusStore"; const DEVCONTAINER_RUNTIME = { @@ -134,4 +135,48 @@ describe("RuntimeStatusStore refresh triggers (#5573)", () => { expect(getRuntimeStatuses).toHaveBeenCalledTimes(calls); }); + + test("a turn that starts a displayed Daytona sandbox refetches when the start begins and ends", async () => { + await setupDisplayed( + new Map([ + [ + "ws-1", + createMetadata("ws-1", { + runtimeConfig: { + type: "ssh", + host: "sb-1.xum--daytona", + srcBaseDir: "~/mux", + daytona: { sandboxId: "sb-1" }, + }, + }), + ], + ]) + ); + let emit: (event: RuntimeStatusEvent) => void = () => undefined; + store.subscribeToRuntimeStatusEvents((listener) => { + emit = listener; + return () => undefined; + }); + const runtimeEvent = (phase: RuntimeStatusEvent["phase"]): RuntimeStatusEvent => ({ + type: "runtime-status", + workspaceId: "ws-1", + phase, + runtimeType: "ssh", + source: "runtime", + }); + + // A turn on a running sandbox: nothing changed. + emit(runtimeEvent("checking")); + emit(runtimeEvent("ready")); + await settle(); + expect(getRuntimeStatuses).toHaveBeenCalledTimes(1); + + emit(runtimeEvent("starting")); + await settle(); + expect(getRuntimeStatuses).toHaveBeenCalledTimes(2); + + emit(runtimeEvent("ready")); + await settle(); + expect(getRuntimeStatuses).toHaveBeenCalledTimes(3); + }); }); diff --git a/src/browser/stores/RuntimeStatusStore.ts b/src/browser/stores/RuntimeStatusStore.ts index 10b390ca417..3c8e54cf9c1 100644 --- a/src/browser/stores/RuntimeStatusStore.ts +++ b/src/browser/stores/RuntimeStatusStore.ts @@ -3,6 +3,7 @@ import type { APIClient } from "@/browser/contexts/API"; import { type FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { isDevcontainerRuntime, isSSHRuntime, type RuntimeConfig } from "@/common/types/runtime"; import type { WorkspaceRuntimeStatus } from "@/common/orpc/schemas/runtime"; +import type { RuntimeStatusEvent } from "@/common/types/stream"; import { MapStore } from "./MapStore"; import { RefreshController } from "@/browser/utils/RefreshController"; import { getStatusInputChanges } from "@/browser/utils/statusRefreshInputs"; @@ -10,7 +11,7 @@ import { getStatusInputChanges } from "@/browser/utils/statusRefreshInputs"; export type RuntimeStatus = WorkspaceRuntimeStatus; /** Runtimes with a start/stop lifecycle the UI shows: devcontainers and Daytona sandboxes. */ -function hasRuntimeStatus(runtimeConfig: RuntimeConfig | undefined): boolean { +export function hasRuntimeStatus(runtimeConfig: RuntimeConfig | undefined): boolean { return ( isDevcontainerRuntime(runtimeConfig) || (isSSHRuntime(runtimeConfig) && runtimeConfig.daytona != null) @@ -50,6 +51,9 @@ export class RuntimeStatusStore { private workspaceMetadata = new Map(); private isActive = true; private readonly refreshController: RefreshController; + /** Workspaces whose runtime a turn is starting: their status changes again once it is up. */ + private readonly startingWorkspaces = new Set(); + private runtimeEventsUnsubscribe: (() => void) | null = null; constructor() { this.refreshController = new RefreshController({ @@ -132,6 +136,43 @@ export class RuntimeStatusStore { } } + /** + * A turn's ensureReady starting a stopped devcontainer or Daytona sandbox changes its status, + * and only the workspace chat stream tells. Idempotent: only the first call subscribes. + */ + subscribeToRuntimeStatusEvents( + subscribe: (listener: (event: RuntimeStatusEvent) => void) => () => void + ): void { + if (this.runtimeEventsUnsubscribe) { + return; + } + this.runtimeEventsUnsubscribe = subscribe((event) => this.handleRuntimeStatusEvent(event)); + } + + private handleRuntimeStatusEvent(event: RuntimeStatusEvent): void { + if (event.source !== "runtime" || !this.tracksRuntimeStart(event)) { + return; + } + if ( + hasRuntimeStatus(this.workspaceMetadata.get(event.workspaceId)?.runtimeConfig) && + this.statuses.hasKeySubscribers(event.workspaceId) + ) { + this.refreshController.requestImmediate(); + } + } + + /** Whether the event begins a start ("starting") or ends one; every turn reports "ready". */ + private tracksRuntimeStart(event: RuntimeStatusEvent): boolean { + if (event.phase === "starting") { + this.startingWorkspaces.add(event.workspaceId); + return true; + } + if (event.phase !== "ready" && event.phase !== "error") { + return false; + } + return this.startingWorkspaces.delete(event.workspaceId); + } + invalidateWorkspace(workspaceId: string): void { this.statusCache.delete(workspaceId); this.statuses.delete(workspaceId); @@ -140,6 +181,9 @@ export class RuntimeStatusStore { dispose(): void { this.isActive = false; + this.runtimeEventsUnsubscribe?.(); + this.runtimeEventsUnsubscribe = null; + this.startingWorkspaces.clear(); this.statusCache.clear(); this.statuses.clear(); this.refreshController.dispose(); diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index bb567e4bd93..8d940d60fa3 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -1203,6 +1203,9 @@ export class WorkspaceStore { private fileModifyingToolMs = new Map(); private fileModifyingToolSubs = new MapStore(); + // RuntimeStatusStore listens: a turn starting a stopped runtime changes its badge. + private runtimeStatusEventListeners = new Set<(event: RuntimeStatusEvent) => void>(); + // Idle callbacks keep high-frequency init logs from blocking the renderer. private deltaIdleHandles = new Map(); private idleBumpPreludes = new Map void>(); @@ -1438,6 +1441,8 @@ export class WorkspaceStore { "runtime-status": (workspaceId, aggregator, data) => { applyWorkspaceChatEventToAggregator(aggregator, data); this.states.bump(workspaceId); + const event = data as RuntimeStatusEvent; + for (const listener of this.runtimeStatusEventListeners) listener(event); }, "auto-compaction-triggered": (workspaceId) => { // Informational event from backend auto-compaction monitor. @@ -5062,6 +5067,11 @@ export class WorkspaceStore { }); } + subscribeRuntimeStatusEvents(listener: (event: RuntimeStatusEvent) => void): () => void { + this.runtimeStatusEventListeners.add(listener); + return () => this.runtimeStatusEventListeners.delete(listener); + } + /** * Get when a file-modifying tool last completed for this workspace. * Returns undefined if no tools have completed since last clear. diff --git a/src/browser/utils/runtimeExecutionPolicy.ts b/src/browser/utils/runtimeExecutionPolicy.ts index bd732bcd482..8f30670fdaa 100644 --- a/src/browser/utils/runtimeExecutionPolicy.ts +++ b/src/browser/utils/runtimeExecutionPolicy.ts @@ -1,16 +1,21 @@ -import type { RuntimeStatus, RuntimeStatusStore } from "@/browser/stores/RuntimeStatusStore"; +import { + hasRuntimeStatus, + type RuntimeStatus, + type RuntimeStatusStore, +} from "@/browser/stores/RuntimeStatusStore"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import { isDevcontainerRuntime } from "@/common/types/runtime"; /** * Whether passive/background runtime-backed work can run without waking the runtime. * Explicit user actions can apply a different policy when they intentionally start work. + * Devcontainers and Daytona sandboxes start on the first command, so background git and PR + * status wait until they run: opening a workspace must not start (and bill) a stopped sandbox. */ export function canRunPassiveRuntimeCommand( runtimeConfig: FrontendWorkspaceMetadata["runtimeConfig"], runtimeStatus: RuntimeStatus | null ): boolean { - if (!isDevcontainerRuntime(runtimeConfig)) { + if (!hasRuntimeStatus(runtimeConfig)) { return true; } @@ -22,11 +27,11 @@ const noopUnsubscribe = () => undefined; export type PassiveRuntimeDeps = Pick; /** - * Arms a one-shot listener that fires `onEligible()` when a devcontainer workspace's + * Arms a one-shot listener that fires `onEligible()` when a devcontainer or Daytona workspace's * runtime transitions from not-passively-runnable to `running`. * - * For non-devcontainer workspaces this is a no-op (returns a no-op unsubscriber) - * because they are always passively eligible. + * For other workspaces this is a no-op (returns a no-op unsubscriber) because they are always + * passively eligible. * * Returns an unsubscribe function. The listener auto-unsubscribes after firing once. */ @@ -36,8 +41,8 @@ export function onPassiveRuntimeEligible( runtimeStatusStore: PassiveRuntimeDeps, onEligible: () => void ): () => void { - // Non-devcontainer workspaces are always eligible; no watcher needed. - if (!isDevcontainerRuntime(runtimeConfig)) { + // Workspaces without a start/stop lifecycle are always eligible; no watcher needed. + if (!hasRuntimeStatus(runtimeConfig)) { return noopUnsubscribe; } From e1248021802e4ce43db1665ad74ec3091393b7ff Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:27:55 +0000 Subject: [PATCH 014/231] fix(daytona): report invalid settings and HTML error pages as short, clear errors - An invalid API URL throws DaytonaConfigError, which daytona.setConfig returns as BAD_REQUEST with its reason instead of Internal Server Error. - Client errors drop HTML tags, collapse whitespace and keep 200 chars of the server message. --- src/node/config/daytonaCredentialsStore.ts | 16 ++++++-- src/node/orpc/router.ts | 13 ++++++- src/node/orpc/routerDaytona.test.ts | 38 +++++++++++++++++++ .../services/daytona/daytonaClient.test.ts | 21 ++++++++++ src/node/services/daytona/daytonaClient.ts | 35 +++++++++++++---- 5 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 src/node/orpc/routerDaytona.test.ts diff --git a/src/node/config/daytonaCredentialsStore.ts b/src/node/config/daytonaCredentialsStore.ts index 733c8ad6bb6..500146fd102 100644 --- a/src/node/config/daytonaCredentialsStore.ts +++ b/src/node/config/daytonaCredentialsStore.ts @@ -44,18 +44,28 @@ function nonEmpty(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +/** Settings input the user must correct; the router reports it as a bad request. */ +export class DaytonaConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "DaytonaConfigError"; + } +} + function normalizeApiUrl(value: string): string { let parsed: URL; try { parsed = new URL(value); } catch { - throw new Error(`Daytona API URL is not a valid URL: ${value}`); + throw new DaytonaConfigError(`Daytona API URL is not a valid URL: ${value}`); } if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { - throw new Error("Daytona API URL must use http or https"); + throw new DaytonaConfigError("Daytona API URL must use http or https"); } if (parsed.username || parsed.password || parsed.search || parsed.hash) { - throw new Error("Daytona API URL must not contain credentials, a query or a fragment"); + throw new DaytonaConfigError( + "Daytona API URL must not contain credentials, a query or a fragment" + ); } return parsed.toString().replace(/\/+$/, ""); } diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index bd592cc21ff..90b0cfa018c 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -19,6 +19,7 @@ import { EXPERIMENT_IDS } from "@/common/constants/experiments"; * providerService.ts and startDesktopFlowEffect in muxGatewayOauthService.ts). */ import { ORPCError, os, type ProcedureConfig } from "@orpc/server"; +import { DaytonaConfigError } from "@/node/config/daytonaCredentialsStore"; import { WorkspaceMutationInProgressError } from "@/node/services/workspaceUseLeases"; import { PerfCaptureRefusedError } from "@/node/services/perf/perfCaptureService"; import { PerfReportRefusedError } from "@/node/services/perf/perfReportService"; @@ -213,6 +214,14 @@ function rethrowDraftTooLarge(error: unknown): never { throw error; } +/** Transports mask plain errors as "Internal Server Error"; invalid settings need the reason. */ +function rethrowDaytonaConfigError(error: unknown): never { + if (error instanceof DaytonaConfigError) { + throw new ORPCError("BAD_REQUEST", { message: error.message }); + } + throw error; +} + /** A refused creation draft save needs corrected choices or a reload, never a retry. */ function rethrowCreationDraftRefusal(error: unknown): never { if (error instanceof CreationDraftRejectedError) { @@ -1822,7 +1831,9 @@ export const router = (authToken?: string) => { setConfig: t .input(schemas.daytona.setConfig.input) .output(schemas.daytona.setConfig.output) - .handler(async ({ context, input }) => context.daytonaService.setConfig(input)), + .handler(async ({ context, input }) => + context.daytonaService.setConfig(input).catch(rethrowDaytonaConfigError) + ), testConnection: t .input(schemas.daytona.testConnection.input) .output(schemas.daytona.testConnection.output) diff --git a/src/node/orpc/routerDaytona.test.ts b/src/node/orpc/routerDaytona.test.ts new file mode 100644 index 00000000000..2f0dea1d285 --- /dev/null +++ b/src/node/orpc/routerDaytona.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { createRouterClient } from "@orpc/server"; + +import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; +import { DaytonaService } from "@/node/services/daytona/daytonaService"; +import type { ORPCContext } from "./context"; +import { router } from "./router"; + +describe("daytona procedures", () => { + let rootDir: string; + + beforeEach(async () => { + rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-router-daytona-")); + }); + + afterEach(async () => { + await fs.rm(rootDir, { recursive: true, force: true }); + }); + + test("an invalid API URL reaches the client as a bad request with its reason", async () => { + const daytonaService = new DaytonaService({ + credentialsStore: new DaytonaCredentialsStore(rootDir, {}), + rootDir, + }); + const context = { daytonaService } as unknown as ORPCContext; + const client = createRouterClient(router(), { context }); + + const error = await client.daytona.setConfig({ apiUrl: "not a url" }).then( + () => null, + (rejection: Error) => rejection + ); + expect(error).toMatchObject({ code: "BAD_REQUEST" }); + expect(error?.message).toContain("not a valid URL"); + }); +}); diff --git a/src/node/services/daytona/daytonaClient.test.ts b/src/node/services/daytona/daytonaClient.test.ts index 652cbbdb770..36816c7fa37 100644 --- a/src/node/services/daytona/daytonaClient.test.ts +++ b/src/node/services/daytona/daytonaClient.test.ts @@ -70,6 +70,27 @@ describe("DaytonaClient", () => { } }); + it("reduces an HTML error page or a long message to a short plain-text excerpt", async () => { + const page = ` +

502 Bad Gateway

+

nginx

`; + const htmlClient = new DaytonaClient({ + apiUrl: API_URL, + apiKey: API_KEY, + fetch: fakeFetch(() => new Response(page, { status: 502 })).fetch, + }); + const htmlError = await captureError(htmlClient.listSandboxes()); + expect(htmlError.serverMessage).toBe("502 Bad Gateway nginx"); + + const longClient = new DaytonaClient({ + apiUrl: API_URL, + apiKey: API_KEY, + fetch: fakeFetch(() => jsonResponse(500, { message: `${"x".repeat(500)}` })).fetch, + }); + const longError = await captureError(longClient.listSandboxes()); + expect(longError.serverMessage).toBe(`${"x".repeat(200)}...`); + }); + it("fails with a timeout error instead of hanging on a stuck control plane", async () => { const hangingFetch = ((_input: string | URL | Request, init?: RequestInit) => new Promise((_resolve, reject) => { diff --git a/src/node/services/daytona/daytonaClient.ts b/src/node/services/daytona/daytonaClient.ts index a018bf9a476..e44e8765216 100644 --- a/src/node/services/daytona/daytonaClient.ts +++ b/src/node/services/daytona/daytonaClient.ts @@ -125,18 +125,39 @@ interface RequestOptions { const MAX_PAGES = 50; const PAGE_LIMIT = 100; +const MAX_SERVER_MESSAGE_CHARS = 200; + +/** + * Error text ends up in toasts and turn errors: a proxy or a non-Daytona URL can answer with a + * whole HTML page, so drop tags, collapse whitespace and keep a short excerpt. + */ +function toPlainExcerpt(text: string): string | undefined { + const plain = text + .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, " ") + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!plain) return undefined; + return plain.length > MAX_SERVER_MESSAGE_CHARS + ? `${plain.slice(0, MAX_SERVER_MESSAGE_CHARS)}...` + : plain; +} + function extractServerMessage(bodyText: string): string | undefined { if (!bodyText) return undefined; + let parsed: unknown; try { - const parsed: unknown = JSON.parse(bodyText); - if (parsed && typeof parsed === "object" && "message" in parsed) { - const message = (parsed as { message: unknown }).message; - if (typeof message === "string") return message; - if (Array.isArray(message)) return message.filter((m) => typeof m === "string").join("; "); - } + parsed = JSON.parse(bodyText); } catch { // Not JSON (proxy error page etc.): keep a short plain-text excerpt instead. - return bodyText.slice(0, 200); + return toPlainExcerpt(bodyText); + } + if (parsed && typeof parsed === "object" && "message" in parsed) { + const message = (parsed as { message: unknown }).message; + if (typeof message === "string") return toPlainExcerpt(message); + if (Array.isArray(message)) { + return toPlainExcerpt(message.filter((m) => typeof m === "string").join("; ")); + } } return undefined; } From 9d114f33c146a37920c982985fe77d5555c4990b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:28:29 +0000 Subject: [PATCH 015/231] fix(daytona): fix UAT round 1 data-loss, host-key, token and lifecycle findings - Delete refuses a dirty worktree under the unresolved ~/... path: checkWorkspaceDeletable expands a leading ~ remotely (also fixes Coder), and Daytona resolves the path first and fails closed when it cannot. - Daytona aliases keep their pinned gateway key: the headless OpenSSH override skips *.xum--daytona, and ssh2 checks the server key against the pinned daytona_known_hosts entries. - Daytona ssh tokens are redacted () from ssh stderr, errors, init output, pool probe errors and reverse-forward logs. - A fork reuses its source's sandbox (`forked`) without marking either workspace attached; the last referencing workspace, archived ones included, deletes it. Attached sandboxes are never deleted; their last workspace only releases Xum's ssh access. Archive skips a sandbox another workspace references. - A turn's runtime restarts a sandbox that stopped within the ready cache and retries the command once; transport failures invalidate the cache. - Tokens are revoked on settings changes and failed installs; a failed ssh -G user check reuses the token on retry. - Later turns after a failed setup report the setup failure and its reason. - Keepalive checks whether background jobs still run, skips stopped or unknown sandboxes, and skips the sweep when the sandbox list fails. --- docs/runtime/daytona.mdx | 7 +- src/common/constants/daytona.ts | 6 + src/common/orpc/schemas/daytona.ts | 13 +- src/common/types/runtime.ts | 13 +- src/node/runtime/DaytonaSSHRuntime.test.ts | 239 +++++++++++++++- src/node/runtime/DaytonaSSHRuntime.ts | 256 +++++++++++++++--- src/node/runtime/SSH2ConnectionPool.test.ts | 61 ++++- src/node/runtime/SSH2ConnectionPool.ts | 62 ++++- src/node/runtime/SSHRuntime.ts | 11 +- src/node/runtime/daytonaTokenRedaction.ts | 108 ++++++++ src/node/runtime/sshConfigParser.ts | 21 ++ src/node/runtime/sshConnectionPool.test.ts | 16 +- src/node/runtime/sshConnectionPool.ts | 15 +- .../runtime/transports/OpenSSHTransport.ts | 17 +- .../builtInSkillContent.generated.ts | 7 +- .../services/backgroundProcessManager.test.ts | 22 ++ src/node/services/backgroundProcessManager.ts | 14 +- .../services/daytona/daytona.testHarness.ts | 9 + .../daytona/daytonaLifecycleHooks.test.ts | 26 ++ .../services/daytona/daytonaLifecycleHooks.ts | 88 ++++-- .../daytona/daytonaReconciler.test.ts | 32 ++- .../services/daytona/daytonaReconciler.ts | 94 +++++-- .../services/daytona/daytonaService.test.ts | 35 +++ src/node/services/daytona/daytonaService.ts | 78 +++++- src/node/services/serviceContainer.ts | 5 +- src/node/services/workspaceService.ts | 2 + 26 files changed, 1135 insertions(+), 122 deletions(-) create mode 100644 src/node/runtime/daytonaTokenRedaction.ts diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index bd608dfa08f..e4f1268189d 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -60,15 +60,20 @@ Select **Existing** and pick a sandbox from your organization. Xum treats an att | Sub-agent with a forked workspace | A new worktree in the same sandbox, not a second sandbox | | Archive | Follows **Settings > General > Remote box on archive** (default: stop) | | Delete | Check for uncommitted or unpushed work, then delete the sandbox (attached sandboxes are kept) | +| Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs | | Sandbox deleted outside Xum | The workspace reports that the sandbox no longer exists; Xum does not recreate it | +A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses. + With **Remote box on archive** set to delete, Xum first checks the workspace for uncommitted or unpushed work. If it finds some, or cannot run the check, it stops the sandbox instead and shows a warning. Once a minute, Xum also stops running sandboxes that carry this install's label but that no workspace uses. It never deletes them. ## SSH setup -Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. +Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered. + +Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts. OpenSSH uses the first `User` value that matches a host. If your `~/.ssh/config` has a `Host *` entry with a `User` line above Xum's block, connections to sandboxes fail. Xum checks for this and reports it; move the `User` line into a narrower `Host` entry, or below Xum's block. diff --git a/src/common/constants/daytona.ts b/src/common/constants/daytona.ts index a4407cc2e8a..9c941641e11 100644 --- a/src/common/constants/daytona.ts +++ b/src/common/constants/daytona.ts @@ -16,6 +16,12 @@ export function toXumDaytonaHost(sandboxId: string): string { return `${sandboxId}.${XUM_DAYTONA_HOST_SUFFIX}`; } +/** Whether an ssh host (optionally `user@host`) is a Daytona sandbox alias Xum manages. */ +export function isXumDaytonaHost(host: string): boolean { + const hostname = host.slice(host.lastIndexOf("@") + 1).toLowerCase(); + return hostname.endsWith(`.${XUM_DAYTONA_HOST_SUFFIX}`); +} + export const XUM_DAYTONA_SSH_BLOCK_START = "# --- START XUM DAYTONA SSH ---"; export const XUM_DAYTONA_SSH_BLOCK_END = "# --- END XUM DAYTONA SSH ---"; diff --git a/src/common/orpc/schemas/daytona.ts b/src/common/orpc/schemas/daytona.ts index 505395e6f1c..1232ec0581f 100644 --- a/src/common/orpc/schemas/daytona.ts +++ b/src/common/orpc/schemas/daytona.ts @@ -27,11 +27,18 @@ export const DaytonaSandboxConfigSchema = z.object({ .meta({ description: "Disk in GiB (size preset)" }), target: z.string().optional().meta({ description: "Daytona target (region)" }), /** - * True when the sandbox is not owned by this workspace alone (attached existing sandbox, or a - * fork sharing its parent's sandbox). Shared sandboxes are never deleted with a workspace. + * True for an existing sandbox the user attached: Xum never stops or deletes it. A sandbox + * Xum created is deleted with the last workspace (archived ones included) that references it. */ shared: z.boolean().optional().meta({ - description: "True if the sandbox is shared with other workspaces or pre-existing", + description: "True if the sandbox is a pre-existing one the workspace attached to", + }), + /** + * True for a fork that reuses its source's sandbox: setup connects to it instead of creating + * one. The sandbox stays Xum's, so the last referencing workspace still deletes it. + */ + forked: z.boolean().optional().meta({ + description: "True if the workspace reuses the sandbox of the workspace it was forked from", }), }); diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts index 68d417ce5d6..c6351b0b257 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -275,16 +275,23 @@ export function isSSHRuntime( return config?.type === "ssh"; } +/** The Daytona sandbox a runtime config points at, if any. */ +export function getDaytonaSandboxId(config: RuntimeConfig | undefined): string | undefined { + const sandboxId = isSSHRuntime(config) ? config.daytona?.sandboxId?.trim() : undefined; + return sandboxId ? sandboxId : undefined; +} + /** - * A Daytona sandbox Xum created for this workspace alone: not an attached existing sandbox - * and not one shared with a fork. Only these may be stopped or deleted with the workspace. + * A Daytona sandbox Xum created (for this workspace or the one it was forked from), not an + * attached existing one. Only these may be stopped or deleted, and only by the last workspace + * that references them. */ export function isDedicatedDaytonaSandbox(config: RuntimeConfig | undefined): boolean { return ( isSSHRuntime(config) && config.daytona != null && config.daytona.shared !== true && - (config.daytona.sandboxId?.trim() ?? "") !== "" + getDaytonaSandboxId(config) !== undefined ); } diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index cffc71fdc44..f92418cef15 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, spyOn, type Mock } from "bun:test"; +import { execFileSync, spawn } from "child_process"; import * as fs from "fs/promises"; import * as path from "path"; import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; @@ -11,8 +12,10 @@ import { DAYTONA_READY_CACHE_MS } from "@/node/services/daytona/daytonaService"; import * as runtimeHelpers from "@/node/utils/runtime/helpers"; import { DaytonaSSHRuntime } from "./DaytonaSSHRuntime"; import type { InitLogger, RuntimeStatusEvent, WorkspaceInitParams } from "./Runtime"; +import { RuntimeError } from "./Runtime"; import { SSHRuntime } from "./SSHRuntime"; -import { createSSHTransport } from "./transports"; +import { DAYTONA_TOKEN_PLACEHOLDER } from "./daytonaTokenRedaction"; +import { createSSHTransport, type SSHTransport } from "./transports"; let rig: DaytonaTestRig; let execSpy: Mock; @@ -32,10 +35,15 @@ afterEach(async () => { await rig.cleanup(); }); -function makeRuntime(daytona: DaytonaSandboxConfig, testRig = rig): DaytonaSSHRuntime { +function makeRuntime( + daytona: DaytonaSandboxConfig, + testRig = rig, + options: { workspaceId?: string; transport?: SSHTransport } = {} +): DaytonaSSHRuntime { const config = { host: "placeholder.xum--daytona", srcBaseDir: "~/src", daytona }; - return new DaytonaSSHRuntime(config, createSSHTransport(config, false), testRig.service, { - workspaceId: "ws-1", + const transport = options.transport ?? createSSHTransport(config, false); + return new DaytonaSSHRuntime(config, transport, testRig.service, { + workspaceId: options.workspaceId ?? "ws-1", // Empty by default: tests that seed write files here. agentHomeSeedSources: { xumHome: path.join(testRig.rootDir, "host-xum"), @@ -68,6 +76,25 @@ function initParams(): WorkspaceInitParams & { log: string[] } { const SMALL = { cpu: 2, memoryGiB: 4, diskGiB: 10 }; +/** Runs each "remote" command in a local bash whose HOME is `home`: the sandbox end of ssh. */ +function localShellTransport(home: string): SSHTransport { + const config = { host: "placeholder.xum--daytona" }; + return { + spawnRemoteProcess: (command) => + Promise.resolve({ + process: spawn("bash", ["-c", command], { + env: { PATH: process.env.PATH ?? "", HOME: home }, + stdio: "pipe", + }), + }), + isConnectionFailure: (exitCode) => exitCode === 255, + acquireConnection: () => Promise.resolve(), + getConfig: () => config, + createPtySession: () => Promise.reject(new Error("no PTY in this test")), + openReverseForward: () => Promise.reject(new Error("no forwards in this test")), + }; +} + describe("DaytonaSSHRuntime.ensureReady", () => { it("trusts a ready result for 60 s across runtime instances, then checks again", async () => { rig.plane.addSandbox({ id: "sb-ready" }); @@ -225,6 +252,18 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { expect(rig.plane.countCalls("POST /sandbox")).toBe(1); expect(rig.plane.find("xum-doomed")).toBeUndefined(); + + // A later turn learns why, not that the sandbox was "deleted outside Xum". + const ready = await makeRuntime({ + sandboxId: "xum-doomed", + shared: false, + ...SMALL, + }).ensureReady(); + expect(ready.ready).toBe(false); + if (!ready.ready) { + expect(ready.error).toContain("setup failed"); + expect(ready.error).toContain("Your ssh config sets User"); + } }); it("seeds the agent home of a created sandbox but never of an attached one", async () => { @@ -292,30 +331,206 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { expect(rig.plane.revokedTokens).toHaveLength(1); }); - it("never deletes a sandbox shared with a fork", async () => { + it("keeps the sandbox for a fork's source, deletes it with the last workspace", async () => { rig.plane.addSandbox({ id: "sb-parent" }); const forkSpy = spyOn(SSHRuntime.prototype, "forkWorkspace").mockResolvedValue({ success: true, workspacePath: "/fork", }); try { - const runtime = makeRuntime({ sandboxId: "sb-parent", shared: false }); - const fork = await runtime.forkWorkspace({ + const sourceDaytona = { sandboxId: "sb-parent", shared: false, ...SMALL }; + const fork = await makeRuntime(sourceDaytona).forkWorkspace({ projectPath: "/home/me/project", sourceWorkspaceName: "feature", newWorkspaceName: "feature-fork", initLogger: initParams().initLogger, }); - for (const config of [fork.forkedRuntimeConfig, fork.sourceRuntimeConfig]) { - expect(config?.type === "ssh" ? config.daytona?.shared : undefined).toBe(true); - } + const forkDaytona = + fork.forkedRuntimeConfig?.type === "ssh" ? fork.forkedRuntimeConfig.daytona : undefined; + if (!forkDaytona) throw new Error("expected a Daytona fork config"); + expect(fork.sourceRuntimeConfig).toBeUndefined(); + rig.workspaces.push( + { workspaceId: "ws-1", runtimeConfig: sshDaytonaConfig(sourceDaytona) }, + { workspaceId: "ws-fork", runtimeConfig: sshDaytonaConfig(forkDaytona) } + ); - expect((await runtime.deleteWorkspace("/home/me/project", "feature", false)).success).toBe( - true + // The fork's setup (a fresh runtime from its config) connects; it creates nothing. + await makeRuntime(forkDaytona, rig, { workspaceId: "ws-fork" }).postCreateSetup(initParams()); + expect(rig.plane.countCalls("POST /sandbox")).toBe(0); + + const deleteFork = makeRuntime(forkDaytona, rig, { workspaceId: "ws-fork" }); + expect( + (await deleteFork.deleteWorkspace("/home/me/project", "feature-fork", false)).success + ).toBe(true); + expect(rig.plane.find("sb-parent")).toBeDefined(); + rig.workspaces.splice(1, 1); + + const deleteSource = makeRuntime(sourceDaytona); + expect( + (await deleteSource.deleteWorkspace("/home/me/project", "feature", false)).success + ).toBe(true); + expect(rig.plane.find("sb-parent")).toBeUndefined(); + expect(rig.writer.removed).toContain("sb-parent"); + } finally { + forkSpy.mockRestore(); + } + }); + + it("deletes the sandbox with a fork that outlives its source", async () => { + rig.plane.addSandbox({ id: "sb-parent" }); + const forkSpy = spyOn(SSHRuntime.prototype, "forkWorkspace").mockResolvedValue({ + success: true, + workspacePath: "/fork", + }); + try { + const sourceDaytona = { sandboxId: "sb-parent", shared: false, ...SMALL }; + const fork = await makeRuntime(sourceDaytona).forkWorkspace({ + projectPath: "/home/me/project", + sourceWorkspaceName: "feature", + newWorkspaceName: "feature-fork", + initLogger: initParams().initLogger, + }); + const forkDaytona = + fork.forkedRuntimeConfig?.type === "ssh" ? fork.forkedRuntimeConfig.daytona : undefined; + if (!forkDaytona) throw new Error("expected a Daytona fork config"); + rig.workspaces.push( + { workspaceId: "ws-1", runtimeConfig: sshDaytonaConfig(sourceDaytona) }, + { workspaceId: "ws-fork", runtimeConfig: sshDaytonaConfig(forkDaytona) } ); + + const deleteSource = makeRuntime(sourceDaytona); + expect( + (await deleteSource.deleteWorkspace("/home/me/project", "feature", false)).success + ).toBe(true); expect(rig.plane.find("sb-parent")).toBeDefined(); + rig.workspaces.splice(0, 1); + + const deleteFork = makeRuntime(forkDaytona, rig, { workspaceId: "ws-fork" }); + expect( + (await deleteFork.deleteWorkspace("/home/me/project", "feature-fork", false)).success + ).toBe(true); + expect(rig.plane.find("sb-parent")).toBeUndefined(); } finally { forkSpy.mockRestore(); } }); + + it("never deletes an attached sandbox, even with its last workspace", async () => { + rig.plane.addSandbox({ id: "sb-users" }); + rig.workspaces.push({ + workspaceId: "ws-1", + runtimeConfig: sshDaytonaConfig({ sandboxId: "sb-users", shared: true }), + }); + + for (const force of [false, true]) { + const runtime = makeRuntime({ sandboxId: "sb-users", shared: true }); + expect((await runtime.deleteWorkspace("/home/me/project", "feature", force)).success).toBe( + true + ); + } + expect(rig.plane.find("sb-users")).toBeDefined(); + // Only Xum's own access goes: the token is revoked and the include block removed. + expect(rig.writer.removed).toContain("sb-users"); + }); +}); + +describe("DaytonaSSHRuntime delete checks on the unresolved ~ workspace path", () => { + it("refuses to delete a dirty worktree under ~/... and keeps the sandbox", async () => { + execSpy.mockRestore(); + const home = path.join(rig.rootDir, "sandbox-home"); + rig.plane.addSandbox({ id: "sb-dirty" }); + const runtime = makeRuntime({ sandboxId: "sb-dirty", shared: false }, rig, { + transport: localShellTransport(home), + }); + // Deferred access never resolves `~` at creation, so this is the path deletion sees. + const workspacePath = runtime.getWorkspacePath("/home/me/project", "feature"); + expect(workspacePath.startsWith("~/")).toBe(true); + const checkout = path.join(home, workspacePath.slice(2)); + await fs.mkdir(checkout, { recursive: true }); + const git = (...args: string[]) => + execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...args], { + cwd: checkout, + }); + git("init", "-q", "-b", "main"); + await fs.writeFile(path.join(checkout, "a.txt"), "a\n"); + git("add", "."); + git("commit", "-q", "-m", "init"); + await fs.writeFile(path.join(checkout, "a.txt"), "uncommitted\n"); + + const result = await runtime.deleteWorkspace("/home/me/project", "feature", false); + + expect(result.success).toBe(false); + if (!result.success) expect(result.error).toContain("uncommitted changes"); + expect(rig.plane.find("sb-dirty")).toBeDefined(); + }); +}); + +describe("DaytonaSSHRuntime ssh token redaction", () => { + it("keeps the token (the ssh user name) out of command stderr and thrown errors", async () => { + execSpy.mockRestore(); + rig.plane.addSandbox({ id: "sb-tok" }); + await rig.service.ensureSshAccess("sb-tok"); + const token = rig.writer.tokens.get("sb-tok"); + if (!token) throw new Error("expected a minted token"); + let refuseConnections = false; + const local = localShellTransport(rig.rootDir); + const runtime = makeRuntime({ sandboxId: "sb-tok" }, rig, { + transport: { + ...local, + spawnRemoteProcess: (command, options) => + refuseConnections + ? Promise.reject( + new RuntimeError(`${token}@127.0.0.1: Permission denied (publickey).`, "network") + ) + : local.spawnRemoteProcess(command, options), + }, + }); + + // OpenSSH's message, split across two writes inside the token. + const result = await runtimeHelpers.execBuffered( + runtime, + `printf '%s' '${token.slice(0, 2)}' >&2; sleep 0.05; ` + + `printf '%s@127.0.0.1: Permission denied (publickey).\\n' '${token.slice(2)}' >&2; exit 255`, + { cwd: "/tmp", timeout: 10 } + ); + expect(result.stderr).not.toContain(token); + expect(result.stderr).toContain(`${DAYTONA_TOKEN_PLACEHOLDER}@127.0.0.1: Permission denied`); + + refuseConnections = true; + const error = await runtime.exec("true", { cwd: "/tmp", timeout: 10 }).then( + () => new Error("exec unexpectedly succeeded"), + (rejection: unknown) => rejection + ); + expect(String(error)).not.toContain(token); + expect(String(error)).toContain(DAYTONA_TOKEN_PLACEHOLDER); + }); +}); + +describe("DaytonaSSHRuntime reconnect after a stop", () => { + it("restarts a sandbox that stopped within the ready cache and retries the command once", async () => { + const sandbox = rig.plane.addSandbox({ id: "sb-nap" }); + const local = localShellTransport(rig.rootDir); + const transport: SSHTransport = { + ...local, + spawnRemoteProcess: (command, options) => + sandbox.state === "started" + ? local.spawnRemoteProcess(command, options) + : Promise.reject(new RuntimeError("Connection closed by 127.0.0.1 port 22", "network")), + }; + const turn = makeRuntime({ sandboxId: "sb-nap" }, rig, { transport }); + expect(await turn.ensureReady()).toEqual({ ready: true }); + sandbox.state = "stopped"; + + // A UI or background runtime never starts the (billed) sandbox. + const background = makeRuntime({ sandboxId: "sb-nap" }, rig, { transport }); + expect(background.exec("echo ok", { cwd: "/tmp", timeout: 10 })).rejects.toThrow( + "Connection closed" + ); + expect(rig.plane.countCalls("POST /sandbox/sb-nap/start")).toBe(0); + + const stream = await turn.exec("echo ok", { cwd: "/tmp", timeout: 10 }); + expect(await new Response(stream.stdout).text()).toBe("ok\n"); + expect(await stream.exitCode).toBe(0); + expect(rig.plane.countCalls("POST /sandbox/sb-nap/start")).toBe(1); + }); }); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 2020e52c53a..925af612600 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -15,6 +15,8 @@ import { randomBytes } from "crypto"; import type { EnsureReadyOptions, EnsureReadyResult, + ExecOptions, + InitLogger, RuntimeCreateFlags, RuntimeStatusEvent, WorkspaceCreationParams, @@ -22,7 +24,10 @@ import type { WorkspaceForkParams, WorkspaceForkResult, WorkspaceInitParams, + WorkspaceInitResult, } from "./Runtime"; +import { isRuntimeTransportError } from "./Runtime"; +import type { SpawnResult } from "./RemoteRuntime"; import { SSHRuntime, type SSHRuntimeConfig } from "./SSHRuntime"; import type { SSHTransport } from "./transports"; import { @@ -48,6 +53,11 @@ import { log } from "@/node/services/log"; import { execBuffered } from "@/node/utils/runtime/helpers"; import { seedAgentHome, type AgentHomeSeedSources } from "./daytonaAgentHomeSeed"; import { expandTildeForSSH } from "./tildeExpansion"; +import { + redactChildStderr, + redactDaytonaError, + redactDaytonaTokens, +} from "./daytonaTokenRedaction"; /** The DaytonaService surface this runtime uses (tests pass an in-memory fake). */ export type DaytonaRuntimeService = Pick< @@ -64,6 +74,11 @@ export type DaytonaRuntimeService = Pick< | "ensureSandboxReady" | "isSandboxReady" | "markSandboxReady" + | "invalidateSandboxReady" + | "isSandboxReferencedElsewhere" + | "releaseSshAccess" + | "recordSetupFailure" + | "getSetupFailure" >; export interface DaytonaSSHRuntimeConfig extends SSHRuntimeConfig { @@ -143,6 +158,22 @@ type DeleteResult = { success: true; deletedPath: string } | { success: false; e type StatusEmitter = (phase: RuntimeStatusEvent["phase"], detail?: string) => void; +/** + * What deleting a workspace does to its sandbox: "keep" it (another workspace, archived ones + * included, still references it), "release" only Xum's ssh access (the last workspace on an + * attached sandbox), or "delete" it (the last workspace on a sandbox Xum created). + */ +type SandboxFate = "keep" | "release" | "delete"; + +function redactingInitLogger(initLogger: InitLogger): InitLogger { + return { + ...initLogger, + logStep: (line) => initLogger.logStep(redactDaytonaTokens(line)), + logStdout: (line) => initLogger.logStdout(redactDaytonaTokens(line)), + logStderr: (line) => initLogger.logStderr(redactDaytonaTokens(line)), + }; +} + function makeStatusEmitter(statusSink: EnsureReadyOptions["statusSink"]): StatusEmitter { return (phase, detail) => statusSink?.({ phase, runtimeType: "ssh", detail }); } @@ -165,10 +196,15 @@ function changesCompute(sandbox: DaytonaSandbox, preset: DaytonaSizePreset): boo * `runtime instanceof SSHRuntime` (PTY, tools, MCP, bash AI proxy) treats Daytona as SSH. */ export class DaytonaSSHRuntime extends SSHRuntime { - private daytonaConfig: DaytonaSandboxConfig; + private readonly daytonaConfig: DaytonaSandboxConfig; private readonly daytonaService: DaytonaRuntimeService; private readonly workspaceId?: string; private readonly agentHomeSeedSources?: AgentHomeSeedSources; + /** + * Set once ensureReady passes on this instance. Only such a runtime (a turn's) restarts a + * sandbox that stopped under it; UI and background runtimes never start a billed sandbox. + */ + private restartsStoppedSandbox = false; /** As Coder: the sandbox does not exist yet at create time, so skip host access. */ readonly createFlags: RuntimeCreateFlags = { @@ -241,7 +277,18 @@ export class DaytonaSSHRuntime extends SSHRuntime { errorType: "runtime_not_ready", }; } + const setupFailure = this.daytonaService.getSetupFailure(sandboxId); + if (setupFailure) { + return { + ready: false, + error: + `Daytona sandbox setup failed: ${setupFailure}. Xum deleted the sandbox it had ` + + `created; delete this workspace and create a new one.`, + errorType: "runtime_not_ready", + }; + } if (this.daytonaService.isSandboxReady(sandboxId)) { + this.restartsStoppedSandbox = true; return { ready: true }; } @@ -249,7 +296,102 @@ export class DaytonaSSHRuntime extends SSHRuntime { emitStatus("checking"); const failure = await this.bringSandboxUp(sandboxId, emitStatus, options?.signal); if (failure) return failure; - return this.markReadyIfRepoPresent(sandboxId, options, emitStatus); + const result = await this.markReadyIfRepoPresent(sandboxId, options, emitStatus); + if (result.ready) this.restartsStoppedSandbox = true; + return result; + } + + /** + * Every ssh command to the sandbox goes through here: its stderr and errors are redacted + * (the token is the ssh user name). A connection failure on a turn's runtime whose sandbox + * stopped within the ready cache starts the sandbox again and retries once. + */ + protected override async spawnRemoteProcess( + fullCommand: string, + options: ExecOptions & { deadlineMs?: number } + ): Promise { + let result: SpawnResult; + try { + result = await super.spawnRemoteProcess(fullCommand, options); + } catch (error) { + if (!(await this.restartIfStopped(error, options.abortSignal))) { + throw redactDaytonaError(error); + } + try { + result = await super.spawnRemoteProcess(fullCommand, options); + } catch (retryError) { + throw redactDaytonaError(retryError); + } + } + redactChildStderr(result.process); + const onExit = result.onExit; + return { + ...result, + onExit: (exitCode, stderr) => { + // The ready cache must not hide a sandbox that stopped: the next ensureReady checks. + const sandboxId = this.daytonaConfig.sandboxId; + if (sandboxId && this.isTransportFailureExit(exitCode, stderr)) { + this.daytonaService.invalidateSandboxReady(sandboxId); + } + onExit?.(exitCode, stderr); + }, + }; + } + + /** True when the sandbox was found stopped (or starting) and is now started again. */ + private async restartIfStopped( + error: unknown, + abortSignal: AbortSignal | undefined + ): Promise { + const sandboxId = this.daytonaConfig.sandboxId; + if (!sandboxId || !this.restartsStoppedSandbox || !isRuntimeTransportError(error)) { + return false; + } + if (abortSignal?.aborted) return false; + this.daytonaService.invalidateSandboxReady(sandboxId); + if (!(await this.isSandboxDown(sandboxId))) return false; + return this.restartSandbox(sandboxId, abortSignal); + } + + private async restartSandbox( + sandboxId: string, + abortSignal: AbortSignal | undefined + ): Promise { + log.info("Daytona sandbox is not running; starting it and retrying the command once", { + sandboxId, + }); + const failure = await this.bringSandboxUp(sandboxId, () => undefined, abortSignal); + if (failure && !failure.ready) { + log.warn("Could not restart the Daytona sandbox", { sandboxId, error: failure.error }); + return false; + } + return true; + } + + /** False when the sandbox runs (the failure is something else) or its state is unknown. */ + private async isSandboxDown(sandboxId: string): Promise { + try { + return (await this.daytonaService.getSandboxState(sandboxId)).state !== "started"; + } catch (stateError) { + log.warn("Could not read Daytona sandbox state after an ssh failure", { + sandboxId, + error: getErrorMessage(stateError), + }); + return false; + } + } + + /** Init output (sync, git push, hook) can quote ssh's stderr, so it is redacted too. */ + override async initWorkspace(params: WorkspaceInitParams): Promise { + try { + const result = await super.initWorkspace({ + ...params, + initLogger: redactingInitLogger(params.initLogger), + }); + return result.error ? { ...result, error: redactDaytonaTokens(result.error) } : result; + } catch (error) { + throw redactDaytonaError(error); + } } /** Start the sandbox if needed and refresh ssh access; a failure result, or undefined. */ @@ -347,6 +489,8 @@ export class DaytonaSSHRuntime extends SSHRuntime { ...daytona, sandboxId, shared: false, + // A creation request never reuses a sandbox, whatever a hand-edited config says. + forked: undefined, cpu: preset.cpu, memoryGiB: preset.memoryGiB, diskGiB: preset.diskGiB, @@ -418,7 +562,8 @@ export class DaytonaSSHRuntime extends SSHRuntime { if (!sandboxId) { throw new Error("Daytona sandbox id is required (should be set by finalizeConfig)"); } - const creates = this.daytonaConfig.shared !== true; + // A fork reuses its source's sandbox: create nothing (and so never delete it on failure). + const creates = this.daytonaConfig.shared !== true && this.daytonaConfig.forked !== true; let createRequested = false; try { @@ -431,10 +576,12 @@ export class DaytonaSSHRuntime extends SSHRuntime { await this.connectAndSeed(sandboxId, creates, params); await this.prepareWorkspaceDirectory(params); } catch (error) { - const errorMsg = getErrorMessage(error); + const errorMsg = redactDaytonaTokens(getErrorMessage(error)); log.error("Daytona sandbox setup failed", { sandboxId, error: errorMsg }); params.initLogger.logStderr(`Daytona sandbox setup failed: ${errorMsg}`); if (createRequested) { + // Later turns report this reason instead of "deleted outside Xum". + this.daytonaService.recordSetupFailure(sandboxId, errorMsg); await this.deleteCreatedSandbox(sandboxId, params.initLogger); } throw new Error(`Daytona sandbox setup failed: ${errorMsg}`); @@ -564,33 +711,29 @@ export class DaytonaSSHRuntime extends SSHRuntime { } /** - * Keep the inherited worktree fork (the child shares the parent's sandbox) and mark both - * workspaces shared, so deleting either never deletes the sandbox under the other. + * Keep the inherited worktree fork: the child reuses the parent's sandbox (`forked`, so its + * setup creates none). Neither workspace is marked attached: deleting either keeps the + * sandbox while the other references it, and the last one deletes it. */ override async forkWorkspace(params: WorkspaceForkParams): Promise { const result = await super.forkWorkspace(params); // Sub-agents must share the parent's sandbox: never fall back to creating a new one. if (!result.success) return { ...result, failureIsFatal: true }; - - const sharedDaytonaConfig = { ...this.daytonaConfig, shared: true }; - // So a postCreateSetup on this instance (for the fork) skips creating a sandbox. - this.daytonaConfig = sharedDaytonaConfig; - const sharedRuntimeConfig = { - type: "ssh" as const, - ...this.getConfig(), - daytona: sharedDaytonaConfig, - }; return { ...result, - forkedRuntimeConfig: sharedRuntimeConfig, - sourceRuntimeConfig: sharedRuntimeConfig, + forkedRuntimeConfig: { + type: "ssh" as const, + ...this.getConfig(), + daytona: { ...this.daytonaConfig, forked: true }, + }, }; } /** - * Delete the workspace; delete the sandbox too unless it is shared. The SSH dirty and - * unpushed checks always run first, so a stopped or archived sandbox is started for them: - * unlike Coder, never delete unchecked work to avoid a start. Force skips the checks. + * Delete the workspace, and the sandbox too when this is the last workspace on a sandbox + * Xum created (see SandboxFate). The SSH dirty and unpushed checks always run first, so a + * stopped or archived sandbox is started for them: unlike Coder, never delete unchecked work + * to avoid a start. Force skips the checks. */ override async deleteWorkspace( projectPath: string, @@ -601,7 +744,6 @@ export class DaytonaSSHRuntime extends SSHRuntime { options?: { keepBranch?: boolean } ): Promise { const sandboxId = this.daytonaConfig.sandboxId; - const shared = this.daytonaConfig.shared === true; const deletedPath = this.getWorkspacePath(projectPath, workspaceName); const sshDelete = () => super.deleteWorkspace(projectPath, workspaceName, force, abortSignal, trusted, options); @@ -611,21 +753,29 @@ export class DaytonaSSHRuntime extends SSHRuntime { return sshDelete(); } + const fate = this.sandboxFateOnDelete(sandboxId); // Force ("cancel creation" or an explicit force delete): the sandbox may not be reachable - // yet, so delete it without SSH. A shared sandbox only loses this workspace's checkout. - if (force && !shared) { + // yet, so delete it without SSH. A kept sandbox only loses this workspace's checkout. + if (force && fate === "delete") { return this.deleteOwnedSandbox(sandboxId, deletedPath, undefined); } const prepared = await this.startForDeleteChecks(sandboxId, abortSignal); if (prepared === "missing") { - return this.forgetMissingSandbox(sandboxId, deletedPath, shared); + return this.forgetMissingSandbox(sandboxId, deletedPath, fate); } // A forced delete still tries the SSH cleanup; a normal one never skips the checks. if (prepared !== "ready" && !force) { return { success: false, error: prepared.error }; } - return this.deleteAfterChecks(sandboxId, await sshDelete(), shared); + return this.deleteAfterChecks(sandboxId, await sshDelete(), fate); + } + + private sandboxFateOnDelete(sandboxId: string): SandboxFate { + if (this.daytonaService.isSandboxReferencedElsewhere(sandboxId, this.workspaceId)) { + return "keep"; + } + return this.daytonaConfig.shared === true ? "release" : "delete"; } /** @@ -652,28 +802,68 @@ export class DaytonaSSHRuntime extends SSHRuntime { } } + /** + * Fail closed: the workspace path is still the unresolved `~/...` default (deferred access + * skips resolution at creation), and a "missing" checkout deletes the whole sandbox. So + * resolve it on the sandbox first; a path that does not resolve to an absolute one throws, + * which keeps the workspace and its sandbox. + */ + protected override async checkWorkspaceDeletable( + deletedPath: string, + force: boolean, + trusted: boolean | undefined, + abortSignal?: AbortSignal + ): Promise<"ok" | "missing" | { error: string }> { + const resolvedPath = await this.resolvePath(deletedPath); + if (!path.posix.isAbsolute(resolvedPath)) { + throw new Error(`Could not resolve the workspace path ${deletedPath} on the sandbox`); + } + return super.checkWorkspaceDeletable(resolvedPath, force, trusted, abortSignal); + } + private forgetMissingSandbox( sandboxId: string, deletedPath: string, - shared: boolean + fate: SandboxFate ): Promise { log.debug("Daytona sandbox already gone, skipping SSH cleanup", { sandboxId }); - // Still drop the token and include block of a sandbox this workspace owned. - return shared - ? Promise.resolve({ success: true, deletedPath }) - : this.deleteOwnedSandbox(sandboxId, deletedPath, undefined); + // Still drop the token and include block of a sandbox no other workspace uses. + return this.settleSandbox(sandboxId, { success: true, deletedPath }, fate, undefined); } /** A failed check (dirty or unpushed work) keeps the workspace and its sandbox. */ private deleteAfterChecks( sandboxId: string, sshResult: DeleteResult, - shared: boolean + fate: SandboxFate ): Promise { - if (!sshResult.success || shared) { + if (!sshResult.success) { return Promise.resolve(sshResult); } - return this.deleteOwnedSandbox(sandboxId, sshResult.deletedPath, "SSH delete succeeded, but "); + return this.settleSandbox(sandboxId, sshResult, fate, "SSH delete succeeded, but "); + } + + private async settleSandbox( + sandboxId: string, + result: { success: true; deletedPath: string }, + fate: SandboxFate, + errorPrefix: string | undefined + ): Promise { + if (fate === "delete") { + return this.deleteOwnedSandbox(sandboxId, result.deletedPath, errorPrefix); + } + if (fate === "release") { + try { + await this.daytonaService.releaseSshAccess(sandboxId); + } catch (error) { + // The token expires within the hour; the workspace is gone either way. + log.warn("Failed to release Daytona ssh access after deleting a workspace", { + sandboxId, + error: getErrorMessage(error), + }); + } + } + return result; } /** Start the sandbox (if stopped or archived) so the dirty and unpushed checks can run. */ diff --git a/src/node/runtime/SSH2ConnectionPool.test.ts b/src/node/runtime/SSH2ConnectionPool.test.ts index 1e0008dbe2f..591f7412653 100644 --- a/src/node/runtime/SSH2ConnectionPool.test.ts +++ b/src/node/runtime/SSH2ConnectionPool.test.ts @@ -112,8 +112,11 @@ describe.skipIf(process.platform === "win32")( // ECDSA keys: ssh2's ed25519 generator emits an unparseable key ~0.4% of the time under Bun. const newPrivateKey = () => utils.generateKeyPairSync("ecdsa", { bits: 256 }).private; - async function startServer(onConnection: ConstructorParameters[1]) { - const server = new Server({ hostKeys: [newPrivateKey()] }, onConnection); + async function startServer( + onConnection: ConstructorParameters[1], + hostKey = newPrivateKey() + ) { + const server = new Server({ hostKeys: [hostKey] }, onConnection); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-ssh2-pool-")); const identityFile = path.join(dir, "id_ecdsa"); @@ -175,6 +178,60 @@ describe.skipIf(process.platform === "win32")( expect(waits.length).toBeGreaterThan(0); }); + // ssh2 reads no known_hosts: without this check a Daytona alias accepted any gateway key. + it("connects to a Daytona alias only when the gateway key matches the pinned one", async () => { + const hostKey = newPrivateKey(); + const { config } = await startServer((conn) => { + conn.on("authentication", (ctx) => ctx.accept()); + conn.on("error", () => undefined); + }, hostKey); + const home = await fs.mkdtemp(path.join(os.tmpdir(), "xum-ssh2-daytona-home-")); + // The ssh config parser reads USERPROFILE first (sshConfigParser.test.ts does the same). + const originalUserProfile = process.env.USERPROFILE; + process.env.USERPROFILE = home; + try { + const knownHostsPath = path.join(home, "daytona_known_hosts"); + await fs.mkdir(path.join(home, ".ssh")); + await fs.writeFile( + path.join(home, ".ssh", "config"), + [ + "Host sb-1.xum--daytona", + " HostName 127.0.0.1", + ` Port ${config.port}`, + " HostKeyAlias gateway.test", + ` UserKnownHostsFile ${knownHostsPath}`, + " StrictHostKeyChecking yes", + ].join("\n") + ); + const pinLine = (privateKey: string) => { + const parsed = utils.parseKey(privateKey); + if (parsed instanceof Error) throw parsed; + return `gateway.test ${parsed.type} ${parsed.getPublicSSH().toString("base64")}\n`; + }; + const daytona: SSHConnectionConfig = { + host: "sb-1.xum--daytona", + identityFile: config.identityFile, + }; + + await fs.writeFile(knownHostsPath, pinLine(newPrivateKey())); + const refused = await new SSH2ConnectionPool() + .acquireConnection(daytona, { maxWaitMs: 0 }) + .then( + () => new Error("acquisition unexpectedly succeeded"), + (error: unknown) => error + ); + expect(String(refused)).toContain("Host denied"); + + await fs.writeFile(knownHostsPath, pinLine(hostKey)); + const entry = await new SSH2ConnectionPool().acquireConnection(daytona, { maxWaitMs: 0 }); + entry.client.end(); + } finally { + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + await fs.rm(home, { recursive: true, force: true }); + } + }); + it("a retired client's late error does not put the host into backoff", async () => { const { config } = await startServer((conn) => { conn.on("authentication", (ctx) => ctx.accept()); diff --git a/src/node/runtime/SSH2ConnectionPool.ts b/src/node/runtime/SSH2ConnectionPool.ts index dcb331c4d1f..0f51c5a8dee 100644 --- a/src/node/runtime/SSH2ConnectionPool.ts +++ b/src/node/runtime/SSH2ConnectionPool.ts @@ -28,6 +28,62 @@ import { withSshBackoffJitter, } from "./sshBackoff"; import { isPermanentSSHFailure } from "./Runtime"; +import { isXumDaytonaHost } from "@/common/constants/daytona"; + +/** + * Daytona aliases pin the gateway key in Xum's own known_hosts (UserKnownHostsFile in the + * include block). ssh2 reads no known_hosts, so check the server key against those lines here. + * Fails closed: no readable pinned key for the gateway accepts no server. + */ +async function createPinnedHostKeyVerifier( + resolved: Pick +): Promise<(key: Buffer) => boolean> { + const content = await readPinnedKnownHosts(resolved.userKnownHostsFile); + const lookupHost = resolved.hostKeyAlias ?? resolved.hostName; + const pinned = parsePinnedHostKeys( + content, + new Set([lookupHost, `[${lookupHost}]:${resolved.port}`]) + ); + return (key) => pinned.some((pinnedKey) => pinnedKey.equals(key)); +} + +/** Unreadable or unset reads as empty: no pinned key, so no server is accepted. */ +async function readPinnedKnownHosts(file: string | undefined): Promise { + if (!file) return ""; + try { + return await fs.readFile(file, "utf8"); + } catch (error) { + log.warn("Could not read the pinned known_hosts of a Daytona gateway", { + file, + error: getErrorMessage(error), + }); + return ""; + } +} + +/** The keys of the known_hosts lines whose (unhashed) host field names one of `hostPatterns`. */ +function parsePinnedHostKeys(content: string, hostPatterns: ReadonlySet): Buffer[] { + const pinned: Buffer[] = []; + for (const line of content.split("\n")) { + const [hosts, , key] = line.trim().split(/\s+/); + if (!hosts || !key || hosts.startsWith("#")) continue; + if (hosts.split(",").some((host) => hostPatterns.has(host))) { + pinned.push(Buffer.from(key, "base64")); + } + } + return pinned; +} + +// TODO(ethanndickson): Implement known_hosts support for SSH2 and restore interactive host key +// verification once approvals can be persisted between connections. +function createHostVerifier( + host: string, + resolved: Parameters[0] +): Promise<(key: Buffer) => boolean> { + return isXumDaytonaHost(host) + ? createPinnedHostKeyVerifier(resolved) + : Promise.resolve(() => true); +} let sshPromptService: SshPromptService | undefined; @@ -584,6 +640,7 @@ export class SSH2ConnectionPool { ? baseIdentityFiles : DEFAULT_IDENTITY_FILES.map((file) => expandLocalPath(file)); const username = resolvedConfig.user ?? getDefaultUsername(); + const hostVerifier = await createHostVerifier(config.host, resolvedConfig); const proxyTokens = { host: resolvedConfig.hostName, port: resolvedConfig.port, @@ -761,10 +818,7 @@ export class SSH2ConnectionPool { keepaliveInterval: 5000, keepaliveCountMax: 2, ...(privateKey ? { privateKey } : {}), - // TODO(ethanndickson): Implement known_hosts support for SSH2 - // and restore interactive host key verification once approvals - // can be persisted between connections. - hostVerifier: () => true, + hostVerifier, }; client.connect(connectOptions); diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index 3fe4d9298aa..69c5db77181 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -2132,7 +2132,7 @@ export class SSHRuntime extends RemoteRuntime { args.push("-o", "ServerAliveCountMax=2"); // Match the runtime's host key policy (permissive in headless mode). - appendOpenSSHHostKeyPolicyArgs(args); + appendOpenSSHHostKeyPolicyArgs(args, config.host); return args.join(" "); } @@ -3257,15 +3257,18 @@ export class SSHRuntime extends RemoteRuntime { ): Promise<"ok" | "missing" | { error: string }> { // Disable git hooks for untrusted projects const nhp = gitNoHooksPrefix(trusted); + // quoteForRemote expands a leading ~ remotely: a deferred-access runtime (Coder, Daytona) + // keeps the unresolved `~/...` path, and a quoted `~` would make a dirty checkout look missing. + const deletedPathArg = this.quoteForRemote(deletedPath); // Combine all pre-deletion checks into a single bash script to minimize round trips // Exit codes: 0=ok to delete, 1=uncommitted changes, 2=unpushed commits, 3=doesn't exist const checkScript = force ? // When force=true, only check existence - `test -d ${shescape.quote(deletedPath)} || exit 3` + `test -d ${deletedPathArg} || exit 3` : // When force=false, perform all safety checks ` - test -d ${shescape.quote(deletedPath)} || exit 3 - cd ${shescape.quote(deletedPath)} || exit 1 + test -d ${deletedPathArg} || exit 3 + cd ${deletedPathArg} || exit 1 git diff --quiet --exit-code && git diff --quiet --cached --exit-code || exit 1 if git remote | grep -q .; then # First, check the original condition: any commits not in any remote diff --git a/src/node/runtime/daytonaTokenRedaction.ts b/src/node/runtime/daytonaTokenRedaction.ts new file mode 100644 index 00000000000..36dba27f772 --- /dev/null +++ b/src/node/runtime/daytonaTokenRedaction.ts @@ -0,0 +1,108 @@ +import type { ChildProcess } from "child_process"; +import { StringDecoder } from "string_decoder"; +import { Transform } from "stream"; +import { isXumDaytonaHost } from "@/common/constants/daytona"; +import { getErrorMessage } from "@/common/utils/errors"; +import { log } from "@/node/services/log"; + +/** + * Daytona's SSH gateway takes the access token as the ssh user name, so OpenSSH messages such + * as `@host: Permission denied` carry it. DaytonaService remembers every token it mints + * (live, replaced and revoked ones alike, until well after they expire), and what Xum keeps + * from a Daytona host's ssh stderr and errors goes through redactDaytonaTokens first. + */ +export const DAYTONA_TOKEN_PLACEHOLDER = ""; + +// Tokens are minted for an hour; a revoked or expired one can still show up in a late error. +const REMEMBER_MS = 3 * 60 * 60_000; +const MAX_REMEMBERED_TOKENS = 256; + +/** token -> when it was remembered */ +const rememberedTokens = new Map(); + +export function rememberDaytonaToken(token: string, now = Date.now()): void { + if (!token) return; + rememberedTokens.delete(token); + rememberedTokens.set(token, now); + for (const [known, at] of rememberedTokens) { + if (rememberedTokens.size <= MAX_REMEMBERED_TOKENS && now - at < REMEMBER_MS) break; + rememberedTokens.delete(known); + } +} + +export function redactDaytonaTokens(text: string): string { + let redacted = text; + for (const token of rememberedTokens.keys()) { + if (redacted.includes(token)) redacted = redacted.split(token).join(DAYTONA_TOKEN_PLACEHOLDER); + } + return redacted; +} + +/** Redact the message (and stack) of an Error in place; keeps its class for callers. */ +export function redactDaytonaError(error: unknown): unknown { + if (!(error instanceof Error)) { + return typeof error === "string" ? redactDaytonaTokens(error) : error; + } + error.message = redactDaytonaTokens(error.message); + if (error.stack) error.stack = redactDaytonaTokens(error.stack); + return error; +} + +/** How many trailing chars of `text` could be the start of a remembered token. */ +function tokenPrefixSuffixLength(text: string): number { + let held = 0; + for (const token of rememberedTokens.keys()) { + for (let length = Math.min(token.length - 1, text.length); length > held; length--) { + if (text.endsWith(token.slice(0, length))) { + held = length; + break; + } + } + } + return held; +} + +/** + * Redact a byte stream. A token split across chunks is caught by holding back a tail that could + * start one until the next chunk (or the end) arrives. + */ +export function createDaytonaRedactingStream(): Transform { + const decoder = new StringDecoder("utf8"); + let carry = ""; + const emit = (stream: Transform, text: string) => { + if (text) stream.push(Buffer.from(redactDaytonaTokens(text), "utf8")); + }; + return new Transform({ + transform(chunk: Buffer, _encoding, callback) { + const text = carry + decoder.write(chunk); + const held = tokenPrefixSuffixLength(text); + carry = text.slice(text.length - held); + emit(this, text.slice(0, text.length - held)); + callback(); + }, + flush(callback) { + emit(this, carry + decoder.end()); + carry = ""; + callback(); + }, + }); +} + +/** redactChildStderr for an ssh process to a Daytona alias; other hosts are left alone. */ +export function redactDaytonaHostStderr(child: ChildProcess, host: string): void { + if (isXumDaytonaHost(host)) redactChildStderr(child); +} + +/** Route a spawned ssh process's stderr through createDaytonaRedactingStream. */ +export function redactChildStderr(child: ChildProcess): void { + const original = child.stderr; + if (!original) return; + const redacting = createDaytonaRedactingStream(); + original.on("error", (error) => { + log.debug("ssh stderr stream failed", { error: redactDaytonaTokens(getErrorMessage(error)) }); + redacting.end(); + }); + original.pipe(redacting); + // Every reader of this process's stderr (exec streams, error reporting) attaches after this. + Object.defineProperty(child, "stderr", { value: redacting, configurable: true }); +} diff --git a/src/node/runtime/sshConfigParser.ts b/src/node/runtime/sshConfigParser.ts index b16451cc2f5..a59cdde31aa 100644 --- a/src/node/runtime/sshConfigParser.ts +++ b/src/node/runtime/sshConfigParser.ts @@ -24,6 +24,9 @@ export interface ResolvedSSHConfig { port: number; identityFiles: string[]; proxyCommand?: string; + /** First UserKnownHostsFile, home-expanded (ssh2 verifies pinned Daytona keys against it). */ + userKnownHostsFile?: string; + hostKeyAlias?: string; } function getHomeDir(): string { @@ -404,5 +407,23 @@ export async function resolveSSHConfig(host: string): Promise port: Number.isFinite(port) ? port : DEFAULT_SSH_PORT, identityFiles, proxyCommand, + ...readHostKeyPinning(computed, homeDir), + }; +} + +function readHostKeyPinning( + computed: Parameters[0], + homeDir: string +): Pick { + const knownHostsValue = toStringValue(getConfigValue(computed, "UserKnownHostsFile")); + const userKnownHostsFile = knownHostsValue + ? splitIncludeArgs(knownHostsValue) + .map((file) => expandHomePath(file, homeDir)) + .at(0) + : undefined; + const hostKeyAlias = toStringValue(getConfigValue(computed, "HostKeyAlias")); + return { + ...(userKnownHostsFile ? { userKnownHostsFile } : {}), + ...(hostKeyAlias ? { hostKeyAlias } : {}), }; } diff --git a/src/node/runtime/sshConnectionPool.test.ts b/src/node/runtime/sshConnectionPool.test.ts index ad75cc2125f..31d43091e4c 100644 --- a/src/node/runtime/sshConnectionPool.test.ts +++ b/src/node/runtime/sshConnectionPool.test.ts @@ -140,7 +140,7 @@ describe("appendOpenSSHHostKeyPolicyArgs", () => { const args: string[] = ["-T"]; setOpenSSHHostKeyPolicyMode("headless-fallback"); - appendOpenSSHHostKeyPolicyArgs(args); + appendOpenSSHHostKeyPolicyArgs(args, "example.test"); expect(args).toEqual([ "-T", @@ -155,7 +155,17 @@ describe("appendOpenSSHHostKeyPolicyArgs", () => { const args: string[] = ["-T"]; setOpenSSHHostKeyPolicyMode("strict"); - appendOpenSSHHostKeyPolicyArgs(args); + appendOpenSSHHostKeyPolicyArgs(args, "example.test"); + + expect(args).toEqual(["-T"]); + }); + + test("never overrides the pinned gateway key of a Daytona alias", () => { + const args: string[] = ["-T"]; + setOpenSSHHostKeyPolicyMode("headless-fallback"); + + // A command-line -o wins over the include file's StrictHostKeyChecking yes. + appendOpenSSHHostKeyPolicyArgs(args, "xum-feature-a1b2c3.xum--daytona"); expect(args).toEqual(["-T"]); }); @@ -163,7 +173,7 @@ describe("appendOpenSSHHostKeyPolicyArgs", () => { test("defaults to headless-fallback", () => { const args: string[] = ["-T"]; - appendOpenSSHHostKeyPolicyArgs(args); + appendOpenSSHHostKeyPolicyArgs(args, "example.test"); expect(args).toEqual([ "-T", diff --git a/src/node/runtime/sshConnectionPool.ts b/src/node/runtime/sshConnectionPool.ts index edd22dd28b2..13bd008fc41 100644 --- a/src/node/runtime/sshConnectionPool.ts +++ b/src/node/runtime/sshConnectionPool.ts @@ -31,6 +31,8 @@ import { withSshBackoffJitter, } from "./sshBackoff"; import { isPermanentSSHFailure } from "./Runtime"; +import { isXumDaytonaHost } from "@/common/constants/daytona"; +import { redactDaytonaHostStderr } from "./daytonaTokenRedaction"; export type OpenSSHHostKeyPolicyMode = "strict" | "headless-fallback"; @@ -49,8 +51,13 @@ export function isInteractiveHostKeyApprovalAvailable(): boolean { return sshPromptService?.hasInteractiveResponder() === true; } -export function appendOpenSSHHostKeyPolicyArgs(args: string[]): void { - if (hostKeyPolicyMode === "strict") { +/** + * Daytona aliases are exempt from the headless fallback: their include block pins the gateway + * key (StrictHostKeyChecking yes, Xum's own known_hosts), and a command-line `-o` would + * override it and accept any key. + */ +export function appendOpenSSHHostKeyPolicyArgs(args: string[], host: string): void { + if (hostKeyPolicyMode === "strict" || isXumDaytonaHost(host)) { return; } @@ -531,7 +538,7 @@ export class SSHConnectionPool { // Scope insecure host-key fallback to explicitly headless contexts where // no verification service is wired (e.g. CLI/test harness without UI). // Responder liveness only affects askpass prompt mechanics, not trust policy. - appendOpenSSHHostKeyPolicyArgs(args); + appendOpenSSHHostKeyPolicyArgs(args, config.host); args.push(config.host, "echo ok"); @@ -568,6 +575,8 @@ export class SSHConnectionPool { stdio: ["ignore", "pipe", "pipe"], ...(askpass ? { env: { ...process.env, ...askpass.env } } : {}), }); + // The probe's stderr becomes the pool's last error, which reaches the UI and logs. + redactDaytonaHostStderr(proc, config.host); let timedOut = false; let timer: ReturnType | undefined; diff --git a/src/node/runtime/transports/OpenSSHTransport.ts b/src/node/runtime/transports/OpenSSHTransport.ts index 001c199ce3c..fd8ce9bdb32 100644 --- a/src/node/runtime/transports/OpenSSHTransport.ts +++ b/src/node/runtime/transports/OpenSSHTransport.ts @@ -6,6 +6,7 @@ import * as path from "path"; import { spawnPtyProcess } from "../ptySpawn"; import { cdThenExecShell, runInPosixShell } from "../streamUtils"; import { expandTildeForSSH } from "../tildeExpansion"; +import { redactDaytonaHostStderr } from "../daytonaTokenRedaction"; import { appendOpenSSHHostKeyPolicyArgs, sshConnectionPool, @@ -108,7 +109,7 @@ export class OpenSSHTransport implements SSHTransport { sshArgs.push("-o", "ServerAliveInterval=5"); sshArgs.push("-o", "ServerAliveCountMax=2"); sshArgs.push("-o", "BatchMode=yes"); - appendOpenSSHHostKeyPolicyArgs(sshArgs); + appendOpenSSHHostKeyPolicyArgs(sshArgs, this.config.host); sshArgs.push(this.config.host, fullCommand); const process = spawn("ssh", sshArgs, { @@ -208,10 +209,11 @@ export class OpenSSHTransport implements SSHTransport { "-o", "LogLevel=ERROR" ); - appendOpenSSHHostKeyPolicyArgs(args); + appendOpenSSHHostKeyPolicyArgs(args, this.config.host); args.push(this.config.host); const master = spawn("ssh", args, { stdio: ["ignore", "ignore", "pipe"], windowsHide: true }); + redactDaytonaHostStderr(master, this.config.host); let stderr = ""; master.stderr?.on("data", (chunk: Buffer) => { if (stderr.length < 4096) stderr += chunk.toString(); @@ -235,7 +237,10 @@ export class OpenSSHTransport implements SSHTransport { if (master.exitCode === null && master.signalCode === null) master.kill(); }; const control = (...command: string[]) => - runSshClient(["-F", "/dev/null", "-S", controlPath, ...command, this.config.host]); + runSshClient( + ["-F", "/dev/null", "-S", controlPath, ...command, this.config.host], + this.config.host + ); const deadline = Date.now() + REVERSE_FORWARD_READY_TIMEOUT_MS; while ((await control("-O", "check")).code !== 0) { @@ -277,9 +282,13 @@ export class OpenSSHTransport implements SSHTransport { } /** Runs a short ssh control command (`-O ...`) and reports its exit code and stderr. */ -function runSshClient(args: string[]): Promise<{ code: number | null; stderr: string }> { +function runSshClient( + args: string[], + host: string +): Promise<{ code: number | null; stderr: string }> { return new Promise((resolve) => { const child = spawn("ssh", args, { stdio: ["ignore", "ignore", "pipe"], windowsHide: true }); + redactDaytonaHostStderr(child, host); let stderr = ""; child.stderr?.on("data", (chunk: Buffer) => { if (stderr.length < 4096) stderr += chunk.toString(); diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index b071477e427..933e13cfa8d 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10062,15 +10062,20 @@ export const BUILTIN_SKILL_FILES: Record> = { "| Sub-agent with a forked workspace | A new worktree in the same sandbox, not a second sandbox |", "| Archive | Follows **Settings > General > Remote box on archive** (default: stop) |", "| Delete | Check for uncommitted or unpushed work, then delete the sandbox (attached sandboxes are kept) |", + "| Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs |", "| Sandbox deleted outside Xum | The workspace reports that the sandbox no longer exists; Xum does not recreate it |", "", + "A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses.", + "", "With **Remote box on archive** set to delete, Xum first checks the workspace for uncommitted or unpushed work. If it finds some, or cannot run the check, it stops the sandbox instead and shows a warning.", "", "Once a minute, Xum also stops running sandboxes that carry this install's label but that no workspace uses. It never deletes them.", "", "## SSH setup", "", - "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires.", + "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered.", + "", + "Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts.", "", "OpenSSH uses the first `User` value that matches a host. If your `~/.ssh/config` has a `Host *` entry with a `User` line above Xum's block, connections to sandboxes fail. Xum checks for this and reports it; move the `User` line into a narrower `Host` entry, or below Xum's block.", "", diff --git a/src/node/services/backgroundProcessManager.test.ts b/src/node/services/backgroundProcessManager.test.ts index d3381a30476..a5f4c534a95 100644 --- a/src/node/services/backgroundProcessManager.test.ts +++ b/src/node/services/backgroundProcessManager.test.ts @@ -16,6 +16,7 @@ import { localBgWorkspaceDir, spawnProcess } from "./backgroundProcessExecutor"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; import type { BackgroundHandle, Runtime } from "@/node/runtime/Runtime"; import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; import { randomUUID } from "node:crypto"; import * as fs from "fs/promises"; import * as path from "path"; @@ -2920,6 +2921,27 @@ describe("BackgroundProcessManager", () => { }); }); + describe("hasLiveBackgroundProcesses", () => { + it("reports a job that exited without Xum noticing as not running", async () => { + // A job killed from a terminal still reads as running in the lazy snapshot; keepalive of a + // remote sandbox must not trust it. + const result = await manager.spawn(runtime, testWorkspaceId, "exit 0", { + cwd: process.cwd(), + displayName: "test-live-exited", + }); + expect(result.success).toBe(true); + if (!result.success) return; + const exitCodeFile = path.join(result.outputDir, "exit_code"); + const deadline = Date.now() + 5_000; + while (!existsSync(exitCodeFile) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + expect(manager.hasRunningBackgroundProcesses(testWorkspaceId)).toBe(true); + + expect(await manager.hasLiveBackgroundProcesses(testWorkspaceId)).toBe(false); + }); + }); + describe("terminate", () => { it("should terminate a running process", async () => { const spawnResult = await manager.spawn(runtime, testWorkspaceId, "sleep 10", { diff --git a/src/node/services/backgroundProcessManager.ts b/src/node/services/backgroundProcessManager.ts index 9ffd9fa423b..61105fe53f3 100644 --- a/src/node/services/backgroundProcessManager.ts +++ b/src/node/services/backgroundProcessManager.ts @@ -3243,6 +3243,16 @@ export class BackgroundProcessManager extends EventEmitter { + await this.refreshRunningStatuses(workspaceId); + return this.hasRunningBackgroundProcesses(workspaceId); + } + /** * Crash-orphan probe: whether a durable spawn record shows a still-running process for this * workspace that this manager does not track. Processes run under nohup/setsid, so they @@ -3601,9 +3611,9 @@ export class BackgroundProcessManager extends EventEmitter { + private async refreshRunningStatuses(workspaceId?: string): Promise { const runningProcesses = Array.from(this.processes.values()).filter( - (p) => p.status === "running" + (p) => p.status === "running" && (workspaceId === undefined || p.workspaceId === workspaceId) ); for (const proc of runningProcesses) { diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index eaf6405cd4b..c4c820cf6a4 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -3,6 +3,7 @@ import * as os from "os"; import * as path from "path"; import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; import type { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; +import type { DaytonaWorkspaceRef } from "./daytonaReconciler"; import { DaytonaService } from "./daytonaService"; /** @@ -39,6 +40,8 @@ export class FakeDaytonaControlPlane { snapshotResources = { cpu: 1, memory: 1, disk: 3 }; /** Routes (`METHOD /path`) that never answer, for timeout tests. */ readonly hangingRoutes = new Set(); + /** Routes (`METHOD /path`) that answer 500. */ + readonly failingRoutes = new Set(); private nextId = 1; private nextToken = 1; @@ -85,6 +88,7 @@ export class FakeDaytonaControlPlane { const json = (status: number, body?: unknown) => new Response(body === undefined ? "" : JSON.stringify(body), { status }); const fail = (status: number, message: string) => json(status, { statusCode: status, message }); + if (this.failingRoutes.has(route)) return fail(500, "Internal error"); if (route === "GET /config") { return json(200, { @@ -241,6 +245,8 @@ export interface DaytonaTestRig { writer: FakeDaytonaSshConfigWriter; service: DaytonaService; clock: { now: number }; + /** The workspaces the service sees (it deletes a sandbox only with its last one). */ + workspaces: DaytonaWorkspaceRef[]; cleanup: () => Promise; } @@ -255,6 +261,7 @@ export async function createDaytonaTestRig( const clock = { now: Date.parse("2026-01-01T00:00:00Z") }; const plane = new FakeDaytonaControlPlane(() => clock.now); const writer = new FakeDaytonaSshConfigWriter(); + const workspaces: DaytonaWorkspaceRef[] = []; const env: NodeJS.ProcessEnv = options.apiKey === null ? {} : { DAYTONA_API_KEY: options.apiKey ?? "test-key" }; const service = new DaytonaService({ @@ -267,6 +274,7 @@ export async function createDaytonaTestRig( clock.now += ms; return Promise.resolve(); }, + listWorkspaces: () => workspaces, }); return { rootDir, @@ -274,6 +282,7 @@ export async function createDaytonaTestRig( writer, service, clock, + workspaces, cleanup: () => fs.rm(rootDir, { recursive: true, force: true }), }; } diff --git a/src/node/services/daytona/daytonaLifecycleHooks.test.ts b/src/node/services/daytona/daytonaLifecycleHooks.test.ts index 837232381dd..55fd135b5ac 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.test.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.test.ts @@ -77,6 +77,27 @@ describe("createDaytonaArchiveHook", () => { expect(rig.plane.find("sb-users")?.state).toBe("started"); }); + it("leaves a sandbox another workspace references alone, else releases Xum's ssh access", async () => { + rig.plane.addSandbox({ id: "sb-shared" }); + await rig.service.ensureSshAccess("sb-shared"); + rig.workspaces.push({ + workspaceId: "fork", + runtimeConfig: metadata({ sandboxId: "sb-shared", forked: true }).runtimeConfig, + }); + + // The fork still works in the sandbox: no stop, no delete, its token stays live. + expect((await archive({ sandboxId: "sb-shared", shared: false }, "delete")).success).toBe(true); + expect(rig.plane.find("sb-shared")?.state).toBe("started"); + expect(rig.plane.revokedTokens).toEqual([]); + + // The last workspace on an attached sandbox: the sandbox stays, the token goes. + rig.workspaces.length = 0; + expect((await archive({ sandboxId: "sb-shared", shared: true }, "delete")).success).toBe(true); + expect(rig.plane.find("sb-shared")?.state).toBe("started"); + expect(rig.plane.revokedTokens).toHaveLength(1); + expect(rig.writer.removed).toEqual(["sb-shared"]); + }); + it("leaves the parent's sandbox alone when a shared-checkout sub-agent is archived", async () => { rig.plane.addSandbox({ id: "sb-parent" }); const hook = createDaytonaArchiveHook({ @@ -106,6 +127,10 @@ describe("createDaytonaArchiveHook", () => { }, "checkWorkspaceDeletable" ).mockResolvedValue({ error: "Workspace contains uncommitted changes." }); + // The Daytona check resolves the `~` workspace path on the sandbox first. + const resolvePath = spyOn(SSHRuntime.prototype, "resolvePath").mockResolvedValue( + "/home/daytona/src/proj/ws" + ); const warnings: string[] = []; try { const daytona = { sandboxId: "sb-dirty", shared: false }; @@ -121,6 +146,7 @@ describe("createDaytonaArchiveHook", () => { expect(warnings[0]).toContain("uncommitted changes"); } finally { sshCheck.mockRestore(); + resolvePath.mockRestore(); } }); }); diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index 36968a59784..1b228869640 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -1,6 +1,6 @@ import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; import { Err, Ok, type Result } from "@/common/types/result"; -import { isDedicatedDaytonaSandbox, isSSHRuntime } from "@/common/types/runtime"; +import { getDaytonaSandboxId, isDedicatedDaytonaSandbox } from "@/common/types/runtime"; import type { WorkspaceMetadata } from "@/common/types/workspace"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; @@ -11,11 +11,20 @@ const DAYTONA_STOP_TIMEOUT_MS = 60_000; /** * Archive reuses the Coder archive behavior setting (keep, stop, delete) for sandboxes Xum - * created; attached and shared sandboxes are never touched. No unarchive hook: ensureReady - * starts a stopped sandbox on the next use. + * created; attached sandboxes are never stopped or deleted, and no sandbox another workspace + * still references is touched at all. Otherwise Xum's ssh access (token, include block) is + * released. No unarchive hook: ensureReady starts a stopped sandbox and mints a token on the + * next use. */ export interface DaytonaArchiveHookOptions { - daytonaService: Pick; + daytonaService: Pick< + DaytonaService, + | "getSandboxState" + | "stopAndWait" + | "deleteSandbox" + | "isSandboxReferencedElsewhere" + | "releaseSshAccess" + >; getArchiveBehavior: () => CoderWorkspaceArchiveBehavior; /** As for Coder: model-driven archives must not stop a box under a surviving detached job. */ hasUnsettledRemoteBackgroundJobs?: ( @@ -31,31 +40,64 @@ export interface DaytonaArchiveHookOptions { export function createDaytonaArchiveHook(options: DaytonaArchiveHookOptions): BeforeArchiveHook { return async (args): Promise> => { - const sandboxId = dedicatedSandboxId(args.workspaceMetadata); + const sandboxId = unsharedSandboxId(options, args.workspaceMetadata); if (!sandboxId) return Ok(undefined); - // The archive operation's policy snapshot wins (see the Coder hook). - const behavior = args.coderWorkspaceArchiveBehavior ?? options.getArchiveBehavior(); - if (behavior === "keep") return Ok(undefined); - try { - if (behavior === "delete") { - return await deleteForArchive(options, args, sandboxId); - } - return await stopForArchive(options, args, sandboxId); - } catch (error) { - return Err(`Failed to ${behavior} Daytona sandbox "${sandboxId}": ${getErrorMessage(error)}`); - } + const result = isDedicatedDaytonaSandbox(args.workspaceMetadata.runtimeConfig) + ? await applyArchiveBehavior(options, args, sandboxId) + : Ok(undefined); + if (result.success) await releaseSshAccess(options, sandboxId); + return result; }; } -/** Only sandboxes Xum created for this workspace; attached and shared ones are never touched. */ -function dedicatedSandboxId(workspaceMetadata: WorkspaceMetadata): string | undefined { - // A shared-checkout sub-agent copies its parent's runtime config, sandbox ID included, - // without marking it shared: the sandbox belongs to the parent. +async function applyArchiveBehavior( + options: DaytonaArchiveHookOptions, + args: Parameters[0], + sandboxId: string +): Promise> { + // The archive operation's policy snapshot wins (see the Coder hook). + const behavior = args.coderWorkspaceArchiveBehavior ?? options.getArchiveBehavior(); + if (behavior === "keep") return Ok(undefined); + try { + if (behavior === "delete") { + return await deleteForArchive(options, args, sandboxId); + } + return await stopForArchive(options, args, sandboxId); + } catch (error) { + return Err(`Failed to ${behavior} Daytona sandbox "${sandboxId}": ${getErrorMessage(error)}`); + } +} + +/** The archived workspace's sandbox, unless another workspace (archived ones too) uses it. */ +function unsharedSandboxId( + options: DaytonaArchiveHookOptions, + workspaceMetadata: WorkspaceMetadata +): string | undefined { + // A shared-checkout sub-agent copies its parent's runtime config, sandbox ID included: + // the sandbox belongs to the parent. if (workspaceMetadata.taskIsolation === "none") return undefined; - const runtimeConfig = workspaceMetadata.runtimeConfig; - if (!isDedicatedDaytonaSandbox(runtimeConfig) || !isSSHRuntime(runtimeConfig)) return undefined; - return runtimeConfig.daytona?.sandboxId; + const sandboxId = getDaytonaSandboxId(workspaceMetadata.runtimeConfig); + if (!sandboxId) return undefined; + if (options.daytonaService.isSandboxReferencedElsewhere(sandboxId, workspaceMetadata.id)) { + return undefined; + } + return sandboxId; +} + +/** The archived workspace needs no token; unarchive mints a new one. */ +async function releaseSshAccess( + options: DaytonaArchiveHookOptions, + sandboxId: string +): Promise { + try { + await options.daytonaService.releaseSshAccess(sandboxId); + } catch (error) { + log.warn("Failed to release Daytona ssh access on archive", { + sandboxId, + error: getErrorMessage(error), + }); + } } /** diff --git a/src/node/services/daytona/daytonaReconciler.test.ts b/src/node/services/daytona/daytonaReconciler.test.ts index bdbef0af54b..48c18a9cc21 100644 --- a/src/node/services/daytona/daytonaReconciler.test.ts +++ b/src/node/services/daytona/daytonaReconciler.test.ts @@ -25,16 +25,23 @@ function daytonaConfig(sandboxId: string): RuntimeConfig { }; } +/** Workspace ids the reconciler asked about, in order. */ +let activityChecks: string[]; + function makeReconciler( workspaces: Array<{ id: string; runtimeConfig: RuntimeConfig; archivedAt?: string }>, activeIds: string[], callTimeoutMs?: number ): DaytonaReconciler { const config = { projects: new Map([["/home/me/project", { workspaces }]]) }; + activityChecks = []; return new DaytonaReconciler({ daytonaService: rig.service, listWorkspaces: () => listDaytonaWorkspaceRefs(config), - isWorkspaceActive: (workspaceId) => activeIds.includes(workspaceId), + isWorkspaceActive: (workspaceId) => { + activityChecks.push(workspaceId); + return Promise.resolve(activeIds.includes(workspaceId)); + }, callTimeoutMs, }); } @@ -107,4 +114,27 @@ describe("DaytonaReconciler.sweep", () => { expect(rig.writer.tokens.get("sb-1")).not.toBe(firstToken); expect(rig.plane.revokedTokens).toEqual([firstToken ?? ""]); }); + + it("keeps only started sandboxes alive and skips them all when the list fails", async () => { + rig.plane.addSandbox({ id: "sb-stopped", state: "stopped", labels: instanceLabels }); + // Attached by the user: no label of this install, so only a lookup finds it. + rig.plane.addSandbox({ id: "sb-attached" }); + const workspaces = [ + { id: "ws-stopped", runtimeConfig: daytonaConfig("sb-stopped") }, + { id: "ws-attached", runtimeConfig: daytonaConfig("sb-attached") }, + ]; + const active = ["ws-stopped", "ws-attached"]; + + await makeReconciler(workspaces, active).sweep(); + expect(rig.plane.countCalls("POST /sandbox/sb-stopped/last-activity")).toBe(0); + // Checking a stopped sandbox's jobs over ssh would start it. + expect(activityChecks).toEqual(["ws-attached"]); + expect(rig.plane.countCalls("POST /sandbox/sb-attached/last-activity")).toBe(1); + + rig.plane.failingRoutes.add("GET /sandbox"); + rig.plane.calls.length = 0; + await makeReconciler(workspaces, active).sweep(); + expect(rig.plane.calls.filter((call) => call.endsWith("/last-activity"))).toEqual([]); + expect(activityChecks).toEqual([]); + }); }); diff --git a/src/node/services/daytona/daytonaReconciler.ts b/src/node/services/daytona/daytonaReconciler.ts index 0ca686fecfa..6cfa4f418b6 100644 --- a/src/node/services/daytona/daytonaReconciler.ts +++ b/src/node/services/daytona/daytonaReconciler.ts @@ -1,5 +1,5 @@ import type { Workspace } from "@/common/types/project"; -import { isSSHRuntime, type RuntimeConfig } from "@/common/types/runtime"; +import { getDaytonaSandboxId, type RuntimeConfig } from "@/common/types/runtime"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; import type { DaytonaSandbox } from "./daytonaClient"; @@ -23,11 +23,15 @@ export interface DaytonaReconcilerOptions { | "stopSandbox" | "hasExpiringSshToken" | "ensureSshAccess" + | "getSandboxState" >; /** Every workspace Xum knows, archived ones included (they still own their sandbox). */ listWorkspaces: () => DaytonaWorkspaceRef[]; - /** Streaming, or running a background job: Daytona counts only SSH and API traffic. */ - isWorkspaceActive: (workspaceId: string) => boolean; + /** + * Streaming, or running a background job that has not exited: Daytona counts only SSH and + * API traffic. May check a job over ssh, so it is only asked about a started sandbox. + */ + isWorkspaceActive: (workspaceId: string) => boolean | Promise; intervalMs?: number; callTimeoutMs?: number; } @@ -52,6 +56,10 @@ export function listDaytonaWorkspaceRefs(config: { return refs; } +function isStarted(sandbox: DaytonaSandbox): boolean { + return mapDaytonaSandboxState(sandbox.state) === "started"; +} + function withTimeout(promise: Promise, timeoutMs: number, what: string): Promise { let timer: ReturnType | undefined; const timeout = new Promise((_, reject) => { @@ -112,13 +120,15 @@ export class DaytonaReconciler { try { sandboxes = await this.call(service.listInstanceSandboxes(), "listing Daytona sandboxes"); } catch (error) { - // Keepalive still runs from config alone; orphans wait for the next sweep. + // Without the list Xum cannot map stored names to ids or see states: keepalive and + // orphans wait for the next sweep instead of guessing. log.warn("Daytona reconcile: could not list sandboxes", { error: getErrorMessage(error) }); } - for (const ref of refs) { - if (!this.options.isWorkspaceActive(ref.workspaceId)) continue; - await this.keepAlive(ref.sandboxId, sandboxes); + if (sandboxes) { + for (const ref of refs) { + await this.keepAlive(ref, sandboxes); + } } if (sandboxes) { await this.stopOrphans(sandboxes, refs); @@ -128,33 +138,85 @@ export class DaytonaReconciler { private collectReferences(): Array<{ workspaceId: string; sandboxId: string }> { const refs: Array<{ workspaceId: string; sandboxId: string }> = []; for (const workspace of this.options.listWorkspaces()) { - const config = workspace.runtimeConfig; - const sandboxId = isSSHRuntime(config) ? config.daytona?.sandboxId?.trim() : undefined; + const sandboxId = getDaytonaSandboxId(workspace.runtimeConfig); if (sandboxId) refs.push({ workspaceId: workspace.workspaceId, sandboxId }); } return refs; } - private async keepAlive(sandboxId: string, sandboxes: DaytonaSandbox[] | undefined) { - const service = this.options.daytonaService; + private async keepAlive( + ref: { workspaceId: string; sandboxId: string }, + sandboxes: DaytonaSandbox[] + ): Promise { + const sandbox = await this.findActiveSandbox(ref, sandboxes); + if (sandbox) await this.refreshActivityAndToken(ref.sandboxId, sandbox.id); + } + + /** The sandbox of a workspace that is active, if the sandbox is known and started. */ + private async findActiveSandbox( + ref: { workspaceId: string; sandboxId: string }, + sandboxes: DaytonaSandbox[] + ): Promise { // Xum stores a name for sandboxes it created; last-activity only takes the real id. - const listed = sandboxes?.find((sb) => sb.id === sandboxId || sb.name === sandboxId); + const listed = sandboxes.find((sb) => sb.id === ref.sandboxId || sb.name === ref.sandboxId); + // A stopped sandbox has nothing to keep alive, and checking its jobs over ssh would start it. + if (listed && !isStarted(listed)) return undefined; + if (!(await this.isWorkspaceActive(ref.workspaceId))) return undefined; + // An attached sandbox carries no label of this install, so the list does not show it. + const sandbox = listed ?? (await this.lookUpSandbox(ref.sandboxId)); + return sandbox && isStarted(sandbox) ? sandbox : undefined; + } + + private async isWorkspaceActive(workspaceId: string): Promise { + try { + return await this.call( + Promise.resolve(this.options.isWorkspaceActive(workspaceId)), + "checking workspace activity" + ); + } catch (error) { + log.warn("Daytona reconcile: could not check workspace activity", { + workspaceId, + error: getErrorMessage(error), + }); + return false; + } + } + + /** `storedId` is what the workspace config holds (tokens use it), `realId` the sandbox id. */ + private async refreshActivityAndToken(storedId: string, realId: string): Promise { + const service = this.options.daytonaService; try { - await this.call(service.refreshActivity(listed?.id ?? sandboxId), "refreshing activity"); + await this.call(service.refreshActivity(realId), "refreshing activity"); } catch (error) { log.warn("Daytona reconcile: activity refresh failed", { - sandboxId, + sandboxId: storedId, error: getErrorMessage(error), }); } - if (!service.hasExpiringSshToken(sandboxId)) return; + if (!service.hasExpiringSshToken(storedId)) return; try { - await this.call(service.ensureSshAccess(sandboxId), "refreshing the ssh token"); + await this.call(service.ensureSshAccess(storedId), "refreshing the ssh token"); } catch (error) { log.warn("Daytona reconcile: ssh token refresh failed", { + sandboxId: storedId, + error: getErrorMessage(error), + }); + } + } + + private async lookUpSandbox(sandboxId: string): Promise { + try { + const { sandbox } = await this.call( + this.options.daytonaService.getSandboxState(sandboxId), + "looking up a sandbox" + ); + return sandbox; + } catch (error) { + log.warn("Daytona reconcile: could not look up a sandbox", { sandboxId, error: getErrorMessage(error), }); + return undefined; } } diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 80d0f38967e..38c872584cd 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -175,6 +175,41 @@ describe("DaytonaService", () => { expect(plane.calls.filter((call) => call === "POST /sandbox/sb-1/ssh-access")).toHaveLength(1); }); + it("revokes the SSH tokens it holds when the settings change", async () => { + const held = await service.getSshToken("sb-1"); + await service.setConfig({ target: "eu" }); + expect(plane.revoked).toEqual([held.token]); + // The next request mints a fresh token instead of reusing the revoked one. + expect((await service.getSshToken("sb-1")).token).not.toBe(held.token); + }); + + it("reuses one token while the user fixes an ssh config that shadows it", async () => { + let shadowed = true; + const writer = { + writeSandbox: () => Promise.resolve(), + updateToken: () => Promise.resolve(), + removeSandbox: () => Promise.resolve(), + verifyResolvedUser: () => + shadowed ? Promise.reject(new Error("ssh config shadows User")) : Promise.resolve(), + }; + const withWriter = new DaytonaService({ + credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), + rootDir, + fetch: plane.fetch, + now: () => clock, + sshConfigWriter: writer, + }); + for (let attempt = 0; attempt < 2; attempt++) { + expect(await withWriter.ensureSshAccess("sb-1").catch((e: unknown) => e)).toBeInstanceOf( + Error + ); + } + shadowed = false; + await withWriter.ensureSshAccess("sb-1"); + expect(plane.calls.filter((call) => call === "POST /sandbox/sb-1/ssh-access")).toHaveLength(1); + expect(plane.revoked).toEqual([]); + }); + it("reports test-connection failures as typed results", async () => { expect(await service.testConnection()).toEqual({ ok: true }); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 5e4769ffe27..b9051d39b07 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -15,6 +15,8 @@ import type { } from "@/node/config/daytonaCredentialsStore"; import { loadOrCreateInstallationId } from "@/node/config/installationIdentity"; import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; +import { rememberDaytonaToken } from "@/node/runtime/daytonaTokenRedaction"; +import { getDaytonaSandboxId } from "@/common/types/runtime"; import { log } from "@/node/services/log"; import { DaytonaApiError, @@ -26,6 +28,7 @@ import { type DaytonaSandbox, type DaytonaSshAccess, } from "./daytonaClient"; +import type { DaytonaWorkspaceRef } from "./daytonaReconciler"; export const DAYTONA_LABEL_INSTANCE = "xum.instance"; export const DAYTONA_LABEL_WORKSPACE_ID = "xum.workspaceId"; @@ -128,6 +131,11 @@ export interface DaytonaServiceOptions { fetch?: typeof fetch; now?: () => number; sleep?: (ms: number) => Promise; + /** + * Every workspace Xum knows, archived ones included: a sandbox Xum created is deleted only + * with the last workspace that references it. Without it no sandbox counts as unreferenced. + */ + listWorkspaces?: () => DaytonaWorkspaceRef[]; } export interface DaytonaStartAndWaitOptions { @@ -204,6 +212,9 @@ export class DaytonaService { private readonly sshConfigured = new Set(); private readonly readyAt = new Map(); private readonly readyRequests = new Map(); + /** Why the setup of a sandbox Xum created (and then deleted) failed, for later turns. */ + private readonly setupFailures = new Map(); + private readonly listWorkspaces?: () => DaytonaWorkspaceRef[]; constructor(options: DaytonaServiceOptions) { this.credentialsStore = options.credentialsStore; @@ -213,6 +224,7 @@ export class DaytonaService { this.fetchImpl = options.fetch; this.now = options.now ?? Date.now; this.sleep = options.sleep ?? defaultSleep; + this.listWorkspaces = options.listWorkspaces; } getPublicConfig(): Promise { @@ -220,15 +232,56 @@ export class DaytonaService { } async setConfig(patch: DaytonaCredentialsPatch): Promise { + const previousClient = await this.getClientIfConfigured(); await this.credentialsStore.update(patch); - // Tokens and gateway data belong to the old account or deployment. + // Tokens and gateway data belong to the old account or deployment. Revoke the tokens with + // the old credentials, which may not work for the new ones. + const tokens = [...this.tokens]; this.gatewayCache = undefined; this.tokens.clear(); this.sshConfigured.clear(); this.readyAt.clear(); + if (previousClient) { + await Promise.all( + tokens.map(([sandboxId, access]) => + this.revokeQuietly(previousClient, sandboxId, access.token) + ) + ); + } return this.credentialsStore.getPublicConfig(); } + private async getClientIfConfigured(): Promise { + try { + return (await this.getClient()).client; + } catch (error) { + log.debug("No Daytona client to revoke SSH tokens with", { error: getErrorMessage(error) }); + return undefined; + } + } + + /** + * Whether a workspace other than `workspaceId` (archived ones included) references the + * sandbox. Fails closed, as referenced, when the workspace list or the caller's id is + * unknown: a kept sandbox only costs storage (the reconciler stops it), a deleted one loses + * the other workspace's work. + */ + isSandboxReferencedElsewhere(sandboxId: string, workspaceId: string | undefined): boolean { + if (!this.listWorkspaces || !workspaceId) return true; + return this.listWorkspaces().some( + (ref) => + ref.workspaceId !== workspaceId && getDaytonaSandboxId(ref.runtimeConfig) === sandboxId + ); + } + + recordSetupFailure(sandboxId: string, reason: string): void { + this.setupFailures.set(sandboxId, reason); + } + + getSetupFailure(sandboxId: string): string | undefined { + return this.setupFailures.get(sandboxId); + } + async checkAvailability(): Promise { const credentials = await this.credentialsStore.resolve(); if (!credentials) { @@ -458,6 +511,11 @@ export class DaytonaService { this.readyAt.set(sandboxId, this.now()); } + /** An ssh connection failed: the sandbox may have stopped within the ready cache. */ + invalidateSandboxReady(sandboxId: string): void { + this.readyAt.delete(sandboxId); + } + /** * Bring the sandbox to `started` and make sure Xum can ssh into it (fresh token, include * block). Concurrent callers for one sandbox share a single request; each still gets its own @@ -506,17 +564,20 @@ export class DaytonaService { hostKeys: gatewayConfig.sshGatewayHostKeys, }; // A cached token was written by the mint that produced it, so only a mint writes files. - await this.getSshToken(sandboxId, { + const access = await this.getSshToken(sandboxId, { onNewToken: async ({ token }) => { if (this.sshConfigured.has(sandboxId)) { await this.sshConfigWriter.updateToken({ sandboxId, token, gateway }); return; } await this.sshConfigWriter.writeSandbox({ sandboxId, token, gateway }); - await this.sshConfigWriter.verifyResolvedUser(sandboxId, token); - this.sshConfigured.add(sandboxId); }, }); + if (this.sshConfigured.has(sandboxId)) return; + // Outside the mint: when the user's ssh config shadows the token, a retry after they fix it + // reuses this token (already written) instead of minting another one. + await this.sshConfigWriter.verifyResolvedUser(sandboxId, access.token); + this.sshConfigured.add(sandboxId); } /** Whether this process holds a token for the sandbox that is close to expiry. */ @@ -569,7 +630,14 @@ export class DaytonaService { ): Promise { const { client } = await this.getClient(); const access = await client.createSshAccess(sandboxId, DAYTONA_SSH_TOKEN_TTL_MINUTES); - await options.onNewToken?.(access); + rememberDaytonaToken(access.token); + try { + await options.onNewToken?.(access); + } catch (error) { + // Nothing holds the new token any more: revoke it rather than leave it live. + await this.revokeQuietly(client, sandboxId, access.token); + throw error; + } this.tokens.set(sandboxId, access); if (previous && previous.token !== access.token) { await this.revokeQuietly(client, sandboxId, previous.token); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 81b8484f393..671c7c50f7e 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -352,6 +352,7 @@ export class ServiceContainer { this.daytonaService = new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(this.config.rootDir), rootDir: this.config.rootDir, + listWorkspaces: () => listDaytonaWorkspaceRefs(this.config.loadConfigOrDefault()), }); this.perfCaptures = new PerfCaptureService({ dir: getXumPerfCapturesDir(this.config.rootDir), @@ -476,9 +477,9 @@ export class ServiceContainer { return new DaytonaReconciler({ daytonaService: this.daytonaService, listWorkspaces: () => listDaytonaWorkspaceRefs(this.config.loadConfigOrDefault()), - isWorkspaceActive: (workspaceId) => + isWorkspaceActive: async (workspaceId) => this.aiService.isStreaming(workspaceId) || - this.backgroundProcessManager.hasRunningBackgroundProcesses(workspaceId), + (await this.backgroundProcessManager.hasLiveBackgroundProcesses(workspaceId)), }); } diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index d2c99a68a91..cab664af396 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -8578,10 +8578,12 @@ export class WorkspaceService }); } else { const projectPath = metadata.projectPath; + // The id lets a Daytona runtime tell whether other workspaces still use the sandbox. const runtime = createRuntime(metadata.runtimeConfig, { projectPath, workspaceName: metadata.name, workspacePath: persistedWorkspacePath, + workspaceId, }); // Delete workspace from runtime first - if this fails with force=false, we abort From 9213b57259e10d7824bdb688712969742471eda1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:02:17 +0000 Subject: [PATCH 016/231] fix(daytona): reject an API URL with an empty host `new URL` reads "http:///api" and "https:api" as host "api", so they were saved as http://api. normalizeApiUrl now requires a written host and throws DaytonaConfigError ("has no host") otherwise. --- src/node/config/daytonaCredentialsStore.test.ts | 9 ++++++++- src/node/config/daytonaCredentialsStore.ts | 12 +++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/node/config/daytonaCredentialsStore.test.ts b/src/node/config/daytonaCredentialsStore.test.ts index 66eca1b879a..f096b5fc4ac 100644 --- a/src/node/config/daytonaCredentialsStore.test.ts +++ b/src/node/config/daytonaCredentialsStore.test.ts @@ -73,7 +73,14 @@ describe("DaytonaCredentialsStore", () => { it("rejects an API URL that is not plain http(s)", async () => { const store = new DaytonaCredentialsStore(rootDir, {}); - for (const apiUrl of ["not a url", "file:///etc/passwd", "https://user:pw@example.com/api"]) { + for (const apiUrl of [ + "not a url", + "file:///etc/passwd", + "https://user:pw@example.com/api", + // WHATWG URL parsing reads these as host "api". + "http:///api", + "https:api", + ]) { // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime await expect(store.update({ apiUrl })).rejects.toThrow(/Daytona API URL/); } diff --git a/src/node/config/daytonaCredentialsStore.ts b/src/node/config/daytonaCredentialsStore.ts index 500146fd102..1ebb8992335 100644 --- a/src/node/config/daytonaCredentialsStore.ts +++ b/src/node/config/daytonaCredentialsStore.ts @@ -52,7 +52,8 @@ export class DaytonaConfigError extends Error { } } -function normalizeApiUrl(value: string): string { +/** An http(s) URL with a host the user wrote. */ +function parseApiUrl(value: string): URL { let parsed: URL; try { parsed = new URL(value); @@ -62,6 +63,15 @@ function normalizeApiUrl(value: string): string { if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { throw new DaytonaConfigError("Daytona API URL must use http or https"); } + // `new URL` turns "http:///api" and "https:api" into host "api": require a written host. + if (!/^https?:\/\/[^/?#\\]/i.test(value)) { + throw new DaytonaConfigError(`Daytona API URL has no host: ${value}`); + } + return parsed; +} + +function normalizeApiUrl(value: string): string { + const parsed = parseApiUrl(value); if (parsed.username || parsed.password || parsed.search || parsed.hash) { throw new DaytonaConfigError( "Daytona API URL must not contain credentials, a query or a fragment" From da988ae1fce841e6407fa997d9696fa2262707f0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:02:18 +0000 Subject: [PATCH 017/231] fix(daytona): fix UAT round 2 orphaned sandbox, token and delete findings - Removing the last workspaces on one sandbox at the same time deletes it exactly once: the fate decision runs under a per-sandbox lock, and a removal that passed its dirty check is recorded so the next one does not count it. A refused (dirty) removal is never recorded and keeps the sandbox. - Startup loads every token from daytona.conf into the redactor and the token cache (due for refresh), so ssh errors after a restart never show it, and the next mint, a settings change or an archive revokes it. - When the gateway refuses a token (revoked outside Xum), the runtime mints a new one, rewrites the include file, resets the pool backoff and retries the command once. - A forced delete whose sandbox DELETE fails returns a removal warning that names the sandbox to delete by hand. - Delete waits (up to 60 s) for a sandbox Daytona is already destroying instead of failing with "is in state destroying". --- docs/runtime/daytona.mdx | 6 +- src/node/runtime/DaytonaSSHRuntime.test.ts | 128 ++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 163 ++++++++++++++---- src/node/runtime/SSH2ConnectionPool.ts | 5 + src/node/runtime/daytonaSshConfig.test.ts | 14 ++ src/node/runtime/daytonaSshConfig.ts | 13 ++ src/node/runtime/sshConnectionPool.ts | 7 + .../runtime/transports/OpenSSHTransport.ts | 4 + src/node/runtime/transports/SSH2Transport.ts | 4 + src/node/runtime/transports/SSHTransport.ts | 6 + .../builtInSkillContent.generated.ts | 6 +- .../services/daytona/daytona.testHarness.ts | 12 +- .../services/daytona/daytonaService.test.ts | 39 +++++ src/node/services/daytona/daytonaService.ts | 99 ++++++++++- src/node/services/serviceContainer.test.ts | 3 +- src/node/services/serviceContainer.ts | 7 + 16 files changed, 470 insertions(+), 46 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index e4f1268189d..a97a5e4d397 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -63,7 +63,9 @@ Select **Existing** and pick a sandbox from your organization. Xum treats an att | Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs | | Sandbox deleted outside Xum | The workspace reports that the sandbox no longer exists; Xum does not recreate it | -A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses. +A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses. When you delete the last workspaces on a sandbox at the same time, Xum deletes the sandbox once, after the last of them passes its check for uncommitted work. + +If the sandbox is still being destroyed (for example after a failed setup), delete waits up to a minute for Daytona to finish. A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard. With **Remote box on archive** set to delete, Xum first checks the workspace for uncommitted or unpushed work. If it finds some, or cannot run the check, it stops the sandbox instead and shows a warning. @@ -71,7 +73,7 @@ Once a minute, Xum also stops running sandboxes that carry this install's label ## SSH setup -Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered. +Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. At startup, Xum reads the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered. Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts. diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index f92418cef15..6e19a6302cc 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -415,6 +415,78 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { } }); + describe("two workspaces on one sandbox removed at the same time", () => { + // Neither removal is persisted yet, so the workspace list shows both throughout. + function removeBoth() { + rig.plane.addSandbox({ id: "sb-pair" }); + const source = { sandboxId: "sb-pair", shared: false, ...SMALL }; + const fork = { ...source, forked: true }; + rig.workspaces.push( + { workspaceId: "ws-1", runtimeConfig: sshDaytonaConfig(source) }, + { workspaceId: "ws-fork", runtimeConfig: sshDaytonaConfig(fork) } + ); + return Promise.all([ + makeRuntime(source).deleteWorkspace("/home/me/project", "feature", false), + makeRuntime(fork, rig, { workspaceId: "ws-fork" }).deleteWorkspace( + "/home/me/project", + "feature-fork", + false + ), + ]); + } + + it("deletes the sandbox exactly once", async () => { + const results = await removeBoth(); + + expect(results.map((result) => result.success)).toEqual([true, true]); + expect(rig.plane.countCalls("DELETE /sandbox/sb-pair")).toBe(1); + expect(rig.writer.removed).toContain("sb-pair"); + }); + + it("keeps the sandbox when the other removal is refused for uncommitted work", async () => { + sshDeleteSpy.mockImplementation((_projectPath, workspaceName) => + Promise.resolve( + workspaceName === "feature-fork" + ? { success: false, error: "uncommitted changes" } + : { success: true, deletedPath: "/path" } + ) + ); + + const results = await removeBoth(); + + expect(results.map((result) => result.success)).toEqual([true, false]); + expect(rig.plane.countCalls("DELETE /sandbox/sb-pair")).toBe(0); + expect(rig.plane.find("sb-pair")).toBeDefined(); + }); + }); + + it("waits for a sandbox Daytona is already destroying instead of refusing the delete", async () => { + // A failed setup deletes the sandbox it created while the workspace's removal runs. + rig.plane.addSandbox({ id: "sb-going", state: "destroying", pendingPolls: 2 }); + + const result = await makeRuntime({ sandboxId: "sb-going", shared: false }).deleteWorkspace( + "/home/me/project", + "feature", + false + ); + + expect(result.success).toBe(true); + expect(rig.plane.find("sb-going")).toBeUndefined(); + }); + + it("names the sandbox left behind when a forced delete cannot delete it", async () => { + rig.plane.addSandbox({ id: "sb-stuck" }); + rig.plane.failingRoutes.add("DELETE /sandbox/sb-stuck"); + + const result = await makeRuntime({ sandboxId: "sb-stuck", shared: false }).deleteWorkspace( + "/home/me/project", + "feature", + true + ); + + expect(result).toMatchObject({ success: false, leftoverPaths: ['Daytona sandbox "sb-stuck"'] }); + }); + it("never deletes an attached sandbox, even with its last workspace", async () => { rig.plane.addSandbox({ id: "sb-users" }); rig.workspaces.push({ @@ -504,6 +576,30 @@ describe("DaytonaSSHRuntime ssh token redaction", () => { expect(String(error)).not.toContain(token); expect(String(error)).toContain(DAYTONA_TOKEN_PLACEHOLDER); }); + it("keeps a token an earlier Xum process left in the include file out of errors", async () => { + // Never minted in this process: only the include file, read at startup, knows it. + const stale = "earlier-process-token-7f3a"; + rig.plane.addSandbox({ id: "sb-old" }); + rig.writer.tokens.set("sb-old", stale); + await rig.service.loadPersistedTokens(); + const runtime = makeRuntime({ sandboxId: "sb-old" }, rig, { + transport: { + ...localShellTransport(rig.rootDir), + // What a skills listing or a workspace open hits right after a restart. + spawnRemoteProcess: () => + Promise.reject( + new RuntimeError(`${stale}@127.0.0.1: Permission denied (publickey).`, "network") + ), + }, + }); + + const error = await runtime.exec("true", { cwd: "/tmp", timeout: 10 }).then( + () => new Error("exec unexpectedly succeeded"), + (rejection: unknown) => rejection + ); + expect(String(error)).not.toContain(stale); + expect(String(error)).toContain(`${DAYTONA_TOKEN_PLACEHOLDER}@127.0.0.1: Permission denied`); + }); }); describe("DaytonaSSHRuntime reconnect after a stop", () => { @@ -533,4 +629,36 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { expect(await stream.exitCode).toBe(0); expect(rig.plane.countCalls("POST /sandbox/sb-nap/start")).toBe(1); }); + + it("replaces a token the gateway refused and retries the command once", async () => { + rig.plane.addSandbox({ id: "sb-rev" }); + await rig.service.ensureSshAccess("sb-rev"); + const refused = rig.writer.tokens.get("sb-rev"); + if (!refused) throw new Error("expected a minted token"); + const local = localShellTransport(rig.rootDir); + let healthResets = 0; + const transport: SSHTransport = { + ...local, + resetConnectionHealth: () => { + healthResets++; + }, + // Revoked outside Xum: the gateway refuses it until the include file holds another. + spawnRemoteProcess: (command, options) => + rig.writer.tokens.get("sb-rev") === refused + ? Promise.reject( + new RuntimeError(`${refused}@127.0.0.1: Permission denied (publickey).`, "network") + ) + : local.spawnRemoteProcess(command, options), + }; + // Not a turn's runtime: a new token starts nothing, so any runtime replaces it. + const runtime = makeRuntime({ sandboxId: "sb-rev" }, rig, { transport }); + + const stream = await runtime.exec("echo ok", { cwd: "/tmp", timeout: 10 }); + expect(await new Response(stream.stdout).text()).toBe("ok\n"); + expect(await stream.exitCode).toBe(0); + expect(rig.writer.tokens.get("sb-rev")).not.toBe(refused); + expect(rig.plane.revokedTokens).toEqual([refused]); + expect(healthResets).toBe(1); + expect(rig.plane.countCalls("POST /sandbox/sb-rev/start")).toBe(0); + }); }); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 925af612600..f5cf1a7d6d0 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -26,7 +26,7 @@ import type { WorkspaceInitParams, WorkspaceInitResult, } from "./Runtime"; -import { isRuntimeTransportError } from "./Runtime"; +import { isPermanentSSHFailure, isRuntimeTransportError } from "./Runtime"; import type { SpawnResult } from "./RemoteRuntime"; import { SSHRuntime, type SSHRuntimeConfig } from "./SSHRuntime"; import type { SSHTransport } from "./transports"; @@ -75,7 +75,11 @@ export type DaytonaRuntimeService = Pick< | "isSandboxReady" | "markSandboxReady" | "invalidateSandboxReady" + | "replaceRejectedSshToken" | "isSandboxReferencedElsewhere" + | "withSandboxRemovalLock" + | "waitUntilDestroyed" + | "recordWorkspaceRemoval" | "releaseSshAccess" | "recordSetupFailure" | "getSetupFailure" @@ -154,7 +158,9 @@ function raceAbort(promise: Promise, signal: AbortSignal | undefined): Pro }); } -type DeleteResult = { success: true; deletedPath: string } | { success: false; error: string }; +type DeleteResult = + | { success: true; deletedPath: string } + | { success: false; error: string; leftoverPaths?: string[] }; type StatusEmitter = (phase: RuntimeStatusEvent["phase"], detail?: string) => void; @@ -165,6 +171,26 @@ type StatusEmitter = (phase: RuntimeStatusEvent["phase"], detail?: string) => vo */ type SandboxFate = "keep" | "release" | "delete"; +const HOST_KEY_FAILURE = /Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/; + +function errorChainText(error: unknown): string { + const parts: string[] = []; + for (let current = error, depth = 0; current instanceof Error && depth < 4; depth++) { + parts.push(current.message); + current = current.cause; + } + return parts.join("\n"); +} + +/** The gateway refused the token (the ssh user name), e.g. one revoked outside Xum. */ +function isTokenRejected(error: unknown): boolean { + return ( + isRuntimeTransportError(error) && + isPermanentSSHFailure(error) && + !HOST_KEY_FAILURE.test(errorChainText(error)) + ); +} + function redactingInitLogger(initLogger: InitLogger): InitLogger { return { ...initLogger, @@ -205,6 +231,8 @@ export class DaytonaSSHRuntime extends SSHRuntime { * sandbox that stopped under it; UI and background runtimes never start a billed sandbox. */ private restartsStoppedSandbox = false; + /** SSHRuntime keeps its own reference private; a replaced token needs its backoff reset. */ + private readonly sshTransport: SSHTransport; /** As Coder: the sandbox does not exist yet at create time, so skip host access. */ readonly createFlags: RuntimeCreateFlags = { @@ -231,6 +259,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { options ); this.daytonaConfig = config.daytona; + this.sshTransport = transport; this.daytonaService = daytonaService; this.workspaceId = options?.workspaceId; this.agentHomeSeedSources = options?.agentHomeSeedSources; @@ -303,8 +332,9 @@ export class DaytonaSSHRuntime extends SSHRuntime { /** * Every ssh command to the sandbox goes through here: its stderr and errors are redacted - * (the token is the ssh user name). A connection failure on a turn's runtime whose sandbox - * stopped within the ready cache starts the sandbox again and retries once. + * (the token is the ssh user name). A refused token is replaced, and a connection failure on + * a turn's runtime whose sandbox stopped within the ready cache starts the sandbox again; + * either way the command is retried once. */ protected override async spawnRemoteProcess( fullCommand: string, @@ -314,7 +344,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { try { result = await super.spawnRemoteProcess(fullCommand, options); } catch (error) { - if (!(await this.restartIfStopped(error, options.abortSignal))) { + if (!(await this.recoverForRetry(error, options.abortSignal))) { throw redactDaytonaError(error); } try { @@ -338,16 +368,39 @@ export class DaytonaSSHRuntime extends SSHRuntime { }; } + /** True when the failure was fixed (a new token, or a restarted sandbox) and a retry may pass. */ + private recoverForRetry(error: unknown, abortSignal: AbortSignal | undefined): Promise { + const sandboxId = this.daytonaConfig.sandboxId; + if (!sandboxId || !isRuntimeTransportError(error) || abortSignal?.aborted) { + return Promise.resolve(false); + } + if (isTokenRejected(error)) return this.replaceRejectedToken(sandboxId, error); + return this.restartIfStopped(sandboxId, abortSignal); + } + + private async replaceRejectedToken(sandboxId: string, error: unknown): Promise { + log.info("The Daytona gateway refused the ssh token; replacing it and retrying once", { + sandboxId, + }); + try { + await this.daytonaService.replaceRejectedSshToken(sandboxId, errorChainText(error)); + } catch (replaceError) { + log.warn("Could not replace the refused Daytona ssh token", { + sandboxId, + error: getErrorMessage(redactDaytonaError(replaceError)), + }); + return false; + } + this.sshTransport.resetConnectionHealth?.(); + return true; + } + /** True when the sandbox was found stopped (or starting) and is now started again. */ private async restartIfStopped( - error: unknown, + sandboxId: string, abortSignal: AbortSignal | undefined ): Promise { - const sandboxId = this.daytonaConfig.sandboxId; - if (!sandboxId || !this.restartsStoppedSandbox || !isRuntimeTransportError(error)) { - return false; - } - if (abortSignal?.aborted) return false; + if (!this.restartsStoppedSandbox) return false; this.daytonaService.invalidateSandboxReady(sandboxId); if (!(await this.isSandboxDown(sandboxId))) return false; return this.restartSandbox(sandboxId, abortSignal); @@ -753,22 +806,43 @@ export class DaytonaSSHRuntime extends SSHRuntime { return sshDelete(); } - const fate = this.sandboxFateOnDelete(sandboxId); // Force ("cancel creation" or an explicit force delete): the sandbox may not be reachable // yet, so delete it without SSH. A kept sandbox only loses this workspace's checkout. - if (force && fate === "delete") { - return this.deleteOwnedSandbox(sandboxId, deletedPath, undefined); - } + const forced = force ? await this.forceDeleteLastReference(sandboxId, deletedPath) : undefined; + if (forced) return forced; const prepared = await this.startForDeleteChecks(sandboxId, abortSignal); if (prepared === "missing") { - return this.forgetMissingSandbox(sandboxId, deletedPath, fate); + log.debug("Daytona sandbox already gone, skipping SSH cleanup", { sandboxId }); + // Still drop the token and include block of a sandbox no other workspace uses. + return this.settleSandbox(sandboxId, { success: true, deletedPath }, undefined); } // A forced delete still tries the SSH cleanup; a normal one never skips the checks. if (prepared !== "ready" && !force) { return { success: false, error: prepared.error }; } - return this.deleteAfterChecks(sandboxId, await sshDelete(), fate); + // A failed check (dirty or unpushed work) keeps the workspace and its sandbox. + const sshResult = await sshDelete(); + if (!sshResult.success) return sshResult; + return this.settleSandbox(sandboxId, sshResult, "SSH delete succeeded, but "); + } + + /** The force path's sandbox delete, when this is the last workspace on a sandbox Xum created. */ + private forceDeleteLastReference( + sandboxId: string, + deletedPath: string + ): Promise { + return this.daytonaService.withSandboxRemovalLock(sandboxId, async () => { + if (this.sandboxFateOnDelete(sandboxId) !== "delete") return undefined; + const result = await this.deleteOwnedSandbox(sandboxId, deletedPath, undefined); + // Forced: the workspace goes whether or not the sandbox delete worked. + this.recordRemoval(sandboxId); + return result; + }); + } + + private recordRemoval(sandboxId: string): void { + if (this.workspaceId) this.daytonaService.recordWorkspaceRemoval(sandboxId, this.workspaceId); } private sandboxFateOnDelete(sandboxId: string): SandboxFate { @@ -821,29 +895,30 @@ export class DaytonaSSHRuntime extends SSHRuntime { return super.checkWorkspaceDeletable(resolvedPath, force, trusted, abortSignal); } - private forgetMissingSandbox( - sandboxId: string, - deletedPath: string, - fate: SandboxFate - ): Promise { - log.debug("Daytona sandbox already gone, skipping SSH cleanup", { sandboxId }); - // Still drop the token and include block of a sandbox no other workspace uses. - return this.settleSandbox(sandboxId, { success: true, deletedPath }, fate, undefined); - } - - /** A failed check (dirty or unpushed work) keeps the workspace and its sandbox. */ - private deleteAfterChecks( + /** + * Decide the sandbox's fate and act on it, one removal of this sandbox at a time: two + * workspaces removed together (a workspace and its fork) would otherwise each see the other + * and both keep it. Only a removal that got here (its checks passed and its checkout is + * gone) is recorded, so a dirty, refused removal still holds the sandbox. + */ + private settleSandbox( sandboxId: string, - sshResult: DeleteResult, - fate: SandboxFate + result: { success: true; deletedPath: string }, + errorPrefix: string | undefined ): Promise { - if (!sshResult.success) { - return Promise.resolve(sshResult); - } - return this.settleSandbox(sandboxId, sshResult, fate, "SSH delete succeeded, but "); + return this.daytonaService.withSandboxRemovalLock(sandboxId, async () => { + const settled = await this.applySandboxFate( + sandboxId, + result, + this.sandboxFateOnDelete(sandboxId), + errorPrefix + ); + if (settled.success) this.recordRemoval(sandboxId); + return settled; + }); } - private async settleSandbox( + private async applySandboxFate( sandboxId: string, result: { success: true; deletedPath: string }, fate: SandboxFate, @@ -876,6 +951,8 @@ export class DaytonaSSHRuntime extends SSHRuntime { return "ready"; } catch (error) { if (error instanceof DaytonaApiError && error.kind === "not_found") return "missing"; + // A sandbox on its way out (a failed setup deleting it) has no work left to check. + if (await this.isBeingDestroyed(sandboxId)) return "missing"; return { error: `Could not start Daytona sandbox "${sandboxId}" to check for uncommitted work: ` + @@ -884,6 +961,18 @@ export class DaytonaSSHRuntime extends SSHRuntime { } } + private async isBeingDestroyed(sandboxId: string): Promise { + try { + return await this.daytonaService.waitUntilDestroyed(sandboxId); + } catch (error) { + log.warn("Could not read Daytona sandbox state before a delete", { + sandboxId, + error: getErrorMessage(error), + }); + return false; + } + } + private async deleteOwnedSandbox( sandboxId: string, deletedPath: string, @@ -900,6 +989,8 @@ export class DaytonaSSHRuntime extends SSHRuntime { error: errorPrefix ? `${errorPrefix}failed to delete Daytona sandbox: ${message}` : `Failed to delete Daytona sandbox: ${message}`, + // A forced removal goes on without it: remove shows this as a warning to act on. + leftoverPaths: [`Daytona sandbox "${sandboxId}"`], }; } } diff --git a/src/node/runtime/SSH2ConnectionPool.ts b/src/node/runtime/SSH2ConnectionPool.ts index 0f51c5a8dee..3d2a32c889e 100644 --- a/src/node/runtime/SSH2ConnectionPool.ts +++ b/src/node/runtime/SSH2ConnectionPool.ts @@ -494,6 +494,11 @@ export class SSH2ConnectionPool { }); } + /** The credentials changed (a replaced Daytona token): the next acquire connects at once. */ + forgetHealth(config: SSHConnectionConfig): void { + this.health.delete(makeConnectionKey(config)); + } + /** * Clear all health state. Used in tests to reset between test cases * so backoff from one test doesn't affect subsequent tests. diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index 1b2d605efe4..8e0387dbac1 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -89,6 +89,20 @@ describe("DaytonaSshConfigWriter", () => { expect(afterRemove).toContain("Host sb-2.xum--daytona"); }); + it("reads back the tokens the include file holds, for a later Xum process", async () => { + const later = new DaytonaSshConfigWriter({ xumHome, sshConfigPath }); + expect(await later.readTokens()).toEqual([]); + + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxId: "sb-2", token: "tokenB", gateway: GATEWAY }); + await writer.updateToken({ sandboxId: "sb-1", token: "tokenA2", gateway: GATEWAY }); + + expect(await later.readTokens()).toEqual([ + { sandboxId: "sb-1", token: "tokenA2" }, + { sandboxId: "sb-2", token: "tokenB" }, + ]); + }); + it("refuses control-plane values that would inject ssh directives", async () => { for (const input of [ { sandboxId: "sb-1", token: "tok\n ProxyCommand touch /tmp/pwned" }, diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 7f6ed151745..a7d69a95caf 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -254,6 +254,19 @@ export class DaytonaSshConfigWriter { }); } + /** The sandbox ids and tokens the include file holds (written by this or an earlier process). */ + async readTokens(): Promise> { + const suffix = `.${XUM_DAYTONA_HOST_SUFFIX}`; + const tokens: Array<{ sandboxId: string; token: string }> = []; + for (const [alias, block] of await this.readBlocks()) { + const token = /^\s*User\s+(\S+)\s*$/m.exec(block)?.[1]; + if (alias.endsWith(suffix) && token) { + tokens.push({ sandboxId: alias.slice(0, -suffix.length), token }); + } + } + return tokens; + } + private upsertBlock(input: { sandboxId: string; token: string; diff --git a/src/node/runtime/sshConnectionPool.ts b/src/node/runtime/sshConnectionPool.ts index 13bd008fc41..abae99380f3 100644 --- a/src/node/runtime/sshConnectionPool.ts +++ b/src/node/runtime/sshConnectionPool.ts @@ -452,6 +452,13 @@ export class SSHConnectionPool { } } + /** The credentials changed (a replaced Daytona token): drop the backoff, probe at once. */ + forgetHealth(config: SSHConnectionConfig): void { + const key = makeConnectionKey(config); + this.health.delete(key); + this.clearReadyControlPaths(key); + } + /** * Mark connection as healthy by key (internal use) */ diff --git a/src/node/runtime/transports/OpenSSHTransport.ts b/src/node/runtime/transports/OpenSSHTransport.ts index fd8ce9bdb32..bfb27334302 100644 --- a/src/node/runtime/transports/OpenSSHTransport.ts +++ b/src/node/runtime/transports/OpenSSHTransport.ts @@ -62,6 +62,10 @@ export class OpenSSHTransport implements SSHTransport { return this.config; } + resetConnectionHealth(): void { + sshConnectionPool.forgetHealth(this.config); + } + async acquireConnection(options?: SSHTransportAcquireOptions): Promise { try { await sshConnectionPool.acquireConnection(this.config, { diff --git a/src/node/runtime/transports/SSH2Transport.ts b/src/node/runtime/transports/SSH2Transport.ts index b7ec41b0b54..135d6ff6b6c 100644 --- a/src/node/runtime/transports/SSH2Transport.ts +++ b/src/node/runtime/transports/SSH2Transport.ts @@ -297,6 +297,10 @@ export class SSH2Transport implements SSHTransport { return this.config; } + resetConnectionHealth(): void { + ssh2ConnectionPool.forgetHealth(this.config); + } + async acquireConnection(options?: SSHTransportAcquireOptions): Promise { try { await ssh2ConnectionPool.acquireConnection(this.config, { diff --git a/src/node/runtime/transports/SSHTransport.ts b/src/node/runtime/transports/SSHTransport.ts index b623c0faa61..f56330539a8 100644 --- a/src/node/runtime/transports/SSHTransport.ts +++ b/src/node/runtime/transports/SSHTransport.ts @@ -77,4 +77,10 @@ export interface SSHTransport { * ReverseForwardRefusedError when the host refused it. */ openReverseForward(remotePort: number, localPort: number): Promise; + + /** + * Forget the host's recorded failures and backoff, after its credentials changed (Xum + * replaced a Daytona token the gateway refused), so the next acquire connects at once. + */ + resetConnectionHealth?(): void; } diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 933e13cfa8d..2076e2c614f 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10065,7 +10065,9 @@ export const BUILTIN_SKILL_FILES: Record> = { "| Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs |", "| Sandbox deleted outside Xum | The workspace reports that the sandbox no longer exists; Xum does not recreate it |", "", - "A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses.", + "A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses. When you delete the last workspaces on a sandbox at the same time, Xum deletes the sandbox once, after the last of them passes its check for uncommitted work.", + "", + "If the sandbox is still being destroyed (for example after a failed setup), delete waits up to a minute for Daytona to finish. A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard.", "", "With **Remote box on archive** set to delete, Xum first checks the workspace for uncommitted or unpushed work. If it finds some, or cannot run the check, it stops the sandbox instead and shows a warning.", "", @@ -10073,7 +10075,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "## SSH setup", "", - "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered.", + "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. At startup, Xum reads the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered.", "", "Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts.", "", diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index c4c820cf6a4..41f9bdd84d5 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -24,7 +24,7 @@ export interface FakeSandbox { memory: number; disk: number; labels: Record; - /** GETs left before a transitional state settles (creating/starting -> started). */ + /** GETs left before a transitional state settles (creating/starting -> started, destroying -> gone). */ pendingPolls: number; } @@ -135,6 +135,10 @@ export class FakeDaytonaControlPlane { if (!sandbox) return fail(404, "Sandbox not found"); if (method === "GET" && !action) { + if (sandbox.state === "destroying" && sandbox.pendingPolls === 0) { + this.sandboxes.delete(sandbox.id); + return fail(404, "Sandbox not found"); + } if (sandbox.pendingPolls > 0) { sandbox.pendingPolls -= 1; } else if (sandbox.state === "creating" || sandbox.state === "starting") { @@ -209,7 +213,7 @@ export class FakeDaytonaControlPlane { /** Records ssh config writes instead of touching `~/.ssh/config`. */ export class FakeDaytonaSshConfigWriter implements Pick< DaytonaSshConfigWriter, - "writeSandbox" | "updateToken" | "removeSandbox" | "verifyResolvedUser" + "writeSandbox" | "updateToken" | "removeSandbox" | "verifyResolvedUser" | "readTokens" > { /** Current token per sandbox alias, as the include file would hold it. */ readonly tokens = new Map(); @@ -234,6 +238,10 @@ export class FakeDaytonaSshConfigWriter implements Pick< return Promise.resolve(); } + readTokens(): Promise> { + return Promise.resolve([...this.tokens].map(([sandboxId, token]) => ({ sandboxId, token }))); + } + verifyResolvedUser(): Promise { return this.verifyError ? Promise.reject(this.verifyError) : Promise.resolve(); } diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 38c872584cd..e33042ab98c 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -189,6 +189,7 @@ describe("DaytonaService", () => { writeSandbox: () => Promise.resolve(), updateToken: () => Promise.resolve(), removeSandbox: () => Promise.resolve(), + readTokens: () => Promise.resolve([]), verifyResolvedUser: () => shadowed ? Promise.reject(new Error("ssh config shadows User")) : Promise.resolve(), }; @@ -210,6 +211,44 @@ describe("DaytonaService", () => { expect(plane.revoked).toEqual([]); }); + it("revokes the tokens an earlier process left in the include file once it replaces them", async () => { + // The include file, shared by every Xum process (a restart is a new service). + const includeFile = new Map([["sb-1", "earlier-process-token"]]); + const store = (input: { sandboxId: string; token: string }) => { + includeFile.set(input.sandboxId, input.token); + return Promise.resolve(); + }; + const writer = { + writeSandbox: store, + updateToken: store, + removeSandbox: () => Promise.resolve(), + readTokens: () => + Promise.resolve([...includeFile].map(([sandboxId, token]) => ({ sandboxId, token }))), + verifyResolvedUser: () => Promise.resolve(), + }; + const startProcess = async () => { + const started = new DaytonaService({ + credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), + rootDir, + fetch: plane.fetch, + now: () => clock, + sshConfigWriter: writer, + }); + await started.loadPersistedTokens(); + return started; + }; + + // The file has no expiry, so the first ssh access replaces the loaded token. + await (await startProcess()).ensureSshAccess("sb-1"); + const minted = includeFile.get("sb-1"); + expect(minted).not.toBe("earlier-process-token"); + expect(plane.revoked).toEqual(["earlier-process-token"]); + + // After another restart, a settings change revokes what it loaded. + await (await startProcess()).setConfig({ target: "eu" }); + expect(plane.revoked).toEqual(["earlier-process-token", minted ?? ""]); + }); + it("reports test-connection failures as typed results", async () => { expect(await service.testConnection()).toEqual({ ok: true }); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index b9051d39b07..52c03f394a3 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -15,7 +15,7 @@ import type { } from "@/node/config/daytonaCredentialsStore"; import { loadOrCreateInstallationId } from "@/node/config/installationIdentity"; import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; -import { rememberDaytonaToken } from "@/node/runtime/daytonaTokenRedaction"; +import { redactDaytonaTokens, rememberDaytonaToken } from "@/node/runtime/daytonaTokenRedaction"; import { getDaytonaSandboxId } from "@/common/types/runtime"; import { log } from "@/node/services/log"; import { @@ -48,6 +48,10 @@ export const DAYTONA_START_TIMEOUT_MS = 180_000; * the cache lives here, keyed by sandbox, rather than on a runtime instance. */ export const DAYTONA_READY_CACHE_MS = 60_000; +/** How long a delete waits for a sandbox Daytona is already destroying to go away. */ +export const DAYTONA_DESTROY_WAIT_MS = 60_000; + +const DESTROYING_STATES = new Set(["destroying", "destroyed"]); type ObservedSandboxState = Exclude; @@ -126,7 +130,7 @@ export interface DaytonaServiceOptions { /** Defaults to a writer for the real `~/.ssh/config`; tests pass one aimed at a temp dir. */ sshConfigWriter?: Pick< DaytonaSshConfigWriter, - "writeSandbox" | "updateToken" | "removeSandbox" | "verifyResolvedUser" + "writeSandbox" | "updateToken" | "removeSandbox" | "verifyResolvedUser" | "readTokens" >; fetch?: typeof fetch; now?: () => number; @@ -215,6 +219,13 @@ export class DaytonaService { /** Why the setup of a sandbox Xum created (and then deleted) failed, for later turns. */ private readonly setupFailures = new Map(); private readonly listWorkspaces?: () => DaytonaWorkspaceRef[]; + /** Per sandbox: the chain that serializes "is this its last workspace" decisions. */ + private readonly removalLocks = new Map>(); + /** + * Per sandbox: workspaces whose removal passed its checks and deleted its checkout, but + * that the workspace list may still show until the removal is persisted. + */ + private readonly removedWorkspaces = new Map>(); constructor(options: DaytonaServiceOptions) { this.credentialsStore = options.credentialsStore; @@ -227,6 +238,21 @@ export class DaytonaService { this.listWorkspaces = options.listWorkspaces; } + /** + * Tokens an earlier Xum process wrote to the include file. ssh keeps sending them until Xum + * replaces them, so the redactor must know them; holding them also lets a replacement, a + * settings change or an archive revoke them. The file has no expiry, so they are due for + * refresh at once. Startup calls this before any ssh runs. + */ + async loadPersistedTokens(): Promise { + for (const { sandboxId, token } of await this.sshConfigWriter.readTokens()) { + rememberDaytonaToken(token); + if (!this.tokens.has(sandboxId)) { + this.tokens.set(sandboxId, { token, expiresAt: new Date(0).toISOString() }); + } + } + } + getPublicConfig(): Promise { return this.credentialsStore.getPublicConfig(); } @@ -268,12 +294,43 @@ export class DaytonaService { */ isSandboxReferencedElsewhere(sandboxId: string, workspaceId: string | undefined): boolean { if (!this.listWorkspaces || !workspaceId) return true; + const removed = this.removedWorkspaces.get(sandboxId); return this.listWorkspaces().some( (ref) => - ref.workspaceId !== workspaceId && getDaytonaSandboxId(ref.runtimeConfig) === sandboxId + ref.workspaceId !== workspaceId && + !removed?.has(ref.workspaceId) && + getDaytonaSandboxId(ref.runtimeConfig) === sandboxId ); } + /** Run `fn` after every earlier removal decision for this sandbox has settled. */ + withSandboxRemovalLock(sandboxId: string, fn: () => Promise): Promise { + const run = (this.removalLocks.get(sandboxId) ?? Promise.resolve()).then(fn); + // The caller gets the rejection through `run`; the chain only needs to keep going. + const settled: Promise = run + .then( + () => undefined, + (error: unknown) => { + log.debug("Daytona sandbox removal step failed", { + sandboxId, + error: getErrorMessage(error), + }); + } + ) + .then(() => { + if (this.removalLocks.get(sandboxId) === settled) this.removalLocks.delete(sandboxId); + }); + this.removalLocks.set(sandboxId, settled); + return run; + } + + /** The workspace no longer references the sandbox, even while the workspace list shows it. */ + recordWorkspaceRemoval(sandboxId: string, workspaceId: string): void { + const removed = this.removedWorkspaces.get(sandboxId) ?? new Set(); + removed.add(workspaceId); + this.removedWorkspaces.set(sandboxId, removed); + } + recordSetupFailure(sandboxId: string, reason: string): void { this.setupFailures.set(sandboxId, reason); } @@ -461,6 +518,26 @@ export class DaytonaService { await client.stopSandbox(sandboxId); } + /** + * Whether the sandbox is gone, waiting up to DAYTONA_DESTROY_WAIT_MS while Daytona destroys + * it (a failed setup deletes its sandbox while a removal of the workspace may already run). + * False for a sandbox in any other state, or one still being destroyed at the deadline. + */ + async waitUntilDestroyed(sandboxId: string): Promise { + const { client } = await this.getClient(); + const deadline = this.now() + DAYTONA_DESTROY_WAIT_MS; + for (;;) { + const sandbox = await client.getSandbox(sandboxId).catch((error: unknown) => { + if (isDaytonaNotFound(error)) return undefined; + throw error; + }); + if (!sandbox) return true; + const remaining = deadline - this.now(); + if (!DESTROYING_STATES.has(sandbox.state ?? "") || remaining <= 0) return false; + await this.sleep(Math.min(DAYTONA_START_POLL_INTERVAL_MS, remaining)); + } + } + /** Stop and wait until Daytona reports the sandbox stopped (a resize of disk needs that). */ async stopAndWait(sandboxId: string, deadline: number): Promise { await this.stopSandbox(sandboxId); @@ -580,6 +657,22 @@ export class DaytonaService { this.sshConfigured.add(sandboxId); } + /** + * The gateway refused the sandbox's token (revoked outside Xum, or one an earlier process + * left): mint a new one and rewrite the include file. `failure` is the raw ssh error; when it + * names a token other than the cached one, a concurrent command already replaced it. + */ + async replaceRejectedSshToken(sandboxId: string, failure: string): Promise { + const cached = this.tokens.get(sandboxId); + if (cached && !failure.includes(cached.token) && redactDaytonaTokens(failure) !== failure) { + return; + } + this.readyAt.delete(sandboxId); + // Due at once, so the next ensureSshAccess mints, writes, and revokes the refused one. + if (cached) this.tokens.set(sandboxId, { ...cached, expiresAt: new Date(0).toISOString() }); + await this.ensureSshAccess(sandboxId); + } + /** Whether this process holds a token for the sandbox that is close to expiry. */ hasExpiringSshToken(sandboxId: string): boolean { return this.tokens.has(sandboxId) && this.tokenNeedsRefresh(sandboxId); diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index c7a6f1bcf8d..093198390e1 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -926,6 +926,7 @@ describe("ServiceContainer", () => { "coderOauthService.separateDiscoveredModels", "experimentsService.initialize", "bashAiProxy.restore", + "daytonaService.loadPersistedTokens", "agentResourceLimits.start", "taskService.recoverInterruptedTasks", ]; @@ -1218,7 +1219,7 @@ describe("ServiceContainer", () => { expect(recoverTasks).not.toHaveBeenCalled(); }); - it("initializeCore records the seven core steps and re-runs them when called again", async () => { + it("initializeCore records the eight core steps and re-runs them when called again", async () => { services = new ServiceContainer(stores); const recoverTasks = spyOn(services.taskService, "recoverInterruptedTasks").mockResolvedValue( undefined diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 671c7c50f7e..1b8c3e9d98c 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -527,6 +527,13 @@ export class ServiceContainer { // Bash commands that outlived a restart still hold the proxy URL and key: bind the saved // port again before clients connect. Best-effort: a proxy problem never blocks startup. { name: "bashAiProxy.restore", run: () => this.bashAiProxy.restore(), bestEffort: true }, + // Before any ssh (skills listing, workspace open, task recovery): an earlier process's + // Daytona tokens still sit in the ssh include file, and ssh errors quote them. + { + name: "daytonaService.loadPersistedTokens", + run: () => this.daytonaService.loadPersistedTokens(), + bestEffort: true, + }, // Before task recovery, so commands of recovered tasks start under the agent resource limit. // Best-effort: a limit that cannot apply is reported in Settings and never blocks startup. { From ce322cf02d774b2a002681a0885cfe3df9983f7d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:10:23 +0000 Subject: [PATCH 018/231] fix(daytona): check the sandbox state before replacing a refused ssh token A stopped or deleted sandbox also answers "Permission denied", so a command sent within the ready cache after a stop minted and revoked a token for nothing and then failed, and the first command after a delete outside Xum showed the refusal instead of "no longer exists". The runtime now reads the sandbox state first on an ssh failure: a stopped sandbox takes the restart path (turn runtimes), a missing one reports that it no longer exists, and only a started sandbox gets a new token. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 48 ++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 77 ++++++++++++++-------- 2 files changed, 99 insertions(+), 26 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 6e19a6302cc..c5d3d483841 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -630,6 +630,54 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { expect(rig.plane.countCalls("POST /sandbox/sb-nap/start")).toBe(1); }); + /** The gateway answers a stopped or deleted sandbox with the same refusal as a bad token. */ + function refusingTransportWhile(sandboxId: string, refuses: () => boolean): SSHTransport { + const local = localShellTransport(rig.rootDir); + return { + ...local, + spawnRemoteProcess: (command, options) => + refuses() + ? Promise.reject( + new RuntimeError( + `${rig.writer.tokens.get(sandboxId) ?? ""}@127.0.0.1: Permission denied (publickey).`, + "network" + ) + ) + : local.spawnRemoteProcess(command, options), + }; + } + + it("restarts a stopped sandbox that refuses ssh, keeping its token", async () => { + const sandbox = rig.plane.addSandbox({ id: "sb-nap2" }); + const transport = refusingTransportWhile("sb-nap2", () => sandbox.state !== "started"); + const turn = makeRuntime({ sandboxId: "sb-nap2" }, rig, { transport }); + expect(await turn.ensureReady()).toEqual({ ready: true }); + const token = rig.writer.tokens.get("sb-nap2"); + // Stopped within the ready cache: the next command goes straight to ssh. + sandbox.state = "stopped"; + + const stream = await turn.exec("echo ok", { cwd: "/tmp", timeout: 10 }); + expect(await new Response(stream.stdout).text()).toBe("ok\n"); + expect(rig.plane.countCalls("POST /sandbox/sb-nap2/start")).toBe(1); + expect(rig.plane.countCalls("POST /sandbox/sb-nap2/ssh-access")).toBe(1); + expect(rig.writer.tokens.get("sb-nap2")).toBe(token); + expect(rig.plane.revokedTokens).toEqual([]); + }); + + it("reports a sandbox deleted outside Xum instead of the refused token", async () => { + rig.plane.addSandbox({ id: "sb-gone" }); + await rig.service.ensureSshAccess("sb-gone"); + rig.plane.sandboxes.delete("sb-gone"); + const runtime = makeRuntime({ sandboxId: "sb-gone" }, rig, { + transport: refusingTransportWhile("sb-gone", () => true), + }); + + await expect(runtime.exec("true", { cwd: "/tmp", timeout: 10 })).rejects.toThrow( + 'Daytona sandbox "sb-gone" no longer exists' + ); + expect(rig.plane.countCalls("POST /sandbox/sb-gone/ssh-access")).toBe(1); + }); + it("replaces a token the gateway refused and retries the command once", async () => { rig.plane.addSandbox({ id: "sb-rev" }); await rig.service.ensureSshAccess("sb-rev"); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index f5cf1a7d6d0..24146a54402 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -26,7 +26,7 @@ import type { WorkspaceInitParams, WorkspaceInitResult, } from "./Runtime"; -import { isPermanentSSHFailure, isRuntimeTransportError } from "./Runtime"; +import { isPermanentSSHFailure, isRuntimeTransportError, RuntimeError } from "./Runtime"; import type { SpawnResult } from "./RemoteRuntime"; import { SSHRuntime, type SSHRuntimeConfig } from "./SSHRuntime"; import type { SSHTransport } from "./transports"; @@ -38,7 +38,7 @@ import { type DaytonaSizePreset, } from "@/common/constants/daytona"; import { getXumHome } from "@/common/constants/paths"; -import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; +import type { DaytonaSandboxConfig, DaytonaSandboxState } from "@/common/orpc/schemas/daytona"; import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime } from "@/common/types/runtime"; import type { Result } from "@/common/types/result"; @@ -191,6 +191,13 @@ function isTokenRejected(error: unknown): boolean { ); } +function sandboxMissingMessage(sandboxId: string): string { + return ( + `Daytona sandbox "${sandboxId}" no longer exists (it was deleted outside Xum). ` + + `Xum does not recreate it; delete this workspace or attach it to another sandbox.` + ); +} + function redactingInitLogger(initLogger: InitLogger): InitLogger { return { ...initLogger, @@ -268,9 +275,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { private sandboxMissing(sandboxId: string): EnsureReadyResult { return { ready: false, - error: - `Daytona sandbox "${sandboxId}" no longer exists (it was deleted outside Xum). ` + - `Xum does not recreate it; delete this workspace or attach it to another sandbox.`, + error: sandboxMissingMessage(sandboxId), errorType: "runtime_not_ready", }; } @@ -368,14 +373,45 @@ export class DaytonaSSHRuntime extends SSHRuntime { }; } - /** True when the failure was fixed (a new token, or a restarted sandbox) and a retry may pass. */ - private recoverForRetry(error: unknown, abortSignal: AbortSignal | undefined): Promise { + /** + * True when the failure was fixed (a new token, or a restarted sandbox) and a retry may pass. + * The sandbox state decides, not the error text: a stopped or deleted sandbox also answers + * "Permission denied", so only a started sandbox gets a new token. Throws a clear error for + * a sandbox deleted outside Xum. + */ + private async recoverForRetry( + error: unknown, + abortSignal: AbortSignal | undefined + ): Promise { const sandboxId = this.daytonaConfig.sandboxId; - if (!sandboxId || !isRuntimeTransportError(error) || abortSignal?.aborted) { - return Promise.resolve(false); + if (!sandboxId || !isRuntimeTransportError(error) || abortSignal?.aborted) return false; + const tokenRejected = isTokenRejected(error); + // A UI or background runtime never starts the sandbox, so only a refused token is worth a look. + if (!tokenRejected && !this.restartsStoppedSandbox) return false; + this.daytonaService.invalidateSandboxReady(sandboxId); + const state = await this.readStateAfterFailure(sandboxId); + return this.recoverInState(sandboxId, state, { error, tokenRejected }, abortSignal); + } + + private recoverInState( + sandboxId: string, + state: DaytonaSandboxState | undefined, + failure: { error: unknown; tokenRejected: boolean }, + abortSignal: AbortSignal | undefined + ): Promise { + const { error, tokenRejected } = failure; + if (state === "missing") { + throw new RuntimeError( + sandboxMissingMessage(sandboxId), + "network", + redactDaytonaError(error) + ); } - if (isTokenRejected(error)) return this.replaceRejectedToken(sandboxId, error); - return this.restartIfStopped(sandboxId, abortSignal); + if (state === "started") { + return tokenRejected ? this.replaceRejectedToken(sandboxId, error) : Promise.resolve(false); + } + if (state === undefined || !this.restartsStoppedSandbox) return Promise.resolve(false); + return this.restartSandbox(sandboxId, abortSignal); } private async replaceRejectedToken(sandboxId: string, error: unknown): Promise { @@ -395,17 +431,6 @@ export class DaytonaSSHRuntime extends SSHRuntime { return true; } - /** True when the sandbox was found stopped (or starting) and is now started again. */ - private async restartIfStopped( - sandboxId: string, - abortSignal: AbortSignal | undefined - ): Promise { - if (!this.restartsStoppedSandbox) return false; - this.daytonaService.invalidateSandboxReady(sandboxId); - if (!(await this.isSandboxDown(sandboxId))) return false; - return this.restartSandbox(sandboxId, abortSignal); - } - private async restartSandbox( sandboxId: string, abortSignal: AbortSignal | undefined @@ -421,16 +446,16 @@ export class DaytonaSSHRuntime extends SSHRuntime { return true; } - /** False when the sandbox runs (the failure is something else) or its state is unknown. */ - private async isSandboxDown(sandboxId: string): Promise { + /** Undefined when the state cannot be read (then nothing is retried). */ + private async readStateAfterFailure(sandboxId: string): Promise { try { - return (await this.daytonaService.getSandboxState(sandboxId)).state !== "started"; + return (await this.daytonaService.getSandboxState(sandboxId)).state; } catch (stateError) { log.warn("Could not read Daytona sandbox state after an ssh failure", { sandboxId, error: getErrorMessage(stateError), }); - return false; + return undefined; } } From a873c962cd7d784bb4b36e426cea279db140033c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:01:17 +0000 Subject: [PATCH 019/231] refactor(daytona): reuse shared lock, sleep and abort helpers; drop dead code --- .../features/Runtime/DaytonaControls.tsx | 2 +- .../Sections/DaytonaSettingsForms.tsx | 2 +- src/browser/hooks/useDaytonaResources.ts | 13 ++++--- src/node/config/daytonaCredentialsStore.ts | 6 +-- src/node/runtime/daytonaSshConfig.ts | 27 +++----------- src/node/runtime/daytonaTokenRedaction.ts | 2 +- src/node/runtime/runtimeFactory.ts | 11 ++---- src/node/services/daytona/daytonaClient.ts | 16 +++----- .../services/daytona/daytonaReconciler.ts | 13 +++---- src/node/services/daytona/daytonaService.ts | 37 ++++--------------- 10 files changed, 40 insertions(+), 89 deletions(-) diff --git a/src/browser/features/Runtime/DaytonaControls.tsx b/src/browser/features/Runtime/DaytonaControls.tsx index be76e5adf1d..5d2d848b044 100644 --- a/src/browser/features/Runtime/DaytonaControls.tsx +++ b/src/browser/features/Runtime/DaytonaControls.tsx @@ -346,6 +346,6 @@ export function DaytonaSandboxForm(props: DaytonaSandboxFormProps) { export function DaytonaCreationForm( props: Omit ) { - const resources = useDaytonaResources({ enabled: true, loadSandboxes: true }); + const resources = useDaytonaResources({ loadSandboxes: true }); return ; } diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx index 8b1df237ecc..63546b18df7 100644 --- a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx @@ -306,7 +306,7 @@ export function DaytonaAccessForm() { * other runtime option defaults so the creation controls start from them. */ export function DaytonaDefaultsForm(props: { projectPath: string; disabled?: boolean }) { - const resources = useDaytonaResources({ enabled: true, loadSandboxes: false }); + const resources = useDaytonaResources({ loadSandboxes: false }); const defaults = readDaytonaOptionDefaults(useRuntimeOptionDefaults(props.projectPath)); return ( (EMPTY_DAYTONA_RESOURCES); - const { enabled, loadSandboxes } = options; + const { loadSandboxes } = options; useEffect(() => { - if (!api || !enabled) return; + if (!api) return; let active = true; const update = (patch: Partial) => { if (active) setResources((prev) => ({ ...prev, ...patch })); @@ -97,7 +98,7 @@ export function useDaytonaResources(options: UseDaytonaResourcesOptions): Dayton return () => { active = false; }; - }, [api, enabled, loadSandboxes]); + }, [api, loadSandboxes]); return resources; } diff --git a/src/node/config/daytonaCredentialsStore.ts b/src/node/config/daytonaCredentialsStore.ts index 1ebb8992335..62d3888fd29 100644 --- a/src/node/config/daytonaCredentialsStore.ts +++ b/src/node/config/daytonaCredentialsStore.ts @@ -4,6 +4,7 @@ import { getXumHome } from "@/common/constants/paths"; import { DAYTONA_DEFAULT_API_URL } from "@/common/constants/daytona"; import type { DaytonaApiKeySource, DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; import { getErrorMessage } from "@/common/utils/errors"; +import { isPlainObject } from "@/common/utils/isPlainObject"; import { log } from "@/node/services/log"; import { ensurePrivateDir, isErrnoWithCode } from "@/node/utils/fs"; import writeFileAtomic from "@/node/utils/writeFileAtomic"; @@ -90,11 +91,10 @@ function parseStoredCredentials(raw: string): DaytonaStoredCredentials | undefin } catch { return undefined; } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; - const record = parsed as Record; + if (!isPlainObject(parsed)) return undefined; const stored: DaytonaStoredCredentials = {}; for (const field of CREDENTIAL_FIELDS) { - const value = nonEmpty(record[field]); + const value = nonEmpty(parsed[field]); if (value) stored[field] = value; } return stored; diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index a7d69a95caf..e4d118b3683 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -11,6 +11,7 @@ import { import { getErrorMessage } from "@/common/utils/errors"; import { ensureManagedSSHConfigBlock } from "@/node/runtime/muxSshConfigWriter"; import { log } from "@/node/services/log"; +import { MutexMap } from "@/node/utils/concurrency/mutexMap"; import { ensurePrivateDir, isErrnoWithCode } from "@/node/utils/fs"; import writeFileAtomic from "@/node/utils/writeFileAtomic"; @@ -173,26 +174,8 @@ function renderIncludeFile(blocks: Map): string { return `${[INCLUDE_FILE_HEADER, ...blocks.values()].join("\n\n")}\n`; } -// One chain per include file across writer instances: blocks of different sandboxes share it. -const includeFileLocks = new Map>(); - -function withIncludeFileLock(includePath: string, fn: () => Promise): Promise { - const previous = includeFileLocks.get(includePath) ?? Promise.resolve(); - const run = previous.then(fn); - // The caller gets the rejection through `run`; the chain only needs to keep going. - const settled: Promise = run - .then( - () => undefined, - (error: unknown) => { - log.debug("Daytona ssh include update failed", { error: getErrorMessage(error) }); - } - ) - .then(() => { - if (includeFileLocks.get(includePath) === settled) includeFileLocks.delete(includePath); - }); - includeFileLocks.set(includePath, settled); - return run; -} +// One lock per include file across writer instances: blocks of different sandboxes share it. +const includeFileLocks = new MutexMap(); export interface DaytonaSshConfigWriterOptions { xumHome: string; @@ -247,7 +230,7 @@ export class DaytonaSshConfigWriter { async removeSandbox(sandboxId: string): Promise { const alias = toXumDaytonaHost(sandboxId); - await withIncludeFileLock(this.paths.includePath, async () => { + await includeFileLocks.withLock(this.paths.includePath, async () => { const blocks = await this.readBlocks(); if (!blocks.delete(alias)) return; await this.writeBlocks(blocks); @@ -274,7 +257,7 @@ export class DaytonaSshConfigWriter { }): Promise { const block = renderDaytonaHostBlock({ ...input, knownHostsPath: this.paths.knownHostsPath }); const alias = toXumDaytonaHost(input.sandboxId); - return withIncludeFileLock(this.paths.includePath, async () => { + return includeFileLocks.withLock(this.paths.includePath, async () => { const blocks = await this.readBlocks(); blocks.set(alias, block); await this.writeBlocks(blocks); diff --git a/src/node/runtime/daytonaTokenRedaction.ts b/src/node/runtime/daytonaTokenRedaction.ts index 36dba27f772..7988bfaaa92 100644 --- a/src/node/runtime/daytonaTokenRedaction.ts +++ b/src/node/runtime/daytonaTokenRedaction.ts @@ -33,7 +33,7 @@ export function rememberDaytonaToken(token: string, now = Date.now()): void { export function redactDaytonaTokens(text: string): string { let redacted = text; for (const token of rememberedTokens.keys()) { - if (redacted.includes(token)) redacted = redacted.split(token).join(DAYTONA_TOKEN_PLACEHOLDER); + redacted = redacted.replaceAll(token, DAYTONA_TOKEN_PLACEHOLDER); } return redacted; } diff --git a/src/node/runtime/runtimeFactory.ts b/src/node/runtime/runtimeFactory.ts index 7dfffdcfc7d..34002781a0c 100644 --- a/src/node/runtime/runtimeFactory.ts +++ b/src/node/runtime/runtimeFactory.ts @@ -376,8 +376,7 @@ type RuntimeAvailabilityMap = Record & { * Returns a record of runtime mode to availability status. */ export async function checkRuntimeAvailability( - projectPath: string, - options: { daytonaService?: DaytonaRuntimeService } = {} + projectPath: string ): Promise { const [ isGit, @@ -395,7 +394,7 @@ export async function checkRuntimeAvailability( isEngineResponsive("docker"), checkDevcontainerCliVersion(), scanDevcontainerConfigs(projectPath), - checkDaytonaAvailability(options.daytonaService), + checkDaytonaAvailability(), ]); const devcontainerConfigInfo = buildDevcontainerConfigInfo(devcontainerConfigs); @@ -441,10 +440,8 @@ export async function checkRuntimeAvailability( }; } -async function checkDaytonaAvailability( - explicitService: DaytonaRuntimeService | undefined -): Promise { - const daytonaService = explicitService ?? globalDaytonaService; +async function checkDaytonaAvailability(): Promise { + const daytonaService = globalDaytonaService; if (!daytonaService) { return { available: false, reason: "Daytona is not initialized" }; } diff --git a/src/node/services/daytona/daytonaClient.ts b/src/node/services/daytona/daytonaClient.ts index e44e8765216..30c1e8cfca0 100644 --- a/src/node/services/daytona/daytonaClient.ts +++ b/src/node/services/daytona/daytonaClient.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { DaytonaErrorKind } from "@/common/orpc/schemas/daytona"; import { getErrorMessage } from "@/common/utils/errors"; +import { linkAbortSignal } from "@/node/utils/abort"; /** * Thin typed fetch client for the Daytona control plane (public OpenAPI spec at @@ -11,7 +12,7 @@ import { getErrorMessage } from "@/common/utils/errors"; * method and path only, never the query string (revoke passes the token there). */ -export const DAYTONA_REQUEST_TIMEOUT_MS = 15_000; +const DAYTONA_REQUEST_TIMEOUT_MS = 15_000; export class DaytonaApiError extends Error { readonly kind: DaytonaErrorKind; @@ -98,7 +99,6 @@ export interface DaytonaCreateSandboxRequest { autoArchiveInterval?: number; /** Minutes; negative disables. */ autoDeleteInterval?: number; - env?: Record; } export interface DaytonaResizeRequest { @@ -153,7 +153,7 @@ function extractServerMessage(bodyText: string): string | undefined { return toPlainExcerpt(bodyText); } if (parsed && typeof parsed === "object" && "message" in parsed) { - const message = (parsed as { message: unknown }).message; + const message = parsed.message; if (typeof message === "string") return toPlainExcerpt(message); if (Array.isArray(message)) { return toPlainExcerpt(message.filter((m) => typeof m === "string").join("; ")); @@ -295,10 +295,8 @@ export class DaytonaClient { controller.abort(); }, this.timeoutMs); const callerSignal = init.callerSignal; - const abortFromCaller = () => controller.abort(); - callerSignal?.addEventListener("abort", abortFromCaller, { once: true }); + const unlinkCaller = linkAbortSignal(callerSignal, controller); try { - if (callerSignal?.aborted) controller.abort(); const response = await this.fetchImpl(url, { method: init.method, headers: init.headers, @@ -316,7 +314,7 @@ export class DaytonaClient { }); } finally { clearTimeout(timer); - callerSignal?.removeEventListener("abort", abortFromCaller); + unlinkCaller(); } } @@ -396,10 +394,6 @@ export class DaytonaClient { await this.request("POST", `/sandbox/${encodeURIComponent(id)}/stop`, { signal }); } - async archiveSandbox(id: string, signal?: AbortSignal): Promise { - await this.request("POST", `/sandbox/${encodeURIComponent(id)}/archive`, { signal }); - } - async deleteSandbox(id: string, signal?: AbortSignal): Promise { await this.request("DELETE", `/sandbox/${encodeURIComponent(id)}`, { signal }); } diff --git a/src/node/services/daytona/daytonaReconciler.ts b/src/node/services/daytona/daytonaReconciler.ts index 6cfa4f418b6..748e01813d2 100644 --- a/src/node/services/daytona/daytonaReconciler.ts +++ b/src/node/services/daytona/daytonaReconciler.ts @@ -116,23 +116,20 @@ export class DaytonaReconciler { if (!(await service.checkAvailability()).available) return; const refs = this.collectReferences(); - let sandboxes: DaytonaSandbox[] | undefined; + let sandboxes: DaytonaSandbox[]; try { sandboxes = await this.call(service.listInstanceSandboxes(), "listing Daytona sandboxes"); } catch (error) { // Without the list Xum cannot map stored names to ids or see states: keepalive and // orphans wait for the next sweep instead of guessing. log.warn("Daytona reconcile: could not list sandboxes", { error: getErrorMessage(error) }); + return; } - if (sandboxes) { - for (const ref of refs) { - await this.keepAlive(ref, sandboxes); - } - } - if (sandboxes) { - await this.stopOrphans(sandboxes, refs); + for (const ref of refs) { + await this.keepAlive(ref, sandboxes); } + await this.stopOrphans(sandboxes, refs); } private collectReferences(): Array<{ workspaceId: string; sandboxId: string }> { diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 52c03f394a3..ff8cf0bd5d3 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -18,6 +18,8 @@ import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; import { redactDaytonaTokens, rememberDaytonaToken } from "@/node/runtime/daytonaTokenRedaction"; import { getDaytonaSandboxId } from "@/common/types/runtime"; import { log } from "@/node/services/log"; +import { sleepWithAbort } from "@/node/utils/abort"; +import { MutexMap } from "@/node/utils/concurrency/mutexMap"; import { DaytonaApiError, DaytonaClient, @@ -145,7 +147,6 @@ export interface DaytonaServiceOptions { export interface DaytonaStartAndWaitOptions { /** Called when Xum asks Daytona to start the sandbox (for a "Starting sandbox" status). */ onStarting?: () => void; - pollIntervalMs?: number; signal?: AbortSignal; } @@ -193,10 +194,6 @@ function toSummary(sandbox: DaytonaSandbox): DaytonaSandboxSummary { }; } -function defaultSleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - /** * Daytona lifecycle and credential access shared by the runtime, the reconciler and the * `daytona` oRPC namespace. One instance per Xum backend: it owns the per-sandbox SSH token @@ -219,8 +216,8 @@ export class DaytonaService { /** Why the setup of a sandbox Xum created (and then deleted) failed, for later turns. */ private readonly setupFailures = new Map(); private readonly listWorkspaces?: () => DaytonaWorkspaceRef[]; - /** Per sandbox: the chain that serializes "is this its last workspace" decisions. */ - private readonly removalLocks = new Map>(); + /** Per sandbox: serializes "is this its last workspace" decisions. */ + private readonly removalLocks = new MutexMap(); /** * Per sandbox: workspaces whose removal passed its checks and deleted its checkout, but * that the workspace list may still show until the removal is persisted. @@ -234,7 +231,7 @@ export class DaytonaService { options.sshConfigWriter ?? new DaytonaSshConfigWriter({ xumHome: options.rootDir }); this.fetchImpl = options.fetch; this.now = options.now ?? Date.now; - this.sleep = options.sleep ?? defaultSleep; + this.sleep = options.sleep ?? sleepWithAbort; this.listWorkspaces = options.listWorkspaces; } @@ -305,23 +302,7 @@ export class DaytonaService { /** Run `fn` after every earlier removal decision for this sandbox has settled. */ withSandboxRemovalLock(sandboxId: string, fn: () => Promise): Promise { - const run = (this.removalLocks.get(sandboxId) ?? Promise.resolve()).then(fn); - // The caller gets the rejection through `run`; the chain only needs to keep going. - const settled: Promise = run - .then( - () => undefined, - (error: unknown) => { - log.debug("Daytona sandbox removal step failed", { - sandboxId, - error: getErrorMessage(error), - }); - } - ) - .then(() => { - if (this.removalLocks.get(sandboxId) === settled) this.removalLocks.delete(sandboxId); - }); - this.removalLocks.set(sandboxId, settled); - return run; + return this.removalLocks.withLock(sandboxId, fn); } /** The workspace no longer references the sandbox, even while the workspace list shows it. */ @@ -396,7 +377,7 @@ export class DaytonaService { return { ok: true, sandboxes: sandboxes - .filter((sandbox) => sandbox.state !== "destroyed" && sandbox.state !== "destroying") + .filter((sandbox) => !DESTROYING_STATES.has(sandbox.state ?? "")) .map(toSummary), }; } catch (error) { @@ -471,9 +452,7 @@ export class DaytonaService { } const remaining = deadline - this.now(); if (remaining <= 0) throw startTimeoutError(sandboxId, sandbox); - await this.sleep( - Math.min(options.pollIntervalMs ?? DAYTONA_START_POLL_INTERVAL_MS, remaining) - ); + await this.sleep(Math.min(DAYTONA_START_POLL_INTERVAL_MS, remaining)); } } From 24537ce7b5c9c4679166c84ccc86d174a4b8a101 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:19:38 +0000 Subject: [PATCH 020/231] test(daytona): make three runtime tests and the schema round-trip fail on their claimed breaks - await the background-runtime rejection so its no-start assertion runs after the command settles - assert both concurrent ensureReady checks report the shared start (the old test passed without the dedupe) - assert the clean-delete test runs the SSH dirty check - round-trip snapshot, target and forked through RuntimeConfigSchema --- src/common/orpc/schemas/runtime.test.ts | 11 ++++++++++- src/node/runtime/DaytonaSSHRuntime.test.ts | 17 ++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/common/orpc/schemas/runtime.test.ts b/src/common/orpc/schemas/runtime.test.ts index 9713219526a..cabbc005167 100644 --- a/src/common/orpc/schemas/runtime.test.ts +++ b/src/common/orpc/schemas/runtime.test.ts @@ -87,7 +87,16 @@ describe("RuntimeConfigSchema ssh backends", () => { const ssh = { type: "ssh", host: "sb-1.xum--daytona", srcBaseDir: "~/xum" } as const; test("keeps the daytona sub-config through a persist round-trip", () => { - const daytona = { sandboxId: "sb-1", cpu: 2, memoryGiB: 4, diskGiB: 10, shared: true }; + const daytona = { + sandboxId: "sb-1", + snapshot: "node-22", + cpu: 2, + memoryGiB: 4, + diskGiB: 10, + target: "eu", + shared: false, + forked: true, + }; expect(RuntimeConfigSchema.parse({ ...ssh, daytona })).toEqual({ ...ssh, daytona }); }); diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index c5d3d483841..8c2d934c5f3 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -127,11 +127,17 @@ describe("DaytonaSSHRuntime.ensureReady", () => { it("shares one start between concurrent checks", async () => { rig.plane.addSandbox({ id: "sb-archived", state: "archived" }); - const results = await Promise.all([ - makeRuntime({ sandboxId: "sb-archived" }).ensureReady(), - makeRuntime({ sandboxId: "sb-archived" }).ensureReady(), - ]); + const phases: string[][] = [[], []]; + const results = await Promise.all( + phases.map((seen) => + makeRuntime({ sandboxId: "sb-archived" }).ensureReady({ + statusSink: (event) => seen.push(event.phase), + }) + ) + ); expect(results).toEqual([{ ready: true }, { ready: true }]); + // The second check joins the first one's start, so it reports that start too. + expect(phases.map((seen) => seen.includes("starting"))).toEqual([true, true]); expect(rig.plane.countCalls("POST /sandbox/sb-archived/start")).toBe(1); expect(rig.plane.countCalls("POST /sandbox/sb-archived/ssh-access")).toBe(1); }); @@ -326,6 +332,7 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { ); expect(result.success).toBe(true); + expect(sshDeleteSpy).toHaveBeenCalledTimes(1); expect(rig.plane.find("sb-clean")).toBeUndefined(); expect(rig.writer.removed).toContain("sb-clean"); expect(rig.plane.revokedTokens).toHaveLength(1); @@ -619,7 +626,7 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { // A UI or background runtime never starts the (billed) sandbox. const background = makeRuntime({ sandboxId: "sb-nap" }, rig, { transport }); - expect(background.exec("echo ok", { cwd: "/tmp", timeout: 10 })).rejects.toThrow( + await expect(background.exec("echo ok", { cwd: "/tmp", timeout: 10 })).rejects.toThrow( "Connection closed" ); expect(rig.plane.countCalls("POST /sandbox/sb-nap/start")).toBe(0); From 26fb32335417d178c816f06a14f6bc30114e1837 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:34:08 +0000 Subject: [PATCH 021/231] fix(daytona): satisfy type-aware lint rules --- src/common/types/runtime.ts | 2 +- src/node/runtime/DaytonaSSHRuntime.test.ts | 2 ++ src/node/runtime/SSH2ConnectionPool.test.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts index c6351b0b257..a2f672e0501 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -278,7 +278,7 @@ export function isSSHRuntime( /** The Daytona sandbox a runtime config points at, if any. */ export function getDaytonaSandboxId(config: RuntimeConfig | undefined): string | undefined { const sandboxId = isSSHRuntime(config) ? config.daytona?.sandboxId?.trim() : undefined; - return sandboxId ? sandboxId : undefined; + return sandboxId === "" ? undefined : sandboxId; } /** diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 8c2d934c5f3..318192cb7a9 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -626,6 +626,7 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { // A UI or background runtime never starts the (billed) sandbox. const background = makeRuntime({ sandboxId: "sb-nap" }, rig, { transport }); + // eslint-disable-next-line @typescript-eslint/await-thenable await expect(background.exec("echo ok", { cwd: "/tmp", timeout: 10 })).rejects.toThrow( "Connection closed" ); @@ -679,6 +680,7 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { transport: refusingTransportWhile("sb-gone", () => true), }); + // eslint-disable-next-line @typescript-eslint/await-thenable await expect(runtime.exec("true", { cwd: "/tmp", timeout: 10 })).rejects.toThrow( 'Daytona sandbox "sb-gone" no longer exists' ); diff --git a/src/node/runtime/SSH2ConnectionPool.test.ts b/src/node/runtime/SSH2ConnectionPool.test.ts index 591f7412653..c7524b030e6 100644 --- a/src/node/runtime/SSH2ConnectionPool.test.ts +++ b/src/node/runtime/SSH2ConnectionPool.test.ts @@ -197,7 +197,7 @@ describe.skipIf(process.platform === "win32")( [ "Host sb-1.xum--daytona", " HostName 127.0.0.1", - ` Port ${config.port}`, + ` Port ${String(config.port)}`, " HostKeyAlias gateway.test", ` UserKnownHostsFile ${knownHostsPath}`, " StrictHostKeyChecking yes", From 7a68b68627aec1d634dc9dbc1f6946abbebd3422 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:50:00 +0000 Subject: [PATCH 022/231] fix(daytona): keep strict gateway host key checking when ssh config shadows it --- docs/runtime/daytona.mdx | 2 +- src/node/runtime/daytonaSshConfig.test.ts | 36 +++++++++++++++++-- src/node/runtime/daytonaSshConfig.ts | 36 +++++++++++++------ src/node/runtime/sshConnectionPool.test.ts | 16 +++++---- src/node/runtime/sshConnectionPool.ts | 21 ++++++++--- .../builtInSkillContent.generated.ts | 2 +- .../services/daytona/daytona.testHarness.ts | 4 +-- .../services/daytona/daytonaService.test.ts | 4 +-- src/node/services/daytona/daytonaService.ts | 4 +-- src/node/services/terminalService.ts | 2 ++ 10 files changed, 94 insertions(+), 33 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index a97a5e4d397..361ea929a99 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -77,7 +77,7 @@ Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and a Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts. -OpenSSH uses the first `User` value that matches a host. If your `~/.ssh/config` has a `Host *` entry with a `User` line above Xum's block, connections to sandboxes fail. Xum checks for this and reports it; move the `User` line into a narrower `Host` entry, or below Xum's block. +OpenSSH uses the first value it finds for each setting. If your `~/.ssh/config` has a `Host *` entry with a `User` or `UserKnownHostsFile` line above Xum's block, connections to sandboxes fail. Xum checks for this and reports it; move the line into a narrower `Host` entry, or below Xum's block. A `StrictHostKeyChecking` line there has no effect: Xum always runs `ssh` with strict host key checking for sandboxes. ## Notes diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index 8e0387dbac1..31b1200544a 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -3,8 +3,13 @@ import { spawnSync } from "node:child_process"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { XUM_DAYTONA_SSH_BLOCK_END, XUM_DAYTONA_SSH_BLOCK_START } from "@/common/constants/daytona"; +import { + XUM_DAYTONA_SSH_BLOCK_END, + XUM_DAYTONA_SSH_BLOCK_START, + toXumDaytonaHost, +} from "@/common/constants/daytona"; import { DaytonaSshConfigWriter, renderDaytonaKnownHosts } from "./daytonaSshConfig"; +import { appendOpenSSHHostKeyPolicyArgs } from "./sshConnectionPool"; const GATEWAY = { host: "ssh.daytona.test", @@ -131,7 +136,7 @@ describe("DaytonaSshConfigWriter", () => { await fs.writeFile(sshConfigPath, "Host other\n User someone\n", "utf8"); await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - await writer.verifyResolvedUser("sb-1", "tokenA"); + await writer.verifyResolvedConfig("sb-1", "tokenA"); // The included Host block must not leak to unrelated hosts. const other = spawnSync("ssh", ["-G", "-F", sshConfigPath, "unrelated.example.com"]); @@ -143,8 +148,33 @@ describe("DaytonaSshConfigWriter", () => { await fs.writeFile(sshConfigPath, "Host *\n User me\n", "utf8"); await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - const error = await writer.verifyResolvedUser("sb-1", "tokenA").catch((e: unknown) => e); + const error = await writer.verifyResolvedConfig("sb-1", "tokenA").catch((e: unknown) => e); expect((error as Error).message).toContain('"Host *"'); expect((error as Error).message).not.toContain("tokenA"); }); + + describe("a user Host * entry that weakens host key checking", () => { + const WEAKENING_CONFIG = "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n"; + + it.skipIf(!hasOpenSsh)("cannot turn off strict checking for Xum's ssh calls", async () => { + await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); + await fs.writeFile(sshConfigPath, WEAKENING_CONFIG, "utf8"); + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + + const args = ["-G", "-F", sshConfigPath]; + appendOpenSSHHostKeyPolicyArgs(args, toXumDaytonaHost("sb-1")); + const resolved = spawnSync("ssh", [...args, toXumDaytonaHost("sb-1")]).stdout.toString(); + + expect(resolved).toContain("\nstricthostkeychecking true\n"); + }); + + it.skipIf(!hasOpenSsh)("fails the ssh config check, naming the setting", async () => { + await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); + await fs.writeFile(sshConfigPath, WEAKENING_CONFIG, "utf8"); + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + + const error = await writer.verifyResolvedConfig("sb-1", "tokenA").catch((e: unknown) => e); + expect((error as Error).message).toContain("sets UserKnownHostsFile"); + }); + }); }); diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index e4d118b3683..6819525af40 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -281,12 +281,14 @@ export class DaytonaSshConfigWriter { } /** - * OpenSSH keeps the first value it sees, so a user `Host *` entry with `User` above Xum's - * managed block silently replaces the token. Resolve the alias with `ssh -G` and fail with a - * clear message instead of an opaque auth failure. Neither value is ever printed. + * OpenSSH keeps the first value it sees, so a user `Host *` entry above Xum's managed block + * silently replaces the token (`User`) or the pinned known_hosts file (`UserKnownHostsFile`). + * Resolve the alias with `ssh -G` and fail with a clear message instead of an opaque auth or + * host key failure. The token is never printed. A shadowed StrictHostKeyChecking needs no check + * here: every OpenSSH invocation forces it on the command line (appendDaytonaHostKeyPinArgs). * Skipped when no OpenSSH client is installed (the ssh2 path parses the config itself). */ - async verifyResolvedUser(sandboxId: string, token: string): Promise { + async verifyResolvedConfig(sandboxId: string, token: string): Promise { const alias = toXumDaytonaHost(sandboxId); const args = ["-G", ...(this.sshConfigPath ? ["-F", this.sshConfigPath] : []), alias]; let stdout: string; @@ -301,14 +303,26 @@ export class DaytonaSshConfigWriter { `Could not check the ssh config for ${alias} with ssh -G (${getErrorMessage(error).split("\n")[0]})` ); } - const userLine = stdout.split("\n").find((line) => line.startsWith("user ")); - const resolvedUser = userLine?.slice("user ".length).trim(); - if (resolvedUser !== token) { - throw new DaytonaSshConfigError( - `Your ssh config sets User for ${alias} before Xum's Daytona block (often a "Host *" ` + - `entry with User), and OpenSSH keeps the first value. Move that User line below the ` + - `"${XUM_DAYTONA_SSH_BLOCK_START}" block in ~/.ssh/config or scope it to specific hosts.` + const resolved = (key: string) => + stdout + .split("\n") + .find((line) => line.startsWith(`${key} `)) + ?.slice(key.length + 1) + .trim(); + const shadowedBy = (setting: string) => + new DaytonaSshConfigError( + `Your ssh config sets ${setting} for ${alias} before Xum's Daytona block (often a ` + + `"Host *" entry with ${setting}), and OpenSSH keeps the first value. Move that ` + + `${setting} line below the "${XUM_DAYTONA_SSH_BLOCK_START}" block in ~/.ssh/config or ` + + `scope it to specific hosts.` ); + if (resolved("user") !== token) { + throw shadowedBy("User"); + } + // ssh -G prints every known_hosts file on one line, separated by spaces. + const knownHostsFiles = ` ${resolved("userknownhostsfile") ?? ""} `; + if (!knownHostsFiles.includes(` ${this.paths.knownHostsPath} `)) { + throw shadowedBy("UserKnownHostsFile"); } } } diff --git a/src/node/runtime/sshConnectionPool.test.ts b/src/node/runtime/sshConnectionPool.test.ts index 31d43091e4c..b8bc4a9b23f 100644 --- a/src/node/runtime/sshConnectionPool.test.ts +++ b/src/node/runtime/sshConnectionPool.test.ts @@ -160,15 +160,17 @@ describe("appendOpenSSHHostKeyPolicyArgs", () => { expect(args).toEqual(["-T"]); }); - test("never overrides the pinned gateway key of a Daytona alias", () => { - const args: string[] = ["-T"]; - setOpenSSHHostKeyPolicyMode("headless-fallback"); + test.each(["headless-fallback", "strict"] as const)( + "forces strict checking of the pinned gateway key of a Daytona alias (%s mode)", + (mode) => { + const args: string[] = ["-T"]; + setOpenSSHHostKeyPolicyMode(mode); - // A command-line -o wins over the include file's StrictHostKeyChecking yes. - appendOpenSSHHostKeyPolicyArgs(args, "xum-feature-a1b2c3.xum--daytona"); + appendOpenSSHHostKeyPolicyArgs(args, "xum-feature-a1b2c3.xum--daytona"); - expect(args).toEqual(["-T"]); - }); + expect(args).toEqual(["-T", "-o", "StrictHostKeyChecking=yes"]); + } + ); test("defaults to headless-fallback", () => { const args: string[] = ["-T"]; diff --git a/src/node/runtime/sshConnectionPool.ts b/src/node/runtime/sshConnectionPool.ts index abae99380f3..9e2473ff17d 100644 --- a/src/node/runtime/sshConnectionPool.ts +++ b/src/node/runtime/sshConnectionPool.ts @@ -52,12 +52,25 @@ export function isInteractiveHostKeyApprovalAvailable(): boolean { } /** - * Daytona aliases are exempt from the headless fallback: their include block pins the gateway - * key (StrictHostKeyChecking yes, Xum's own known_hosts), and a command-line `-o` would - * override it and accept any key. + * A Daytona alias's include block pins the gateway key, but OpenSSH keeps the first value it + * reads, so a user `Host *` entry above Xum's block with `StrictHostKeyChecking no` would win and + * hand the token (the ssh user name) to any server. Command-line options beat every config file, + * so force strict checking: if an earlier entry also points UserKnownHostsFile elsewhere, the + * gateway key is unknown and ssh refuses before it sends the user name. */ +export function appendDaytonaHostKeyPinArgs(args: string[], host: string): void { + if (isXumDaytonaHost(host)) { + args.push("-o", "StrictHostKeyChecking=yes"); + } +} + +/** Daytona aliases never get the headless fallback, which would accept any gateway key. */ export function appendOpenSSHHostKeyPolicyArgs(args: string[], host: string): void { - if (hostKeyPolicyMode === "strict" || isXumDaytonaHost(host)) { + if (isXumDaytonaHost(host)) { + appendDaytonaHostKeyPinArgs(args, host); + return; + } + if (hostKeyPolicyMode === "strict") { return; } diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 2076e2c614f..16b98bba52a 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10079,7 +10079,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts.", "", - "OpenSSH uses the first `User` value that matches a host. If your `~/.ssh/config` has a `Host *` entry with a `User` line above Xum's block, connections to sandboxes fail. Xum checks for this and reports it; move the `User` line into a narrower `Host` entry, or below Xum's block.", + "OpenSSH uses the first value it finds for each setting. If your `~/.ssh/config` has a `Host *` entry with a `User` or `UserKnownHostsFile` line above Xum's block, connections to sandboxes fail. Xum checks for this and reports it; move the line into a narrower `Host` entry, or below Xum's block. A `StrictHostKeyChecking` line there has no effect: Xum always runs `ssh` with strict host key checking for sandboxes.", "", "## Notes", "", diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 41f9bdd84d5..8d03e8fc62e 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -213,7 +213,7 @@ export class FakeDaytonaControlPlane { /** Records ssh config writes instead of touching `~/.ssh/config`. */ export class FakeDaytonaSshConfigWriter implements Pick< DaytonaSshConfigWriter, - "writeSandbox" | "updateToken" | "removeSandbox" | "verifyResolvedUser" | "readTokens" + "writeSandbox" | "updateToken" | "removeSandbox" | "verifyResolvedConfig" | "readTokens" > { /** Current token per sandbox alias, as the include file would hold it. */ readonly tokens = new Map(); @@ -242,7 +242,7 @@ export class FakeDaytonaSshConfigWriter implements Pick< return Promise.resolve([...this.tokens].map(([sandboxId, token]) => ({ sandboxId, token }))); } - verifyResolvedUser(): Promise { + verifyResolvedConfig(): Promise { return this.verifyError ? Promise.reject(this.verifyError) : Promise.resolve(); } } diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index e33042ab98c..fb56c068d2c 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -190,7 +190,7 @@ describe("DaytonaService", () => { updateToken: () => Promise.resolve(), removeSandbox: () => Promise.resolve(), readTokens: () => Promise.resolve([]), - verifyResolvedUser: () => + verifyResolvedConfig: () => shadowed ? Promise.reject(new Error("ssh config shadows User")) : Promise.resolve(), }; const withWriter = new DaytonaService({ @@ -224,7 +224,7 @@ describe("DaytonaService", () => { removeSandbox: () => Promise.resolve(), readTokens: () => Promise.resolve([...includeFile].map(([sandboxId, token]) => ({ sandboxId, token }))), - verifyResolvedUser: () => Promise.resolve(), + verifyResolvedConfig: () => Promise.resolve(), }; const startProcess = async () => { const started = new DaytonaService({ diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index ff8cf0bd5d3..23d66e41ca8 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -132,7 +132,7 @@ export interface DaytonaServiceOptions { /** Defaults to a writer for the real `~/.ssh/config`; tests pass one aimed at a temp dir. */ sshConfigWriter?: Pick< DaytonaSshConfigWriter, - "writeSandbox" | "updateToken" | "removeSandbox" | "verifyResolvedUser" | "readTokens" + "writeSandbox" | "updateToken" | "removeSandbox" | "verifyResolvedConfig" | "readTokens" >; fetch?: typeof fetch; now?: () => number; @@ -632,7 +632,7 @@ export class DaytonaService { if (this.sshConfigured.has(sandboxId)) return; // Outside the mint: when the user's ssh config shadows the token, a retry after they fix it // reuses this token (already written) instead of minting another one. - await this.sshConfigWriter.verifyResolvedUser(sandboxId, access.token); + await this.sshConfigWriter.verifyResolvedConfig(sandboxId, access.token); this.sshConfigured.add(sandboxId); } diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index 3cc2d8c702d..b3f0ec6bd5f 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -34,6 +34,7 @@ import { workspaceUseLeasesFor, type WorkspaceUseLease } from "@/node/services/w import { isCommandAvailable, findAvailableCommand } from "@/node/utils/commandDiscovery"; import { resolveContainerCli } from "@/node/runtime/containerCli"; import { sanitizeXumChildEnv } from "@/node/runtime/childProcessEnv"; +import { appendDaytonaHostKeyPinArgs } from "@/node/runtime/sshConnectionPool"; import { Terminal } from "@xterm/headless"; import { SerializeAddon } from "@xterm/addon-serialize"; import { NO_OSC_IDLE_FALLBACK_MS } from "@/constants/terminalActivity"; @@ -992,6 +993,7 @@ export class TerminalService { } // Force pseudo-terminal allocation sshArgs.push("-t"); + appendDaytonaHostKeyPinArgs(sshArgs, config.sshConfig.host); // Add host sshArgs.push(config.sshConfig.host); // Add remote command to cd into directory and start shell From a038b0be70de68d98f9636c519c5e4df9f02ed95 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:04:52 +0000 Subject: [PATCH 023/231] fix(daytona): revoke a token minted across a settings change setConfig revoked only cached tokens; a mint in flight finished afterwards and wrote and cached an old-account token. A credentials generation is captured at mint start: a mint that spans a change revokes its token with the client that minted it, never writes or caches it, and mints again for the waiting callers. --- .../services/daytona/daytonaService.test.ts | 46 +++++++++++++++++++ src/node/services/daytona/daytonaService.ts | 27 +++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index fb56c068d2c..8c4bdc31aa9 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -211,6 +211,52 @@ describe("DaytonaService", () => { expect(plane.revoked).toEqual([]); }); + it("revokes a token minted across a settings change instead of writing or caching it", async () => { + let mintStarted!: () => void; + const started = new Promise((resolve) => (mintStarted = resolve)); + let releaseMint!: () => void; + const released = new Promise((resolve) => (releaseMint = resolve)); + let held = false; + const fetchHoldingFirstMint = (async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : input.toString(); + if (!held && init?.method === "POST" && url.includes("/ssh-access")) { + held = true; + mintStarted(); + await released; + } + return plane.fetch(input, init); + }) as typeof fetch; + const written: string[] = []; + const store = (input: { token: string }) => { + written.push(input.token); + return Promise.resolve(); + }; + const withHeldMint = new DaytonaService({ + credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), + rootDir, + fetch: fetchHoldingFirstMint, + now: () => clock, + sshConfigWriter: { + writeSandbox: store, + updateToken: store, + removeSandbox: () => Promise.resolve(), + readTokens: () => Promise.resolve([]), + verifyResolvedConfig: () => Promise.resolve(), + }, + }); + + const access = withHeldMint.ensureSshAccess("sb-1"); + await started; + await withHeldMint.setConfig({ target: "eu" }); + releaseMint(); + await access; + + // tok1 was minted under the old settings: revoked, never written or kept. + expect(plane.revoked).toEqual(["tok1"]); + expect(written).not.toContain("tok1"); + expect((await withHeldMint.getSshToken("sb-1")).token).not.toBe("tok1"); + }); + it("revokes the tokens an earlier process left in the include file once it replaces them", async () => { // The include file, shared by every Xum process (a restart is a new service). const includeFile = new Map([["sb-1", "earlier-process-token"]]); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 23d66e41ca8..e0e78b50e0a 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -208,6 +208,8 @@ export class DaytonaService { private gatewayCache?: { apiUrl: string; config: DaytonaGatewayConfig }; private readonly tokens = new Map(); private readonly tokenRequests = new Map>(); + /** Bumped by every settings change, so a mint that spans one can tell. */ + private credentialsGeneration = 0; private readonly sshConfigWriter: NonNullable; /** Sandboxes whose include block and `~/.ssh/config` block this process has written. */ private readonly sshConfigured = new Set(); @@ -257,6 +259,8 @@ export class DaytonaService { async setConfig(patch: DaytonaCredentialsPatch): Promise { const previousClient = await this.getClientIfConfigured(); await this.credentialsStore.update(patch); + // A mint in flight finishes under the old settings: it sees the bump and starts over. + this.credentialsGeneration++; // Tokens and gateway data belong to the old account or deployment. Revoke the tokens with // the old credentials, which may not work for the new ones. const tokens = [...this.tokens]; @@ -700,9 +704,13 @@ export class DaytonaService { previous: DaytonaSshAccess | undefined, options: DaytonaSshTokenOptions ): Promise { + const generation = this.credentialsGeneration; const { client } = await this.getClient(); const access = await client.createSshAccess(sandboxId, DAYTONA_SSH_TOKEN_TTL_MINUTES); rememberDaytonaToken(access.token); + if (generation !== this.credentialsGeneration) { + return this.remintAfterSettingsChange(client, sandboxId, access, options); + } try { await options.onNewToken?.(access); } catch (error) { @@ -710,6 +718,10 @@ export class DaytonaService { await this.revokeQuietly(client, sandboxId, access.token); throw error; } + if (generation !== this.credentialsGeneration) { + // The retry's onNewToken overwrites what this one wrote. + return this.remintAfterSettingsChange(client, sandboxId, access, options); + } this.tokens.set(sandboxId, access); if (previous && previous.token !== access.token) { await this.revokeQuietly(client, sandboxId, previous.token); @@ -717,6 +729,21 @@ export class DaytonaService { return access; } + /** + * The settings changed while this token was minted: it belongs to the old account, which + * setConfig already revoked everything else of. Revoke it with the client that minted it, + * never cache it, and mint again with the new settings for the waiting callers. + */ + private async remintAfterSettingsChange( + client: DaytonaClient, + sandboxId: string, + stale: DaytonaSshAccess, + options: DaytonaSshTokenOptions + ): Promise { + await this.revokeQuietly(client, sandboxId, stale.token); + return this.mintSshToken(sandboxId, undefined, options); + } + /** Revoke and forget the cached token (workspace deleted or sandbox detached). */ async forgetSshToken(sandboxId: string): Promise { const cached = this.tokens.get(sandboxId); From 478b2b2f563b02efa91716ef678ec75dc2ddfe4e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:04:52 +0000 Subject: [PATCH 024/231] fix(daytona): delete a sandbox after a failed create only when it is ours A name taken between validateBeforePersist and the create made Daytona refuse the create, and the cleanup then deleted the other install's sandbox. A failed create now deletes only a sandbox carrying this install's and workspace's labels; a successful create is still deleted when a later setup step fails. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 46 +++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 76 +++++++++++++++---- .../services/daytona/daytona.testHarness.ts | 7 ++ 3 files changed, 115 insertions(+), 14 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 318192cb7a9..7eab8923632 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -272,6 +272,52 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { } }); + it("never deletes a sandbox that took its name before the create request", async () => { + // Another Xum install created this name between validateBeforePersist and the create. + const other = rig.plane.addSandbox({ + id: "sb-other", + name: "xum-raced", + labels: { "xum.instance": "another-install", "xum.workspaceId": "ws-other" }, + }); + + const failure = await makeRuntime({ sandboxId: "xum-raced", shared: false, ...SMALL }) + .postCreateSetup(initParams()) + .then( + () => undefined, + (error: unknown) => error + ); + + expect(String(failure)).toContain("already exists"); + expect(rig.plane.calls.filter((call) => call.startsWith("DELETE /sandbox/"))).toEqual([]); + expect(rig.plane.find("xum-raced")).toBe(other); + }); + + it("after a create of unknown outcome, deletes the sandbox only when it has its labels", async () => { + const setUp = (sandboxId: string) => + makeRuntime({ sandboxId, shared: false, ...SMALL }) + .postCreateSetup(initParams()) + .then( + () => undefined, + (error: unknown) => error + ); + + // The create went through, but the answer was a 500: the sandbox is this setup's. + rig.plane.failAfterCreate = true; + expect(String(await setUp("xum-maybe"))).toContain("HTTP 500"); + expect(rig.plane.find("xum-maybe")).toBeUndefined(); + + // A 500 without a create, while another install holds the name: keep that sandbox. + rig.plane.failAfterCreate = false; + rig.plane.failingRoutes.add("POST /sandbox"); + const other = rig.plane.addSandbox({ + id: "sb-other", + name: "xum-theirs", + labels: { "xum.instance": "another-install", "xum.workspaceId": "ws-1" }, + }); + expect(String(await setUp("xum-theirs"))).toContain("HTTP 500"); + expect(rig.plane.find("xum-theirs")).toBe(other); + }); + it("seeds the agent home of a created sandbox but never of an attached one", async () => { const hostXum = path.join(rig.rootDir, "host-xum"); await fs.mkdir(hostXum, { recursive: true }); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 24146a54402..f67a9a3dd37 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -46,6 +46,8 @@ import { Err, Ok } from "@/common/types/result"; import { getErrorMessage } from "@/common/utils/errors"; import { DaytonaApiError, type DaytonaSandbox } from "@/node/services/daytona/daytonaClient"; import { + DAYTONA_LABEL_INSTANCE, + DAYTONA_LABEL_WORKSPACE_ID, DAYTONA_START_TIMEOUT_MS, type DaytonaService, } from "@/node/services/daytona/daytonaService"; @@ -138,6 +140,20 @@ function isBadRequest(error: unknown): boolean { return error instanceof DaytonaApiError && error.kind === "bad_request"; } +/** + * What a failed setup may delete: "none" before any create, "created" after a create that + * succeeded, "unknown" after a failed one. A failed create may still have created the sandbox + * (a timeout, a 5xx), or the name may belong to another install's sandbox (a 409 when it was + * taken after validateBeforePersist): only the labels tell, so they are checked first. + */ +type CreateOutcome = "none" | "created" | "unknown"; + +interface CreateAttempt { + outcome: CreateOutcome; + /** The labels the last create request carried. */ + labels?: Record; +} + /** Resolve with `promise`, or reject as soon as `signal` aborts (the shared work keeps going). */ function raceAbort(promise: Promise, signal: AbortSignal | undefined): Promise { if (!signal) return promise; @@ -643,12 +659,11 @@ export class DaytonaSSHRuntime extends SSHRuntime { // A fork reuses its source's sandbox: create nothing (and so never delete it on failure). const creates = this.daytonaConfig.shared !== true && this.daytonaConfig.forked !== true; - let createRequested = false; + const create: CreateAttempt = { outcome: "none" }; try { if (creates) { params.initLogger.logStep(`Creating Daytona sandbox "${sandboxId}"...`); - createRequested = true; - await this.createSandbox(sandboxId, params); + await this.createSandbox(sandboxId, params, create); params.initLogger.logStep("Daytona sandbox created"); } await this.connectAndSeed(sandboxId, creates, params); @@ -657,10 +672,13 @@ export class DaytonaSSHRuntime extends SSHRuntime { const errorMsg = redactDaytonaTokens(getErrorMessage(error)); log.error("Daytona sandbox setup failed", { sandboxId, error: errorMsg }); params.initLogger.logStderr(`Daytona sandbox setup failed: ${errorMsg}`); - if (createRequested) { - // Later turns report this reason instead of "deleted outside Xum". + if (creates) { + // Later turns report this reason instead of "deleted outside Xum", and never use a + // sandbox of that name that Xum did not create. this.daytonaService.recordSetupFailure(sandboxId, errorMsg); - await this.deleteCreatedSandbox(sandboxId, params.initLogger); + } + if (create.outcome !== "none") { + await this.deleteCreatedSandbox(sandboxId, create, params.initLogger); } throw new Error(`Daytona sandbox setup failed: ${errorMsg}`); } @@ -707,9 +725,14 @@ export class DaytonaSSHRuntime extends SSHRuntime { private async deleteCreatedSandbox( sandboxId: string, + create: CreateAttempt, initLogger: WorkspaceInitParams["initLogger"] ): Promise { try { + if (create.outcome === "unknown" && !(await this.carriesLabels(sandboxId, create.labels))) { + log.warn("Not deleting a Daytona sandbox this setup may not have created", { sandboxId }); + return; + } initLogger.logStep(`Deleting the sandbox "${sandboxId}" this setup created...`); await this.daytonaService.deleteSandbox(sandboxId); } catch (error) { @@ -723,11 +746,38 @@ export class DaytonaSSHRuntime extends SSHRuntime { } } + /** After a create of unknown outcome: the sandbox has this install's and workspace's labels. */ + private async carriesLabels( + sandboxId: string, + labels: Record | undefined + ): Promise { + const { sandbox } = await this.daytonaService.getSandboxState(sandboxId); + if (!sandbox || !labels) return false; + return [DAYTONA_LABEL_INSTANCE, DAYTONA_LABEL_WORKSPACE_ID].every( + (key) => labels[key] !== undefined && sandbox.labels?.[key] === labels[key] + ); + } + + /** One create request; `create.outcome` tells postCreateSetup what a failure may delete. */ + private async requestCreate( + request: Parameters[0], + create: CreateAttempt + ): Promise { + create.outcome = "unknown"; + create.labels = request.labels; + await this.daytonaService.createSandbox(request); + create.outcome = "created"; + } + /** * Create with the preset resources. Daytona rejects resources (HTTP 400) for snapshots with * fixed resources: then create with the snapshot's own size and resize to the preset. */ - private async createSandbox(sandboxId: string, params: WorkspaceInitParams): Promise { + private async createSandbox( + sandboxId: string, + params: WorkspaceInitParams, + create: CreateAttempt + ): Promise { const preset = resolveSizePreset(this.daytonaConfig); if (!preset) throw new Error(invalidSizeError()); const base = { @@ -743,12 +793,10 @@ export class DaytonaSSHRuntime extends SSHRuntime { autoDeleteInterval: DAYTONA_AUTO_DELETE_DISABLED, }; try { - await this.daytonaService.createSandbox({ - ...base, - cpu: preset.cpu, - memory: preset.memoryGiB, - disk: preset.diskGiB, - }); + await this.requestCreate( + { ...base, cpu: preset.cpu, memory: preset.memoryGiB, disk: preset.diskGiB }, + create + ); return; } catch (error) { if (!isBadRequest(error)) throw error; @@ -757,7 +805,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { `creating with the snapshot's size and resizing to ${preset.label}...` ); } - await this.daytonaService.createSandbox(base); + await this.requestCreate(base, create); await this.resizeToPreset(sandboxId, preset); } diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 8d03e8fc62e..c754a536d66 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -42,6 +42,8 @@ export class FakeDaytonaControlPlane { readonly hangingRoutes = new Set(); /** Routes (`METHOD /path`) that answer 500. */ readonly failingRoutes = new Set(); + /** POST /sandbox creates the sandbox, then answers 500 (the create's outcome looks unknown). */ + failAfterCreate = false; private nextId = 1; private nextToken = 1; @@ -112,6 +114,10 @@ export class FakeDaytonaControlPlane { if (this.rejectCreateResources && hasResources) { return fail(400, "Cannot specify Sandbox resources when using a snapshot"); } + // Daytona names are unique per organization: a taken name is a conflict. + if (typeof body.name === "string" && this.find(body.name)) { + return fail(409, `Sandbox with name ${body.name} already exists`); + } const id = `sb-${this.nextId++}`; const sandbox = this.addSandbox({ id, @@ -123,6 +129,7 @@ export class FakeDaytonaControlPlane { disk: typeof body.disk === "number" ? body.disk : this.snapshotResources.disk, labels: (body.labels as Record | undefined) ?? {}, }); + if (this.failAfterCreate) return fail(500, "Internal error"); return json(200, this.view(sandbox)); } From 221902038c8c8c1c3fc22f7459e10cff9ba5b209 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:04:53 +0000 Subject: [PATCH 025/231] fix(daytona): keep every token within the retention window redactable The 256-token cap evicted tokens that were still live. Tokens now expire by age only. --- .../runtime/daytonaTokenRedaction.test.ts | 28 +++++++++++++++++++ src/node/runtime/daytonaTokenRedaction.ts | 6 ++-- 2 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 src/node/runtime/daytonaTokenRedaction.test.ts diff --git a/src/node/runtime/daytonaTokenRedaction.test.ts b/src/node/runtime/daytonaTokenRedaction.test.ts new file mode 100644 index 00000000000..044bb2d9d6b --- /dev/null +++ b/src/node/runtime/daytonaTokenRedaction.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "bun:test"; +import { + DAYTONA_TOKEN_PLACEHOLDER, + redactDaytonaTokens, + rememberDaytonaToken, +} from "./daytonaTokenRedaction"; + +describe("Daytona token redaction", () => { + it("still redacts the oldest of many live tokens", () => { + const now = Date.now(); + const tokens = Array.from({ length: 300 }, (_, i) => `many-live-token-${i}-x9q`); + for (const token of tokens) rememberDaytonaToken(token, now); + + expect(redactDaytonaTokens(`${tokens[0]}@host: Permission denied`)).toBe( + `${DAYTONA_TOKEN_PLACEHOLDER}@host: Permission denied` + ); + }); + + it("forgets a token once it is older than the retention window", () => { + // Past times only: the store is process-wide, and a future clock would age out other tests. + rememberDaytonaToken("aged-out-token-k3v", Date.now() - 4 * 60 * 60_000); + rememberDaytonaToken("fresh-token-k3v"); + + expect(redactDaytonaTokens("aged-out-token-k3v fresh-token-k3v")).toBe( + `aged-out-token-k3v ${DAYTONA_TOKEN_PLACEHOLDER}` + ); + }); +}); diff --git a/src/node/runtime/daytonaTokenRedaction.ts b/src/node/runtime/daytonaTokenRedaction.ts index 7988bfaaa92..d1d7ff5a64d 100644 --- a/src/node/runtime/daytonaTokenRedaction.ts +++ b/src/node/runtime/daytonaTokenRedaction.ts @@ -14,8 +14,9 @@ import { log } from "@/node/services/log"; export const DAYTONA_TOKEN_PLACEHOLDER = ""; // Tokens are minted for an hour; a revoked or expired one can still show up in a late error. +// Expiry is by age only: a count cap would drop tokens that are still live. Real mints (one per +// sandbox per hour or so) bound the growth. const REMEMBER_MS = 3 * 60 * 60_000; -const MAX_REMEMBERED_TOKENS = 256; /** token -> when it was remembered */ const rememberedTokens = new Map(); @@ -25,8 +26,7 @@ export function rememberDaytonaToken(token: string, now = Date.now()): void { rememberedTokens.delete(token); rememberedTokens.set(token, now); for (const [known, at] of rememberedTokens) { - if (rememberedTokens.size <= MAX_REMEMBERED_TOKENS && now - at < REMEMBER_MS) break; - rememberedTokens.delete(known); + if (now - at >= REMEMBER_MS) rememberedTokens.delete(known); } } From c49c5b28c6598ed7152fb57f93c6f9916463db08 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:04:53 +0000 Subject: [PATCH 026/231] fix(ssh): expand an Include only when its enclosing block applies to the host An Include inside a Host or Match block was inlined for every host, so its Host blocks could apply options (a ProxyCommand) to hosts OpenSSH skips. Expansion now takes the host: an Include in a Host block that does not match it is skipped, as OpenSSH reads it with SSHCONF_NEVERMATCH; under Match only 'Match all' expands (the other criteria are not known there, and skipping can only miss an option). --- src/node/runtime/sshConfigParser.test.ts | 43 ++++++++++++++++++++ src/node/runtime/sshConfigParser.ts | 52 +++++++++++++++++++----- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/node/runtime/sshConfigParser.test.ts b/src/node/runtime/sshConfigParser.test.ts index 41a9cc3c4ae..49cd49f2363 100644 --- a/src/node/runtime/sshConfigParser.test.ts +++ b/src/node/runtime/sshConfigParser.test.ts @@ -1,9 +1,23 @@ +import { spawnSync } from "child_process"; import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; import { DaytonaSshConfigWriter } from "./daytonaSshConfig"; import { resolveSSHConfig } from "./sshConfigParser"; +const hasOpenSsh = spawnSync("ssh", ["-V"]).status === 0; + +/** What OpenSSH resolves for `host` from `configPath` alone: the parity reference. */ +function opensshUserAndProxy(configPath: string, host: string) { + const output = spawnSync("ssh", ["-G", "-F", configPath, host], { encoding: "utf8" }).stdout; + const value = (key: string) => + output + .split("\n") + .find((line) => line.startsWith(`${key} `)) + ?.slice(key.length + 1); + return { user: value("user"), proxyCommand: value("proxycommand") }; +} + describe("resolveSSHConfig", () => { test("applies Host + Match host proxy rules", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-ssh-config-")); @@ -145,4 +159,33 @@ describe("resolveSSHConfig", () => { expect(resolved.user).toBe("boxuser"); }); }); + + test("applies an Include inside a Host block only to the hosts that block matches", async () => { + await withTempHome(async (homeDir) => { + const included = path.join(homeDir, ".ssh", "gated.conf"); + await fs.writeFile(included, "Host *\n User gateduser\n ProxyCommand nc %h %p\n", "utf8"); + const configPath = path.join(homeDir, ".ssh", "config"); + await fs.writeFile( + configPath, + ["Host gated !gated-not", ` Include ${included}`, "Host *", " User fallback", ""].join( + "\n" + ), + "utf8" + ); + + const resolve = async (host: string) => { + const { user, proxyCommand } = await resolveSSHConfig(host); + return { user, proxyCommand }; + }; + expect(await resolve("gated")).toEqual({ user: "gateduser", proxyCommand: "nc %h %p" }); + for (const host of ["elsewhere", "gated-not"]) { + expect(await resolve(host)).toEqual({ user: "fallback", proxyCommand: undefined }); + } + if (hasOpenSsh) { + for (const host of ["gated", "elsewhere", "gated-not"]) { + expect(await resolve(host)).toEqual(opensshUserAndProxy(configPath, host)); + } + } + }); + }); }); diff --git a/src/node/runtime/sshConfigParser.ts b/src/node/runtime/sshConfigParser.ts index a59cdde31aa..6d33f8a1bb8 100644 --- a/src/node/runtime/sshConfigParser.ts +++ b/src/node/runtime/sshConfigParser.ts @@ -255,6 +255,8 @@ function toStringArray(value: ComputedConfigValue | undefined): string[] { const MAX_INCLUDE_DEPTH = 16; const INCLUDE_LINE_PATTERN = /^\s*include(?:\s*=\s*|\s+)(.+?)\s*$/i; const SECTION_LINE_PATTERN = /^\s*(?:host|match)(?:\s*=\s*|\s+)\S/i; +const HOST_LINE_PATTERN = /^\s*host(?:\s*=\s*|\s+)(.+?)\s*$/i; +const MATCH_ALL_LINE_PATTERN = /^\s*match(?:\s*=\s*|\s+)all\s*$/i; function splitIncludeArgs(value: string): string[] { const args: string[] = []; @@ -297,12 +299,33 @@ async function resolveIncludePaths(value: string, homeDir: string): Promise { +async function readIncludedFiles( + value: string, + homeDir: string, + depth: number, + host: string +): Promise { const contents: string[] = []; for (const file of await resolveIncludePaths(value, homeDir)) { try { - contents.push(await expandIncludes(await fs.readFile(file, "utf8"), homeDir, depth)); + contents.push(await expandIncludes(await fs.readFile(file, "utf8"), homeDir, depth, host)); } catch (error) { // OpenSSH skips missing include files silently. if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { @@ -317,12 +340,17 @@ async function readIncludedFiles(value: string, homeDir: string, depth: number): } /** - * Inline `Include` directives, which the ssh-config package does not resolve (Xum's Daytona - * block relies on one). An Include inside a Host/Match block is followed by that block's header - * again, so later lines of the block stay scoped to it as in OpenSSH. Approximation: Host blocks - * inside the included file are not additionally gated by the enclosing block's pattern. + * Inline `Include` directives for resolving `host`, which the ssh-config package does not + * resolve (Xum's Daytona block relies on one). An Include inside a block that does not apply + * to `host` is skipped, as OpenSSH does. One inside a block that applies is followed by that + * block's header again, so later lines of the block stay scoped to it as in OpenSSH. */ -async function expandIncludes(content: string, homeDir: string, depth: number): Promise { +async function expandIncludes( + content: string, + homeDir: string, + depth: number, + host: string +): Promise { const out: string[] = []; let sectionHeader: string | undefined; for (const line of content.split(/\r?\n/)) { @@ -340,7 +368,8 @@ async function expandIncludes(content: string, homeDir: string, depth: number): log.debug("SSH config Include nesting too deep; ignoring", { include: includeMatch[1] }); continue; } - out.push(...(await readIncludedFiles(includeMatch[1], homeDir, depth + 1))); + if (!sectionAppliesTo(sectionHeader, host)) continue; + out.push(...(await readIncludedFiles(includeMatch[1], homeDir, depth + 1, host))); if (sectionHeader) { out.push(sectionHeader); } @@ -348,13 +377,14 @@ async function expandIncludes(content: string, homeDir: string, depth: number): return out.join("\n"); } -async function loadSSHConfig(): Promise { +/** The user's ssh config with its Includes expanded for `host`. */ +async function loadSSHConfig(host: string): Promise { const homeDir = getHomeDir(); const configPath = path.join(homeDir, ".ssh", "config"); try { const content = await fs.readFile(configPath, "utf8"); - const parsed = SSHConfig.parse(await expandIncludes(content, homeDir, 0)); + const parsed = SSHConfig.parse(await expandIncludes(content, homeDir, 0, host)); return parsed; } catch (error) { if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { @@ -371,7 +401,7 @@ export async function resolveSSHConfig(host: string): Promise const { host: hostAlias, user: userOverride } = parseHostAndUser(host); const homeDir = getHomeDir(); - const config = await loadSSHConfig(); + const config = await loadSSHConfig(hostAlias); const computed = config ? userOverride ? config.compute({ Host: hostAlias, User: userOverride }) From 7d5aef036fa9d616921b4e0a68484796677eaae7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:04:53 +0000 Subject: [PATCH 027/231] fix(daytona): reload creation state after Daytona access is saved Settings covers the creation view, which stays mounted, so its runtime availability, Daytona config, snapshots and sandboxes kept the old access until remount. Saving access dispatches DAYTONA_CONFIG_CHANGED; useDaytonaResources and the creation view's load reload on it. --- .../ChatInput/useCreationWorkspace.test.tsx | 37 ++++++++++++ .../ChatInput/useCreationWorkspace.ts | 6 +- .../Sections/DaytonaSettingsForms.tsx | 2 + src/browser/hooks/useDaytonaResources.test.ts | 60 +++++++++++++++++++ src/browser/hooks/useDaytonaResources.ts | 18 +++++- src/common/constants/events.ts | 8 +++ 6 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 src/browser/hooks/useDaytonaResources.test.ts diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index f60bb6be364..e3ce08239ae 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -12,6 +12,7 @@ import { } from "@/browser/utils/aiSelectionIntent"; import { getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import type { UserPreferences } from "@/common/config/schemas/userPreferences"; import * as ProjectContextModule from "@/browser/contexts/ProjectContext"; import * as RouterContextModule from "@/browser/contexts/RouterContext"; @@ -796,6 +797,42 @@ describe("useCreationWorkspace", () => { }); }); + test("reloads runtime availability when Daytona access is saved in Settings", async () => { + setupWindow(); + const previous = currentORPCClient; + if (!previous) throw new Error("setupWindow() sets the client"); + let daytonaAvailable = false; + currentORPCClient = { + ...previous, + projects: { + ...previous.projects, + runtimeAvailability: async (input) => ({ + ...(await previous.projects.runtimeAvailability(input)), + daytona: daytonaAvailable + ? { available: true } + : { available: false, reason: "Set a Daytona API key" }, + }), + }, + }; + const getHook = renderUseCreationWorkspace({ + projectPath: TEST_PROJECT_PATH, + onWorkspaceCreated: mock((metadata: FrontendWorkspaceMetadata) => metadata), + }); + const daytonaStatus = () => { + const state = getHook().runtimeAvailabilityState; + return state.status === "loaded" ? state.data.daytona?.available : undefined; + }; + await waitFor(() => expect(daytonaStatus()).toBe(false)); + + // Settings covers the creation view, which stays mounted while the key is saved. + daytonaAvailable = true; + act(() => { + window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.DAYTONA_CONFIG_CHANGED)); + }); + + await waitFor(() => expect(daytonaStatus()).toBe(true)); + }); + test("loads branches when projectPath is provided", async () => { const listBranchesMock = mock( (): Promise => diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 265164ca673..e1e7841d605 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -12,6 +12,7 @@ import type { RuntimeChoice } from "@/browser/utils/runtimeUi"; import { publishProjectTrustChanged } from "@/browser/utils/workspaceMcpMutations"; import { buildRuntimeConfig, RUNTIME_MODE } from "@/common/types/runtime"; import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; +import { useDaytonaConfigVersion } from "@/browser/hooks/useDaytonaResources"; import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings"; import { handOffCreationAiSelection } from "@/browser/utils/aiSelectionIntent"; import { getAutoRouting } from "@/browser/utils/workspaceAiSettingsSync"; @@ -380,6 +381,9 @@ export function useCreationWorkspace({ // again: a new client (reconnect) reloads the same project behind the shown values. const shownProjectPathRef = useRef(null); + // Saving Daytona access in Settings (which covers this view) changes Daytona's availability. + const daytonaConfigVersion = useDaytonaConfigVersion(); + // Load branches and runtime availability on mount with mounted guard useEffect(() => { if (kind === "scratch") { @@ -422,7 +426,7 @@ export function useCreationWorkspace({ mounted = false; cancelBranchLoad(); }; - }, [kind, projectPath, api]); + }, [kind, projectPath, api, daytonaConfigVersion]); // Cleanup: resolve trust prompt on unmount so handleSend doesn't wedge useEffect(() => { diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx index 63546b18df7..a1a9714a018 100644 --- a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx @@ -4,6 +4,7 @@ import { Button } from "@/browser/components/Button/Button"; import { Input } from "@/browser/components/Input/Input"; import { useAPI } from "@/browser/contexts/API"; import { DAYTONA_DEFAULT_API_URL } from "@/common/constants/daytona"; +import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import type { DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; import { getErrorMessage } from "@/common/utils/errors"; import { DaytonaSandboxForm } from "@/browser/features/Runtime/DaytonaControls"; @@ -88,6 +89,7 @@ function useDaytonaAccess() { resetTest(); try { setConfig(await api.daytona.setConfig(input)); + window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.DAYTONA_CONFIG_CHANGED)); return true; } catch (error) { setSaveError(`Could not save Daytona settings: ${getErrorMessage(error)}`); diff --git a/src/browser/hooks/useDaytonaResources.test.ts b/src/browser/hooks/useDaytonaResources.test.ts new file mode 100644 index 00000000000..329003b8f8d --- /dev/null +++ b/src/browser/hooks/useDaytonaResources.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { restoreDomGlobals, saveDomGlobals } from "../../../tests/ui/domGlobals"; +import { createElement, type ComponentProps, type ReactNode } from "react"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { GlobalWindow } from "happy-dom"; +import { APIProvider } from "@/browser/contexts/API"; +import { createTestApiClient } from "@/browser/testUtils"; +import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; +import type { DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; +import { useDaytonaResources } from "./useDaytonaResources"; + +describe("useDaytonaResources", () => { + beforeEach(() => { + saveDomGlobals(); + globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; + globalThis.document = globalThis.window.document; + }); + + afterEach(() => { + cleanup(); + mock.restore(); + restoreDomGlobals(); + }); + + test("reloads the config and snapshots when Daytona access is saved in Settings", async () => { + let apiKeySet = false; + const getConfig = mock( + (): Promise => + Promise.resolve({ + apiKeySet, + apiKeySource: apiKeySet ? "settings" : null, + apiUrl: "https://app.daytona.io/api", + target: null, + }) + ); + const client = createTestApiClient({ + daytona: { + getConfig, + listSnapshots: () => Promise.resolve({ ok: true as const, snapshots: [{ name: "base" }] }), + listSandboxes: () => Promise.resolve({ ok: true as const, sandboxes: [] }), + }, + }); + const wrapper = (props: { children: ReactNode }) => + createElement(APIProvider, { client } as ComponentProps, props.children); + const { result } = renderHook(() => useDaytonaResources({ loadSandboxes: true }), { + wrapper, + }); + await waitFor(() => expect(result.current.config?.apiKeySet).toBe(false)); + expect(result.current.snapshots).toEqual([]); + + // Settings covers the creation controls, which stay mounted while the key is saved. + apiKeySet = true; + act(() => { + window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.DAYTONA_CONFIG_CHANGED)); + }); + + await waitFor(() => expect(result.current.snapshots).toEqual(["base"])); + expect(result.current.config?.apiKeySet).toBe(true); + }); +}); diff --git a/src/browser/hooks/useDaytonaResources.ts b/src/browser/hooks/useDaytonaResources.ts index c2e29a7ebe2..eb620f89399 100644 --- a/src/browser/hooks/useDaytonaResources.ts +++ b/src/browser/hooks/useDaytonaResources.ts @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import { useAPI } from "@/browser/contexts/API"; import type { DaytonaPublicConfig, DaytonaSandboxSummary } from "@/common/orpc/schemas/daytona"; +import { CUSTOM_EVENTS } from "@/common/constants/events"; import { getErrorMessage } from "@/common/utils/errors"; export interface DaytonaResources { @@ -40,8 +41,23 @@ interface UseDaytonaResourcesOptions { loadSandboxes: boolean; } +/** + * Changes each time Daytona access is saved in Settings. Settings covers views that stay + * mounted (the creation controls), so what they loaded from the old access must reload. + */ +export function useDaytonaConfigVersion(): number { + const [version, setVersion] = useState(0); + useEffect(() => { + const bump = () => setVersion((previous) => previous + 1); + window.addEventListener(CUSTOM_EVENTS.DAYTONA_CONFIG_CHANGED, bump); + return () => window.removeEventListener(CUSTOM_EVENTS.DAYTONA_CONFIG_CHANGED, bump); + }, []); + return version; +} + export function useDaytonaResources(options: UseDaytonaResourcesOptions): DaytonaResources { const { api } = useAPI(); + const configVersion = useDaytonaConfigVersion(); // One state object so consumers get a stable identity until something actually loads. const [resources, setResources] = useState(EMPTY_DAYTONA_RESOURCES); const { loadSandboxes } = options; @@ -98,7 +114,7 @@ export function useDaytonaResources(options: UseDaytonaResourcesOptions): Dayton return () => { active = false; }; - }, [api, loadSandboxes]); + }, [api, loadSandboxes, configVersion]); return resources; } diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index b8f94634cdd..520948c00b3 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -195,6 +195,13 @@ export const CUSTOM_EVENTS = { * Detail: { enabled: boolean } */ LLM_DEBUG_LOGS_CHANGED: "mux:llmDebugLogsChanged", + + /** + * Event emitted when Daytona access (API key, URL or target) is saved in Settings. Settings + * covers views that stay mounted, such as the creation controls, which reload from it. + * Detail: none + */ + DAYTONA_CONFIG_CHANGED: "mux:daytonaConfigChanged", } as const; /** @@ -247,6 +254,7 @@ export interface CustomEventPayloads { [CUSTOM_EVENTS.CLOSE_AGENT_PICKER]: never; // No payload [CUSTOM_EVENTS.AGENTS_REFRESH_REQUESTED]: never; // No payload [CUSTOM_EVENTS.SKILLS_REFRESH_REQUESTED]: never; // No payload + [CUSTOM_EVENTS.DAYTONA_CONFIG_CHANGED]: never; // No payload [CUSTOM_EVENTS.OPEN_MODEL_SELECTOR]: never; // No payload [CUSTOM_EVENTS.MUX_GATEWAY_SESSION_EXPIRED]: never; // No payload [CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH]: { From fa92cf6eeac65cf8020294553333d2add66fc330 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:37:36 +0000 Subject: [PATCH 028/231] fix(daytona): pin the gateway host key for integrated terminals createPtySession built its ssh call without the Daytona strict host key option, so a user Host * with StrictHostKeyChecking no could let a terminal accept an unpinned gateway and send the token. Every other OpenSSH call to a runtime host already carried it. --- .../transports/OpenSSHTransport.test.ts | 25 +++++++++++++++++++ .../runtime/transports/OpenSSHTransport.ts | 3 +++ 2 files changed, 28 insertions(+) diff --git a/src/node/runtime/transports/OpenSSHTransport.test.ts b/src/node/runtime/transports/OpenSSHTransport.test.ts index 6900b2117e9..54741b3b470 100644 --- a/src/node/runtime/transports/OpenSSHTransport.test.ts +++ b/src/node/runtime/transports/OpenSSHTransport.test.ts @@ -1,4 +1,7 @@ import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import type { IPty } from "@lydell/node-pty"; +import { toXumDaytonaHost } from "@/common/constants/daytona"; +import * as ptySpawn from "../ptySpawn"; import { RuntimeError, isRuntimeTransportError } from "../Runtime"; import { sshConnectionPool } from "../sshConnectionPool"; import { OpenSSHTransport } from "./OpenSSHTransport"; @@ -46,3 +49,25 @@ describe("OpenSSHTransport connection acquisition failures", () => { expect(error).not.toBeInstanceOf(RuntimeError); }); }); + +describe("OpenSSHTransport terminals on a Daytona alias", () => { + it("force strict host key checking, as every other ssh call to the alias does", async () => { + spyOn(sshConnectionPool, "acquireConnection").mockResolvedValue(undefined); + const spawnPty = spyOn(ptySpawn, "spawnPtyProcess").mockReturnValue({} as IPty); + const host = toXumDaytonaHost("sb-1"); + + await new OpenSSHTransport({ host }).createPtySession({ + workspacePath: "~/src/project/feature", + cols: 80, + rows: 24, + }); + + const args = spawnPty.mock.calls[0][0].args; + // Command-line options beat a user `Host *` entry with `StrictHostKeyChecking no`. + const strict = args.findIndex( + (arg, i) => args[i - 1] === "-o" && arg === "StrictHostKeyChecking=yes" + ); + expect(strict).toBeGreaterThan(-1); + expect(strict).toBeLessThan(args.indexOf(host)); + }); +}); diff --git a/src/node/runtime/transports/OpenSSHTransport.ts b/src/node/runtime/transports/OpenSSHTransport.ts index bfb27334302..0851a52a823 100644 --- a/src/node/runtime/transports/OpenSSHTransport.ts +++ b/src/node/runtime/transports/OpenSSHTransport.ts @@ -8,6 +8,7 @@ import { cdThenExecShell, runInPosixShell } from "../streamUtils"; import { expandTildeForSSH } from "../tildeExpansion"; import { redactDaytonaHostStderr } from "../daytonaTokenRedaction"; import { + appendDaytonaHostKeyPinArgs, appendOpenSSHHostKeyPolicyArgs, sshConnectionPool, type SSHConnectionConfig, @@ -151,6 +152,8 @@ export class OpenSSHTransport implements SSHTransport { args.push("-o", "ServerAliveInterval=5"); args.push("-o", "ServerAliveCountMax=2"); args.push("-t"); + // Not the full host key policy: a terminal on another host may ask about an unknown key. + appendDaytonaHostKeyPinArgs(args, this.config.host); args.push(this.config.host); // expandTildeForSSH already returns a quoted string (e.g., "$HOME/path") From 6a3aede29e92ec05142a5c210aa8674a675f04fd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:37:36 +0000 Subject: [PATCH 029/231] fix(daytona): drop a token minted across a release of ssh access releaseSshAccess while a mint was in flight forgot only the cached token; the mint then cached a live token and wrote the removed include block back. A per-sandbox release counter joins the settings generation: a mint that spans a release revokes its token, removes any block it wrote, and fails the waiting call instead of minting again. --- .../services/daytona/daytonaService.test.ts | 47 ++++++++--- src/node/services/daytona/daytonaService.ts | 78 +++++++++++++++---- 2 files changed, 98 insertions(+), 27 deletions(-) diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 8c4bdc31aa9..1e84aaf70e5 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -211,7 +211,8 @@ describe("DaytonaService", () => { expect(plane.revoked).toEqual([]); }); - it("revokes a token minted across a settings change instead of writing or caching it", async () => { + /** A service whose first token mint waits until `releaseMint`; `includeFile` is its writer. */ + function serviceWithHeldMint() { let mintStarted!: () => void; const started = new Promise((resolve) => (mintStarted = resolve)); let releaseMint!: () => void; @@ -227,11 +228,13 @@ describe("DaytonaService", () => { return plane.fetch(input, init); }) as typeof fetch; const written: string[] = []; - const store = (input: { token: string }) => { + const includeFile = new Map(); + const store = (input: { sandboxId: string; token: string }) => { written.push(input.token); + includeFile.set(input.sandboxId, input.token); return Promise.resolve(); }; - const withHeldMint = new DaytonaService({ + const heldService = new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), rootDir, fetch: fetchHoldingFirstMint, @@ -239,22 +242,46 @@ describe("DaytonaService", () => { sshConfigWriter: { writeSandbox: store, updateToken: store, - removeSandbox: () => Promise.resolve(), + removeSandbox: (sandboxId: string) => { + includeFile.delete(sandboxId); + return Promise.resolve(); + }, readTokens: () => Promise.resolve([]), verifyResolvedConfig: () => Promise.resolve(), }, }); + return { service: heldService, started, releaseMint, written, includeFile }; + } + + it("revokes a token minted across a settings change instead of writing or caching it", async () => { + const held = serviceWithHeldMint(); - const access = withHeldMint.ensureSshAccess("sb-1"); - await started; - await withHeldMint.setConfig({ target: "eu" }); - releaseMint(); + const access = held.service.ensureSshAccess("sb-1"); + await held.started; + await held.service.setConfig({ target: "eu" }); + held.releaseMint(); await access; // tok1 was minted under the old settings: revoked, never written or kept. expect(plane.revoked).toEqual(["tok1"]); - expect(written).not.toContain("tok1"); - expect((await withHeldMint.getSshToken("sb-1")).token).not.toBe("tok1"); + expect(held.written).not.toContain("tok1"); + expect((await held.service.getSshToken("sb-1")).token).not.toBe("tok1"); + }); + + it("revokes a token minted across a release, keeping the access released", async () => { + const held = serviceWithHeldMint(); + + const access = held.service.ensureSshAccess("sb-1").catch((error: unknown) => error); + await held.started; + await held.service.releaseSshAccess("sb-1"); + held.releaseMint(); + + // The caller asked for access the release took away: it fails instead of minting again. + expect(String(await access)).toContain("released"); + expect(plane.revoked).toEqual(["tok1"]); + expect(held.includeFile.has("sb-1")).toBe(false); + expect(held.service.hasExpiringSshToken("sb-1")).toBe(false); + expect(plane.calls.filter((call) => call === "POST /sandbox/sb-1/ssh-access")).toHaveLength(1); }); it("revokes the tokens an earlier process left in the include file once it replaces them", async () => { diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index e0e78b50e0a..0aa5aa777bc 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -99,6 +99,19 @@ function sandboxStateError(sandboxId: string, sandbox: DaytonaSandbox): DaytonaA ); } +interface MintStamp { + generation: number; + release: number; +} + +interface OutdatedMintInput { + client: DaytonaClient; + sandboxId: string; + access: DaytonaSshAccess; + stamp: MintStamp; + options: DaytonaSshTokenOptions; +} + function startTimeoutError(sandboxId: string, sandbox: DaytonaSandbox): DaytonaApiError { return new DaytonaApiError( "timeout", @@ -210,6 +223,8 @@ export class DaytonaService { private readonly tokenRequests = new Map>(); /** Bumped by every settings change, so a mint that spans one can tell. */ private credentialsGeneration = 0; + /** Per sandbox: bumped by every release of its SSH access, so a mint that spans one can tell. */ + private readonly accessReleases = new Map(); private readonly sshConfigWriter: NonNullable; /** Sandboxes whose include block and `~/.ssh/config` block this process has written. */ private readonly sshConfigured = new Set(); @@ -663,6 +678,8 @@ export class DaytonaService { /** Revoke the token and remove the sandbox's include block. */ async releaseSshAccess(sandboxId: string): Promise { + // A mint in flight sees the bump and drops its token instead of writing it back. + this.accessReleases.set(sandboxId, (this.accessReleases.get(sandboxId) ?? 0) + 1); this.sshConfigured.delete(sandboxId); this.readyAt.delete(sandboxId); await this.forgetSshToken(sandboxId); @@ -704,13 +721,13 @@ export class DaytonaService { previous: DaytonaSshAccess | undefined, options: DaytonaSshTokenOptions ): Promise { - const generation = this.credentialsGeneration; + const stamp = this.accessStamp(sandboxId); const { client } = await this.getClient(); const access = await client.createSshAccess(sandboxId, DAYTONA_SSH_TOKEN_TTL_MINUTES); rememberDaytonaToken(access.token); - if (generation !== this.credentialsGeneration) { - return this.remintAfterSettingsChange(client, sandboxId, access, options); - } + const mint = { client, sandboxId, access, stamp, options }; + const outdated = this.settleOutdatedMint(mint, false); + if (outdated) return outdated; try { await options.onNewToken?.(access); } catch (error) { @@ -718,10 +735,8 @@ export class DaytonaService { await this.revokeQuietly(client, sandboxId, access.token); throw error; } - if (generation !== this.credentialsGeneration) { - // The retry's onNewToken overwrites what this one wrote. - return this.remintAfterSettingsChange(client, sandboxId, access, options); - } + const outdatedAfterWrite = this.settleOutdatedMint(mint, true); + if (outdatedAfterWrite) return outdatedAfterWrite; this.tokens.set(sandboxId, access); if (previous && previous.token !== access.token) { await this.revokeQuietly(client, sandboxId, previous.token); @@ -729,19 +744,48 @@ export class DaytonaService { return access; } + /** What a mint must still match when it completes: the settings and the sandbox's access. */ + private accessStamp(sandboxId: string): MintStamp { + return { + generation: this.credentialsGeneration, + release: this.accessReleases.get(sandboxId) ?? 0, + }; + } + /** - * The settings changed while this token was minted: it belongs to the old account, which - * setConfig already revoked everything else of. Revoke it with the client that minted it, - * never cache it, and mint again with the new settings for the waiting callers. + * Undefined while the mint is current. A mint that spans a settings change or a release of + * the sandbox's access is never cached or kept in the include file: its token is revoked + * with the client that minted it. After a settings change it mints again under the new + * settings; after a release it fails, since the caller's access was taken away. */ - private async remintAfterSettingsChange( + private settleOutdatedMint( + mint: OutdatedMintInput, + wroteIncludeFile: boolean + ): Promise | undefined { + const { client, sandboxId, access, stamp, options } = mint; + if ((this.accessReleases.get(sandboxId) ?? 0) !== stamp.release) { + return this.discardReleasedMint(client, sandboxId, access, wroteIncludeFile); + } + if (this.credentialsGeneration !== stamp.generation) { + // The retry's onNewToken overwrites whatever this one wrote. + return this.revokeQuietly(client, sandboxId, access.token).then(() => + this.mintSshToken(sandboxId, undefined, options) + ); + } + return undefined; + } + + private async discardReleasedMint( client: DaytonaClient, sandboxId: string, - stale: DaytonaSshAccess, - options: DaytonaSshTokenOptions - ): Promise { - await this.revokeQuietly(client, sandboxId, stale.token); - return this.mintSshToken(sandboxId, undefined, options); + access: DaytonaSshAccess, + wroteIncludeFile: boolean + ): Promise { + await this.revokeQuietly(client, sandboxId, access.token); + if (wroteIncludeFile) await this.sshConfigWriter.removeSandbox(sandboxId); + throw new Error( + `Xum released its SSH access to Daytona sandbox "${sandboxId}" while a token was minted` + ); } /** Revoke and forget the cached token (workspace deleted or sandbox detached). */ From 99920fc14ac8e3b950d3aa21ad63f49efd9c36a0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:37:37 +0000 Subject: [PATCH 030/231] fix(daytona): ignore a stored API URL that does not validate A hand-edited or old daytona.json apiUrl was returned verbatim, so the client failed outside typed handling while availability said ready. The stored URL now goes through the same validation as setConfig and DAYTONA_API_URL; an invalid one is logged and ignored. --- .../config/daytonaCredentialsStore.test.ts | 16 ++++++++++++ src/node/config/daytonaCredentialsStore.ts | 26 ++++++++++++------- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/node/config/daytonaCredentialsStore.test.ts b/src/node/config/daytonaCredentialsStore.test.ts index f096b5fc4ac..58f91144224 100644 --- a/src/node/config/daytonaCredentialsStore.test.ts +++ b/src/node/config/daytonaCredentialsStore.test.ts @@ -71,6 +71,22 @@ describe("DaytonaCredentialsStore", () => { expect((await store.resolve())?.apiKey).toBe(KEY); }); + it("ignores a stored API URL that does not validate, like an invalid DAYTONA_API_URL", async () => { + await fs.writeFile( + path.join(rootDir, "daytona.json"), + JSON.stringify({ apiKey: KEY, apiUrl: "not a url" }), + "utf-8" + ); + const withEnvUrl = new DaytonaCredentialsStore(rootDir, { + DAYTONA_API_URL: "https://env.example.com/api/", + }); + expect((await withEnvUrl.resolve())?.apiUrl).toBe("https://env.example.com/api"); + expect((await withEnvUrl.getPublicConfig()).apiUrl).toBe("https://env.example.com/api"); + expect((await new DaytonaCredentialsStore(rootDir, {}).resolve())?.apiUrl).toBe( + DAYTONA_DEFAULT_API_URL + ); + }); + it("rejects an API URL that is not plain http(s)", async () => { const store = new DaytonaCredentialsStore(rootDir, {}); for (const apiUrl of [ diff --git a/src/node/config/daytonaCredentialsStore.ts b/src/node/config/daytonaCredentialsStore.ts index 62d3888fd29..19f9d998c74 100644 --- a/src/node/config/daytonaCredentialsStore.ts +++ b/src/node/config/daytonaCredentialsStore.ts @@ -81,6 +81,16 @@ function normalizeApiUrl(value: string): string { return parsed.toString().replace(/\/+$/, ""); } +function validApiUrl(value: string | undefined, source: string): string | undefined { + if (!value) return undefined; + try { + return normalizeApiUrl(value); + } catch (error) { + log.warn(`Ignoring invalid ${source}`, { error: getErrorMessage(error) }); + return undefined; + } +} + const CREDENTIAL_FIELDS = ["apiKey", "apiUrl", "target"] as const; /** The non-empty string fields of a credentials file, or undefined when it is not an object. */ @@ -193,16 +203,12 @@ export class DaytonaCredentialsStore { }; } + /** A stored or env URL that does not validate is ignored (self-healing), as if unset. */ private resolveApiUrl(stored: DaytonaStoredCredentials): string { - if (stored.apiUrl) return stored.apiUrl; - const envUrl = nonEmpty(this.env.DAYTONA_API_URL); - if (envUrl) { - try { - return normalizeApiUrl(envUrl); - } catch (error) { - log.warn("Ignoring invalid DAYTONA_API_URL", { error: getErrorMessage(error) }); - } - } - return DAYTONA_DEFAULT_API_URL; + return ( + validApiUrl(stored.apiUrl, "the stored Daytona API URL") ?? + validApiUrl(nonEmpty(this.env.DAYTONA_API_URL), "DAYTONA_API_URL") ?? + DAYTONA_DEFAULT_API_URL + ); } } From 44a72927daaa4a002cb00b9c6e0153cbafa80240 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:37:37 +0000 Subject: [PATCH 031/231] fix(ssh): serialize managed ssh config block writes Concurrent Coder and Daytona setups both read the old ~/.ssh/config and the last rename dropped the other's block. A per-file lock now covers each read-modify-rename (within this process). --- src/node/runtime/muxSshConfigWriter.test.ts | 23 ++++++++++++++++++++- src/node/runtime/muxSshConfigWriter.ts | 20 ++++++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/node/runtime/muxSshConfigWriter.test.ts b/src/node/runtime/muxSshConfigWriter.test.ts index e00a0067ab7..43b72ac3129 100644 --- a/src/node/runtime/muxSshConfigWriter.test.ts +++ b/src/node/runtime/muxSshConfigWriter.test.ts @@ -8,7 +8,7 @@ import { MUX_CODER_SSH_BLOCK_END, MUX_CODER_SSH_BLOCK_START, } from "@/constants/coder"; -import { ensureMuxCoderSSHConfigFile } from "./muxSshConfigWriter"; +import { ensureManagedSSHConfigBlock, ensureMuxCoderSSHConfigFile } from "./muxSshConfigWriter"; function renderExpectedMuxBlock(coderBinaryPath: string): string { const quotedPath = `"${coderBinaryPath.replaceAll('"', String.raw`\"`)}"`; @@ -49,6 +49,27 @@ describe("ensureMuxCoderSSHConfigFile", () => { tempDirs = []; }); + it("keeps both blocks when two providers write the same config at the same time", async () => { + const sshConfigPath = await makeSSHConfigPath(); + await writeSSHConfig(sshConfigPath, "Host work\n User me\n"); + const block = (label: string) => ({ + markers: { start: `# >>> ${label} >>>`, end: `# <<< ${label} <<<`, label }, + block: [`# >>> ${label} >>>`, `Host *.${label}`, `# <<< ${label} <<<`].join("\n"), + sshConfigPath, + }); + + // Coder and Daytona setups started together, each a read-modify-rename of the file. + await Promise.all([ + ensureManagedSSHConfigBlock(block("first")), + ensureManagedSSHConfigBlock(block("second")), + ]); + + const content = await readSSHConfig(sshConfigPath); + expect(content).toContain("Host *.first"); + expect(content).toContain("Host *.second"); + expect(content).toContain("Host work"); + }); + it("creates block from an empty config", async () => { const sshConfigPath = await makeSSHConfigPath(); diff --git a/src/node/runtime/muxSshConfigWriter.ts b/src/node/runtime/muxSshConfigWriter.ts index 9a02d1ad277..9ff5af10f49 100644 --- a/src/node/runtime/muxSshConfigWriter.ts +++ b/src/node/runtime/muxSshConfigWriter.ts @@ -7,6 +7,7 @@ import { MUX_CODER_SSH_BLOCK_END, MUX_CODER_SSH_BLOCK_START, } from "@/constants/coder"; +import { MutexMap } from "@/node/utils/concurrency/mutexMap"; interface EnsureMuxCoderSSHConfigFileOptions { coderBinaryPath: string; @@ -208,6 +209,9 @@ async function loadSSHConfigContent( } } +/** Per resolved ssh config path: serializes each managed block's read-modify-rename. */ +const sshConfigFileLocks = new MutexMap(); + /** Collision-proof temp path: UUID nonce ensures uniqueness even when concurrent calls share a PID + timestamp. */ function makeAtomicTempPath(sshConfigPath: string): string { return `${sshConfigPath}.mux-tmp.${process.pid}.${Date.now()}.${crypto.randomUUID()}`; @@ -249,6 +253,18 @@ export async function ensureManagedSSHConfigBlock(opts: { await fs.mkdir(sshConfigDir, { recursive: true, mode: 0o700 }); + // Coder and Daytona setups can run at once: without the lock both read the old file and the + // last rename drops the other's block. (In this process only, like the include file lock.) + await sshConfigFileLocks.withLock(sshConfigPath, () => + writeManagedBlock(sshConfigPath, markers, opts.block) + ); +} + +async function writeManagedBlock( + sshConfigPath: string, + markers: ManagedBlockMarkers, + block: string +): Promise { const { content: existingContent, mode: existingMode } = await loadSSHConfigContent(sshConfigPath); @@ -265,8 +281,8 @@ export async function ensureManagedSSHConfigBlock(opts: { const nextContent = startMarkerCount === 1 - ? replaceMuxBlock(existingContent, opts.block, markers) - : appendMuxBlock(existingContent, opts.block); + ? replaceMuxBlock(existingContent, block, markers) + : appendMuxBlock(existingContent, block); await writeConfigAtomically(sshConfigPath, nextContent, existingMode); } From 8b656c894dc34f9c4d4f6691249c65c1a2dc7af5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:37:37 +0000 Subject: [PATCH 032/231] fix(daytona): check each checkout within the ready cache and keep untracked work on archive - The 60 s ready cache is per sandbox, so a fork on the same sandbox skipped its own repository check. The cached path now runs the per-workspace check, as SSHRuntime and Coder do on every ensureReady. - Archive with the delete behavior deleted a sandbox whose only unsaved work was an untracked file. That path now also lists untracked files and stops the sandbox with a warning instead; the regular delete checks are unchanged. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 52 +++++++----- src/node/runtime/DaytonaSSHRuntime.ts | 82 +++++++++++++++---- .../services/daytona/daytona.testHarness.ts | 21 +++++ .../daytona/daytonaLifecycleHooks.test.ts | 37 ++++++++- 4 files changed, 153 insertions(+), 39 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 7eab8923632..cf757800248 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, spyOn, type Mock } from "bun:test"; -import { execFileSync, spawn } from "child_process"; +import { execFileSync } from "child_process"; import * as fs from "fs/promises"; import * as path from "path"; import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; import type { RuntimeConfig } from "@/common/types/runtime"; import { createDaytonaTestRig, + localShellTransport, type DaytonaTestRig, } from "@/node/services/daytona/daytona.testHarness"; import { DAYTONA_READY_CACHE_MS } from "@/node/services/daytona/daytonaService"; @@ -38,12 +39,20 @@ afterEach(async () => { function makeRuntime( daytona: DaytonaSandboxConfig, testRig = rig, - options: { workspaceId?: string; transport?: SSHTransport } = {} + options: { + workspaceId?: string; + transport?: SSHTransport; + /** Both set: ensureReady also checks this workspace's checkout. */ + projectPath?: string; + workspaceName?: string; + } = {} ): DaytonaSSHRuntime { const config = { host: "placeholder.xum--daytona", srcBaseDir: "~/src", daytona }; const transport = options.transport ?? createSSHTransport(config, false); return new DaytonaSSHRuntime(config, transport, testRig.service, { workspaceId: options.workspaceId ?? "ws-1", + projectPath: options.projectPath, + workspaceName: options.workspaceName, // Empty by default: tests that seed write files here. agentHomeSeedSources: { xumHome: path.join(testRig.rootDir, "host-xum"), @@ -76,25 +85,6 @@ function initParams(): WorkspaceInitParams & { log: string[] } { const SMALL = { cpu: 2, memoryGiB: 4, diskGiB: 10 }; -/** Runs each "remote" command in a local bash whose HOME is `home`: the sandbox end of ssh. */ -function localShellTransport(home: string): SSHTransport { - const config = { host: "placeholder.xum--daytona" }; - return { - spawnRemoteProcess: (command) => - Promise.resolve({ - process: spawn("bash", ["-c", command], { - env: { PATH: process.env.PATH ?? "", HOME: home }, - stdio: "pipe", - }), - }), - isConnectionFailure: (exitCode) => exitCode === 255, - acquireConnection: () => Promise.resolve(), - getConfig: () => config, - createPtySession: () => Promise.reject(new Error("no PTY in this test")), - openReverseForward: () => Promise.reject(new Error("no forwards in this test")), - }; -} - describe("DaytonaSSHRuntime.ensureReady", () => { it("trusts a ready result for 60 s across runtime instances, then checks again", async () => { rig.plane.addSandbox({ id: "sb-ready" }); @@ -110,6 +100,26 @@ describe("DaytonaSSHRuntime.ensureReady", () => { expect(rig.plane.countCalls("GET /sandbox/sb-ready")).toBeGreaterThan(getsAfterFirst); }); + it("checks each workspace's checkout while their shared sandbox is cached as ready", async () => { + execSpy.mockRestore(); + rig.plane.addSandbox({ id: "sb-shared" }); + const runtimeFor = (workspaceName: string) => + makeRuntime({ sandboxId: "sb-shared" }, rig, { + transport: localShellTransport(rig.rootDir), + projectPath: "/home/me/project", + workspaceName, + }); + const checkout = runtimeFor("ws-a").getWorkspacePath("/home/me/project", "ws-a"); + execFileSync("git", ["init", "-q", checkout.replace(/^~/, rig.rootDir)]); + + expect(await runtimeFor("ws-a").ensureReady()).toEqual({ ready: true }); + // A fork on the same sandbox whose checkout is gone, within the ready cache. + const fork = await runtimeFor("ws-b").ensureReady(); + + expect(fork.ready).toBe(false); + if (!fork.ready) expect(fork.error).toContain("repository not found"); + }); + it("starts a stopped sandbox, reports it in the status sink, and writes the ssh token", async () => { rig.plane.addSandbox({ id: "sb-stopped", state: "stopped" }); const events: RuntimeStatusEvent[] = []; diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index f67a9a3dd37..bc50836794b 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -327,22 +327,13 @@ export class DaytonaSSHRuntime extends SSHRuntime { errorType: "runtime_not_ready", }; } - const setupFailure = this.daytonaService.getSetupFailure(sandboxId); - if (setupFailure) { - return { - ready: false, - error: - `Daytona sandbox setup failed: ${setupFailure}. Xum deleted the sandbox it had ` + - `created; delete this workspace and create a new one.`, - errorType: "runtime_not_ready", - }; - } + const setupFailure = this.setupFailureResult(sandboxId); + if (setupFailure) return setupFailure; + const emitStatus = makeStatusEmitter(options?.statusSink); if (this.daytonaService.isSandboxReady(sandboxId)) { - this.restartsStoppedSandbox = true; - return { ready: true }; + return this.ensureReadyFromCache(options, emitStatus); } - const emitStatus = makeStatusEmitter(options?.statusSink); emitStatus("checking"); const failure = await this.bringSandboxUp(sandboxId, emitStatus, options?.signal); if (failure) return failure; @@ -351,6 +342,29 @@ export class DaytonaSSHRuntime extends SSHRuntime { return result; } + private setupFailureResult(sandboxId: string): EnsureReadyResult | undefined { + const setupFailure = this.daytonaService.getSetupFailure(sandboxId); + if (!setupFailure) return undefined; + return { + ready: false, + error: + `Daytona sandbox setup failed: ${setupFailure}. Xum deleted the sandbox it had ` + + `created; delete this workspace and create a new one.`, + errorType: "runtime_not_ready", + }; + } + + /** The cache covers the sandbox, not this workspace's checkout: a fork shares the sandbox. */ + private async ensureReadyFromCache( + options: EnsureReadyOptions | undefined, + emitStatus: StatusEmitter + ): Promise { + const repoFailure = await this.checkRepo(options, emitStatus); + if (repoFailure) return repoFailure; + this.restartsStoppedSandbox = true; + return { ready: true }; + } + /** * Every ssh command to the sandbox goes through here: its stderr and errors are redacted * (the token is the ssh user name). A refused token is replaced, and a connection failure on @@ -513,14 +527,26 @@ export class DaytonaSSHRuntime extends SSHRuntime { options: EnsureReadyOptions | undefined, emitStatus: StatusEmitter ): Promise { + const repoFailure = await this.checkRepo(options, emitStatus); + if (repoFailure) return repoFailure; + this.daytonaService.markSandboxReady(sandboxId); + emitStatus("ready"); + return { ready: true }; + } + + /** The failure when this workspace's checkout is missing or broken; reports ready otherwise. */ + private async checkRepo( + options: EnsureReadyOptions | undefined, + emitStatus: StatusEmitter + ): Promise { const repoCheck = await this.checkWorkspaceRepo(options); - if (repoCheck && !repoCheck.ready) { + if (!repoCheck) return undefined; + if (!repoCheck.ready) { emitStatus("error", repoCheck.error); return repoCheck; } - this.daytonaService.markSandboxReady(sandboxId); emitStatus("ready"); - return { ready: true }; + return undefined; } private ensureReadyFailure( @@ -943,12 +969,34 @@ export class DaytonaSSHRuntime extends SSHRuntime { try { const deletedPath = this.getWorkspacePath(projectPath, workspaceName); const check = await this.checkWorkspaceDeletable(deletedPath, false, trusted); - return check === "ok" || check === "missing" ? Ok(undefined) : Err(check.error); + if (check === "missing") return Ok(undefined); + if (check !== "ok") return Err(check.error); + return await this.checkNoUntrackedFiles(deletedPath); } catch (error) { return Err(`Failed to check workspace state: ${getErrorMessage(error)}`); } } + /** + * Deleting the sandbox loses untracked files too, which the shared dirty check (made for + * deleting one worktree) ignores. Only this archive path checks them; a failed check keeps + * the sandbox. + */ + private async checkNoUntrackedFiles(deletedPath: string): Promise> { + const result = await execBuffered( + this, + `git -C ${this.quoteForRemote(deletedPath)} ls-files --others --exclude-standard`, + { cwd: "/tmp", timeout: 30 } + ); + if (result.exitCode !== 0) { + return Err(`Failed to list files git does not track: ${result.stderr.trim()}`); + } + const files = result.stdout.split("\n").filter(Boolean); + if (files.length === 0) return Ok(undefined); + const shown = files.slice(0, 5).join(", "); + return Err(`Workspace contains untracked files (${shown}${files.length > 5 ? ", ..." : ""})`); + } + /** * Fail closed: the workspace path is still the unresolved `~/...` default (deferred access * skips resolution at creation), and a "missing" checkout deletes the whole sandbox. So diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index c754a536d66..409c7c50276 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -1,8 +1,10 @@ +import { spawn } from "child_process"; import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; import type { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; +import type { SSHTransport } from "@/node/runtime/transports"; import type { DaytonaWorkspaceRef } from "./daytonaReconciler"; import { DaytonaService } from "./daytonaService"; @@ -301,3 +303,22 @@ export async function createDaytonaTestRig( cleanup: () => fs.rm(rootDir, { recursive: true, force: true }), }; } + +/** Runs each "remote" command in a local bash whose HOME is `home`: the sandbox end of ssh. */ +export function localShellTransport(home: string): SSHTransport { + const config = { host: "placeholder.xum--daytona" }; + return { + spawnRemoteProcess: (command) => + Promise.resolve({ + process: spawn("bash", ["-c", command], { + env: { PATH: process.env.PATH ?? "", HOME: home }, + stdio: "pipe", + }), + }), + isConnectionFailure: (exitCode) => exitCode === 255, + acquireConnection: () => Promise.resolve(), + getConfig: () => config, + createPtySession: () => Promise.reject(new Error("no PTY in this test")), + openReverseForward: () => Promise.reject(new Error("no forwards in this test")), + }; +} diff --git a/src/node/services/daytona/daytonaLifecycleHooks.test.ts b/src/node/services/daytona/daytonaLifecycleHooks.test.ts index 55fd135b5ac..9a4b04a8230 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.test.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { execFileSync } from "child_process"; +import * as fs from "fs/promises"; import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; import { Ok, type Result } from "@/common/types/result"; @@ -6,7 +8,11 @@ import type { WorkspaceMetadata } from "@/common/types/workspace"; import { DaytonaSSHRuntime } from "@/node/runtime/DaytonaSSHRuntime"; import { SSHRuntime } from "@/node/runtime/SSHRuntime"; import { createSSHTransport } from "@/node/runtime/transports"; -import { createDaytonaTestRig, type DaytonaTestRig } from "./daytona.testHarness"; +import { + createDaytonaTestRig, + localShellTransport, + type DaytonaTestRig, +} from "./daytona.testHarness"; import { createDaytonaArchiveHook, type DaytonaArchiveHookOptions } from "./daytonaLifecycleHooks"; let rig: DaytonaTestRig; @@ -149,4 +155,33 @@ describe("createDaytonaArchiveHook", () => { resolvePath.mockRestore(); } }); + + it("keeps a sandbox whose only unsaved work is an untracked file on archive delete", async () => { + rig.plane.addSandbox({ id: "sb-untracked" }); + const daytona = { sandboxId: "sb-untracked", shared: false }; + const config = { host: "x.xum--daytona", srcBaseDir: "~/src", daytona }; + // Real git in a local "sandbox": a clean, committed checkout plus one new file. + const runtime = new DaytonaSSHRuntime(config, localShellTransport(rig.rootDir), rig.service); + const checkout = runtime.getWorkspacePath("/tmp/proj", "ws").replace(/^~/, rig.rootDir); + const git = (...args: string[]) => + execFileSync("git", ["-C", checkout, "-c", "user.name=t", "-c", "user.email=t@t", ...args]); + await fs.mkdir(checkout, { recursive: true }); + git("init", "-q"); + git("commit", "-q", "--allow-empty", "-m", "init"); + await fs.writeFile(`${checkout}/notes.md`, "not committed yet\n"); + const warnings: string[] = []; + + const result = await archive( + daytona, + "delete", + (workspaceMetadata) => + runtime.checkSandboxDeletable(workspaceMetadata.projectPath, workspaceMetadata.name, true), + (warning) => warnings.push(warning) + ); + + expect(result.success).toBe(true); + expect(rig.plane.find("sb-untracked")?.state).toBe("stopped"); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("untracked"); + }); }); From 7bc3be005de7d6123b7481f4af5578c97c90db12 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:58:10 +0000 Subject: [PATCH 033/231] fix(daytona): check a seed file's size before reading it The agent-home seed read each file whole and only then compared it with the 16 MiB budget. It now checks stat.size against the remaining budget first and skips (and logs) a file that does not fit. --- src/node/runtime/daytonaAgentHomeSeed.test.ts | 19 ++++++++++++++++++- src/node/runtime/daytonaAgentHomeSeed.ts | 13 ++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/node/runtime/daytonaAgentHomeSeed.test.ts b/src/node/runtime/daytonaAgentHomeSeed.test.ts index ff156dfe5ac..afd1be1fc66 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.test.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import { execFile } from "child_process"; import * as fs from "fs/promises"; import * as os from "os"; @@ -71,4 +71,21 @@ describe("agent-home seeding", () => { [] ); }); + + it("never reads a file larger than the remaining seed budget", async () => { + const huge = path.join(hostXum, "skills", "data", "model.bin"); + await fs.mkdir(path.dirname(huge), { recursive: true }); + // Sparse: 64 MiB on paper, no disk used. + await fs.writeFile(huge, ""); + await fs.truncate(huge, 64 * 1024 * 1024); + const readFile = spyOn(fs, "readFile"); + try { + const files = await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux"); + + expect(files).toEqual([]); + expect(readFile.mock.calls.map(([file]) => String(file))).not.toContain(huge); + } finally { + readFile.mockRestore(); + } + }); }); diff --git a/src/node/runtime/daytonaAgentHomeSeed.ts b/src/node/runtime/daytonaAgentHomeSeed.ts index e5a413548cc..7d1625ee300 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.ts @@ -45,12 +45,19 @@ interface SeedBudget { } async function readRegularFile( - filePath: string + filePath: string, + budget: SeedBudget ): Promise<{ content: Buffer; mode: number } | null> { try { // stat follows symlinks: plugin installs link skills into the skills directory. const stat = await fs.stat(filePath); if (!stat.isFile()) return null; + // Check before reading: a large file must not be loaded only to be dropped. + if (stat.size > budget.remainingBytes) { + log.debug("Skipping a file past the Daytona seed size limit", { filePath, size: stat.size }); + budget.exhausted = true; + return null; + } return { content: await fs.readFile(filePath), mode: stat.mode }; } catch (error) { if (isErrnoWithCode(error, "ENOENT")) return null; @@ -134,7 +141,7 @@ async function collectEntry( await collectDirectory(files, budget, hostPath, remotePath, depth + 1); return; } - const file = stat.isFile() ? await readRegularFile(hostPath) : null; + const file = stat.isFile() ? await readRegularFile(hostPath, budget) : null; if (file) addFile(files, budget, remotePath, file); } @@ -146,7 +153,7 @@ export async function collectAgentHomeSeedFiles( const files: AgentHomeSeedFile[] = []; const budget: SeedBudget = { remainingBytes: MAX_SEED_BYTES, exhausted: false }; for (const name of SEEDED_HOOK_FILES) { - const file = await readRegularFile(path.join(sources.xumHome, name)); + const file = await readRegularFile(path.join(sources.xumHome, name), budget); if (file) addFile(files, budget, path.posix.join(remoteXumHome, name), file); } await collectDirectory( From 1c151b1d4974528fc6fb9dc6a0d5ff4fdecddd3a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:59:20 +0000 Subject: [PATCH 034/231] fix(daytona): never seed files a symlink brings in from outside a skill The seed followed every symlink inside a skill tree, so a link to a host credential directory was copied into the sandbox. A skill directory in a skills root may still be a link (skill discovery follows those, and plugin installs create them); inside a skill, a link is followed only while its real path stays within that skill's real directory. --- src/node/runtime/daytonaAgentHomeSeed.test.ts | 28 +++++ src/node/runtime/daytonaAgentHomeSeed.ts | 109 ++++++++++++------ 2 files changed, 103 insertions(+), 34 deletions(-) diff --git a/src/node/runtime/daytonaAgentHomeSeed.test.ts b/src/node/runtime/daytonaAgentHomeSeed.test.ts index afd1be1fc66..0560a3f5971 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.test.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.test.ts @@ -88,4 +88,32 @@ describe("agent-home seeding", () => { readFile.mockRestore(); } }); + + it("follows symlinks only inside the skill being copied, except a linked skill itself", async () => { + const secrets = path.join(hostHome, ".ssh"); + await fs.mkdir(secrets, { recursive: true }); + await fs.writeFile(path.join(secrets, "id_ed25519"), "PRIVATE KEY\n"); + const skill = path.join(hostXum, "skills", "deploy"); + await fs.mkdir(skill, { recursive: true }); + await fs.writeFile(path.join(skill, "SKILL.md"), "# deploy\n"); + // Links that escape the skill: a directory and a file. + await fs.symlink(secrets, path.join(skill, "creds")); + await fs.symlink(path.join(secrets, "id_ed25519"), path.join(skill, "key")); + // A link that stays inside the skill. + await fs.symlink(path.join(skill, "SKILL.md"), path.join(skill, "README.md")); + // A skill directory linked into the skills root, as plugin installs and users do. + const linkedTarget = path.join(root, "plugins", "lint"); + await fs.mkdir(linkedTarget, { recursive: true }); + await fs.writeFile(path.join(linkedTarget, "SKILL.md"), "# lint\n"); + await fs.symlink(linkedTarget, path.join(hostXum, "skills", "lint")); + + const files = await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux"); + + expect(files.map((file) => file.remotePath).sort()).toEqual([ + "~/.mux/skills/deploy/README.md", + "~/.mux/skills/deploy/SKILL.md", + "~/.mux/skills/lint/SKILL.md", + ]); + expect(files.some((file) => file.content.toString().includes("PRIVATE KEY"))).toBe(false); + }); }); diff --git a/src/node/runtime/daytonaAgentHomeSeed.ts b/src/node/runtime/daytonaAgentHomeSeed.ts index 7d1625ee300..5355a605526 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.ts @@ -98,50 +98,93 @@ async function readDirectory(hostDir: string): Promise { } } +/** The real path and its stat (links followed); null for a broken link or a missing path. */ +async function resolveEntry( + hostPath: string +): Promise<{ real: string; stat: Awaited> } | null> { + try { + const real = await fs.realpath(hostPath); + return { real, stat: await fs.stat(real) }; + } catch (error) { + // Broken symlinks are common in skill directories; skip them. + log.debug("Skipping a missing path while seeding a Daytona sandbox", { + hostPath, + error: getErrorMessage(error), + }); + return null; + } +} + +function isInside(realPath: string, realRoot: string): boolean { + const relative = path.relative(realRoot, realPath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +/** + * A skills root (`~/.xum/skills`, `~/.agents/skills`). Each skill directory in it may be a + * symlink (users and plugin installs link skills, and skill discovery follows them); inside a + * skill, links are followed only while they stay within that skill's real directory, so a + * link to `~/.ssh` is never copied into the sandbox. + */ +async function collectSkillsRoot( + files: AgentHomeSeedFile[], + budget: SeedBudget, + hostRoot: string, + remoteRoot: string +): Promise { + const entries = await readDirectory(hostRoot); + const root = entries ? await resolveEntry(hostRoot) : null; + if (!entries || !root) return; + for (const entry of entries) { + if (budget.exhausted) return; + const resolved = await resolveEntry(path.join(hostRoot, entry)); + if (!resolved) continue; + const remotePath = path.posix.join(remoteRoot, entry); + if (resolved.stat.isDirectory()) { + await collectDirectory(files, budget, resolved.real, remotePath, 1, resolved.real); + } else if (isInside(resolved.real, root.real)) { + await collectFile(files, budget, resolved, remotePath); + } + } +} + async function collectDirectory( files: AgentHomeSeedFile[], budget: SeedBudget, hostDir: string, remoteDir: string, - depth: number + depth: number, + skillRoot: string ): Promise { if (depth > MAX_SKILL_DIR_DEPTH || budget.exhausted) return; const entries = await readDirectory(hostDir); if (!entries) return; for (const entry of entries) { - await collectEntry( - files, - budget, - path.join(hostDir, entry), - path.posix.join(remoteDir, entry), - depth - ); + const resolved = await resolveEntry(path.join(hostDir, entry)); + if (!resolved) continue; + if (!isInside(resolved.real, skillRoot)) { + log.warn("Skipping a symlink that leaves its skill while seeding a Daytona sandbox", { + hostPath: path.join(hostDir, entry), + }); + continue; + } + const remotePath = path.posix.join(remoteDir, entry); + if (resolved.stat.isDirectory()) { + await collectDirectory(files, budget, resolved.real, remotePath, depth + 1, skillRoot); + } else { + await collectFile(files, budget, resolved, remotePath); + } } } -async function collectEntry( +async function collectFile( files: AgentHomeSeedFile[], budget: SeedBudget, - hostPath: string, - remotePath: string, - depth: number + resolved: { real: string; stat: Awaited> }, + remotePath: string ): Promise { - let stat: Awaited>; - try { - stat = await fs.stat(hostPath); - } catch (error) { - // Broken symlinks are common in skill directories; skip them. - log.debug("Skipping a missing path while seeding a Daytona sandbox", { - hostPath, - error: getErrorMessage(error), - }); - return; - } - if (stat.isDirectory()) { - await collectDirectory(files, budget, hostPath, remotePath, depth + 1); - return; - } - const file = stat.isFile() ? await readRegularFile(hostPath, budget) : null; + if (!resolved.stat.isFile()) return; + const file = await readRegularFile(resolved.real, budget); if (file) addFile(files, budget, remotePath, file); } @@ -156,19 +199,17 @@ export async function collectAgentHomeSeedFiles( const file = await readRegularFile(path.join(sources.xumHome, name), budget); if (file) addFile(files, budget, path.posix.join(remoteXumHome, name), file); } - await collectDirectory( + await collectSkillsRoot( files, budget, path.join(sources.xumHome, "skills"), - path.posix.join(remoteXumHome, "skills"), - 0 + path.posix.join(remoteXumHome, "skills") ); - await collectDirectory( + await collectSkillsRoot( files, budget, path.join(sources.home, ".agents", "skills"), - "~/.agents/skills", - 0 + "~/.agents/skills" ); if (budget.exhausted) { log.warn("Daytona agent-home seed hit its size limit; some files were not copied", { From 4afe905692e15ea14ebfb31d081b4c3815541d5f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:01:03 +0000 Subject: [PATCH 035/231] fix(daytona): bring the sandbox up before a terminal opens Integrated terminals (SSHRuntime.createPtySession) and the native terminal went straight to ssh, so a stopped sandbox or an expiring token failed instead of starting or refreshing. DaytonaSSHRuntime.createPtySession and openNative for a Daytona workspace now run ensureReady first and report its error. The user opened the terminal, so starting the billed sandbox is expected. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 40 ++++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 13 ++++++- src/node/services/terminalService.test.ts | 44 ++++++++++++++++++++++ src/node/services/terminalService.ts | 7 ++++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index cf757800248..deb47cb77d4 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -164,6 +164,46 @@ describe("DaytonaSSHRuntime.ensureReady", () => { }); }); +describe("DaytonaSSHRuntime integrated terminals", () => { + it("start a stopped sandbox before the terminal's ssh session opens", async () => { + const sandbox = rig.plane.addSandbox({ id: "sb-term", state: "stopped" }); + let stateWhenOpened: string | undefined; + const runtime = makeRuntime({ sandboxId: "sb-term" }, rig, { + transport: { + ...localShellTransport(rig.rootDir), + createPtySession: () => { + stateWhenOpened = sandbox.state; + return Promise.resolve({} as Awaited>); + }, + }, + }); + + await runtime.createPtySession({ workspacePath: "~/src/project/feature", cols: 80, rows: 24 }); + + expect(rig.plane.countCalls("POST /sandbox/sb-term/start")).toBe(1); + expect(stateWhenOpened).toBe("started"); + }); + + it("report a sandbox that cannot be brought up instead of opening ssh", async () => { + let opened = false; + const runtime = makeRuntime({ sandboxId: "sb-gone" }, rig, { + transport: { + ...localShellTransport(rig.rootDir), + createPtySession: () => { + opened = true; + return Promise.resolve({} as Awaited>); + }, + }, + }); + + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect( + runtime.createPtySession({ workspacePath: "~/src/project/feature", cols: 80, rows: 24 }) + ).rejects.toThrow("no longer exists"); + expect(opened).toBe(false); + }); +}); + describe("DaytonaSSHRuntime.finalizeConfig and validateBeforePersist", () => { it("names a new sandbox before persist and fills the default size", async () => { const runtime = makeRuntime({}); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index bc50836794b..87227271bdb 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -29,7 +29,7 @@ import type { import { isPermanentSSHFailure, isRuntimeTransportError, RuntimeError } from "./Runtime"; import type { SpawnResult } from "./RemoteRuntime"; import { SSHRuntime, type SSHRuntimeConfig } from "./SSHRuntime"; -import type { SSHTransport } from "./transports"; +import type { PtyHandle, PtySessionParams, SSHTransport } from "./transports"; import { DAYTONA_DEFAULT_SIZE_PRESET_ID, DAYTONA_SIZE_PRESETS, @@ -489,6 +489,17 @@ export class DaytonaSSHRuntime extends SSHRuntime { } } + /** + * A terminal goes straight to ssh, so it brings the sandbox up first (a stopped or archived + * sandbox starts, an expiring token is replaced). The user opened it, so starting the + * billed sandbox is expected, as for a turn. + */ + override async createPtySession(params: PtySessionParams): Promise { + const ready = await this.ensureReady(); + if (!ready.ready) throw new Error(ready.error); + return super.createPtySession(params); + } + /** Init output (sync, git push, hook) can quote ssh's stderr, so it is redacted too. */ override async initWorkspace(params: WorkspaceInitParams): Promise { try { diff --git a/src/node/services/terminalService.test.ts b/src/node/services/terminalService.test.ts index dc917fe7343..eead23eb211 100644 --- a/src/node/services/terminalService.test.ts +++ b/src/node/services/terminalService.test.ts @@ -13,6 +13,8 @@ import type { TerminalCreateParams } from "@/common/types/terminal"; import type { RuntimeConfig } from "@/common/types/runtime"; import * as childProcess from "child_process"; import * as fs from "fs/promises"; +import type { Runtime } from "@/node/runtime/Runtime"; +import * as runtimeHelpers from "@/node/runtime/runtimeHelpers"; // Unique per test run: native-terminal markers persist on disk, so a shared path would leak // sticky state across runs and flake the "not yet opened" assertions. @@ -1884,6 +1886,48 @@ describe("TerminalService.openNative", () => { setPlatform("linux"); }); + it("brings a Daytona sandbox up before launching its terminal, and stops if it cannot", async () => { + const daytonaConfig = { + ...configWithSSHWorkspace, + getAllWorkspaceMetadata: mock(() => + Promise.resolve([ + { + id: "ws-ssh", + projectPath: "/home/user/project", + name: "feature", + namedWorkspacePath: "~/src/project/feature", + runtimeConfig: { + type: "ssh", + host: "sb-1.xum--daytona", + srcBaseDir: "~/src", + daytona: { sandboxId: "sb-1" }, + }, + }, + ]) + ), + } as unknown as Config; + const ensureReady = mock(() => + Promise.resolve({ + ready: false as const, + error: 'Daytona sandbox "sb-1" no longer exists', + errorType: "runtime_not_ready" as const, + }) + ); + const createRuntime = spyOn(runtimeHelpers, "createRuntimeForWorkspace").mockReturnValue({ + ensureReady, + } as unknown as Runtime); + try { + service = new TerminalService(daytonaConfig, mockPTYService, mockSecretsStore); + + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(service.openNative("ws-ssh")).rejects.toThrow("no longer exists"); + expect(ensureReady).toHaveBeenCalledTimes(1); + expect(spawnSpy).not.toHaveBeenCalled(); + } finally { + createRuntime.mockRestore(); + } + }); + it("should try terminal emulators in order of preference", async () => { // Make gnome-terminal the first available spawnSyncSpy.mockImplementation((cmd: string, args: string[]) => { diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index b3f0ec6bd5f..9582475d452 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -799,6 +799,13 @@ export class TerminalService { ); } + // A Daytona sandbox may be stopped, or its token (the ssh user name) due for refresh: + // bring it up before the emulator runs ssh. The user asked for this terminal. + if (isSSHRuntime(workspace.runtimeConfig) && workspace.runtimeConfig.daytona) { + const ready = await createRuntimeForWorkspace(workspace).ensureReady(); + if (!ready.ready) throw new Error(ready.error); + } + // Before any durable effect: one share per workspace is kept, after a failed launch too // (fail closed; see nativeTerminalUseLeases), and a refusal above releases this open's. if (this.nativeTerminalUseLeases.has(workspaceId)) { From bfd6a5df98fb64a6974559467a840e46f3ba59fb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:01:59 +0000 Subject: [PATCH 036/231] fix(daytona): check an attached sandbox's state before probing its jobs The reconciler asked whether a workspace was active (which may check background jobs over ssh) before it knew the state of an attached sandbox, which its instance list does not show, so the probe could start a stopped sandbox. The attached sandbox is now looked up first (with the reconciler's call timeout) and only a started one is probed. --- .../services/daytona/daytonaReconciler.test.ts | 14 ++++++++++++++ src/node/services/daytona/daytonaReconciler.ts | 14 +++++++------- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/node/services/daytona/daytonaReconciler.test.ts b/src/node/services/daytona/daytonaReconciler.test.ts index 48c18a9cc21..99a296a4426 100644 --- a/src/node/services/daytona/daytonaReconciler.test.ts +++ b/src/node/services/daytona/daytonaReconciler.test.ts @@ -137,4 +137,18 @@ describe("DaytonaReconciler.sweep", () => { expect(rig.plane.calls.filter((call) => call.endsWith("/last-activity"))).toEqual([]); expect(activityChecks).toEqual([]); }); + + it("asks about an attached sandbox's jobs only once its state shows it started", async () => { + // Attached by the user (no label of this install) and stopped: an ssh job probe would + // start it. + rig.plane.addSandbox({ id: "sb-attached-off", state: "stopped" }); + + await makeReconciler( + [{ id: "ws-attached-off", runtimeConfig: daytonaConfig("sb-attached-off") }], + ["ws-attached-off"] + ).sweep(); + + expect(activityChecks).toEqual([]); + expect(rig.plane.countCalls("POST /sandbox/sb-attached-off/last-activity")).toBe(0); + }); }); diff --git a/src/node/services/daytona/daytonaReconciler.ts b/src/node/services/daytona/daytonaReconciler.ts index 748e01813d2..4c169b17e72 100644 --- a/src/node/services/daytona/daytonaReconciler.ts +++ b/src/node/services/daytona/daytonaReconciler.ts @@ -154,14 +154,14 @@ export class DaytonaReconciler { ref: { workspaceId: string; sandboxId: string }, sandboxes: DaytonaSandbox[] ): Promise { - // Xum stores a name for sandboxes it created; last-activity only takes the real id. - const listed = sandboxes.find((sb) => sb.id === ref.sandboxId || sb.name === ref.sandboxId); + // Xum stores a name for sandboxes it created; last-activity only takes the real id. An + // attached sandbox carries no label of this install, so the list does not show it. + const sandbox = + sandboxes.find((sb) => sb.id === ref.sandboxId || sb.name === ref.sandboxId) ?? + (await this.lookUpSandbox(ref.sandboxId)); // A stopped sandbox has nothing to keep alive, and checking its jobs over ssh would start it. - if (listed && !isStarted(listed)) return undefined; - if (!(await this.isWorkspaceActive(ref.workspaceId))) return undefined; - // An attached sandbox carries no label of this install, so the list does not show it. - const sandbox = listed ?? (await this.lookUpSandbox(ref.sandboxId)); - return sandbox && isStarted(sandbox) ? sandbox : undefined; + if (!sandbox || !isStarted(sandbox)) return undefined; + return (await this.isWorkspaceActive(ref.workspaceId)) ? sandbox : undefined; } private async isWorkspaceActive(workspaceId: string): Promise { From 476ac2533a69456ece66584dc8325debf61183a4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:05:27 +0000 Subject: [PATCH 037/231] fix(daytona): keep sandboxes alive under jobs that survived a Xum crash The reconciler's activity check saw streams and this process's background jobs only, so detached jobs that outlived an unclean exit (durable remote spawn records) let Daytona auto-stop the sandbox under them. A workspace that is not otherwise active now gets the hasUnsettledRemoteSpawnRecords probe; a failed probe keeps the sandbox alive. Survivors only predate this process and each probe is ssh traffic Daytona counts as activity, so a workspace that reads clear is not probed again. --- .../daytona/daytonaReconciler.test.ts | 38 +++++++++++- .../services/daytona/daytonaReconciler.ts | 60 +++++++++++++++---- src/node/services/serviceContainer.ts | 8 ++- 3 files changed, 92 insertions(+), 14 deletions(-) diff --git a/src/node/services/daytona/daytonaReconciler.test.ts b/src/node/services/daytona/daytonaReconciler.test.ts index 99a296a4426..92dbbee2dd1 100644 --- a/src/node/services/daytona/daytonaReconciler.test.ts +++ b/src/node/services/daytona/daytonaReconciler.test.ts @@ -1,7 +1,12 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import type { RuntimeConfig } from "@/common/types/runtime"; import { createDaytonaTestRig, type DaytonaTestRig } from "./daytona.testHarness"; -import { DaytonaReconciler, listDaytonaWorkspaceRefs } from "./daytonaReconciler"; +import { Ok } from "@/common/types/result"; +import { + DaytonaReconciler, + listDaytonaWorkspaceRefs, + type DaytonaReconcilerOptions, +} from "./daytonaReconciler"; import { DAYTONA_LABEL_INSTANCE } from "./daytonaService"; let rig: DaytonaTestRig; @@ -31,7 +36,8 @@ let activityChecks: string[]; function makeReconciler( workspaces: Array<{ id: string; runtimeConfig: RuntimeConfig; archivedAt?: string }>, activeIds: string[], - callTimeoutMs?: number + callTimeoutMs?: number, + hasUnsettledRemoteJobs?: DaytonaReconcilerOptions["hasUnsettledRemoteJobs"] ): DaytonaReconciler { const config = { projects: new Map([["/home/me/project", { workspaces }]]) }; activityChecks = []; @@ -43,6 +49,7 @@ function makeReconciler( return Promise.resolve(activeIds.includes(workspaceId)); }, callTimeoutMs, + hasUnsettledRemoteJobs, }); } @@ -151,4 +158,31 @@ describe("DaytonaReconciler.sweep", () => { expect(activityChecks).toEqual([]); expect(rig.plane.countCalls("POST /sandbox/sb-attached-off/last-activity")).toBe(0); }); + + it("keeps a sandbox alive under jobs that survived a Xum crash, probing a clear one once", async () => { + rig.plane.addSandbox({ id: "sb-1", name: "xum-survivor", labels: instanceLabels }); + rig.plane.addSandbox({ id: "sb-2", name: "xum-quiet", labels: instanceLabels }); + const probed: string[] = []; + const reconciler = makeReconciler( + [ + { id: "ws-survivor", runtimeConfig: daytonaConfig("xum-survivor") }, + { id: "ws-quiet", runtimeConfig: daytonaConfig("xum-quiet") }, + ], + // Neither streams nor holds a job in this process's memory. + [], + undefined, + (ref) => { + probed.push(ref.workspaceId); + return Promise.resolve(Ok(ref.workspaceId === "ws-survivor")); + } + ); + + await reconciler.sweep(); + await reconciler.sweep(); + + expect(rig.plane.countCalls("POST /sandbox/sb-1/last-activity")).toBe(2); + expect(rig.plane.countCalls("POST /sandbox/sb-2/last-activity")).toBe(0); + // A probe is itself ssh traffic that Daytona counts: a clear workspace is not probed again. + expect(probed).toEqual(["ws-survivor", "ws-quiet", "ws-survivor"]); + }); }); diff --git a/src/node/services/daytona/daytonaReconciler.ts b/src/node/services/daytona/daytonaReconciler.ts index 4c169b17e72..0a9918ad235 100644 --- a/src/node/services/daytona/daytonaReconciler.ts +++ b/src/node/services/daytona/daytonaReconciler.ts @@ -1,5 +1,6 @@ import type { Workspace } from "@/common/types/project"; import { getDaytonaSandboxId, type RuntimeConfig } from "@/common/types/runtime"; +import { Err, type Result } from "@/common/types/result"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; import type { DaytonaSandbox } from "./daytonaClient"; @@ -32,6 +33,11 @@ export interface DaytonaReconcilerOptions { * API traffic. May check a job over ssh, so it is only asked about a started sandbox. */ isWorkspaceActive: (workspaceId: string) => boolean | Promise; + /** + * Whether detached jobs that survived an unclean Xum exit still run (durable spawn records + * on the sandbox). Asked over ssh, so only about a started sandbox; an error counts as yes. + */ + hasUnsettledRemoteJobs?: (ref: DaytonaSandboxRef) => Promise>; intervalMs?: number; callTimeoutMs?: number; } @@ -56,6 +62,13 @@ export function listDaytonaWorkspaceRefs(config: { return refs; } +/** A workspace on a Daytona sandbox. */ +export interface DaytonaSandboxRef { + workspaceId: string; + runtimeConfig: RuntimeConfig; + sandboxId: string; +} + function isStarted(sandbox: DaytonaSandbox): boolean { return mapDaytonaSandboxState(sandbox.state) === "started"; } @@ -83,6 +96,12 @@ function withTimeout(promise: Promise, timeoutMs: number, what: string): P */ export class DaytonaReconciler { private readonly options: DaytonaReconcilerOptions; + /** + * Workspaces whose remote spawn records read clear. Crash survivors only predate this + * process (later jobs are tracked in memory), and each probe is ssh traffic that Daytona + * counts as activity, so a clear workspace is never probed again. + */ + private readonly remoteJobsClear = new Set(); private timer: ReturnType | undefined; private sweeping: Promise | undefined; @@ -132,26 +151,23 @@ export class DaytonaReconciler { await this.stopOrphans(sandboxes, refs); } - private collectReferences(): Array<{ workspaceId: string; sandboxId: string }> { - const refs: Array<{ workspaceId: string; sandboxId: string }> = []; - for (const workspace of this.options.listWorkspaces()) { - const sandboxId = getDaytonaSandboxId(workspace.runtimeConfig); - if (sandboxId) refs.push({ workspaceId: workspace.workspaceId, sandboxId }); + private collectReferences(): DaytonaSandboxRef[] { + const refs: DaytonaSandboxRef[] = []; + for (const { workspaceId, runtimeConfig } of this.options.listWorkspaces()) { + const sandboxId = getDaytonaSandboxId(runtimeConfig); + if (sandboxId && runtimeConfig) refs.push({ workspaceId, runtimeConfig, sandboxId }); } return refs; } - private async keepAlive( - ref: { workspaceId: string; sandboxId: string }, - sandboxes: DaytonaSandbox[] - ): Promise { + private async keepAlive(ref: DaytonaSandboxRef, sandboxes: DaytonaSandbox[]): Promise { const sandbox = await this.findActiveSandbox(ref, sandboxes); if (sandbox) await this.refreshActivityAndToken(ref.sandboxId, sandbox.id); } /** The sandbox of a workspace that is active, if the sandbox is known and started. */ private async findActiveSandbox( - ref: { workspaceId: string; sandboxId: string }, + ref: DaytonaSandboxRef, sandboxes: DaytonaSandbox[] ): Promise { // Xum stores a name for sandboxes it created; last-activity only takes the real id. An @@ -161,7 +177,8 @@ export class DaytonaReconciler { (await this.lookUpSandbox(ref.sandboxId)); // A stopped sandbox has nothing to keep alive, and checking its jobs over ssh would start it. if (!sandbox || !isStarted(sandbox)) return undefined; - return (await this.isWorkspaceActive(ref.workspaceId)) ? sandbox : undefined; + if (await this.isWorkspaceActive(ref.workspaceId)) return sandbox; + return (await this.hasUnsettledRemoteJobs(ref)) ? sandbox : undefined; } private async isWorkspaceActive(workspaceId: string): Promise { @@ -179,6 +196,27 @@ export class DaytonaReconciler { } } + /** Fails closed (keeps the sandbox alive) when the probe fails or times out. */ + private async hasUnsettledRemoteJobs(ref: DaytonaSandboxRef): Promise { + const probe = this.options.hasUnsettledRemoteJobs; + if (!probe || this.remoteJobsClear.has(ref.workspaceId)) return false; + let result: Result; + try { + result = await this.call(probe(ref), "checking jobs that survived a restart"); + } catch (error) { + result = Err(getErrorMessage(error)); + } + if (!result.success) { + log.warn("Daytona reconcile: could not check jobs that survived a restart", { + workspaceId: ref.workspaceId, + error: result.error, + }); + return true; + } + if (!result.data) this.remoteJobsClear.add(ref.workspaceId); + return result.data; + } + /** `storedId` is what the workspace config holds (tokens use it), `realId` the sandbox id. */ private async refreshActivityAndToken(storedId: string, realId: string): Promise { const service = this.options.daytonaService; diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 1b8c3e9d98c..9addf882669 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -67,7 +67,7 @@ import { } from "@/node/services/daytona/daytonaReconciler"; import { createDaytonaArchiveHook } from "@/node/services/daytona/daytonaLifecycleHooks"; import { DEFAULT_CODER_ARCHIVE_BEHAVIOR } from "@/common/config/coderArchiveBehavior"; -import { setGlobalDaytonaService } from "@/node/runtime/runtimeFactory"; +import { createRuntime, setGlobalDaytonaService } from "@/node/runtime/runtimeFactory"; import { createRuntimeForWorkspace } from "@/node/runtime/runtimeHelpers"; import { DaytonaSSHRuntime } from "@/node/runtime/DaytonaSSHRuntime"; import { Err } from "@/common/types/result"; @@ -480,6 +480,12 @@ export class ServiceContainer { isWorkspaceActive: async (workspaceId) => this.aiService.isStreaming(workspaceId) || (await this.backgroundProcessManager.hasLiveBackgroundProcesses(workspaceId)), + // Detached jobs that outlived an unclean exit are in no in-memory list. + hasUnsettledRemoteJobs: (ref) => + this.backgroundProcessManager.hasUnsettledRemoteSpawnRecords( + createRuntime(ref.runtimeConfig, { workspaceId: ref.workspaceId }), + ref.workspaceId + ), }); } From c91c92741e45f8acddb509fbf4ca377357e684fd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:08:08 +0000 Subject: [PATCH 038/231] fix(daytona): keep a draft's Daytona choices across runtime switches Switching from Daytona to another runtime and back rebuilt the Daytona selection from the project defaults, losing a picked existing sandbox, snapshot or size. The creation draft now keeps its last Daytona choice (lastDaytona, optional in the draft schema) and switching back restores it. It stays in the draft only: an attached sandbox never becomes a project default, and a project change drops it. --- .../hooks/useDraftWorkspaceSettings.test.tsx | 33 +++++++++++++++++++ .../hooks/useDraftWorkspaceSettings.ts | 11 +++++-- src/common/orpc/schemas/drafts.ts | 5 +++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx index 1236f779d8f..143186c3727 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx +++ b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx @@ -6,6 +6,10 @@ import { APIProvider, type APIClient } from "@/browser/contexts/API"; import { ProjectProvider } from "@/browser/contexts/ProjectContext"; import { ThinkingProvider } from "@/browser/contexts/ThinkingContext"; import { CODER_RUNTIME_PLACEHOLDER } from "@/common/types/runtime"; +import { + readDaytonaOptionDefaults, + readRuntimeOptionDefaults, +} from "@/browser/utils/runtimeOptionDefaults"; import { useDraftWorkspaceSettings } from "./useDraftWorkspaceSettings"; import { createTestApiClient, createTestPreferencesConfig } from "@/browser/testUtils"; import { getAppConfigStore, getUserPreferences } from "@/browser/stores/AppConfigStore"; @@ -357,6 +361,35 @@ describe("useDraftWorkspaceSettings", () => { }); }); + test("restores the draft's Daytona choices after switching runtimes, without saving them as defaults", async () => { + const projectPath = "/tmp/project"; + const projectDefaults = { + ssh: { daytona: { snapshot: "base", cpu: 2, memoryGiB: 4, diskGiB: 10 } }, + }; + const wrapper = createWrapper(projectPath, runtimeConfigPrefs(projectPath, projectDefaults)); + const { result } = renderHook( + () => useDraftWorkspaceSettings(projectPath, ["main"], "main", draftId), + { wrapper } + ); + await waitFor(() => expect(result.current.daytonaConfigFallback.snapshot).toBe("base")); + + // An existing sandbox picked in this draft, then another runtime. + const attached = { sandboxId: "sb-mine", shared: true }; + act(() => { + result.current.setSelectedRuntime({ mode: "ssh", host: "placeholder", daytona: attached }); + }); + act(() => { + result.current.setSelectedRuntime({ mode: "worktree" }); + }); + + // Switching back to Daytona starts from this fallback. + expect(result.current.daytonaConfigFallback).toEqual(attached); + // The attached sandbox never becomes a project default. + expect(readDaytonaOptionDefaults(readRuntimeOptionDefaults(projectPath))).toEqual( + projectDefaults.ssh.daytona + ); + }); + test("exposes persisted SSH host as fallback when leaving Coder", async () => { const projectPath = "/tmp/project"; diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index ca10df1d944..47757dec686 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -437,7 +437,8 @@ export function useDraftWorkspaceSettings( // useDraftWorkspaceSettings.compiler.test.tsx guards their identity. // Uses discriminated union: SSH has host, Docker has image. // Parsed into a fresh object, so it too must follow the last hook to keep its identity. - const daytonaConfigFallback = readDaytonaOptionDefaults(lastRuntimeConfigs); + const daytonaConfigFallback = + execution?.lastDaytona ?? readDaytonaOptionDefaults(lastRuntimeConfigs); const defaultRuntime = buildRuntimeForMode( defaultRuntimeMode, { @@ -469,7 +470,13 @@ export function useDraftWorkspaceSettings( devcontainerShareCredentials: lastDevcontainerShareCredentials, }); - getCreationDraftStore().updateExecution(draftId, { runtime: mergedRuntime }); + getCreationDraftStore().updateExecution(draftId, { + runtime: mergedRuntime, + // Only in the draft: an attached sandbox must never become a project default. + ...(mergedRuntime.mode === RUNTIME_MODE.SSH && mergedRuntime.daytona + ? { lastDaytona: mergedRuntime.daytona } + : {}), + }); // Persist host/image/coder so they're remembered when switching modes. // Avoid wiping the remembered value when the UI switches modes with an empty field. diff --git a/src/common/orpc/schemas/drafts.ts b/src/common/orpc/schemas/drafts.ts index 91e0371a730..47de8fc133c 100644 --- a/src/common/orpc/schemas/drafts.ts +++ b/src/common/orpc/schemas/drafts.ts @@ -280,6 +280,11 @@ export const CreationDraftExecutionSchema = z.object({ agent: z.object({ id: z.string().min(1), scope: AgentDefinitionScopeSchema }).nullable(), runtime: ParsedRuntimeSchema.nullable(), trunkBranch: z.string().nullable(), + /** + * The draft's last Daytona choice (an existing sandbox, a snapshot, a size), restored when + * the user switches to another runtime and back. Never written to the project defaults. + */ + lastDaytona: DaytonaSandboxConfigSchema.optional(), }); /** From f1ecfa07733957a34f8dee850ba55e33795b3648 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:10:29 +0000 Subject: [PATCH 039/231] fixup! fix(daytona): check a seed file's size before reading it --- src/node/runtime/daytonaAgentHomeSeed.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/node/runtime/daytonaAgentHomeSeed.test.ts b/src/node/runtime/daytonaAgentHomeSeed.test.ts index 0560a3f5971..6116edd1c95 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.test.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.test.ts @@ -83,7 +83,9 @@ describe("agent-home seeding", () => { const files = await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux"); expect(files).toEqual([]); - expect(readFile.mock.calls.map(([file]) => String(file))).not.toContain(huge); + expect( + readFile.mock.calls.map(([file]) => (typeof file === "string" ? file : "")) + ).not.toContain(huge); } finally { readFile.mockRestore(); } From 94cad17fce532485a28d2b6bd1fd76c658fde55c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:11:59 +0000 Subject: [PATCH 040/231] fixup! fix(daytona): never seed files a symlink brings in from outside a skill --- src/node/runtime/daytonaAgentHomeSeed.ts | 30 +++++++++++++++++------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/node/runtime/daytonaAgentHomeSeed.ts b/src/node/runtime/daytonaAgentHomeSeed.ts index 5355a605526..15bbbc14df9 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.ts @@ -137,14 +137,28 @@ async function collectSkillsRoot( if (!entries || !root) return; for (const entry of entries) { if (budget.exhausted) return; - const resolved = await resolveEntry(path.join(hostRoot, entry)); - if (!resolved) continue; - const remotePath = path.posix.join(remoteRoot, entry); - if (resolved.stat.isDirectory()) { - await collectDirectory(files, budget, resolved.real, remotePath, 1, resolved.real); - } else if (isInside(resolved.real, root.real)) { - await collectFile(files, budget, resolved, remotePath); - } + await collectSkillsRootEntry( + files, + budget, + { hostPath: path.join(hostRoot, entry), remotePath: path.posix.join(remoteRoot, entry) }, + root.real + ); + } +} + +/** A skill directory (its real path bounds what it may link to), or a file of the root. */ +async function collectSkillsRootEntry( + files: AgentHomeSeedFile[], + budget: SeedBudget, + entry: { hostPath: string; remotePath: string }, + realRoot: string +): Promise { + const resolved = await resolveEntry(entry.hostPath); + if (!resolved) return; + if (resolved.stat.isDirectory()) { + await collectDirectory(files, budget, resolved.real, entry.remotePath, 1, resolved.real); + } else if (isInside(resolved.real, realRoot)) { + await collectFile(files, budget, resolved, entry.remotePath); } } From 745ce52d08ab1a84d6cf612b2884942490e94a86 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:11:59 +0000 Subject: [PATCH 041/231] fixup! fix(daytona): bring the sandbox up before a terminal opens --- src/node/services/terminalService.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index 9582475d452..f1ae5a4afa3 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -72,6 +72,18 @@ function assertTerminalsEnabled(): void { } } +/** + * A Daytona sandbox may be stopped, or its token (the ssh user name) due for refresh: bring it + * up before a native emulator runs ssh. The user asked for this terminal, so starting it is fine. + */ +async function ensureDaytonaSandboxUp( + workspace: Parameters[0] +): Promise { + if (!isSSHRuntime(workspace.runtimeConfig) || !workspace.runtimeConfig.daytona) return; + const ready = await createRuntimeForWorkspace(workspace).ensureReady(); + if (!ready.ready) throw new Error(ready.error); +} + export class TerminalService { private readonly config: Config; private readonly ptyService: PTYService; @@ -799,12 +811,7 @@ export class TerminalService { ); } - // A Daytona sandbox may be stopped, or its token (the ssh user name) due for refresh: - // bring it up before the emulator runs ssh. The user asked for this terminal. - if (isSSHRuntime(workspace.runtimeConfig) && workspace.runtimeConfig.daytona) { - const ready = await createRuntimeForWorkspace(workspace).ensureReady(); - if (!ready.ready) throw new Error(ready.error); - } + await ensureDaytonaSandboxUp(workspace); // Before any durable effect: one share per workspace is kept, after a failed launch too // (fail closed; see nativeTerminalUseLeases), and a refusal above releases this open's. From fddee2d20b1a49e69990530a830cb12659cd2aa0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:12:00 +0000 Subject: [PATCH 042/231] fixup! fix(daytona): keep a draft's Daytona choices across runtime switches --- .../hooks/useDraftWorkspaceSettings.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index 47757dec686..6f5e4a2d69c 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -171,6 +171,21 @@ const mergeRememberedRuntimeConfig = ( } }; +/** The draft's last Daytona choice, else the project's Daytona defaults. */ +function readDraftDaytonaFallback( + execution: { lastDaytona?: DaytonaSandboxConfig } | null | undefined, + defaults: RuntimeOptionDefaults +): DaytonaSandboxConfig { + return execution?.lastDaytona ?? readDaytonaOptionDefaults(defaults); +} + +/** Only in the draft: an attached sandbox must never become a project default. */ +function rememberedDaytonaPatch(runtime: ParsedRuntime): { lastDaytona?: DaytonaSandboxConfig } { + return runtime.mode === RUNTIME_MODE.SSH && runtime.daytona + ? { lastDaytona: runtime.daytona } + : {}; +} + /** The default runtime-type button: Coder and Daytona are SSH sub-choices. */ const resolveDefaultRuntimeChoice = ( settingsDefaultRuntime: RuntimeChoice, @@ -437,8 +452,7 @@ export function useDraftWorkspaceSettings( // useDraftWorkspaceSettings.compiler.test.tsx guards their identity. // Uses discriminated union: SSH has host, Docker has image. // Parsed into a fresh object, so it too must follow the last hook to keep its identity. - const daytonaConfigFallback = - execution?.lastDaytona ?? readDaytonaOptionDefaults(lastRuntimeConfigs); + const daytonaConfigFallback = readDraftDaytonaFallback(execution, lastRuntimeConfigs); const defaultRuntime = buildRuntimeForMode( defaultRuntimeMode, { @@ -472,10 +486,7 @@ export function useDraftWorkspaceSettings( getCreationDraftStore().updateExecution(draftId, { runtime: mergedRuntime, - // Only in the draft: an attached sandbox must never become a project default. - ...(mergedRuntime.mode === RUNTIME_MODE.SSH && mergedRuntime.daytona - ? { lastDaytona: mergedRuntime.daytona } - : {}), + ...rememberedDaytonaPatch(mergedRuntime), }); // Persist host/image/coder so they're remembered when switching modes. From 40016c5f40eb266595da5807023ca854b345a61a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:54:58 +0000 Subject: [PATCH 043/231] fix(daytona): bind the gateway to the mint's settings and refresh it while Xum runs A token minted after a settings change was written with the gateway looked up before it. The gateway is now looked up again after the mint, and the gateway cache belongs to one settings generation. The gateway cache never expired, so a moved gateway or rotated host keys broke ssh until a restart. It now expires after 5 minutes; a refetch that finds a different gateway makes every sandbox's next ssh access mint a token and rewrite its ssh setup. --- .../services/daytona/daytonaService.test.ts | 86 ++++++++++++++++++- src/node/services/daytona/daytonaService.ts | 70 +++++++++++++-- 2 files changed, 146 insertions(+), 10 deletions(-) diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 1e84aaf70e5..9e056161fb8 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -19,6 +19,8 @@ class FakeControlPlane { readonly calls: string[] = []; private tokenCounter = 0; readonly revoked: string[] = []; + gatewayHost = "ssh.test"; + gatewayHostKeys = ["ssh-ed25519 AAAA"]; constructor(private readonly now: () => number) {} @@ -34,7 +36,10 @@ class FakeControlPlane { new Response(body === undefined ? "" : JSON.stringify(body), { status }); if (route === "GET /config") { - return json(200, { sshGatewayHost: "ssh.test", sshGatewayHostKeys: ["ssh-ed25519 AAAA"] }); + return json(200, { + sshGatewayHost: this.gatewayHost, + sshGatewayHostKeys: this.gatewayHostKeys, + }); } if (route === "GET /sandbox") { if (init?.headers && (init.headers as Record).Authorization !== "Bearer k") { @@ -268,6 +273,85 @@ describe("DaytonaService", () => { expect((await held.service.getSshToken("sb-1")).token).not.toBe("tok1"); }); + /** A service whose ssh setup writes are recorded as `token@host keys`. */ + function serviceRecordingWrites(fetchImpl: typeof fetch = plane.fetch) { + const writes: string[] = []; + const record = (input: { token: string; gateway: { host: string; hostKeys: string[] } }) => { + writes.push(`${input.token}@${input.gateway.host} ${input.gateway.hostKeys.join(",")}`); + return Promise.resolve(); + }; + const recording = new DaytonaService({ + credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), + rootDir, + fetch: fetchImpl, + now: () => clock, + sshConfigWriter: { + writeSandbox: record, + updateToken: record, + removeSandbox: () => Promise.resolve(), + readTokens: () => Promise.resolve([]), + verifyResolvedConfig: () => Promise.resolve(), + }, + }); + return { service: recording, writes }; + } + + it("pairs a token minted after a settings change with the new settings' gateway", async () => { + let gatewayFetched!: () => void; + const fetched = new Promise((resolve) => (gatewayFetched = resolve)); + let releaseGateway!: () => void; + const released = new Promise((resolve) => (releaseGateway = resolve)); + let held = false; + // The first /config answer is computed under the old settings, then delivered late. + const fetchHoldingFirstConfig = (async (input: string | URL | Request, init?: RequestInit) => { + const response = await plane.fetch(input, init); + const url = input instanceof Request ? input.url : input.toString(); + if (!held && url.endsWith("/config")) { + held = true; + gatewayFetched(); + await released; + } + return response; + }) as typeof fetch; + const { service: racing, writes } = serviceRecordingWrites(fetchHoldingFirstConfig); + + const access = racing.ensureSshAccess("sb-1"); + await fetched; + await racing.setConfig({ target: "eu" }); + plane.gatewayHost = "ssh.eu.test"; + releaseGateway(); + await access; + + expect(writes).toEqual(["tok1@ssh.eu.test ssh-ed25519 AAAA"]); + }); + + it("rewrites a sandbox's ssh setup once Daytona moves its gateway keys", async () => { + const { service: watching, writes } = serviceRecordingWrites(); + const configFetches = () => plane.calls.filter((call) => call === "GET /config").length; + + await watching.ensureSshAccess("sb-1"); + clock += 4 * 60_000; + await watching.ensureSshAccess("sb-1"); + expect(configFetches()).toBe(1); + + // Rotated (and listed in another order) while Xum runs. + plane.gatewayHostKeys = ["ssh-rsa CCCC", "ssh-ed25519 BBBB"]; + clock += 2 * 60_000; + await watching.ensureSshAccess("sb-1"); + expect(writes).toEqual([ + "tok1@ssh.test ssh-ed25519 AAAA", + "tok2@ssh.test ssh-rsa CCCC,ssh-ed25519 BBBB", + ]); + expect(plane.revoked).toEqual(["tok1"]); + + // A refetch that finds the same gateway, keys reordered, rewrites nothing. + plane.gatewayHostKeys = ["ssh-ed25519 BBBB", "ssh-rsa CCCC"]; + clock += 6 * 60_000; + await watching.ensureSshAccess("sb-1"); + expect(configFetches()).toBe(3); + expect(writes).toHaveLength(2); + }); + it("revokes a token minted across a release, keeping the access released", async () => { const held = serviceWithHeldMint(); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 0aa5aa777bc..1fbe381c5ff 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -50,6 +50,12 @@ export const DAYTONA_START_TIMEOUT_MS = 180_000; * the cache lives here, keyed by sandbox, rather than on a runtime instance. */ export const DAYTONA_READY_CACHE_MS = 60_000; +/** + * How long Daytona's `/config` (gateway endpoint and host keys) is trusted. Daytona can move + * its gateway or rotate its keys while Xum runs; a refetch that sees a change rewrites every + * sandbox's ssh setup on its next use. + */ +export const DAYTONA_GATEWAY_CONFIG_TTL_MS = 5 * 60_000; /** How long a delete waits for a sandbox Daytona is already destroying to go away. */ export const DAYTONA_DESTROY_WAIT_MS = 60_000; @@ -207,6 +213,20 @@ function toSummary(sandbox: DaytonaSandbox): DaytonaSandboxSummary { }; } +function toSshGateway(config: DaytonaGatewayConfig) { + return { + host: config.sshGatewayHost, + port: config.sshGatewayPort, + hostKeys: config.sshGatewayHostKeys, + }; +} + +/** What the ssh setup depends on; key order from Daytona is not significant. */ +function gatewayIdentity(config: DaytonaGatewayConfig): string { + const { host, port, hostKeys } = toSshGateway(config); + return JSON.stringify([host, port, [...hostKeys].sort()]); +} + /** * Daytona lifecycle and credential access shared by the runtime, the reconciler and the * `daytona` oRPC namespace. One instance per Xum backend: it owns the per-sandbox SSH token @@ -218,7 +238,12 @@ export class DaytonaService { private readonly fetchImpl?: typeof fetch; private readonly now: () => number; private readonly sleep: (ms: number) => Promise; - private gatewayCache?: { apiUrl: string; config: DaytonaGatewayConfig }; + private gatewayCache?: { + apiUrl: string; + generation: number; + fetchedAt: number; + config: DaytonaGatewayConfig; + }; private readonly tokens = new Map(); private readonly tokenRequests = new Map>(); /** Bumped by every settings change, so a mint that spans one can tell. */ @@ -370,13 +395,41 @@ export class DaytonaService { } async getGatewayConfig(): Promise { + const generation = this.credentialsGeneration; const { client, credentials } = await this.getClient(); - if (this.gatewayCache?.apiUrl === credentials.apiUrl) return this.gatewayCache.config; + const cached = this.cachedGateway(credentials.apiUrl, generation); + if (cached && this.now() - cached.fetchedAt < DAYTONA_GATEWAY_CONFIG_TTL_MS) { + return cached.config; + } const config = await client.getConfig(); - this.gatewayCache = { apiUrl: credentials.apiUrl, config }; + // Fetched across a settings change: never cache it for lookups under the new settings. + if (this.credentialsGeneration !== generation) return config; + if (cached && gatewayIdentity(cached.config) !== gatewayIdentity(config)) { + this.retireSshSetup(); + } + this.gatewayCache = { apiUrl: credentials.apiUrl, generation, fetchedAt: this.now(), config }; return config; } + /** The cached `/config` of these settings, fresh or not. */ + private cachedGateway(apiUrl: string, generation: number) { + const cached = this.gatewayCache; + return cached?.apiUrl === apiUrl && cached.generation === generation ? cached : undefined; + } + + /** + * Daytona moved its gateway or rotated its host keys: every include block and the + * known_hosts pins are stale. Each sandbox's next ssh access mints a token, which rewrites + * its whole ssh setup and revokes the old token. + */ + private retireSshSetup(): void { + this.sshConfigured.clear(); + this.readyAt.clear(); + for (const [sandboxId, access] of this.tokens) { + this.tokens.set(sandboxId, { ...access, expiresAt: new Date(0).toISOString() }); + } + } + async testConnection(): Promise { try { const { client } = await this.getClient(); @@ -632,15 +685,14 @@ export class DaytonaService { * the user; a refresh rewrites only the include file. */ async ensureSshAccess(sandboxId: string): Promise { - const gatewayConfig = await this.getGatewayConfig(); - const gateway = { - host: gatewayConfig.sshGatewayHost, - port: gatewayConfig.sshGatewayPort, - hostKeys: gatewayConfig.sshGatewayHostKeys, - }; + // Fails before a mint when the control plane cannot say where the gateway is. + await this.getGatewayConfig(); // A cached token was written by the mint that produced it, so only a mint writes files. const access = await this.getSshToken(sandboxId, { onNewToken: async ({ token }) => { + // Looked up after the mint, so a settings change before it cannot pair this token with + // the old deployment's gateway; one after it makes the mint start over. + const gateway = toSshGateway(await this.getGatewayConfig()); if (this.sshConfigured.has(sandboxId)) { await this.sshConfigWriter.updateToken({ sandboxId, token, gateway }); return; From dcf4245e4dca99e5d1813903e43bfe5994ac083f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:08:46 +0000 Subject: [PATCH 044/231] fix(daytona): save the API URL only when the user edits it The access form sent the URL it shows, which may come from DAYTONA_API_URL or the built-in default, with every save. Saving only a new key or target then pinned that URL in daytona.json, where it beats later changes to the environment or the default. The form now sends the URL and the target only when the user edited them. --- .../Sections/DaytonaSettingsForms.test.tsx | 75 +++++++++++++++++++ .../Sections/DaytonaSettingsForms.tsx | 22 ++++-- 2 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 src/browser/features/Settings/Sections/DaytonaSettingsForms.test.tsx diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.test.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.test.tsx new file mode 100644 index 00000000000..5dd19efb70e --- /dev/null +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.test.tsx @@ -0,0 +1,75 @@ +import "../../../../../tests/ui/dom"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { installDom } from "../../../../../tests/ui/dom"; +import { APIProvider } from "@/browser/contexts/API"; +import { createTestApiClient } from "@/browser/testUtils"; +import type { DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; +import { DaytonaAccessForm } from "./DaytonaSettingsForms"; + +describe("DaytonaAccessForm", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + mock.restore(); + cleanupDom?.(); + cleanupDom = null; + }); + + const ENV_CONFIG: DaytonaPublicConfig = { + apiKeySet: false, + apiKeySource: null, + // From DAYTONA_API_URL: storing it would override later changes to the environment. + apiUrl: "https://daytona.internal.example/api", + target: null, + }; + + /** Renders the form, runs `edit`, presses Save, and returns what was sent. */ + async function saveAfter( + edit: ( + fields: { key: HTMLElement; url: HTMLElement }, + user: ReturnType + ) => Promise + ) { + const setConfig = mock((input: Record) => + Promise.resolve({ ...ENV_CONFIG, apiKeySet: input.apiKey !== undefined }) + ); + const client = createTestApiClient({ + daytona: { getConfig: () => Promise.resolve(ENV_CONFIG), setConfig }, + }); + const view = render( + + + + ); + const key = await waitFor(() => { + const input = view.getByLabelText("API key"); + expect((input as HTMLInputElement).disabled).toBe(false); + return input; + }); + const user = userEvent.setup({ document: key.ownerDocument }); + await edit({ key, url: view.getByLabelText("API URL") }, user); + await user.click(view.getByRole("button", { name: "Save" })); + await waitFor(() => expect(setConfig).toHaveBeenCalledTimes(1)); + return setConfig.mock.calls[0][0]; + } + + test("saving only a new key leaves the URL from the environment unsaved", async () => { + const sent = await saveAfter((fields, user) => user.type(fields.key, "new-key")); + expect(sent).toEqual({ apiKey: "new-key" }); + }); + + test("saves a URL the user edited", async () => { + const sent = await saveAfter(async (fields, user) => { + await user.clear(fields.url); + await user.type(fields.url, "https://other.example/api"); + }); + expect(sent).toEqual({ apiUrl: "https://other.example/api" }); + }); +}); diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx index a1a9714a018..cc4c8a88c2d 100644 --- a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx @@ -130,16 +130,28 @@ function useDaytonaAccessDrafts(config: DaytonaPublicConfig | null) { setApiUrl(null); setTarget(null); }; - return { apiKey, apiUrl, target, setApiKey, setApiUrl, setTarget, isDirty, reset }; + // An unedited URL may come from DAYTONA_API_URL or the default: saving it would pin it in + // daytona.json, where it beats later changes to either. So only an edit is sent. + const edited = (draft: string | null, savedValue: string) => + draft !== null && draft.trim() !== savedValue ? draft.trim() : undefined; + const changes = { + apiUrl: edited(apiUrlDraft, saved.apiUrl), + target: edited(targetDraft, saved.target), + }; + return { apiKey, apiUrl, target, changes, setApiKey, setApiUrl, setTarget, isDirty, reset }; } -/** A blank key field means "keep the saved key", so it is left out of the update. */ -function toAccessInput(drafts: { apiKey: string; apiUrl: string; target: string }) { +/** A blank key field means "keep the saved key"; unedited fields are left out too. */ +function toAccessInput(drafts: { + apiKey: string; + changes: { apiUrl?: string; target?: string }; +}): DaytonaAccessInput { const apiKey = drafts.apiKey.trim(); + const { apiUrl, target } = drafts.changes; return { ...(apiKey ? { apiKey } : {}), - apiUrl: drafts.apiUrl.trim(), - target: drafts.target.trim(), + ...(apiUrl !== undefined ? { apiUrl } : {}), + ...(target !== undefined ? { target } : {}), }; } From 6b51403d56872cd1312fe43c6cc548d7dd69ae9a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:11:38 +0000 Subject: [PATCH 045/231] fix(daytona): refresh the runtime status after a terminal brings a sandbox up Opening a terminal runs ensureReady on the backend, which starts a stopped or archived Daytona sandbox, but outside a turn no runtime-status events reach the RuntimeStatusStore, so it kept showing the sandbox stopped and the passive-runtime policy kept Git and PR status off. After a terminal session is created (and after a native terminal opens), the store now refetches a displayed workspace whose runtime has a status, as it does when a turn's start begins and ends. --- src/browser/hooks/useOpenTerminal.ts | 2 + src/browser/stores/RuntimeStatusStore.ts | 19 ++++++-- src/browser/utils/terminal.test.ts | 59 ++++++++++++++++++++++++ src/browser/utils/terminal.ts | 5 +- 4 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 src/browser/utils/terminal.test.ts diff --git a/src/browser/hooks/useOpenTerminal.ts b/src/browser/hooks/useOpenTerminal.ts index 368693fd7e6..f77a5311b77 100644 --- a/src/browser/hooks/useOpenTerminal.ts +++ b/src/browser/hooks/useOpenTerminal.ts @@ -1,5 +1,6 @@ import { useCallback } from "react"; import { useAPI } from "@/browser/contexts/API"; +import { getRuntimeStatusStoreInstance } from "@/browser/stores/RuntimeStatusStore"; import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime, isDevcontainerRuntime } from "@/common/types/runtime"; import { @@ -71,6 +72,7 @@ export function useOpenTerminal() { await openTerminalPopout(api, workspaceId, session.sessionId); } else { await api.terminal.openNative({ workspaceId }); + getRuntimeStatusStoreInstance().refreshAfterTerminalOpen(workspaceId); } } catch (err) { if (err instanceof TerminalPopupBlockedError && createdSessionId != null) { diff --git a/src/browser/stores/RuntimeStatusStore.ts b/src/browser/stores/RuntimeStatusStore.ts index 3c8e54cf9c1..9d2cf6814e8 100644 --- a/src/browser/stores/RuntimeStatusStore.ts +++ b/src/browser/stores/RuntimeStatusStore.ts @@ -153,9 +153,21 @@ export class RuntimeStatusStore { if (event.source !== "runtime" || !this.tracksRuntimeStart(event)) { return; } + this.refreshIfDisplayed(event.workspaceId); + } + + /** + * Opening a terminal brings a stopped Daytona sandbox up first (the backend runs ensureReady), + * outside any turn and so without runtime-status events: refetch a displayed one. + */ + refreshAfterTerminalOpen(workspaceId: string): void { + this.refreshIfDisplayed(workspaceId); + } + + private refreshIfDisplayed(workspaceId: string): void { if ( - hasRuntimeStatus(this.workspaceMetadata.get(event.workspaceId)?.runtimeConfig) && - this.statuses.hasKeySubscribers(event.workspaceId) + hasRuntimeStatus(this.workspaceMetadata.get(workspaceId)?.runtimeConfig) && + this.statuses.hasKeySubscribers(workspaceId) ) { this.refreshController.requestImmediate(); } @@ -226,7 +238,8 @@ export class RuntimeStatusStore { let runtimeStatusStoreInstance: RuntimeStatusStore | null = null; -function getRuntimeStatusStoreInstance(): RuntimeStatusStore { +/** The shared store, for non-React callers (useRuntimeStatusStoreRaw in components). */ +export function getRuntimeStatusStoreInstance(): RuntimeStatusStore { runtimeStatusStoreInstance ??= new RuntimeStatusStore(); return runtimeStatusStoreInstance; } diff --git a/src/browser/utils/terminal.test.ts b/src/browser/utils/terminal.test.ts new file mode 100644 index 00000000000..278c03f807b --- /dev/null +++ b/src/browser/utils/terminal.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { useRuntimeStatusStoreRaw, type RuntimeStatus } from "@/browser/stores/RuntimeStatusStore"; +import { createTestApiClient } from "@/browser/testUtils"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import { createTerminalSession } from "./terminal"; + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const DAYTONA_WORKSPACE: FrontendWorkspaceMetadata = { + id: "ws-day", + name: "ws-day", + projectName: "project", + projectPath: "/home/user/project", + namedWorkspacePath: "~/src/project/ws-day", + runtimeConfig: { + type: "ssh", + host: "sb-1.xum--daytona", + srcBaseDir: "~/src", + daytona: { sandboxId: "sb-1" }, + }, +}; + +describe("createTerminalSession", () => { + let unsubscribe: (() => void) | undefined; + + afterEach(() => { + unsubscribe?.(); + const store = useRuntimeStatusStoreRaw(); + store.syncWorkspaces(new Map()); + store.setClient(null); + }); + + test("refreshes the runtime status of a Daytona workspace the terminal may have started", async () => { + const getRuntimeStatuses = mock((input: { workspaceIds: string[] }) => + Promise.resolve( + Object.fromEntries(input.workspaceIds.map((id): [string, RuntimeStatus] => [id, "stopped"])) + ) + ); + const create = mock(() => + Promise.resolve({ sessionId: "s-1", workspaceId: "ws-day", cols: 80, rows: 24 }) + ); + const client = createTestApiClient({ + workspace: { getRuntimeStatuses }, + terminal: { create }, + }); + const store = useRuntimeStatusStoreRaw(); + store.setClient(client); + store.syncWorkspaces(new Map([["ws-day", DAYTONA_WORKSPACE]])); + unsubscribe = store.subscribeKey("ws-day", () => undefined); + await settle(); + const fetchesBefore = getRuntimeStatuses.mock.calls.length; + + // The backend brought the stopped sandbox up before it opened the shell. + await createTerminalSession(client, "ws-day"); + await settle(); + + expect(getRuntimeStatuses.mock.calls.length).toBe(fetchesBefore + 1); + }); +}); diff --git a/src/browser/utils/terminal.ts b/src/browser/utils/terminal.ts index 82702193d28..dd04ddcdbea 100644 --- a/src/browser/utils/terminal.ts +++ b/src/browser/utils/terminal.ts @@ -11,6 +11,7 @@ import type { AppRouter } from "@/node/orpc/router"; import { isIosStandaloneWebApp } from "@/browser/utils/env"; import { resolveBrowserAssetUrl } from "@/browser/utils/frontendBasePath"; import { showTerminalDialog } from "@/browser/utils/terminalDialogStore"; +import { getRuntimeStatusStoreInstance } from "@/browser/stores/RuntimeStatusStore"; type APIClient = RouterClient; @@ -122,10 +123,12 @@ export async function createTerminalSession( workspaceId: string, options?: TerminalSessionCreateOptions ): Promise<{ sessionId: string; workspaceId: string; cols: number; rows: number }> { - return api.terminal.create({ + const session = await api.terminal.create({ workspaceId, cols: DEFAULT_TERMINAL_SIZE.cols, rows: DEFAULT_TERMINAL_SIZE.rows, initialCommand: options?.initialCommand, }); + getRuntimeStatusStoreInstance().refreshAfterTerminalOpen(workspaceId); + return session; } From a2ba06a91ea08d87caaaaf21e8284b054cf0b91e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:16:33 +0000 Subject: [PATCH 046/231] fix(daytona): match sandbox references by both the sandbox's id and name Xum stores the name of a sandbox it created, and the attach picker stores the real id of the sandbox picked; Daytona takes either. The last-reference check compared the strings, so deleting or archive-deleting the owning workspace could destroy a sandbox another workspace had attached by its id. When another workspace stores a different string, the sandbox is now looked up and matched by its id and its name; a failed lookup counts as referenced (fail closed), a sandbox already gone as unreferenced. Removal decisions now share one lock and record removed workspaces by workspace, since one sandbox can go by two strings. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 20 +++++ src/node/runtime/DaytonaSSHRuntime.ts | 20 ++--- .../daytona/daytonaLifecycleHooks.test.ts | 22 ++++++ .../services/daytona/daytonaLifecycleHooks.ts | 8 +- src/node/services/daytona/daytonaService.ts | 75 +++++++++++++------ 5 files changed, 108 insertions(+), 37 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index deb47cb77d4..60a49e3e1a3 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -402,6 +402,26 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { sshDeleteSpy.mockRestore(); }); + it("keeps a sandbox another workspace attached by its id while the owner stores its name", async () => { + // Xum created it under a name; the attach picker stores the real id of the same sandbox. + rig.plane.addSandbox({ id: "sb-7", name: "xum-owner-abc123" }); + const owner = { sandboxId: "xum-owner-abc123", shared: false, ...SMALL }; + rig.workspaces.push( + { workspaceId: "ws-1", runtimeConfig: sshDaytonaConfig(owner) }, + { + workspaceId: "ws-attached", + runtimeConfig: sshDaytonaConfig({ sandboxId: "sb-7", shared: true }), + } + ); + + for (const force of [false, true]) { + const result = await makeRuntime(owner).deleteWorkspace("/home/me/project", "feature", force); + expect(result.success).toBe(true); + expect(rig.plane.find("sb-7")).toBeDefined(); + } + expect(rig.plane.calls.filter((call) => call.startsWith("DELETE /sandbox/"))).toEqual([]); + }); + it("starts a stopped sandbox to run the dirty check and keeps a dirty workspace", async () => { rig.plane.addSandbox({ id: "sb-dirty", state: "stopped" }); sshDeleteSpy.mockResolvedValue({ success: false, error: "uncommitted changes" }); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 87227271bdb..cf71faf5e39 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -942,21 +942,21 @@ export class DaytonaSSHRuntime extends SSHRuntime { sandboxId: string, deletedPath: string ): Promise { - return this.daytonaService.withSandboxRemovalLock(sandboxId, async () => { - if (this.sandboxFateOnDelete(sandboxId) !== "delete") return undefined; + return this.daytonaService.withSandboxRemovalLock(async () => { + if ((await this.sandboxFateOnDelete(sandboxId)) !== "delete") return undefined; const result = await this.deleteOwnedSandbox(sandboxId, deletedPath, undefined); // Forced: the workspace goes whether or not the sandbox delete worked. - this.recordRemoval(sandboxId); + this.recordRemoval(); return result; }); } - private recordRemoval(sandboxId: string): void { - if (this.workspaceId) this.daytonaService.recordWorkspaceRemoval(sandboxId, this.workspaceId); + private recordRemoval(): void { + if (this.workspaceId) this.daytonaService.recordWorkspaceRemoval(this.workspaceId); } - private sandboxFateOnDelete(sandboxId: string): SandboxFate { - if (this.daytonaService.isSandboxReferencedElsewhere(sandboxId, this.workspaceId)) { + private async sandboxFateOnDelete(sandboxId: string): Promise { + if (await this.daytonaService.isSandboxReferencedElsewhere(sandboxId, this.workspaceId)) { return "keep"; } return this.daytonaConfig.shared === true ? "release" : "delete"; @@ -1038,14 +1038,14 @@ export class DaytonaSSHRuntime extends SSHRuntime { result: { success: true; deletedPath: string }, errorPrefix: string | undefined ): Promise { - return this.daytonaService.withSandboxRemovalLock(sandboxId, async () => { + return this.daytonaService.withSandboxRemovalLock(async () => { const settled = await this.applySandboxFate( sandboxId, result, - this.sandboxFateOnDelete(sandboxId), + await this.sandboxFateOnDelete(sandboxId), errorPrefix ); - if (settled.success) this.recordRemoval(sandboxId); + if (settled.success) this.recordRemoval(); return settled; }); } diff --git a/src/node/services/daytona/daytonaLifecycleHooks.test.ts b/src/node/services/daytona/daytonaLifecycleHooks.test.ts index 9a4b04a8230..0af16e97492 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.test.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.test.ts @@ -104,6 +104,28 @@ describe("createDaytonaArchiveHook", () => { expect(rig.writer.removed).toEqual(["sb-shared"]); }); + it("leaves a sandbox alone that another workspace attached by its id, or that cannot be looked up", async () => { + // Created by Xum under a name; the attach picker stored the same sandbox's real id. + rig.plane.addSandbox({ id: "sb-9", name: "xum-owner-def456" }); + rig.workspaces.push({ + workspaceId: "attached", + runtimeConfig: metadata({ sandboxId: "sb-9", shared: true }).runtimeConfig, + }); + const owner = { sandboxId: "xum-owner-def456", shared: false }; + + expect((await archive(owner, "delete")).success).toBe(true); + expect(rig.plane.find("sb-9")?.state).toBe("started"); + + // Without the lookup Xum cannot tell: it keeps the sandbox. + rig.workspaces[0] = { + workspaceId: "attached", + runtimeConfig: metadata({ sandboxId: "sb-other", shared: true }).runtimeConfig, + }; + rig.plane.failingRoutes.add("GET /sandbox/xum-owner-def456"); + expect((await archive(owner, "delete")).success).toBe(true); + expect(rig.plane.find("sb-9")?.state).toBe("started"); + }); + it("leaves the parent's sandbox alone when a shared-checkout sub-agent is archived", async () => { rig.plane.addSandbox({ id: "sb-parent" }); const hook = createDaytonaArchiveHook({ diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index 1b228869640..df3d418354e 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -40,7 +40,7 @@ export interface DaytonaArchiveHookOptions { export function createDaytonaArchiveHook(options: DaytonaArchiveHookOptions): BeforeArchiveHook { return async (args): Promise> => { - const sandboxId = unsharedSandboxId(options, args.workspaceMetadata); + const sandboxId = await unsharedSandboxId(options, args.workspaceMetadata); if (!sandboxId) return Ok(undefined); const result = isDedicatedDaytonaSandbox(args.workspaceMetadata.runtimeConfig) @@ -70,16 +70,16 @@ async function applyArchiveBehavior( } /** The archived workspace's sandbox, unless another workspace (archived ones too) uses it. */ -function unsharedSandboxId( +async function unsharedSandboxId( options: DaytonaArchiveHookOptions, workspaceMetadata: WorkspaceMetadata -): string | undefined { +): Promise { // A shared-checkout sub-agent copies its parent's runtime config, sandbox ID included: // the sandbox belongs to the parent. if (workspaceMetadata.taskIsolation === "none") return undefined; const sandboxId = getDaytonaSandboxId(workspaceMetadata.runtimeConfig); if (!sandboxId) return undefined; - if (options.daytonaService.isSandboxReferencedElsewhere(sandboxId, workspaceMetadata.id)) { + if (await options.daytonaService.isSandboxReferencedElsewhere(sandboxId, workspaceMetadata.id)) { return undefined; } return sandboxId; diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 1fbe381c5ff..42bd008417d 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -258,13 +258,16 @@ export class DaytonaService { /** Why the setup of a sandbox Xum created (and then deleted) failed, for later turns. */ private readonly setupFailures = new Map(); private readonly listWorkspaces?: () => DaytonaWorkspaceRef[]; - /** Per sandbox: serializes "is this its last workspace" decisions. */ + /** + * Serializes "is this its last workspace" decisions. One key for every sandbox: workspaces + * may name one sandbox by its name or by its id, so a per-string key would not serialize them. + */ private readonly removalLocks = new MutexMap(); /** - * Per sandbox: workspaces whose removal passed its checks and deleted its checkout, but - * that the workspace list may still show until the removal is persisted. + * Workspaces whose removal passed its checks and deleted its checkout, but that the + * workspace list may still show until the removal is persisted. */ - private readonly removedWorkspaces = new Map>(); + private readonly removedWorkspaces = new Set(); constructor(options: DaytonaServiceOptions) { this.credentialsStore = options.credentialsStore; @@ -329,31 +332,57 @@ export class DaytonaService { /** * Whether a workspace other than `workspaceId` (archived ones included) references the - * sandbox. Fails closed, as referenced, when the workspace list or the caller's id is - * unknown: a kept sandbox only costs storage (the reconciler stops it), a deleted one loses - * the other workspace's work. + * sandbox. Daytona takes a sandbox's name or its id, and both get stored: Xum keeps the name + * of a sandbox it created, the attach picker the id. So when another workspace stores a + * different string, the sandbox is looked up and matched by both. Fails closed, as + * referenced, when the workspace list, the caller's id or the lookup is unavailable: a kept + * sandbox only costs storage (the reconciler stops it), a deleted one loses the other + * workspace's work. A sandbox that is already gone has no references left to protect. */ - isSandboxReferencedElsewhere(sandboxId: string, workspaceId: string | undefined): boolean { + async isSandboxReferencedElsewhere( + sandboxId: string, + workspaceId: string | undefined + ): Promise { if (!this.listWorkspaces || !workspaceId) return true; - const removed = this.removedWorkspaces.get(sandboxId); - return this.listWorkspaces().some( - (ref) => - ref.workspaceId !== workspaceId && - !removed?.has(ref.workspaceId) && - getDaytonaSandboxId(ref.runtimeConfig) === sandboxId - ); + const others = this.sandboxIdsStoredElsewhere(this.listWorkspaces(), workspaceId); + if (others.includes(sandboxId)) return true; + if (others.length === 0) return false; + const aliases = await this.sandboxAliases(sandboxId); + return aliases === undefined || others.some((stored) => aliases.has(stored)); + } + + /** What the other workspaces (removals in flight excluded) store as their sandbox. */ + private sandboxIdsStoredElsewhere(refs: DaytonaWorkspaceRef[], workspaceId: string): string[] { + return refs.flatMap((ref) => { + const stored = getDaytonaSandboxId(ref.runtimeConfig); + const counts = + ref.workspaceId !== workspaceId && !this.removedWorkspaces.has(ref.workspaceId); + return counts && stored ? [stored] : []; + }); + } + + /** The sandbox's id and name (empty when it is gone); undefined when the lookup fails. */ + private async sandboxAliases(sandboxId: string): Promise | undefined> { + try { + const { sandbox } = await this.getSandboxState(sandboxId); + return new Set([sandbox?.id, sandbox?.name].filter((alias): alias is string => !!alias)); + } catch (error) { + log.warn("Could not look up a Daytona sandbox to match its references", { + sandboxId, + error: getErrorMessage(error), + }); + return undefined; + } } - /** Run `fn` after every earlier removal decision for this sandbox has settled. */ - withSandboxRemovalLock(sandboxId: string, fn: () => Promise): Promise { - return this.removalLocks.withLock(sandboxId, fn); + /** Run `fn` after every earlier removal decision has settled. */ + withSandboxRemovalLock(fn: () => Promise): Promise { + return this.removalLocks.withLock("daytona-sandbox-removal", fn); } - /** The workspace no longer references the sandbox, even while the workspace list shows it. */ - recordWorkspaceRemoval(sandboxId: string, workspaceId: string): void { - const removed = this.removedWorkspaces.get(sandboxId) ?? new Set(); - removed.add(workspaceId); - this.removedWorkspaces.set(sandboxId, removed); + /** The workspace no longer references its sandbox, even while the workspace list shows it. */ + recordWorkspaceRemoval(workspaceId: string): void { + this.removedWorkspaces.add(workspaceId); } recordSetupFailure(sandboxId: string, reason: string): void { From ce706b12c0c9d3f97e56f570c66594b8eea6560f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:44:23 +0000 Subject: [PATCH 047/231] fix(daytona): delete a failed setup's sandbox in the account that created it A settings change between the create request and the end of setup made readiness and cleanup use the new account: the readiness check found no sandbox there, the cleanup's 404 counted as deleted, and the sandbox in the old account stayed with auto-delete disabled. The create now holds the account it was sent to (client and settings generation). Cleanup looks up and deletes the sandbox with that client, and a setup that spans a settings change fails with a clear reason instead of going on against the new account. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 17 +++++++ src/node/runtime/DaytonaSSHRuntime.ts | 41 +++++++++++++---- .../services/daytona/daytona.testHarness.ts | 22 ++++++++- src/node/services/daytona/daytonaService.ts | 45 +++++++++++++++---- 4 files changed, 106 insertions(+), 19 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 60a49e3e1a3..060b157a8e3 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -322,6 +322,23 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { } }); + it("deletes its sandbox in the account that created it when Settings switch accounts during setup", async () => { + const newAccount = rig.addAccount("new-key"); + // The user saves another account's key while the create request is answered. + rig.plane.beforeAnswer.set("POST /sandbox", () => rig.service.setConfig({ apiKey: "new-key" })); + + const failure = await makeRuntime({ sandboxId: "xum-moved", shared: false, ...SMALL }) + .postCreateSetup(initParams()) + .then( + () => undefined, + (error: unknown) => error + ); + + expect(String(failure)).toContain("Daytona settings changed"); + expect(rig.plane.find("xum-moved")).toBeUndefined(); + expect(newAccount.calls).toEqual([]); + }); + it("never deletes a sandbox that took its name before the create request", async () => { // Another Xum install created this name between validateBeforePersist and the create. const other = rig.plane.addSandbox({ diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index cf71faf5e39..810cd5737ad 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -49,6 +49,7 @@ import { DAYTONA_LABEL_INSTANCE, DAYTONA_LABEL_WORKSPACE_ID, DAYTONA_START_TIMEOUT_MS, + type DaytonaAccount, type DaytonaService, } from "@/node/services/daytona/daytonaService"; import { log } from "@/node/services/log"; @@ -66,6 +67,8 @@ export type DaytonaRuntimeService = Pick< DaytonaService, | "checkAvailability" | "getSandboxState" + | "getAccount" + | "isAccountCurrent" | "buildLabels" | "getDefaultTarget" | "createSandbox" @@ -107,6 +110,8 @@ const DAYTONA_AUTO_ARCHIVE_MINUTES = 1440; const DAYTONA_AUTO_DELETE_DISABLED = -1; const SANDBOX_NAME_MAX_BRANCH_LENGTH = 40; +const SETTINGS_CHANGED_DURING_SETUP = + "Daytona settings changed while the sandbox was set up; create the workspace again"; /** `xum--`: unique per creation, and readable in the Daytona dashboard. */ export function buildDaytonaSandboxName(branchName: string): string { @@ -152,6 +157,8 @@ interface CreateAttempt { outcome: CreateOutcome; /** The labels the last create request carried. */ labels?: Record; + /** The account the create requests went to: cleanup deletes there, whatever Settings say. */ + account?: DaytonaAccount; } /** Resolve with `promise`, or reject as soon as `signal` aborts (the shared work keeps going). */ @@ -700,13 +707,19 @@ export class DaytonaSSHRuntime extends SSHRuntime { try { if (creates) { params.initLogger.logStep(`Creating Daytona sandbox "${sandboxId}"...`); + create.account = await this.daytonaService.getAccount(); await this.createSandbox(sandboxId, params, create); + this.throwIfSettingsChanged(create); params.initLogger.logStep("Daytona sandbox created"); } await this.connectAndSeed(sandboxId, creates, params); await this.prepareWorkspaceDirectory(params); + this.throwIfSettingsChanged(create); } catch (error) { - const errorMsg = redactDaytonaTokens(getErrorMessage(error)); + // After a switch of account the step's own error (often "not found") would mislead. + const errorMsg = this.settingsChanged(create) + ? SETTINGS_CHANGED_DURING_SETUP + : redactDaytonaTokens(getErrorMessage(error)); log.error("Daytona sandbox setup failed", { sandboxId, error: errorMsg }); params.initLogger.logStderr(`Daytona sandbox setup failed: ${errorMsg}`); if (creates) { @@ -721,6 +734,18 @@ export class DaytonaSSHRuntime extends SSHRuntime { } } + /** + * Readiness, ssh and the checkout use the current settings, so a setup that spans a change + * would go on in another account (or deployment) than its sandbox's: it fails instead. + */ + private settingsChanged(create: CreateAttempt): boolean { + return create.account !== undefined && !this.daytonaService.isAccountCurrent(create.account); + } + + private throwIfSettingsChanged(create: CreateAttempt): void { + if (this.settingsChanged(create)) throw new Error(SETTINGS_CHANGED_DURING_SETUP); + } + /** Wait until the sandbox runs, write ssh access, and seed the home of a box Xum created. */ private async connectAndSeed( sandboxId: string, @@ -766,12 +791,12 @@ export class DaytonaSSHRuntime extends SSHRuntime { initLogger: WorkspaceInitParams["initLogger"] ): Promise { try { - if (create.outcome === "unknown" && !(await this.carriesLabels(sandboxId, create.labels))) { + if (create.outcome === "unknown" && !(await this.carriesLabels(sandboxId, create))) { log.warn("Not deleting a Daytona sandbox this setup may not have created", { sandboxId }); return; } initLogger.logStep(`Deleting the sandbox "${sandboxId}" this setup created...`); - await this.daytonaService.deleteSandbox(sandboxId); + await this.daytonaService.deleteSandbox(sandboxId, create.account); } catch (error) { // The reconciler stops labeled, unreferenced sandboxes, so a leak here is bounded. const message = getErrorMessage(error); @@ -784,11 +809,9 @@ export class DaytonaSSHRuntime extends SSHRuntime { } /** After a create of unknown outcome: the sandbox has this install's and workspace's labels. */ - private async carriesLabels( - sandboxId: string, - labels: Record | undefined - ): Promise { - const { sandbox } = await this.daytonaService.getSandboxState(sandboxId); + private async carriesLabels(sandboxId: string, create: CreateAttempt): Promise { + const { labels } = create; + const { sandbox } = await this.daytonaService.getSandboxState(sandboxId, create.account); if (!sandbox || !labels) return false; return [DAYTONA_LABEL_INSTANCE, DAYTONA_LABEL_WORKSPACE_ID].every( (key) => labels[key] !== undefined && sandbox.labels?.[key] === labels[key] @@ -802,7 +825,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { ): Promise { create.outcome = "unknown"; create.labels = request.labels; - await this.daytonaService.createSandbox(request); + await this.daytonaService.createSandbox(request, create.account); create.outcome = "created"; } diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 409c7c50276..e99d2df45d3 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -46,6 +46,8 @@ export class FakeDaytonaControlPlane { readonly failingRoutes = new Set(); /** POST /sandbox creates the sandbox, then answers 500 (the create's outcome looks unknown). */ failAfterCreate = false; + /** Runs once a route (`METHOD /path`) is handled, before its answer reaches the client. */ + readonly beforeAnswer = new Map Promise>(); private nextId = 1; private nextToken = 1; @@ -60,7 +62,9 @@ export class FakeDaytonaControlPlane { init?.signal?.addEventListener("abort", () => reject(new Error("aborted"))); }); } - return Promise.resolve(this.handle(route, url, init)); + const response = this.handle(route, url, init); + const hook = this.beforeAnswer.get(route); + return hook ? hook().then(() => response) : Promise.resolve(response); }) as typeof fetch; addSandbox(partial: Partial & { id: string }): FakeSandbox { @@ -258,7 +262,10 @@ export class FakeDaytonaSshConfigWriter implements Pick< export interface DaytonaTestRig { rootDir: string; + /** The account of the default API key. */ plane: FakeDaytonaControlPlane; + /** Another account: requests with this API key reach its own control plane. */ + addAccount: (apiKey: string) => FakeDaytonaControlPlane; writer: FakeDaytonaSshConfigWriter; service: DaytonaService; clock: { now: number }; @@ -277,6 +284,11 @@ export async function createDaytonaTestRig( const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-daytona-test-")); const clock = { now: Date.parse("2026-01-01T00:00:00Z") }; const plane = new FakeDaytonaControlPlane(() => clock.now); + const accounts = new Map(); + const planeFor = (init: RequestInit | undefined) => { + const auth = (init?.headers as Record | undefined)?.Authorization; + return accounts.get(auth?.replace(/^Bearer /, "") ?? "") ?? plane; + }; const writer = new FakeDaytonaSshConfigWriter(); const workspaces: DaytonaWorkspaceRef[] = []; const env: NodeJS.ProcessEnv = @@ -285,7 +297,8 @@ export async function createDaytonaTestRig( credentialsStore: new DaytonaCredentialsStore(rootDir, env), rootDir, sshConfigWriter: writer, - fetch: plane.fetch, + fetch: ((input: string | URL | Request, init?: RequestInit) => + planeFor(init).fetch(input, init)) as typeof fetch, now: () => clock.now, sleep: (ms) => { clock.now += ms; @@ -296,6 +309,11 @@ export async function createDaytonaTestRig( return { rootDir, plane, + addAccount: (apiKey) => { + const account = new FakeDaytonaControlPlane(() => clock.now); + accounts.set(apiKey, account); + return account; + }, writer, service, clock, diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 42bd008417d..47675920cf5 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -105,6 +105,15 @@ function sandboxStateError(sandboxId: string, sandbox: DaytonaSandbox): DaytonaA ); } +/** + * The client of the settings at one moment, stamped with their generation: work that must stay + * in one Daytona account (a create and its cleanup) holds it across a settings change. + */ +export interface DaytonaAccount { + client: DaytonaClient; + generation: number; +} + interface MintStamp { generation: number; release: number; @@ -423,6 +432,22 @@ export class DaytonaService { }; } + /** Stamped before the lookup, so a change during it reads as a change. */ + async getAccount(): Promise { + const generation = this.credentialsGeneration; + const { client } = await this.getClient(); + return { client, generation }; + } + + /** Whether the settings changed since `account` was taken. */ + isAccountCurrent(account: DaytonaAccount): boolean { + return account.generation === this.credentialsGeneration; + } + + private async clientOf(account: DaytonaAccount | undefined): Promise { + return account?.client ?? (await this.getClient()).client; + } + async getGatewayConfig(): Promise { const generation = this.credentialsGeneration; const { client, credentials } = await this.getClient(); @@ -518,9 +543,10 @@ export class DaytonaService { } async getSandboxState( - sandboxId: string + sandboxId: string, + account?: DaytonaAccount ): Promise<{ state: DaytonaSandboxState; sandbox?: DaytonaSandbox }> { - const { client } = await this.getClient(); + const client = await this.clientOf(account); try { const sandbox = await client.getSandbox(sandboxId); return { state: mapDaytonaSandboxState(sandbox.state), sandbox }; @@ -581,9 +607,11 @@ export class DaytonaService { return (await this.credentialsStore.resolve())?.target; } - async createSandbox(request: DaytonaCreateSandboxRequest): Promise { - const { client } = await this.getClient(); - return client.createSandbox(request); + async createSandbox( + request: DaytonaCreateSandboxRequest, + account?: DaytonaAccount + ): Promise { + return (await this.clientOf(account)).createSandbox(request); } async resizeSandbox(sandboxId: string, request: DaytonaResizeRequest): Promise { @@ -640,10 +668,11 @@ export class DaytonaService { /** * Delete the sandbox and drop Xum's SSH access to it (token and include block). A sandbox - * that is already gone counts as deleted. + * that is already gone counts as deleted. `account` deletes it in the account it was + * created in, even after the settings changed to another one. */ - async deleteSandbox(sandboxId: string): Promise { - const { client } = await this.getClient(); + async deleteSandbox(sandboxId: string, account?: DaytonaAccount): Promise { + const client = await this.clientOf(account); // Revoke first: Daytona refuses to revoke a token of a sandbox that no longer exists. await this.releaseSshAccess(sandboxId); try { From 0d4c467f4e9e1adc95e9a727738aede534e49c37 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:46:24 +0000 Subject: [PATCH 048/231] fix(daytona): read workspace references after the sandbox list for the orphan pass The orphan pass matched the sandbox list against references read before the list. A creation that persisted its workspace after that read, then created and started its sandbox before the list returned, had its new sandbox stopped as an orphan while setup still provisioned it. A creation persists its workspace before it sends the create request, so references read after the list cover every sandbox the list shows. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 4 ++-- src/node/services/daytona/daytona.testHarness.ts | 9 ++++----- .../services/daytona/daytonaReconciler.test.ts | 14 ++++++++++++++ src/node/services/daytona/daytonaReconciler.ts | 4 +++- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 060b157a8e3..ad2584ad168 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -324,8 +324,8 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { it("deletes its sandbox in the account that created it when Settings switch accounts during setup", async () => { const newAccount = rig.addAccount("new-key"); - // The user saves another account's key while the create request is answered. - rig.plane.beforeAnswer.set("POST /sandbox", () => rig.service.setConfig({ apiKey: "new-key" })); + // The user saves another account's key while the create request is in flight. + rig.plane.onRequest.set("POST /sandbox", () => rig.service.setConfig({ apiKey: "new-key" })); const failure = await makeRuntime({ sandboxId: "xum-moved", shared: false, ...SMALL }) .postCreateSetup(initParams()) diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index e99d2df45d3..cd7f8ab63f5 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -46,8 +46,8 @@ export class FakeDaytonaControlPlane { readonly failingRoutes = new Set(); /** POST /sandbox creates the sandbox, then answers 500 (the create's outcome looks unknown). */ failAfterCreate = false; - /** Runs once a route (`METHOD /path`) is handled, before its answer reaches the client. */ - readonly beforeAnswer = new Map Promise>(); + /** Runs when a route (`METHOD /path`) is requested; the plane answers once it settles. */ + readonly onRequest = new Map Promise>(); private nextId = 1; private nextToken = 1; @@ -62,9 +62,8 @@ export class FakeDaytonaControlPlane { init?.signal?.addEventListener("abort", () => reject(new Error("aborted"))); }); } - const response = this.handle(route, url, init); - const hook = this.beforeAnswer.get(route); - return hook ? hook().then(() => response) : Promise.resolve(response); + const hook = this.onRequest.get(route) ?? (() => Promise.resolve()); + return hook().then(() => this.handle(route, url, init)); }) as typeof fetch; addSandbox(partial: Partial & { id: string }): FakeSandbox { diff --git a/src/node/services/daytona/daytonaReconciler.test.ts b/src/node/services/daytona/daytonaReconciler.test.ts index 92dbbee2dd1..d46e84c3474 100644 --- a/src/node/services/daytona/daytonaReconciler.test.ts +++ b/src/node/services/daytona/daytonaReconciler.test.ts @@ -96,6 +96,20 @@ describe("DaytonaReconciler.sweep", () => { expect(rig.plane.calls.some((call) => call.startsWith("DELETE /sandbox/"))).toBe(false); }); + it("keeps the sandbox of a workspace created while the sandboxes were listed", async () => { + const workspaces: Array<{ id: string; runtimeConfig: RuntimeConfig }> = []; + // A creation persists its workspace, then creates and starts its sandbox, during the list. + rig.plane.onRequest.set("GET /sandbox", () => { + workspaces.push({ id: "ws-new", runtimeConfig: daytonaConfig("xum-new") }); + rig.plane.addSandbox({ id: "sb-new", name: "xum-new", labels: instanceLabels }); + return Promise.resolve(); + }); + + await makeReconciler(workspaces, []).sweep(); + + expect(rig.plane.find("xum-new")?.state).toBe("started"); + }); + it("moves on past a hung control-plane call", async () => { rig.plane.addSandbox({ id: "sb-hung", labels: instanceLabels }); rig.plane.addSandbox({ id: "sb-orphan", labels: instanceLabels }); diff --git a/src/node/services/daytona/daytonaReconciler.ts b/src/node/services/daytona/daytonaReconciler.ts index 0a9918ad235..b8be209ca77 100644 --- a/src/node/services/daytona/daytonaReconciler.ts +++ b/src/node/services/daytona/daytonaReconciler.ts @@ -148,7 +148,9 @@ export class DaytonaReconciler { for (const ref of refs) { await this.keepAlive(ref, sandboxes); } - await this.stopOrphans(sandboxes, refs); + // A creation persists its workspace before it creates the sandbox, so references read + // after the list cover every sandbox in it; the first read may predate a creation. + await this.stopOrphans(sandboxes, this.collectReferences()); } private collectReferences(): DaytonaSandboxRef[] { From ea0a98ebbdf28998ec18a3098eebc11c9dca6542 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:54:00 +0000 Subject: [PATCH 049/231] fix(daytona): keep a sandbox alive only for a job proven to run The reconciler keeps a started sandbox alive while a detached job that survived a Xum crash still runs. Its probe read a leftover claimed record dir (no meta.json, no exit marker) as an unsettled job, so the sandbox was refreshed every sweep long after its job exited, forever. hasUnsettledRemoteSpawnRecords takes liveProcessesOnly: then only a running record with a live PID counts. Only the reconciler passes it; the archive check still fails closed on such records. A probe that fails three sweeps in a row no longer keeps the sandbox alive either: the workspace then counts as clear, and a success resets the count. --- .../services/backgroundProcessManager.test.ts | 20 ++++++++++ src/node/services/backgroundProcessManager.ts | 13 +++++-- .../daytona/daytonaReconciler.test.ts | 37 ++++++++++++++++++- .../services/daytona/daytonaReconciler.ts | 29 ++++++++++++++- src/node/services/serviceContainer.ts | 4 +- 5 files changed, 96 insertions(+), 7 deletions(-) diff --git a/src/node/services/backgroundProcessManager.test.ts b/src/node/services/backgroundProcessManager.test.ts index a5f4c534a95..88692c926f3 100644 --- a/src/node/services/backgroundProcessManager.test.ts +++ b/src/node/services/backgroundProcessManager.test.ts @@ -4510,6 +4510,26 @@ describe("BackgroundProcessManager", () => { } }); + it("with liveProcessesOnly, reports only running records with a live PID", async () => { + const remote = createRemoteLikeRuntime(new LocalRuntime(process.cwd())); + const probe = () => + manager.hasUnsettledRemoteSpawnRecords(remote, orphanWorkspaceId, { + liveProcessesOnly: true, + }); + + // A leftover claimed dir (no meta.json, no exit marker) and a running record without a + // pid prove no job runs: clear with the option, unsettled without it. + await fs.mkdir(path.join(workspaceDir, "leftover"), { recursive: true }); + await writeSpawnRecord("no-pid", JSON.stringify({ status: "running" })); + expect(await probe()).toEqual(Ok(false)); + expect(await manager.hasUnsettledRemoteSpawnRecords(remote, orphanWorkspaceId)).toEqual( + Ok(true) + ); + + await writeSpawnRecord("live", { pid: process.pid, status: "running" }); + expect(await probe()).toEqual(Ok(true)); + }); + it("treats running records under extra record dirs as live without host PID probes", async () => { // Devcontainer records (passed via extraRecordDirs) carry container-namespace PIDs: a // host ESRCH proves nothing about the container process, so a running record without diff --git a/src/node/services/backgroundProcessManager.ts b/src/node/services/backgroundProcessManager.ts index 61105fe53f3..f15c064587e 100644 --- a/src/node/services/backgroundProcessManager.ts +++ b/src/node/services/backgroundProcessManager.ts @@ -3526,15 +3526,22 @@ export class BackgroundProcessManager extends EventEmitter> { assert(workspaceId.length > 0, "hasUnsettledRemoteSpawnRecords requires workspaceId"); try { const tempDir = await runtime.tempDir(); const root = `${tempDir}/${BG_OUTPUT_SUBDIR}/${workspaceId}`; + const unprovenRecord = options.liveProcessesOnly ? "continue" : "unsettled=1; break"; // One POSIX-shell pass over the per-process record dirs (see recordRootHoldsOrphan for the // host-local equivalent of these rules): // - exit marker present → settled; missing meta.json (or one without a "status" field, @@ -3566,10 +3573,10 @@ export class BackgroundProcessManager extends EventEmitter/dev/null; then unsettled=1; break; fi`, + ` if ! grep -q '"status"' "$p/${BG_META_FILENAME}" 2>/dev/null; then ${unprovenRecord}; fi`, ` grep -q '"status"[[:space:]]*:[[:space:]]*"running"' "$p/${BG_META_FILENAME}" 2>/dev/null || continue`, ` pid=$(sed -n 's/.*"pid"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p' "$p/${BG_META_FILENAME}" 2>/dev/null | head -n 1)`, - ` if [ -z "$pid" ] || [ "$pid" -le 1 ]; then unsettled=1; break; fi`, + ` if [ -z "$pid" ] || [ "$pid" -le 1 ]; then ${unprovenRecord}; fi`, ` if kill -0 "$pid" 2>/dev/null || [ -e "/proc/$pid" ]; then unsettled=1; break; fi`, `done`, `if [ "$unsettled" = 1 ]; then echo __MUX_BG_REMOTE_UNSETTLED__; else echo __MUX_BG_REMOTE_CLEAR__; fi`, diff --git a/src/node/services/daytona/daytonaReconciler.test.ts b/src/node/services/daytona/daytonaReconciler.test.ts index d46e84c3474..d94fff53b67 100644 --- a/src/node/services/daytona/daytonaReconciler.test.ts +++ b/src/node/services/daytona/daytonaReconciler.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import type { RuntimeConfig } from "@/common/types/runtime"; import { createDaytonaTestRig, type DaytonaTestRig } from "./daytona.testHarness"; -import { Ok } from "@/common/types/result"; +import { Err, Ok } from "@/common/types/result"; import { DaytonaReconciler, listDaytonaWorkspaceRefs, @@ -173,6 +173,41 @@ describe("DaytonaReconciler.sweep", () => { expect(rig.plane.countCalls("POST /sandbox/sb-attached-off/last-activity")).toBe(0); }); + it("stops keeping a sandbox alive once its job probe keeps failing, a success resetting the count", async () => { + rig.plane.addSandbox({ id: "sb-1", name: "xum-stuck", labels: instanceLabels }); + rig.plane.addSandbox({ id: "sb-2", name: "xum-flaky", labels: instanceLabels }); + // Per sweep: the stuck probe always fails; the flaky one succeeds once between failures. + const flaky = [ + Err("ssh failed"), + Err("ssh failed"), + Ok(true), + Err("ssh failed"), + Err("ssh failed"), + ]; + const probed: string[] = []; + const reconciler = makeReconciler( + [ + { id: "ws-stuck", runtimeConfig: daytonaConfig("xum-stuck") }, + { id: "ws-flaky", runtimeConfig: daytonaConfig("xum-flaky") }, + ], + [], + undefined, + (ref) => { + probed.push(ref.workspaceId); + return Promise.resolve( + ref.workspaceId === "ws-stuck" ? Err("ssh failed") : (flaky.shift() ?? Ok(false)) + ); + } + ); + + for (let sweep = 0; sweep < 5; sweep++) await reconciler.sweep(); + + // Two failures still keep it alive (fail closed); the third gives up on the workspace. + expect(rig.plane.countCalls("POST /sandbox/sb-1/last-activity")).toBe(2); + expect(probed.filter((id) => id === "ws-stuck")).toHaveLength(3); + expect(rig.plane.countCalls("POST /sandbox/sb-2/last-activity")).toBe(5); + }); + it("keeps a sandbox alive under jobs that survived a Xum crash, probing a clear one once", async () => { rig.plane.addSandbox({ id: "sb-1", name: "xum-survivor", labels: instanceLabels }); rig.plane.addSandbox({ id: "sb-2", name: "xum-quiet", labels: instanceLabels }); diff --git a/src/node/services/daytona/daytonaReconciler.ts b/src/node/services/daytona/daytonaReconciler.ts index b8be209ca77..c50016a5fed 100644 --- a/src/node/services/daytona/daytonaReconciler.ts +++ b/src/node/services/daytona/daytonaReconciler.ts @@ -9,6 +9,8 @@ import { mapDaytonaSandboxState, type DaytonaService } from "./daytonaService"; export const DAYTONA_RECONCILE_INTERVAL_MS = 60_000; /** Each control-plane call gets this long; a hung call is logged and the sweep moves on. */ export const DAYTONA_RECONCILE_CALL_TIMEOUT_MS = 20_000; +/** Consecutive failed job probes of one workspace after which keepalive stops waiting on it. */ +export const DAYTONA_JOB_PROBE_FAILURE_LIMIT = 3; export interface DaytonaWorkspaceRef { workspaceId: string; @@ -102,6 +104,8 @@ export class DaytonaReconciler { * counts as activity, so a clear workspace is never probed again. */ private readonly remoteJobsClear = new Set(); + /** Consecutive failed job probes per workspace; a success resets it. */ + private readonly probeFailures = new Map(); private timer: ReturnType | undefined; private sweeping: Promise | undefined; @@ -198,7 +202,11 @@ export class DaytonaReconciler { } } - /** Fails closed (keeps the sandbox alive) when the probe fails or times out. */ + /** + * Fails closed (keeps the sandbox alive) when the probe fails or times out, but only for + * DAYTONA_JOB_PROBE_FAILURE_LIMIT sweeps in a row: a probe that never succeeds would keep the + * sandbox running forever, so the workspace then counts as clear. + */ private async hasUnsettledRemoteJobs(ref: DaytonaSandboxRef): Promise { const probe = this.options.hasUnsettledRemoteJobs; if (!probe || this.remoteJobsClear.has(ref.workspaceId)) return false; @@ -213,12 +221,29 @@ export class DaytonaReconciler { workspaceId: ref.workspaceId, error: result.error, }); - return true; + return !this.giveUpAfterProbeFailure(ref.workspaceId); } + this.probeFailures.delete(ref.workspaceId); if (!result.data) this.remoteJobsClear.add(ref.workspaceId); return result.data; } + /** Whether this failure reached the limit: the workspace then counts as clear. */ + private giveUpAfterProbeFailure(workspaceId: string): boolean { + const failures = (this.probeFailures.get(workspaceId) ?? 0) + 1; + if (failures < DAYTONA_JOB_PROBE_FAILURE_LIMIT) { + this.probeFailures.set(workspaceId, failures); + return false; + } + this.probeFailures.delete(workspaceId); + this.remoteJobsClear.add(workspaceId); + log.warn("Daytona reconcile: job probe keeps failing; no longer keeping the sandbox alive", { + workspaceId, + failures, + }); + return true; + } + /** `storedId` is what the workspace config holds (tokens use it), `realId` the sandbox id. */ private async refreshActivityAndToken(storedId: string, realId: string): Promise { const service = this.options.daytonaService; diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 9addf882669..de05a5b5d1e 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -481,10 +481,12 @@ export class ServiceContainer { this.aiService.isStreaming(workspaceId) || (await this.backgroundProcessManager.hasLiveBackgroundProcesses(workspaceId)), // Detached jobs that outlived an unclean exit are in no in-memory list. + // Keepalive needs a job proven to run: a leftover record dir must not keep a sandbox up. hasUnsettledRemoteJobs: (ref) => this.backgroundProcessManager.hasUnsettledRemoteSpawnRecords( createRuntime(ref.runtimeConfig, { workspaceId: ref.workspaceId }), - ref.workspaceId + ref.workspaceId, + { liveProcessesOnly: true } ), }); } From 3618bee5420432c03904594a6d06acf3f03adad5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:55:46 +0000 Subject: [PATCH 050/231] fix(daytona): skip an oversized seed file and copy the files that still fit One file larger than the remaining seed budget ended the agent-home seed: every later skill, ~/.agents/skills included, was dropped with only a log warning. Such a file is now skipped (unread, as before) and seeding goes on with the files that still fit; only a spent budget stops the walk. --- src/node/runtime/daytonaAgentHomeSeed.test.ts | 12 ++++++++-- src/node/runtime/daytonaAgentHomeSeed.ts | 24 ++++++++++++------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/node/runtime/daytonaAgentHomeSeed.test.ts b/src/node/runtime/daytonaAgentHomeSeed.test.ts index 6116edd1c95..f86ef6f951a 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.test.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.test.ts @@ -72,17 +72,25 @@ describe("agent-home seeding", () => { ); }); - it("never reads a file larger than the remaining seed budget", async () => { + it("skips a file larger than the remaining seed budget, unread, and copies the rest", async () => { const huge = path.join(hostXum, "skills", "data", "model.bin"); await fs.mkdir(path.dirname(huge), { recursive: true }); // Sparse: 64 MiB on paper, no disk used. await fs.writeFile(huge, ""); await fs.truncate(huge, 64 * 1024 * 1024); + // Files after the oversized one still fit, in the same root and in the next one. + await fs.mkdir(path.join(hostXum, "skills", "deploy"), { recursive: true }); + await fs.writeFile(path.join(hostXum, "skills", "deploy", "SKILL.md"), "# deploy\n"); + await fs.mkdir(path.join(hostHome, ".agents", "skills", "lint"), { recursive: true }); + await fs.writeFile(path.join(hostHome, ".agents", "skills", "lint", "SKILL.md"), "# lint\n"); const readFile = spyOn(fs, "readFile"); try { const files = await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux"); - expect(files).toEqual([]); + expect(files.map((file) => file.remotePath)).toEqual([ + "~/.mux/skills/deploy/SKILL.md", + "~/.agents/skills/lint/SKILL.md", + ]); expect( readFile.mock.calls.map(([file]) => (typeof file === "string" ? file : "")) ).not.toContain(huge); diff --git a/src/node/runtime/daytonaAgentHomeSeed.ts b/src/node/runtime/daytonaAgentHomeSeed.ts index 15bbbc14df9..d84fd032428 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.ts @@ -19,7 +19,10 @@ import { expandTildeForSSH } from "./tildeExpansion"; const SEEDED_HOOK_FILES = ["tool_env", "tool_pre", "tool_post"] as const; -/** A box seed is a convenience: past this size the rest is skipped with a warning. */ +/** + * A box seed is a convenience: a file larger than what is left of this is skipped, and files + * that still fit are copied, so one large file does not drop every later skill. + */ const MAX_SEED_BYTES = 16 * 1024 * 1024; const MAX_SKILL_DIR_DEPTH = 8; const SEED_TIMEOUT_SECONDS = 120; @@ -41,7 +44,12 @@ export interface AgentHomeSeedSources { interface SeedBudget { remainingBytes: number; - exhausted: boolean; + /** A file did not fit. */ + skipped: boolean; +} + +function isSpent(budget: SeedBudget): boolean { + return budget.remainingBytes <= 0; } async function readRegularFile( @@ -55,7 +63,7 @@ async function readRegularFile( // Check before reading: a large file must not be loaded only to be dropped. if (stat.size > budget.remainingBytes) { log.debug("Skipping a file past the Daytona seed size limit", { filePath, size: stat.size }); - budget.exhausted = true; + budget.skipped = true; return null; } return { content: await fs.readFile(filePath), mode: stat.mode }; @@ -76,7 +84,7 @@ function addFile( file: { content: Buffer; mode: number } ): void { if (file.content.length > budget.remainingBytes) { - budget.exhausted = true; + budget.skipped = true; return; } budget.remainingBytes -= file.content.length; @@ -136,7 +144,7 @@ async function collectSkillsRoot( const root = entries ? await resolveEntry(hostRoot) : null; if (!entries || !root) return; for (const entry of entries) { - if (budget.exhausted) return; + if (isSpent(budget)) return; await collectSkillsRootEntry( files, budget, @@ -170,7 +178,7 @@ async function collectDirectory( depth: number, skillRoot: string ): Promise { - if (depth > MAX_SKILL_DIR_DEPTH || budget.exhausted) return; + if (depth > MAX_SKILL_DIR_DEPTH || isSpent(budget)) return; const entries = await readDirectory(hostDir); if (!entries) return; for (const entry of entries) { @@ -208,7 +216,7 @@ export async function collectAgentHomeSeedFiles( remoteXumHome: string ): Promise { const files: AgentHomeSeedFile[] = []; - const budget: SeedBudget = { remainingBytes: MAX_SEED_BYTES, exhausted: false }; + const budget: SeedBudget = { remainingBytes: MAX_SEED_BYTES, skipped: false }; for (const name of SEEDED_HOOK_FILES) { const file = await readRegularFile(path.join(sources.xumHome, name), budget); if (file) addFile(files, budget, path.posix.join(remoteXumHome, name), file); @@ -225,7 +233,7 @@ export async function collectAgentHomeSeedFiles( path.join(sources.home, ".agents", "skills"), "~/.agents/skills" ); - if (budget.exhausted) { + if (budget.skipped) { log.warn("Daytona agent-home seed hit its size limit; some files were not copied", { limitBytes: MAX_SEED_BYTES, }); From 132975911d2cb3ab2ef4ad71196cea71a7cf8714 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:05:25 +0000 Subject: [PATCH 051/231] fix(runtime): log a failed init's error message, not the serialized Error The background-init failure log printed {"error":{}}: an Error has no enumerable fields for the JSON logger. It now logs the message, with remembered Daytona ssh tokens redacted (the ssh user name is the token). --- src/node/runtime/runtimeFactory.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/node/runtime/runtimeFactory.ts b/src/node/runtime/runtimeFactory.ts index 34002781a0c..2cbe1ba05b8 100644 --- a/src/node/runtime/runtimeFactory.ts +++ b/src/node/runtime/runtimeFactory.ts @@ -7,6 +7,7 @@ import { WorktreeRuntime } from "./WorktreeRuntime"; import { SSHRuntime } from "./SSHRuntime"; import { CoderSSHRuntime } from "./CoderSSHRuntime"; import { DaytonaSSHRuntime, type DaytonaRuntimeService } from "./DaytonaSSHRuntime"; +import { redactDaytonaTokens } from "./daytonaTokenRedaction"; import { createSSHTransport } from "./transports"; import { DockerRuntime, getContainerName } from "./DockerRuntime"; import { DevcontainerRuntime } from "./DevcontainerRuntime"; @@ -90,8 +91,9 @@ export function runBackgroundInit( return withInitUseLease(useLeases, workspaceId, () => runFullInit(runtime, params)).then( () => undefined, (error: unknown) => { - const errorMsg = getErrorMessage(error); - logger?.error(`Workspace init failed for ${workspaceId}:`, { error }); + // An Error serializes to `{}` in the log; a Daytona ssh error may name its token. + const errorMsg = redactDaytonaTokens(getErrorMessage(error)); + logger?.error(`Workspace init failed for ${workspaceId}:`, { error: errorMsg }); params.initLogger.logStderr(`Initialization failed: ${errorMsg}`); params.initLogger.logComplete(-1); } From f40013c5a914b009fd7a7559257eceac1f54df66 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:46:31 +0000 Subject: [PATCH 052/231] fix(daytona): refuse a settings change while a Daytona operation runs Rounds 1, 4, 6 and 7 each found one more path where a multi-step Daytona operation spanned a settings change: the checks ran against one account and the delete (or readiness, or cleanup) against another. A sandbox then stayed billable, or one of the same name in the new account was deleted. DaytonaService.withAccountLease now counts operations that make more than one control-plane call or act on what an earlier call returned: workspace create and setup, delete from its checks to the sandbox delete, ensureReady and the ssh recovery, the archive hook, the reconcile sweep, and in the service the shared start-and-mint request, ssh access setup and token mints. setConfig refuses at once while any runs (a DaytonaConfigError the Settings form shows), and an operation that starts while a change is saved waits for it. So no operation spans a settings change. The generation stamps stay as a second line of defense. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 82 ++++++++++- src/node/runtime/DaytonaSSHRuntime.ts | 128 +++++++++++------- .../services/daytona/daytonaLifecycleHooks.ts | 25 ++-- .../services/daytona/daytonaReconciler.ts | 8 +- .../services/daytona/daytonaService.test.ts | 28 ++-- src/node/services/daytona/daytonaService.ts | 102 ++++++++++---- 6 files changed, 273 insertions(+), 100 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index ad2584ad168..341a20786ed 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -4,6 +4,7 @@ import * as fs from "fs/promises"; import * as path from "path"; import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; import type { RuntimeConfig } from "@/common/types/runtime"; +import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; import { createDaytonaTestRig, localShellTransport, @@ -322,10 +323,29 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { } }); - it("deletes its sandbox in the account that created it when Settings switch accounts during setup", async () => { + it("refuses a settings change while it creates a sandbox, and takes one afterwards", async () => { const newAccount = rig.addAccount("new-key"); // The user saves another account's key while the create request is in flight. - rig.plane.onRequest.set("POST /sandbox", () => rig.service.setConfig({ apiKey: "new-key" })); + let refusal: unknown; + rig.plane.onRequest.set("POST /sandbox", () => + rig.service.setConfig({ apiKey: "new-key" }).catch((error: unknown) => { + refusal = error; + }) + ); + + await makeRuntime({ sandboxId: "xum-kept", shared: false, ...SMALL }).postCreateSetup( + initParams() + ); + + expect(String(refusal)).toContain("Daytona is busy"); + expect(rig.plane.find("xum-kept")?.state).toBe("started"); + expect(newAccount.calls).toEqual([]); + expect((await rig.service.setConfig({ apiKey: "new-key" })).apiKeySource).toBe("settings"); + }); + + it("deletes its sandbox in the account that created it if the settings still change", async () => { + // Defense behind the refusal above: setup fails, and cleans up where it created. + spyOn(rig.service, "isAccountCurrent").mockReturnValue(false); const failure = await makeRuntime({ sandboxId: "xum-moved", shared: false, ...SMALL }) .postCreateSetup(initParams()) @@ -336,7 +356,6 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { expect(String(failure)).toContain("Daytona settings changed"); expect(rig.plane.find("xum-moved")).toBeUndefined(); - expect(newAccount.calls).toEqual([]); }); it("never deletes a sandbox that took its name before the create request", async () => { @@ -439,6 +458,63 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { expect(rig.plane.calls.filter((call) => call.startsWith("DELETE /sandbox/"))).toEqual([]); }); + it("refuses a settings change between its checks and the sandbox delete", async () => { + rig.plane.addSandbox({ id: "sb-clean" }); + const newAccount = rig.addAccount("new-key"); + let refusal: unknown; + sshDeleteSpy.mockImplementation(async () => { + await rig.service.setConfig({ apiKey: "new-key" }).catch((error: unknown) => { + refusal = error; + }); + return { success: true, deletedPath: "/path" }; + }); + + const result = await makeRuntime({ sandboxId: "sb-clean", shared: false }).deleteWorkspace( + "/home/me/project", + "feature", + false + ); + + expect(result.success).toBe(true); + expect(String(refusal)).toContain("Daytona is busy"); + expect(rig.plane.find("sb-clean")).toBeUndefined(); + expect(newAccount.calls).toEqual([]); + expect((await rig.service.setConfig({ apiKey: "new-key" })).apiKeySource).toBe("settings"); + }); + + it("starts a delete after a settings change being saved, in the new account only", async () => { + // The same name in both accounts: the delete must check and delete the new one's. + rig.plane.addSandbox({ id: "sb-1", name: "xum-same" }); + const newAccount = rig.addAccount("new-key"); + newAccount.addSandbox({ id: "sb-9", name: "xum-same" }); + let releaseUpdate = () => undefined as void; + const updateGate = new Promise((resolve) => (releaseUpdate = resolve)); + const updateSpy = spyOn(DaytonaCredentialsStore.prototype, "update").mockImplementation( + async function (this: DaytonaCredentialsStore, patch) { + await updateGate; + updateSpy.mockRestore(); + return this.update(patch); + } + ); + try { + const saved = rig.service.setConfig({ apiKey: "new-key" }); + const deleted = makeRuntime({ sandboxId: "xum-same", shared: false }).deleteWorkspace( + "/home/me/project", + "feature", + false + ); + releaseUpdate(); + await saved; + expect((await deleted).success).toBe(true); + } finally { + updateSpy.mockRestore(); + } + + expect(rig.plane.calls.filter((call) => call.includes("xum-same"))).toEqual([]); + expect(rig.plane.find("xum-same")).toBeDefined(); + expect(newAccount.find("xum-same")).toBeUndefined(); + }); + it("starts a stopped sandbox to run the dirty check and keeps a dirty workspace", async () => { rig.plane.addSandbox({ id: "sb-dirty", state: "stopped" }); sshDeleteSpy.mockResolvedValue({ success: false, error: "uncommitted changes" }); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 810cd5737ad..076618d9b03 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -67,6 +67,7 @@ export type DaytonaRuntimeService = Pick< DaytonaService, | "checkAvailability" | "getSandboxState" + | "withAccountLease" | "getAccount" | "isAccountCurrent" | "buildLabels" @@ -325,28 +326,30 @@ export class DaytonaSSHRuntime extends SSHRuntime { * checks share one control-plane request. A sandbox removed outside Xum is reported, never * silently recreated. */ - override async ensureReady(options?: EnsureReadyOptions): Promise { - const sandboxId = this.daytonaConfig.sandboxId; - if (!sandboxId) { - return { - ready: false, - error: "Daytona sandbox id not set", - errorType: "runtime_not_ready", - }; - } - const setupFailure = this.setupFailureResult(sandboxId); - if (setupFailure) return setupFailure; - const emitStatus = makeStatusEmitter(options?.statusSink); - if (this.daytonaService.isSandboxReady(sandboxId)) { - return this.ensureReadyFromCache(options, emitStatus); - } + override ensureReady(options?: EnsureReadyOptions): Promise { + return this.daytonaService.withAccountLease(async () => { + const sandboxId = this.daytonaConfig.sandboxId; + if (!sandboxId) { + return { + ready: false, + error: "Daytona sandbox id not set", + errorType: "runtime_not_ready", + }; + } + const setupFailure = this.setupFailureResult(sandboxId); + if (setupFailure) return setupFailure; + const emitStatus = makeStatusEmitter(options?.statusSink); + if (this.daytonaService.isSandboxReady(sandboxId)) { + return this.ensureReadyFromCache(options, emitStatus); + } - emitStatus("checking"); - const failure = await this.bringSandboxUp(sandboxId, emitStatus, options?.signal); - if (failure) return failure; - const result = await this.markReadyIfRepoPresent(sandboxId, options, emitStatus); - if (result.ready) this.restartsStoppedSandbox = true; - return result; + emitStatus("checking"); + const failure = await this.bringSandboxUp(sandboxId, emitStatus, options?.signal); + if (failure) return failure; + const result = await this.markReadyIfRepoPresent(sandboxId, options, emitStatus); + if (result.ready) this.restartsStoppedSandbox = true; + return result; + }); } private setupFailureResult(sandboxId: string): EnsureReadyResult | undefined { @@ -416,18 +419,17 @@ export class DaytonaSSHRuntime extends SSHRuntime { * "Permission denied", so only a started sandbox gets a new token. Throws a clear error for * a sandbox deleted outside Xum. */ - private async recoverForRetry( - error: unknown, - abortSignal: AbortSignal | undefined - ): Promise { - const sandboxId = this.daytonaConfig.sandboxId; - if (!sandboxId || !isRuntimeTransportError(error) || abortSignal?.aborted) return false; - const tokenRejected = isTokenRejected(error); - // A UI or background runtime never starts the sandbox, so only a refused token is worth a look. - if (!tokenRejected && !this.restartsStoppedSandbox) return false; - this.daytonaService.invalidateSandboxReady(sandboxId); - const state = await this.readStateAfterFailure(sandboxId); - return this.recoverInState(sandboxId, state, { error, tokenRejected }, abortSignal); + private recoverForRetry(error: unknown, abortSignal: AbortSignal | undefined): Promise { + return this.daytonaService.withAccountLease(async () => { + const sandboxId = this.daytonaConfig.sandboxId; + if (!sandboxId || !isRuntimeTransportError(error) || abortSignal?.aborted) return false; + const tokenRejected = isTokenRejected(error); + // A UI or background runtime never starts the sandbox, so only a refused token is worth a look. + if (!tokenRejected && !this.restartsStoppedSandbox) return false; + this.daytonaService.invalidateSandboxReady(sandboxId); + const state = await this.readStateAfterFailure(sandboxId); + return this.recoverInState(sandboxId, state, { error, tokenRejected }, abortSignal); + }); } private recoverInState( @@ -695,7 +697,11 @@ export class DaytonaSSHRuntime extends SSHRuntime { * sync, worktree creation and init hook. If anything fails after the create request was * sent, the sandbox this call created is deleted again so it cannot linger and bill. */ - async postCreateSetup(params: WorkspaceInitParams): Promise { + postCreateSetup(params: WorkspaceInitParams): Promise { + return this.daytonaService.withAccountLease(() => this.provisionSandbox(params)); + } + + private async provisionSandbox(params: WorkspaceInitParams): Promise { const sandboxId = this.daytonaConfig.sandboxId; if (!sandboxId) { throw new Error("Daytona sandbox id is required (should be set by finalizeConfig)"); @@ -921,7 +927,27 @@ export class DaytonaSSHRuntime extends SSHRuntime { * stopped or archived sandbox is started for them: unlike Coder, never delete unchecked work * to avoid a start. Force skips the checks. */ - override async deleteWorkspace( + override deleteWorkspace( + projectPath: string, + workspaceName: string, + force: boolean, + abortSignal?: AbortSignal, + trusted?: boolean, + options?: { keepBranch?: boolean } + ): Promise { + return this.daytonaService.withAccountLease(() => + this.deleteWorkspaceAndSandbox( + projectPath, + workspaceName, + force, + abortSignal, + trusted, + options + ) + ); + } + + private async deleteWorkspaceAndSandbox( projectPath: string, workspaceName: string, force: boolean, @@ -990,25 +1016,27 @@ export class DaytonaSSHRuntime extends SSHRuntime { * the "delete" behavior uses it to decide between deleting and stopping the sandbox. A * stopped or archived sandbox is started for the check; a missing one has nothing to lose. */ - async checkSandboxDeletable( + checkSandboxDeletable( projectPath: string, workspaceName: string, trusted: boolean | undefined ): Promise> { - const sandboxId = this.daytonaConfig.sandboxId; - if (!sandboxId) return Err("Daytona sandbox id is not set"); - const prepared = await this.startForDeleteChecks(sandboxId, undefined); - if (prepared === "missing") return Ok(undefined); - if (prepared !== "ready") return Err(prepared.error); - try { - const deletedPath = this.getWorkspacePath(projectPath, workspaceName); - const check = await this.checkWorkspaceDeletable(deletedPath, false, trusted); - if (check === "missing") return Ok(undefined); - if (check !== "ok") return Err(check.error); - return await this.checkNoUntrackedFiles(deletedPath); - } catch (error) { - return Err(`Failed to check workspace state: ${getErrorMessage(error)}`); - } + return this.daytonaService.withAccountLease(async () => { + const sandboxId = this.daytonaConfig.sandboxId; + if (!sandboxId) return Err("Daytona sandbox id is not set"); + const prepared = await this.startForDeleteChecks(sandboxId, undefined); + if (prepared === "missing") return Ok(undefined); + if (prepared !== "ready") return Err(prepared.error); + try { + const deletedPath = this.getWorkspacePath(projectPath, workspaceName); + const check = await this.checkWorkspaceDeletable(deletedPath, false, trusted); + if (check === "missing") return Ok(undefined); + if (check !== "ok") return Err(check.error); + return await this.checkNoUntrackedFiles(deletedPath); + } catch (error) { + return Err(`Failed to check workspace state: ${getErrorMessage(error)}`); + } + }); } /** diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index df3d418354e..7f28f24f66d 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -24,6 +24,7 @@ export interface DaytonaArchiveHookOptions { | "deleteSandbox" | "isSandboxReferencedElsewhere" | "releaseSshAccess" + | "withAccountLease" >; getArchiveBehavior: () => CoderWorkspaceArchiveBehavior; /** As for Coder: model-driven archives must not stop a box under a surviving detached job. */ @@ -39,16 +40,22 @@ export interface DaytonaArchiveHookOptions { } export function createDaytonaArchiveHook(options: DaytonaArchiveHookOptions): BeforeArchiveHook { - return async (args): Promise> => { - const sandboxId = await unsharedSandboxId(options, args.workspaceMetadata); - if (!sandboxId) return Ok(undefined); + // Its checks decide what it stops or deletes: all of it runs on one account. + return (args) => options.daytonaService.withAccountLease(() => archiveSandbox(options, args)); +} + +async function archiveSandbox( + options: DaytonaArchiveHookOptions, + args: Parameters[0] +): Promise> { + const sandboxId = await unsharedSandboxId(options, args.workspaceMetadata); + if (!sandboxId) return Ok(undefined); - const result = isDedicatedDaytonaSandbox(args.workspaceMetadata.runtimeConfig) - ? await applyArchiveBehavior(options, args, sandboxId) - : Ok(undefined); - if (result.success) await releaseSshAccess(options, sandboxId); - return result; - }; + const result = isDedicatedDaytonaSandbox(args.workspaceMetadata.runtimeConfig) + ? await applyArchiveBehavior(options, args, sandboxId) + : Ok(undefined); + if (result.success) await releaseSshAccess(options, sandboxId); + return result; } async function applyArchiveBehavior( diff --git a/src/node/services/daytona/daytonaReconciler.ts b/src/node/services/daytona/daytonaReconciler.ts index c50016a5fed..b419dc0b70a 100644 --- a/src/node/services/daytona/daytonaReconciler.ts +++ b/src/node/services/daytona/daytonaReconciler.ts @@ -27,6 +27,7 @@ export interface DaytonaReconcilerOptions { | "hasExpiringSshToken" | "ensureSshAccess" | "getSandboxState" + | "withAccountLease" >; /** Every workspace Xum knows, archived ones included (they still own their sandbox). */ listWorkspaces: () => DaytonaWorkspaceRef[]; @@ -133,7 +134,12 @@ export class DaytonaReconciler { this.timer = undefined; } - async sweep(): Promise { + /** One account for the whole sweep: it stops sandboxes from what it listed. */ + sweep(): Promise { + return this.options.daytonaService.withAccountLease(() => this.sweepOnce()); + } + + private async sweepOnce(): Promise { const service = this.options.daytonaService; // No credentials: no Daytona workspaces can be reached, and nothing to reconcile. if (!(await service.checkAvailability()).available) return; diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 9e056161fb8..6ed44379e63 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -5,6 +5,7 @@ import * as path from "path"; import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; import { DaytonaApiError } from "./daytonaClient"; import { + DAYTONA_BUSY_MESSAGE, DAYTONA_LABEL_INSTANCE, DAYTONA_LABEL_PROJECT, DaytonaService, @@ -258,19 +259,23 @@ describe("DaytonaService", () => { return { service: heldService, started, releaseMint, written, includeFile }; } - it("revokes a token minted across a settings change instead of writing or caching it", async () => { + it("refuses a settings change while a token is minted, and takes it afterwards", async () => { const held = serviceWithHeldMint(); const access = held.service.ensureSshAccess("sb-1"); await held.started; - await held.service.setConfig({ target: "eu" }); + const refusal = await held.service.setConfig({ target: "eu" }).then( + () => undefined, + (error: unknown) => error + ); + expect(String(refusal)).toContain(DAYTONA_BUSY_MESSAGE); held.releaseMint(); await access; - // tok1 was minted under the old settings: revoked, never written or kept. + expect(held.written).toEqual(["tok1"]); + expect((await held.service.setConfig({ target: "eu" })).target).toBe("eu"); + // The change revokes the old settings' token. expect(plane.revoked).toEqual(["tok1"]); - expect(held.written).not.toContain("tok1"); - expect((await held.service.getSshToken("sb-1")).token).not.toBe("tok1"); }); /** A service whose ssh setup writes are recorded as `token@host keys`. */ @@ -296,13 +301,13 @@ describe("DaytonaService", () => { return { service: recording, writes }; } - it("pairs a token minted after a settings change with the new settings' gateway", async () => { + it("refuses a settings change while it looks up the gateway for a token", async () => { let gatewayFetched!: () => void; const fetched = new Promise((resolve) => (gatewayFetched = resolve)); let releaseGateway!: () => void; const released = new Promise((resolve) => (releaseGateway = resolve)); let held = false; - // The first /config answer is computed under the old settings, then delivered late. + // The first /config answer is delivered late, while the user saves new settings. const fetchHoldingFirstConfig = (async (input: string | URL | Request, init?: RequestInit) => { const response = await plane.fetch(input, init); const url = input instanceof Request ? input.url : input.toString(); @@ -317,12 +322,15 @@ describe("DaytonaService", () => { const access = racing.ensureSshAccess("sb-1"); await fetched; - await racing.setConfig({ target: "eu" }); - plane.gatewayHost = "ssh.eu.test"; + const refusal = await racing.setConfig({ target: "eu" }).then( + () => undefined, + (error: unknown) => error + ); + expect(String(refusal)).toContain(DAYTONA_BUSY_MESSAGE); releaseGateway(); await access; - expect(writes).toEqual(["tok1@ssh.eu.test ssh-ed25519 AAAA"]); + expect(writes).toEqual(["tok1@ssh.test ssh-ed25519 AAAA"]); }); it("rewrites a sandbox's ssh setup once Daytona moves its gateway keys", async () => { diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 47675920cf5..cb31cb9a8a6 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -8,10 +8,11 @@ import type { DaytonaTestConnectionResult, } from "@/common/orpc/schemas/daytona"; import { getErrorMessage } from "@/common/utils/errors"; -import type { - DaytonaCredentialsPatch, - DaytonaCredentialsStore, - DaytonaResolvedCredentials, +import { + DaytonaConfigError, + type DaytonaCredentialsPatch, + type DaytonaCredentialsStore, + type DaytonaResolvedCredentials, } from "@/node/config/daytonaCredentialsStore"; import { loadOrCreateInstallationId } from "@/node/config/installationIdentity"; import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; @@ -114,6 +115,15 @@ export interface DaytonaAccount { generation: number; } +export const DAYTONA_BUSY_MESSAGE = + "Daytona is busy creating, starting, deleting or checking sandboxes. Save these settings " + + "again when it is done."; + +/** Wait until `promise` settles; its outcome belongs to whoever started it. */ +async function settled(promise: Promise): Promise { + await Promise.allSettled([promise]); +} + interface MintStamp { generation: number; release: number; @@ -257,6 +267,10 @@ export class DaytonaService { private readonly tokenRequests = new Map>(); /** Bumped by every settings change, so a mint that spans one can tell. */ private credentialsGeneration = 0; + /** Operations running under withAccountLease; setConfig refuses while any runs. */ + private accountLeases = 0; + /** The settings change being saved; new leases wait for it. */ + private settingsChange?: Promise; /** Per sandbox: bumped by every release of its SSH access, so a mint that spans one can tell. */ private readonly accessReleases = new Map(); private readonly sshConfigWriter: NonNullable; @@ -308,7 +322,37 @@ export class DaytonaService { return this.credentialsStore.getPublicConfig(); } + /** + * Run a Daytona operation that makes more than one control-plane call, or acts on what an + * earlier one returned (create and set up, delete after its checks, start and mint, archive, + * a reconcile sweep), against one account. setConfig refuses while any runs, so none spans a + * settings change; one that starts while a change is saved waits for it. Leases nest. + */ + async withAccountLease(fn: () => Promise): Promise { + while (this.settingsChange) await settled(this.settingsChange); + this.accountLeases++; + try { + return await fn(); + } finally { + this.accountLeases--; + } + } + + /** Refused at once (DaytonaConfigError, shown in Settings) while a lease is held. */ async setConfig(patch: DaytonaCredentialsPatch): Promise { + while (this.settingsChange) await settled(this.settingsChange); + // No await between this check and the assignment: a lease cannot start in between. + if (this.accountLeases > 0) throw new DaytonaConfigError(DAYTONA_BUSY_MESSAGE); + const change = this.applyConfig(patch); + this.settingsChange = change; + try { + return await change; + } finally { + if (this.settingsChange === change) this.settingsChange = undefined; + } + } + + private async applyConfig(patch: DaytonaCredentialsPatch): Promise { const previousClient = await this.getClientIfConfigured(); await this.credentialsStore.update(patch); // A mint in flight finishes under the old settings: it sees the bump and starts over. @@ -722,7 +766,7 @@ export class DaytonaService { onStarting: new Set(options.onStarting ? [options.onStarting] : []), starting: false, }; - request.promise = (async () => { + request.promise = this.withAccountLease(async () => { await this.startAndWait(sandboxId, this.now() + DAYTONA_START_TIMEOUT_MS, { onStarting: () => { request.starting = true; @@ -730,7 +774,7 @@ export class DaytonaService { }, }); await this.ensureSshAccess(sandboxId); - })().finally(() => { + }).finally(() => { this.readyRequests.delete(sandboxId); }); this.readyRequests.set(sandboxId, request); @@ -742,27 +786,29 @@ export class DaytonaService { * writes known_hosts and the `~/.ssh/config` block and checks that ssh resolves the token as * the user; a refresh rewrites only the include file. */ - async ensureSshAccess(sandboxId: string): Promise { - // Fails before a mint when the control plane cannot say where the gateway is. - await this.getGatewayConfig(); - // A cached token was written by the mint that produced it, so only a mint writes files. - const access = await this.getSshToken(sandboxId, { - onNewToken: async ({ token }) => { - // Looked up after the mint, so a settings change before it cannot pair this token with - // the old deployment's gateway; one after it makes the mint start over. - const gateway = toSshGateway(await this.getGatewayConfig()); - if (this.sshConfigured.has(sandboxId)) { - await this.sshConfigWriter.updateToken({ sandboxId, token, gateway }); - return; - } - await this.sshConfigWriter.writeSandbox({ sandboxId, token, gateway }); - }, + ensureSshAccess(sandboxId: string): Promise { + return this.withAccountLease(async () => { + // Fails before a mint when the control plane cannot say where the gateway is. + await this.getGatewayConfig(); + // A cached token was written by the mint that produced it, so only a mint writes files. + const access = await this.getSshToken(sandboxId, { + onNewToken: async ({ token }) => { + // Looked up after the mint, so a settings change before it cannot pair this token with + // the old deployment's gateway; one after it makes the mint start over. + const gateway = toSshGateway(await this.getGatewayConfig()); + if (this.sshConfigured.has(sandboxId)) { + await this.sshConfigWriter.updateToken({ sandboxId, token, gateway }); + return; + } + await this.sshConfigWriter.writeSandbox({ sandboxId, token, gateway }); + }, + }); + if (this.sshConfigured.has(sandboxId)) return; + // Outside the mint: when the user's ssh config shadows the token, a retry after they fix it + // reuses this token (already written) instead of minting another one. + await this.sshConfigWriter.verifyResolvedConfig(sandboxId, access.token); + this.sshConfigured.add(sandboxId); }); - if (this.sshConfigured.has(sandboxId)) return; - // Outside the mint: when the user's ssh config shadows the token, a retry after they fix it - // reuses this token (already written) instead of minting another one. - await this.sshConfigWriter.verifyResolvedConfig(sandboxId, access.token); - this.sshConfigured.add(sandboxId); } /** @@ -819,7 +865,9 @@ export class DaytonaService { const inFlight = this.tokenRequests.get(sandboxId); if (inFlight) return inFlight; - const request = this.mintSshToken(sandboxId, cached, options).finally(() => { + const request = this.withAccountLease(() => + this.mintSshToken(sandboxId, cached, options) + ).finally(() => { this.tokenRequests.delete(sandboxId); }); this.tokenRequests.set(sandboxId, request); From 8cadb56bba58413cae3c9799c1cfadc654b1c4a2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:53:34 +0000 Subject: [PATCH 053/231] fix(daytona): retire a sandbox's pooled ssh connections when its gateway changes A settings change or a gateway identity change rewrote the include file and revoked the token, but a live OpenSSH ControlMaster or a cached ssh2 client for the unchanged sandbox alias kept carrying new commands to the old deployment or account, indefinitely under steady activity. Both now retire the pooled connections of every sandbox this process set up ssh for: `ssh -O stop` on each exec ControlMaster socket (the master removes its socket and refuses new sessions; running ones finish), and the ssh2 pool drops its client for the host, closing it with its last channel. The next command connects with the new configuration. --- src/node/runtime/SSH2ConnectionPool.test.ts | 26 +++++++++++++ src/node/runtime/SSH2ConnectionPool.ts | 35 +++++++++++++++-- .../transports/OpenSSHTransport.test.ts | 37 +++++++++++++++++- .../runtime/transports/OpenSSHTransport.ts | 36 ++++++++++++++++++ src/node/runtime/transports/index.ts | 9 ++++- .../services/daytona/daytonaService.test.ts | 22 ++++++++++- src/node/services/daytona/daytonaService.ts | 38 ++++++++++++++++++- 7 files changed, 193 insertions(+), 10 deletions(-) diff --git a/src/node/runtime/SSH2ConnectionPool.test.ts b/src/node/runtime/SSH2ConnectionPool.test.ts index c7524b030e6..789916411e1 100644 --- a/src/node/runtime/SSH2ConnectionPool.test.ts +++ b/src/node/runtime/SSH2ConnectionPool.test.ts @@ -232,6 +232,32 @@ describe.skipIf(process.platform === "win32")( } }); + it("a retired connection carries no new work and closes once its channels do", async () => { + const { config } = await startServer((conn) => { + conn.on("authentication", (ctx) => ctx.accept()); + conn.on("error", () => undefined); + }); + const pool = new SSH2ConnectionPool(); + const first = await pool.acquireConnection(config); + const slot = pool.reserveChannel(config, first); + let closed = false; + const firstClosed = new Promise((resolve) => + first.client.once("close", () => { + closed = true; + resolve(); + }) + ); + + pool.retireConnection(config); + const second = await pool.acquireConnection(config, { maxWaitMs: 0 }); + expect(second === first).toBe(false); + // A command still running on it keeps it open. + expect(closed).toBe(false); + slot.release(); + await firstClosed; + second.client.end(); + }); + it("a retired client's late error does not put the host into backoff", async () => { const { config } = await startServer((conn) => { conn.on("authentication", (ctx) => ctx.accept()); diff --git a/src/node/runtime/SSH2ConnectionPool.ts b/src/node/runtime/SSH2ConnectionPool.ts index 3d2a32c889e..c89ce4fc22e 100644 --- a/src/node/runtime/SSH2ConnectionPool.ts +++ b/src/node/runtime/SSH2ConnectionPool.ts @@ -111,6 +111,8 @@ interface SSH2ConnectionEntry { idleTimer?: ReturnType; /** Exec/shell channels still open on this connection (see trackChannel). */ openChannels: number; + /** Out of the pool (retireConnection): it closes when its last channel does. */ + retired?: boolean; } /** @@ -561,6 +563,8 @@ export class SSH2ConnectionPool { // dead client only held the event loop open until it fired as a no-op (#5507). if (entry.openChannels === 0 && this.connections.get(key) === entry) { this.touchConnection(entry, key); + } else if (entry.openChannels === 0 && entry.retired) { + this.endClient(entry); } }; return { @@ -592,18 +596,41 @@ export class SSH2ConnectionPool { } this.connections.delete(key); + this.endClient(entry); + } + + /** + * The host's credentials or gateway changed (Daytona settings, a moved gateway): later + * acquires connect anew instead of reusing this client. Commands still running on it finish; + * it closes with its last channel. + */ + retireConnection(config: SSHConnectionConfig): void { + const key = makeConnectionKey(config); + this.health.delete(key); + const entry = this.connections.get(key); + if (!entry) return; + this.connections.delete(key); + if (entry.idleTimer) clearTimeout(entry.idleTimer); + entry.retired = true; + if (entry.openChannels === 0) this.endClient(entry); + } + private endClient(entry: SSH2ConnectionEntry): void { try { entry.client.end(); - } catch { - // Ignore errors closing the connection + } catch (error) { + log.debug("Ignoring an error while closing an ssh2 connection", { + error: getErrorMessage(error), + }); } if (entry.proxyProcess?.exitCode === null) { try { entry.proxyProcess.kill(); - } catch { - // Ignore errors killing proxy + } catch (error) { + log.debug("Ignoring an error while killing an ssh2 ProxyCommand", { + error: getErrorMessage(error), + }); } } } diff --git a/src/node/runtime/transports/OpenSSHTransport.test.ts b/src/node/runtime/transports/OpenSSHTransport.test.ts index 54741b3b470..00bef4b3648 100644 --- a/src/node/runtime/transports/OpenSSHTransport.test.ts +++ b/src/node/runtime/transports/OpenSSHTransport.test.ts @@ -1,10 +1,13 @@ import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; import type { IPty } from "@lydell/node-pty"; import { toXumDaytonaHost } from "@/common/constants/daytona"; import * as ptySpawn from "../ptySpawn"; import { RuntimeError, isRuntimeTransportError } from "../Runtime"; import { sshConnectionPool } from "../sshConnectionPool"; -import { OpenSSHTransport } from "./OpenSSHTransport"; +import { OpenSSHTransport, retireOpenSSHMasters } from "./OpenSSHTransport"; const BACKOFF_MESSAGE = "SSH connection to example.test is in backoff for 2s. Last error: Connection refused"; @@ -71,3 +74,35 @@ describe("OpenSSHTransport terminals on a Daytona alias", () => { expect(strict).toBeLessThan(args.indexOf(host)); }); }); + +describe("retireOpenSSHMasters", () => { + it("stops every exec ControlMaster of the host that has a socket", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-fake-ssh-")); + const calls = path.join(dir, "calls"); + // A fake ssh on PATH records its arguments, one call per line. + await fs.writeFile(path.join(dir, "ssh"), `#!/bin/sh\necho "$@" >> ${calls}\n`, { + mode: 0o755, + }); + const originalPath = process.env.PATH; + process.env.PATH = `${dir}:${originalPath ?? ""}`; + const config = { host: toXumDaytonaHost("sb-retire") }; + const base = sshConnectionPool.getControlPath(config); + // Two masters are up: the default path and one exec shard. + await fs.writeFile(base, ""); + await fs.writeFile(`${base}-2`, ""); + try { + await retireOpenSSHMasters(config); + + const lines = (await fs.readFile(calls, "utf-8")).trim().split("\n").sort(); + expect(lines).toEqual([ + `-F /dev/null -S ${base} -O stop ${config.host}`, + `-F /dev/null -S ${base}-2 -O stop ${config.host}`, + ]); + } finally { + process.env.PATH = originalPath; + await fs.rm(base, { force: true }); + await fs.rm(`${base}-2`, { force: true }); + await fs.rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/node/runtime/transports/OpenSSHTransport.ts b/src/node/runtime/transports/OpenSSHTransport.ts index 0851a52a823..5995830905c 100644 --- a/src/node/runtime/transports/OpenSSHTransport.ts +++ b/src/node/runtime/transports/OpenSSHTransport.ts @@ -1,5 +1,6 @@ import { spawn } from "child_process"; import { randomBytes } from "crypto"; +import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; @@ -18,6 +19,7 @@ import { RuntimeError } from "../Runtime"; import { assert } from "@/common/utils/assert"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; +import { isErrnoWithCode } from "@/node/utils/fs"; import { ReverseForwardRefusedError, type SSHTransport, @@ -52,6 +54,40 @@ function getShardedControlPath(config: SSHConnectionConfig): string { return `${baseControlPath}-${nextShard}`; } +/** + * The host's credentials or gateway changed (Daytona settings, a moved gateway): stop its exec + * ControlMasters, so the next command connects anew instead of multiplexing onto the old + * connection. `-O stop` makes a master remove its socket and refuse new sessions while running + * ones finish. Reverse forwards keep their own connections. + */ +export async function retireOpenSSHMasters(config: SSHConnectionConfig): Promise { + sshConnectionPool.forgetHealth(config); + const base = sshConnectionPool.getControlPath(config); + const shards = Array.from({ length: OPENSSH_EXEC_SHARD_COUNT }, (_, shard) => `${base}-${shard}`); + await Promise.all( + [base, ...shards].map(async (controlPath) => { + if (!(await pathExists(controlPath))) return; + const result = await runSshClient( + ["-F", "/dev/null", "-S", controlPath, "-O", "stop", config.host], + config.host + ); + if (result.code !== 0) { + log.debug("[ssh] could not stop a ControlMaster", { controlPath, stderr: result.stderr }); + } + }) + ); +} + +async function pathExists(filePath: string): Promise { + try { + await fs.lstat(filePath); + return true; + } catch (error) { + if (isErrnoWithCode(error, "ENOENT")) return false; + throw error; + } +} + export class OpenSSHTransport implements SSHTransport { constructor(private readonly config: SSHConnectionConfig) {} diff --git a/src/node/runtime/transports/index.ts b/src/node/runtime/transports/index.ts index d983f4b0806..2026beada18 100644 --- a/src/node/runtime/transports/index.ts +++ b/src/node/runtime/transports/index.ts @@ -1,5 +1,6 @@ +import { ssh2ConnectionPool } from "../SSH2ConnectionPool"; import type { SSHConnectionConfig } from "../sshConnectionPool"; -import { OpenSSHTransport } from "./OpenSSHTransport"; +import { OpenSSHTransport, retireOpenSSHMasters } from "./OpenSSHTransport"; import { SSH2Transport } from "./SSH2Transport"; import type { SSHTransport } from "./SSHTransport"; @@ -10,3 +11,9 @@ export { ReverseForwardRefusedError } from "./SSHTransport"; export function createSSHTransport(config: SSHConnectionConfig, useSSH2: boolean): SSHTransport { return useSSH2 ? new SSH2Transport(config) : new OpenSSHTransport(config); } + +/** Retire the host's pooled connections on both transports (credentials or gateway changed). */ +export async function retireSshConnections(config: SSHConnectionConfig): Promise { + ssh2ConnectionPool.retireConnection(config); + await retireOpenSSHMasters(config); +} diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 6ed44379e63..9cc2b0f0957 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -281,6 +281,8 @@ describe("DaytonaService", () => { /** A service whose ssh setup writes are recorded as `token@host keys`. */ function serviceRecordingWrites(fetchImpl: typeof fetch = plane.fetch) { const writes: string[] = []; + /** Sandboxes whose pooled ssh connections were retired. */ + const retired: string[] = []; const record = (input: { token: string; gateway: { host: string; hostKeys: string[] } }) => { writes.push(`${input.token}@${input.gateway.host} ${input.gateway.hostKeys.join(",")}`); return Promise.resolve(); @@ -297,8 +299,12 @@ describe("DaytonaService", () => { readTokens: () => Promise.resolve([]), verifyResolvedConfig: () => Promise.resolve(), }, + retireSshConnections: (sandboxId) => { + retired.push(sandboxId); + return Promise.resolve(); + }, }); - return { service: recording, writes }; + return { service: recording, writes, retired }; } it("refuses a settings change while it looks up the gateway for a token", async () => { @@ -334,7 +340,7 @@ describe("DaytonaService", () => { }); it("rewrites a sandbox's ssh setup once Daytona moves its gateway keys", async () => { - const { service: watching, writes } = serviceRecordingWrites(); + const { service: watching, writes, retired } = serviceRecordingWrites(); const configFetches = () => plane.calls.filter((call) => call === "GET /config").length; await watching.ensureSshAccess("sb-1"); @@ -351,6 +357,8 @@ describe("DaytonaService", () => { "tok2@ssh.test ssh-rsa CCCC,ssh-ed25519 BBBB", ]); expect(plane.revoked).toEqual(["tok1"]); + // A pooled connection to the old gateway must not carry later commands. + expect(retired).toEqual(["sb-1"]); // A refetch that finds the same gateway, keys reordered, rewrites nothing. plane.gatewayHostKeys = ["ssh-ed25519 BBBB", "ssh-rsa CCCC"]; @@ -358,6 +366,16 @@ describe("DaytonaService", () => { await watching.ensureSshAccess("sb-1"); expect(configFetches()).toBe(3); expect(writes).toHaveLength(2); + expect(retired).toEqual(["sb-1"]); + }); + + it("retires the pooled ssh connections of its sandboxes on a settings change", async () => { + const { service: changing, retired } = serviceRecordingWrites(); + await changing.ensureSshAccess("sb-1"); + + await changing.setConfig({ target: "eu" }); + + expect(retired).toEqual(["sb-1"]); }); it("revokes a token minted across a release, keeping the access released", async () => { diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index cb31cb9a8a6..1d3778faba8 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -7,6 +7,7 @@ import type { DaytonaSandboxSummary, DaytonaTestConnectionResult, } from "@/common/orpc/schemas/daytona"; +import { toXumDaytonaHost } from "@/common/constants/daytona"; import { getErrorMessage } from "@/common/utils/errors"; import { DaytonaConfigError, @@ -16,6 +17,7 @@ import { } from "@/node/config/daytonaCredentialsStore"; import { loadOrCreateInstallationId } from "@/node/config/installationIdentity"; import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; +import { retireSshConnections } from "@/node/runtime/transports"; import { redactDaytonaTokens, rememberDaytonaToken } from "@/node/runtime/daytonaTokenRedaction"; import { getDaytonaSandboxId } from "@/common/types/runtime"; import { log } from "@/node/services/log"; @@ -180,6 +182,8 @@ export interface DaytonaServiceOptions { * with the last workspace that references it. Without it no sandbox counts as unreferenced. */ listWorkspaces?: () => DaytonaWorkspaceRef[]; + /** Defaults to retiring the pooled OpenSSH and ssh2 connections of the sandbox's alias. */ + retireSshConnections?: (sandboxId: string) => Promise; } export interface DaytonaStartAndWaitOptions { @@ -274,6 +278,7 @@ export class DaytonaService { /** Per sandbox: bumped by every release of its SSH access, so a mint that spans one can tell. */ private readonly accessReleases = new Map(); private readonly sshConfigWriter: NonNullable; + private readonly retireSshConnections: NonNullable; /** Sandboxes whose include block and `~/.ssh/config` block this process has written. */ private readonly sshConfigured = new Set(); private readonly readyAt = new Map(); @@ -301,6 +306,9 @@ export class DaytonaService { this.now = options.now ?? Date.now; this.sleep = options.sleep ?? sleepWithAbort; this.listWorkspaces = options.listWorkspaces; + this.retireSshConnections = + options.retireSshConnections ?? + ((sandboxId) => retireSshConnections({ host: toXumDaytonaHost(sandboxId) })); } /** @@ -360,10 +368,12 @@ export class DaytonaService { // Tokens and gateway data belong to the old account or deployment. Revoke the tokens with // the old credentials, which may not work for the new ones. const tokens = [...this.tokens]; + const connected = this.sandboxesWithSsh(); this.gatewayCache = undefined; this.tokens.clear(); this.sshConfigured.clear(); this.readyAt.clear(); + await this.retirePooledSsh(connected); if (previousClient) { await Promise.all( tokens.map(([sandboxId, access]) => @@ -503,7 +513,7 @@ export class DaytonaService { // Fetched across a settings change: never cache it for lookups under the new settings. if (this.credentialsGeneration !== generation) return config; if (cached && gatewayIdentity(cached.config) !== gatewayIdentity(config)) { - this.retireSshSetup(); + await this.retireSshSetup(); } this.gatewayCache = { apiUrl: credentials.apiUrl, generation, fetchedAt: this.now(), config }; return config; @@ -520,7 +530,8 @@ export class DaytonaService { * known_hosts pins are stale. Each sandbox's next ssh access mints a token, which rewrites * its whole ssh setup and revokes the old token. */ - private retireSshSetup(): void { + private async retireSshSetup(): Promise { + await this.retirePooledSsh(this.sandboxesWithSsh()); this.sshConfigured.clear(); this.readyAt.clear(); for (const [sandboxId, access] of this.tokens) { @@ -528,6 +539,29 @@ export class DaytonaService { } } + private sandboxesWithSsh(): string[] { + return [...new Set([...this.tokens.keys(), ...this.sshConfigured])]; + } + + /** + * Pooled ssh connections (OpenSSH ControlMasters, ssh2 clients) outlive the include file + * they were opened with: without this, later commands keep reaching the old deployment. + */ + private async retirePooledSsh(sandboxIds: string[]): Promise { + await Promise.all( + sandboxIds.map(async (sandboxId) => { + try { + await this.retireSshConnections(sandboxId); + } catch (error) { + log.warn("Could not retire the pooled ssh connections of a Daytona sandbox", { + sandboxId, + error: getErrorMessage(error), + }); + } + }) + ); + } + async testConnection(): Promise { try { const { client } = await this.getClient(); From 02b7a409d1dfd7f60d80900ee0460924d9727e08 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:58:05 +0000 Subject: [PATCH 054/231] fix(daytona): refuse a plain-http API URL unless its host is loopback Every authenticated Daytona call sends the API key as a bearer token, and the API URL check accepted any http:// URL: with a non-loopback http BYOC endpoint, anyone on the network path could read the key. The URL check now rejects http unless the host is loopback (localhost, 127.0.0.0/8, ::1), so the Settings form shows the error. A stored URL that fails the check is ignored, as any invalid stored URL is. An http DAYTONA_API_URL makes Daytona unusable with a clear availability and Test connection error instead of falling back to another deployment; the key is never sent. isLoopbackHost moves from the CLI to common/utils so the server and the credentials store share it. --- docs/runtime/daytona.mdx | 2 +- src/cli/serverSecurity.ts | 29 ++------------- src/common/utils/loopbackHost.ts | 27 ++++++++++++++ .../config/daytonaCredentialsStore.test.ts | 35 +++++++++++++++++++ src/node/config/daytonaCredentialsStore.ts | 30 +++++++++++++++- .../builtInSkillContent.generated.ts | 2 +- .../services/daytona/daytonaService.test.ts | 15 ++++++++ src/node/services/daytona/daytonaService.ts | 24 +++++++++++-- 8 files changed, 131 insertions(+), 33 deletions(-) create mode 100644 src/common/utils/loopbackHost.ts diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index 361ea929a99..143fdff6953 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -14,7 +14,7 @@ Xum can run workspaces in [Daytona](https://www.daytona.io/docs) sandboxes. Each ## Set up access 1. Open **Settings > Runtimes** and find the **Daytona** row. -2. Paste your API key and click **Save**. Change **API URL** only for a self-hosted (BYOC) Daytona install. **Target** picks a region; leave it empty for Daytona's default. +2. Paste your API key and click **Save**. Change **API URL** only for a self-hosted (BYOC) Daytona install. It must use `https://`, so that no one on the network can read the API key; `http://` works only for a loopback host (`localhost`, `127.0.0.1` or `::1`). **Target** picks a region; leave it empty for Daytona's default. 3. Click **Test connection**. Xum stores the key in `~/.xum/daytona.json` (under `XUM_ROOT` when it is set) with file mode `0600`. The settings page never shows a saved key again: to change it, enter a new one, or click **Remove key**. diff --git a/src/cli/serverSecurity.ts b/src/cli/serverSecurity.ts index 1ae008bc562..9058dafbd54 100644 --- a/src/cli/serverSecurity.ts +++ b/src/cli/serverSecurity.ts @@ -1,32 +1,7 @@ import { randomBytes } from "crypto"; +import { isLoopbackHost } from "@/common/utils/loopbackHost"; -/** - * Check if a host string is a literal loopback address. - * - * Only matches literal IPv4/IPv6 loopback addresses and "localhost". - * Hostnames that happen to start with "127." (e.g., "127.example.com") are - * NOT treated as loopback — this matters for auth-token generation policy. - */ -export function isLoopbackHost(host: string): boolean { - const normalized = host.trim().toLowerCase(); - - // Strip IPv6 brackets if present (e.g., "[::1]" → "::1"). - const bare = - normalized.startsWith("[") && normalized.endsWith("]") ? normalized.slice(1, -1) : normalized; - - if (bare === "localhost" || bare === "::1") return true; - - // Match 127.x.y.z only for literal IPv4 addresses (4 numeric octets, 0-255). - // This prevents hostnames like "127.example.com" from being misclassified. - if (bare.startsWith("127.")) { - const parts = bare.split("."); - if (parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)) { - return true; - } - } - - return false; -} +export { isLoopbackHost } from "@/common/utils/loopbackHost"; export interface ResolvedAuthToken { /** The token to use (undefined = no auth required). */ diff --git a/src/common/utils/loopbackHost.ts b/src/common/utils/loopbackHost.ts new file mode 100644 index 00000000000..3c2c9939dd7 --- /dev/null +++ b/src/common/utils/loopbackHost.ts @@ -0,0 +1,27 @@ +/** + * Check if a host string is a literal loopback address. + * + * Only matches literal IPv4/IPv6 loopback addresses and "localhost". + * Hostnames that happen to start with "127." (e.g., "127.example.com") are + * NOT treated as loopback — this matters for auth-token generation policy. + */ +export function isLoopbackHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + + // Strip IPv6 brackets if present (e.g., "[::1]" → "::1"). + const bare = + normalized.startsWith("[") && normalized.endsWith("]") ? normalized.slice(1, -1) : normalized; + + if (bare === "localhost" || bare === "::1") return true; + + // Match 127.x.y.z only for literal IPv4 addresses (4 numeric octets, 0-255). + // This prevents hostnames like "127.example.com" from being misclassified. + if (bare.startsWith("127.")) { + const parts = bare.split("."); + if (parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)) { + return true; + } + } + + return false; +} diff --git a/src/node/config/daytonaCredentialsStore.test.ts b/src/node/config/daytonaCredentialsStore.test.ts index 58f91144224..962c54b5cf0 100644 --- a/src/node/config/daytonaCredentialsStore.test.ts +++ b/src/node/config/daytonaCredentialsStore.test.ts @@ -87,6 +87,41 @@ describe("DaytonaCredentialsStore", () => { ); }); + it("accepts plain http only for a loopback host, where no network sees the key", async () => { + const store = new DaytonaCredentialsStore(rootDir, {}); + for (const apiUrl of ["http://byoc.example.com/api", "http://10.0.0.5/api"]) { + // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime + await expect(store.update({ apiUrl })).rejects.toThrow(/https/); + } + for (const apiUrl of [ + "http://127.0.0.1:3000/api", + "http://localhost/api", + "http://[::1]:8080/api", + ]) { + await store.update({ apiUrl }); + expect((await store.read()).apiUrl).toBe(apiUrl); + } + }); + + it("ignores a stored non-loopback http URL, and refuses one from DAYTONA_API_URL", async () => { + await fs.writeFile( + path.join(rootDir, "daytona.json"), + JSON.stringify({ apiKey: KEY, apiUrl: "http://byoc.example.com/api" }), + "utf-8" + ); + expect((await new DaytonaCredentialsStore(rootDir, {}).resolve())?.apiUrl).toBe( + DAYTONA_DEFAULT_API_URL + ); + + // The environment names the deployment, so another one must not get the key instead. + const fromEnv = new DaytonaCredentialsStore(rootDir, { + DAYTONA_API_URL: "http://byoc.example.com/api", + }); + // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime + await expect(fromEnv.resolve()).rejects.toThrow(/DAYTONA_API_URL/); + expect((await fromEnv.getPublicConfig()).apiUrl).toBe("http://byoc.example.com/api"); + }); + it("rejects an API URL that is not plain http(s)", async () => { const store = new DaytonaCredentialsStore(rootDir, {}); for (const apiUrl of [ diff --git a/src/node/config/daytonaCredentialsStore.ts b/src/node/config/daytonaCredentialsStore.ts index 19f9d998c74..363b5e89fa3 100644 --- a/src/node/config/daytonaCredentialsStore.ts +++ b/src/node/config/daytonaCredentialsStore.ts @@ -5,6 +5,7 @@ import { DAYTONA_DEFAULT_API_URL } from "@/common/constants/daytona"; import type { DaytonaApiKeySource, DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; import { getErrorMessage } from "@/common/utils/errors"; import { isPlainObject } from "@/common/utils/isPlainObject"; +import { isLoopbackHost } from "@/common/utils/loopbackHost"; import { log } from "@/node/services/log"; import { ensurePrivateDir, isErrnoWithCode } from "@/node/utils/fs"; import writeFileAtomic from "@/node/utils/writeFileAtomic"; @@ -68,6 +69,13 @@ function parseApiUrl(value: string): URL { if (!/^https?:\/\/[^/?#\\]/i.test(value)) { throw new DaytonaConfigError(`Daytona API URL has no host: ${value}`); } + // Every authenticated call sends the API key as a bearer token: over plain http anyone on + // the network path could read it. Only a loopback host keeps it on this machine. + if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) { + throw new DaytonaConfigError( + "Daytona API URL must use https (http is accepted only for localhost and 127.0.0.1)" + ); + } return parsed; } @@ -183,6 +191,14 @@ export class DaytonaCredentialsStore { const envKey = nonEmpty(this.env.DAYTONA_API_KEY); const apiKey = stored.apiKey ?? envKey; if (!apiKey) return null; + // The environment names the deployment to use: falling back to the default one would send + // the key to a deployment the operator did not pick, so it stays unusable until fixed. + if (this.insecureEnvApiUrl(stored)) { + throw new DaytonaConfigError( + "DAYTONA_API_URL must use https (http is accepted only for localhost and 127.0.0.1); " + + "fix it, or set the API URL in Settings > Runtimes" + ); + } return { apiKey, apiKeySource: stored.apiKey ? "settings" : "env", @@ -203,12 +219,24 @@ export class DaytonaCredentialsStore { }; } - /** A stored or env URL that does not validate is ignored (self-healing), as if unset. */ + /** + * A stored or env URL that does not validate is ignored (self-healing), as if unset, except + * an env URL that only fails for plain http: that one is shown, and resolve refuses it. + */ private resolveApiUrl(stored: DaytonaStoredCredentials): string { return ( validApiUrl(stored.apiUrl, "the stored Daytona API URL") ?? + this.insecureEnvApiUrl(stored) ?? validApiUrl(nonEmpty(this.env.DAYTONA_API_URL), "DAYTONA_API_URL") ?? DAYTONA_DEFAULT_API_URL ); } + + /** DAYTONA_API_URL when it would be used and is http to a host that is not loopback. */ + private insecureEnvApiUrl(stored: DaytonaStoredCredentials): string | undefined { + const envUrl = nonEmpty(this.env.DAYTONA_API_URL); + if (!envUrl || validApiUrl(stored.apiUrl, "the stored Daytona API URL")) return undefined; + const parsed = URL.canParse(envUrl) ? new URL(envUrl) : undefined; + return parsed?.protocol === "http:" && !isLoopbackHost(parsed.hostname) ? envUrl : undefined; + } } diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 16b98bba52a..fe58d5a1ef3 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10016,7 +10016,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "## Set up access", "", "1. Open **Settings > Runtimes** and find the **Daytona** row.", - "2. Paste your API key and click **Save**. Change **API URL** only for a self-hosted (BYOC) Daytona install. **Target** picks a region; leave it empty for Daytona's default.", + "2. Paste your API key and click **Save**. Change **API URL** only for a self-hosted (BYOC) Daytona install. It must use `https://`, so that no one on the network can read the API key; `http://` works only for a loopback host (`localhost`, `127.0.0.1` or `::1`). **Target** picks a region; leave it empty for Daytona's default.", "3. Click **Test connection**.", "", "Xum stores the key in `~/.xum/daytona.json` (under `XUM_ROOT` when it is set) with file mode `0600`. The settings page never shows a saved key again: to change it, enter a new one, or click **Remove key**.", diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 9cc2b0f0957..d613ab3a031 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -446,6 +446,21 @@ describe("DaytonaService", () => { expect(await unconfigured.checkAvailability()).toMatchObject({ available: false }); }); + it("never sends the key to a non-loopback http DAYTONA_API_URL", async () => { + const insecure = makeService({ DAYTONA_API_KEY: "k", DAYTONA_API_URL: "http://byoc.test/api" }); + + expect(await insecure.testConnection()).toMatchObject({ + ok: false, + kind: "not_configured", + error: expect.stringContaining("DAYTONA_API_URL") as unknown, + }); + expect(await insecure.checkAvailability()).toMatchObject({ + available: false, + reason: expect.stringContaining("https") as unknown, + }); + expect(plane.calls).toEqual([]); + }); + it("labels sandboxes with an instance id that is stable across service instances", async () => { const labels = await service.buildLabels({ workspaceId: "ws-1", diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 1d3778faba8..52e0926de79 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -457,7 +457,13 @@ export class DaytonaService { } async checkAvailability(): Promise { - const credentials = await this.credentialsStore.resolve(); + let credentials: DaytonaResolvedCredentials | null; + try { + credentials = await this.resolveCredentials(); + } catch (error) { + if (!(error instanceof DaytonaApiError)) throw error; + return { available: false, reason: error.message }; + } if (!credentials) { return { available: false, @@ -467,9 +473,21 @@ export class DaytonaService { return { available: true }; } + /** Unusable settings (an http DAYTONA_API_URL) throw a `not_configured` DaytonaApiError. */ + private async resolveCredentials(): Promise { + try { + return await this.credentialsStore.resolve(); + } catch (error) { + if (error instanceof DaytonaConfigError) { + throw new DaytonaApiError("not_configured", error.message); + } + throw error; + } + } + /** Throws a `not_configured` DaytonaApiError when no API key is set. */ async getClient(): Promise<{ client: DaytonaClient; credentials: DaytonaResolvedCredentials }> { - const credentials = await this.credentialsStore.resolve(); + const credentials = await this.resolveCredentials(); if (!credentials) { throw new DaytonaApiError( "not_configured", @@ -682,7 +700,7 @@ export class DaytonaService { /** The default target (region) from Settings > Runtimes, if one is set. */ async getDefaultTarget(): Promise { - return (await this.credentialsStore.resolve())?.target; + return (await this.resolveCredentials())?.target; } async createSandbox( From 6f5a5ada13cef14c63798f6f5cd19c285b4259da Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:02:47 +0000 Subject: [PATCH 055/231] fix(daytona): build the seed script without spreading every base64 line lines.push(...lines) passed each base64 line of a seeded file as an argument. Node refuses about 125k arguments, so a skill file of about 10 MiB made workspace creation fail with "Maximum call stack size exceeded". Bun allows more, so the tests did not see it. The lines are now pushed in a loop. --- src/node/runtime/daytonaAgentHomeSeed.test.ts | 9 +++++++++ src/node/runtime/daytonaAgentHomeSeed.ts | 5 +++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/node/runtime/daytonaAgentHomeSeed.test.ts b/src/node/runtime/daytonaAgentHomeSeed.test.ts index f86ef6f951a..e96ad436395 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.test.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.test.ts @@ -99,6 +99,15 @@ describe("agent-home seeding", () => { } }); + it("builds the script for a file of more base64 lines than a call takes arguments", () => { + // Node refuses ~125k spread arguments (a 10 MiB file); Bun only ~640k, so 40 MiB here. + const content = Buffer.alloc(40 * 1024 * 1024); + const script = buildAgentHomeSeedScript([ + { remotePath: "~/.mux/skills/big/data.bin", content, executable: false }, + ]); + expect(script.length).toBeGreaterThan(content.length); + }); + it("follows symlinks only inside the skill being copied, except a linked skill itself", async () => { const secrets = path.join(hostHome, ".ssh"); await fs.mkdir(secrets, { recursive: true }); diff --git a/src/node/runtime/daytonaAgentHomeSeed.ts b/src/node/runtime/daytonaAgentHomeSeed.ts index d84fd032428..c5f22c13175 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.ts @@ -248,8 +248,9 @@ export function buildAgentHomeSeedScript(files: readonly AgentHomeSeedFile[]): s const target = expandTildeForSSH(file.remotePath); lines.push(`mkdir -p ${expandTildeForSSH(path.posix.dirname(file.remotePath))}`); lines.push(`base64 -d > ${target} <<'${HEREDOC_MARKER}'`); - // Line-wrapped base64 never contains the marker. - lines.push(...(file.content.toString("base64").match(/.{1,76}/g) ?? [])); + // Line-wrapped base64 never contains the marker. A loop, not a spread: a spread passes + // every line as an argument, and Node throws a RangeError past ~125k (a 10 MiB file). + for (const line of file.content.toString("base64").match(/.{1,76}/g) ?? []) lines.push(line); lines.push(HEREDOC_MARKER); lines.push(`chmod ${file.executable ? "755" : "644"} ${target}`); } From 4ba7387d9f6cf7ed9900a0a5849523b918b242f9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:05:06 +0000 Subject: [PATCH 056/231] fix(daytona): delete a sandbox Xum created with its last workspace, attached or not Workspace O creates a sandbox and stores its name; workspace T attaches it by id. Removing O keeps the sandbox for T. Removing T then only released it, since T had attached it: the sandbox stayed, labeled, and the reconciler only ever stops it. Xum deletes a sandbox when the user deletes its last workspace. So when the last referencing workspace attached the sandbox, the sandbox is deleted if it carries this install's label (Xum created it), and only released otherwise. Either removal order sends one DELETE. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 42 ++++++++++++++++++++- src/node/runtime/DaytonaSSHRuntime.ts | 6 ++- src/node/services/daytona/daytonaService.ts | 18 +++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 341a20786ed..11b4c454879 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -10,7 +10,10 @@ import { localShellTransport, type DaytonaTestRig, } from "@/node/services/daytona/daytona.testHarness"; -import { DAYTONA_READY_CACHE_MS } from "@/node/services/daytona/daytonaService"; +import { + DAYTONA_LABEL_INSTANCE, + DAYTONA_READY_CACHE_MS, +} from "@/node/services/daytona/daytonaService"; import * as runtimeHelpers from "@/node/utils/runtime/helpers"; import { DaytonaSSHRuntime } from "./DaytonaSSHRuntime"; import type { InitLogger, RuntimeStatusEvent, WorkspaceInitParams } from "./Runtime"; @@ -703,6 +706,43 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { expect(result).toMatchObject({ success: false, leftoverPaths: ['Daytona sandbox "sb-stuck"'] }); }); + it("deletes a sandbox Xum created with its last workspace, even one that attached it", async () => { + const labels = { [DAYTONA_LABEL_INSTANCE]: await rig.service.getInstanceId() }; + const owner = (sandboxId: string) => ({ sandboxId, shared: false, ...SMALL }); + const attached = (sandboxId: string) => ({ sandboxId, shared: true }); + const remove = (workspaceId: string, daytona: DaytonaSandboxConfig) => + makeRuntime(daytona, rig, { workspaceId }).deleteWorkspace( + "/home/me/project", + workspaceId, + false + ); + const deletes = () => + rig.plane.calls.filter((call) => /^DELETE \/sandbox\/[^/]+$/.test(call)).length; + // Each sandbox: the owner stores its name, another workspace attached it by its id. + rig.plane.addSandbox({ id: "sb-1", name: "xum-owner-first", labels }); + rig.plane.addSandbox({ id: "sb-2", name: "xum-attached-first", labels }); + rig.workspaces.push( + { workspaceId: "ws-o1", runtimeConfig: sshDaytonaConfig(owner("xum-owner-first")) }, + { workspaceId: "ws-t1", runtimeConfig: sshDaytonaConfig(attached("sb-1")) }, + { workspaceId: "ws-o2", runtimeConfig: sshDaytonaConfig(owner("xum-attached-first")) }, + { workspaceId: "ws-t2", runtimeConfig: sshDaytonaConfig(attached("sb-2")) } + ); + + // The owner goes first: the attached workspace's removal deletes it. + expect((await remove("ws-o1", owner("xum-owner-first"))).success).toBe(true); + expect(rig.plane.find("sb-1")).toBeDefined(); + expect((await remove("ws-t1", attached("sb-1"))).success).toBe(true); + expect(rig.plane.find("sb-1")).toBeUndefined(); + expect(deletes()).toBe(1); + + // The attached workspace goes first: the owner's removal deletes it. + expect((await remove("ws-t2", attached("sb-2"))).success).toBe(true); + expect(rig.plane.find("sb-2")).toBeDefined(); + expect((await remove("ws-o2", owner("xum-attached-first"))).success).toBe(true); + expect(rig.plane.find("sb-2")).toBeUndefined(); + expect(deletes()).toBe(2); + }); + it("never deletes an attached sandbox, even with its last workspace", async () => { rig.plane.addSandbox({ id: "sb-users" }); rig.workspaces.push({ diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 076618d9b03..96296b5947e 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -83,6 +83,7 @@ export type DaytonaRuntimeService = Pick< | "invalidateSandboxReady" | "replaceRejectedSshToken" | "isSandboxReferencedElsewhere" + | "isCreatedByThisInstall" | "withSandboxRemovalLock" | "waitUntilDestroyed" | "recordWorkspaceRemoval" @@ -1008,7 +1009,10 @@ export class DaytonaSSHRuntime extends SSHRuntime { if (await this.daytonaService.isSandboxReferencedElsewhere(sandboxId, this.workspaceId)) { return "keep"; } - return this.daytonaConfig.shared === true ? "release" : "delete"; + if (this.daytonaConfig.shared !== true) return "delete"; + // Attached to a sandbox Xum created for another workspace, which was removed first: this + // is its last workspace, so it goes as it would with its owner. + return (await this.daytonaService.isCreatedByThisInstall(sandboxId)) ? "delete" : "release"; } /** diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 52e0926de79..80722b25abd 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -414,6 +414,24 @@ export class DaytonaService { return aliases === undefined || others.some((stored) => aliases.has(stored)); } + /** + * Whether the sandbox carries this install's label, so Xum created it. False when it is gone + * or the lookup fails: then it is only released, never deleted. + */ + async isCreatedByThisInstall(sandboxId: string): Promise { + try { + const { sandbox } = await this.getSandboxState(sandboxId); + const instance = sandbox?.labels?.[DAYTONA_LABEL_INSTANCE]; + return instance !== undefined && instance === (await this.getInstanceId()); + } catch (error) { + log.warn("Could not look up a Daytona sandbox's labels; keeping it", { + sandboxId, + error: getErrorMessage(error), + }); + return false; + } + } + /** What the other workspaces (removals in flight excluded) store as their sandbox. */ private sandboxIdsStoredElsewhere(refs: DaytonaWorkspaceRef[], workspaceId: string): string[] { return refs.flatMap((ref) => { From 5bba7306538ad29cc556f46f6e7cffe0e3303826 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:08:18 +0000 Subject: [PATCH 057/231] fix(daytona): never start a stopped sandbox for a forced removal, and drop the removed workspace's ssh access A forced removal skips the delete checks, so it no longer starts a stopped (billed) sandbox to run them. Any removal that keeps a sandbox now releases the workspace's ssh access (token, include block) unless another workspace stores the same sandbox string, since that access is keyed by the stored name or id. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 53 ++++++++++++++++++++- src/node/runtime/DaytonaSSHRuntime.ts | 18 ++++++- src/node/services/daytona/daytonaService.ts | 10 ++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 11b4c454879..3dbfab4d78a 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -458,7 +458,7 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { expect(result.success).toBe(true); expect(rig.plane.find("sb-7")).toBeDefined(); } - expect(rig.plane.calls.filter((call) => call.startsWith("DELETE /sandbox/"))).toEqual([]); + expect(rig.plane.calls.filter((call) => /^DELETE \/sandbox\/[^/]+$/.test(call))).toEqual([]); }); it("refuses a settings change between its checks and the sandbox delete", async () => { @@ -743,6 +743,57 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { expect(deletes()).toBe(2); }); + it("never starts a stopped sandbox for the checks a forced removal skips", async () => { + rig.plane.addSandbox({ id: "sb-users", state: "stopped" }); + rig.workspaces.push({ + workspaceId: "ws-1", + runtimeConfig: sshDaytonaConfig({ sandboxId: "sb-users", shared: true }), + }); + + const result = await makeRuntime({ sandboxId: "sb-users", shared: true }).deleteWorkspace( + "/home/me/project", + "feature", + true + ); + + expect(result.success).toBe(true); + expect(rig.plane.find("sb-users")?.state).toBe("stopped"); + expect(rig.plane.calls.filter((call) => call.startsWith("POST /sandbox/sb-users/"))).toEqual( + [] + ); + expect(rig.writer.removed).toContain("sb-users"); + }); + + it("drops the removed workspace's ssh access to a sandbox another workspace keeps", async () => { + const labels = { [DAYTONA_LABEL_INSTANCE]: await rig.service.getInstanceId() }; + rig.plane.addSandbox({ id: "sb-7", name: "xum-owner-abc123", labels }); + const owner = { sandboxId: "xum-owner-abc123", shared: false, ...SMALL }; + const attached = { sandboxId: "sb-7", shared: true }; + rig.workspaces.push( + { workspaceId: "ws-owner", runtimeConfig: sshDaytonaConfig(owner) }, + { workspaceId: "ws-fork", runtimeConfig: sshDaytonaConfig({ ...owner, forked: true }) }, + { workspaceId: "ws-attached", runtimeConfig: sshDaytonaConfig(attached) } + ); + // Tokens and include blocks are per stored string: the name and the id. + await rig.service.ensureSshAccess("xum-owner-abc123"); + await rig.service.ensureSshAccess("sb-7"); + const remove = (workspaceId: string, daytona: DaytonaSandboxConfig) => + makeRuntime(daytona, rig, { workspaceId }).deleteWorkspace( + "/home/me/project", + workspaceId, + false + ); + + // The fork still uses the name: its access stays. + expect((await remove("ws-owner", owner)).success).toBe(true); + expect(rig.writer.removed).toEqual([]); + // The fork was the last one to use the name. + expect((await remove("ws-fork", { ...owner, forked: true })).success).toBe(true); + expect(rig.writer.removed).toEqual(["xum-owner-abc123"]); + expect(rig.writer.tokens.has("sb-7")).toBe(true); + expect(rig.plane.find("sb-7")).toBeDefined(); + }); + it("never deletes an attached sandbox, even with its last workspace", async () => { rig.plane.addSandbox({ id: "sb-users" }); rig.workspaces.push({ diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 96296b5947e..83e3a1a04cb 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -84,6 +84,7 @@ export type DaytonaRuntimeService = Pick< | "replaceRejectedSshToken" | "isSandboxReferencedElsewhere" | "isCreatedByThisInstall" + | "isAliasStoredElsewhere" | "withSandboxRemovalLock" | "waitUntilDestroyed" | "recordWorkspaceRemoval" @@ -308,7 +309,10 @@ export class DaytonaSSHRuntime extends SSHRuntime { /** Keep cleanup hooks from starting a stopped (billed) sandbox through SSH. */ async isRunningWithoutStart(_abortSignal: AbortSignal): Promise { const sandboxId = this.daytonaConfig.sandboxId; - if (!sandboxId) return false; + return sandboxId ? this.isSandboxStarted(sandboxId) : false; + } + + private async isSandboxStarted(sandboxId: string): Promise { try { const { state } = await this.daytonaService.getSandboxState(sandboxId); return state === "started"; @@ -970,6 +974,11 @@ export class DaytonaSSHRuntime extends SSHRuntime { // yet, so delete it without SSH. A kept sandbox only loses this workspace's checkout. const forced = force ? await this.forceDeleteLastReference(sandboxId, deletedPath) : undefined; if (forced) return forced; + // A forced removal skips the checks, so it never starts a stopped (billed) sandbox for + // them: its checkout stays on the sandbox, which this removal keeps or releases. + if (force && !(await this.isSandboxStarted(sandboxId))) { + return this.settleSandbox(sandboxId, { success: true, deletedPath }, undefined); + } const prepared = await this.startForDeleteChecks(sandboxId, abortSignal); if (prepared === "missing") { @@ -1114,7 +1123,12 @@ export class DaytonaSSHRuntime extends SSHRuntime { if (fate === "delete") { return this.deleteOwnedSandbox(sandboxId, result.deletedPath, errorPrefix); } - if (fate === "release") { + // Access is per stored string (a sandbox's name or its id): a kept sandbox keeps it only + // while another workspace stores the same one. + const releases = + fate === "release" || + !this.daytonaService.isAliasStoredElsewhere(sandboxId, this.workspaceId); + if (releases) { try { await this.daytonaService.releaseSshAccess(sandboxId); } catch (error) { diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 80722b25abd..d5420cc0c98 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -432,6 +432,16 @@ export class DaytonaService { } } + /** + * Whether a workspace other than `workspaceId` stores exactly this string as its sandbox: + * Xum's ssh access (token, include block) is per stored string. True (keep the access) when + * the workspace list or the caller's id is unavailable. + */ + isAliasStoredElsewhere(alias: string, workspaceId: string | undefined): boolean { + if (!this.listWorkspaces || !workspaceId) return true; + return this.sandboxIdsStoredElsewhere(this.listWorkspaces(), workspaceId).includes(alias); + } + /** What the other workspaces (removals in flight excluded) store as their sandbox. */ private sandboxIdsStoredElsewhere(refs: DaytonaWorkspaceRef[], workspaceId: string): string[] { return refs.flatMap((ref) => { From 33d48fa6a6a8879c80049e616197869933257e8d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:09:46 +0000 Subject: [PATCH 058/231] refactor(daytona): keep deleteWorkspaceAndSandbox and isLoopbackHost under the new-function CC limit The forced removal's SSH-free outcomes (last-reference delete, stopped sandbox) move to forcedRemovalWithoutSsh, and the literal IPv4 loopback match moves to isIpv4Loopback. --- src/common/utils/loopbackHost.ts | 22 +++++++++++----------- src/node/runtime/DaytonaSSHRuntime.ts | 26 ++++++++++++++++++-------- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/common/utils/loopbackHost.ts b/src/common/utils/loopbackHost.ts index 3c2c9939dd7..5c8bbb15f1d 100644 --- a/src/common/utils/loopbackHost.ts +++ b/src/common/utils/loopbackHost.ts @@ -12,16 +12,16 @@ export function isLoopbackHost(host: string): boolean { const bare = normalized.startsWith("[") && normalized.endsWith("]") ? normalized.slice(1, -1) : normalized; - if (bare === "localhost" || bare === "::1") return true; - - // Match 127.x.y.z only for literal IPv4 addresses (4 numeric octets, 0-255). - // This prevents hostnames like "127.example.com" from being misclassified. - if (bare.startsWith("127.")) { - const parts = bare.split("."); - if (parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)) { - return true; - } - } + return bare === "localhost" || bare === "::1" || isIpv4Loopback(bare); +} - return false; +// Match 127.x.y.z only for literal IPv4 addresses (4 numeric octets, 0-255). +// This prevents hostnames like "127.example.com" from being misclassified. +function isIpv4Loopback(host: string): boolean { + const parts = host.split("."); + return ( + parts[0] === "127" && + parts.length === 4 && + parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255) + ); } diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 83e3a1a04cb..1be313c3c42 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -970,15 +970,8 @@ export class DaytonaSSHRuntime extends SSHRuntime { return sshDelete(); } - // Force ("cancel creation" or an explicit force delete): the sandbox may not be reachable - // yet, so delete it without SSH. A kept sandbox only loses this workspace's checkout. - const forced = force ? await this.forceDeleteLastReference(sandboxId, deletedPath) : undefined; + const forced = force ? await this.forcedRemovalWithoutSsh(sandboxId, deletedPath) : undefined; if (forced) return forced; - // A forced removal skips the checks, so it never starts a stopped (billed) sandbox for - // them: its checkout stays on the sandbox, which this removal keeps or releases. - if (force && !(await this.isSandboxStarted(sandboxId))) { - return this.settleSandbox(sandboxId, { success: true, deletedPath }, undefined); - } const prepared = await this.startForDeleteChecks(sandboxId, abortSignal); if (prepared === "missing") { @@ -996,6 +989,23 @@ export class DaytonaSSHRuntime extends SSHRuntime { return this.settleSandbox(sandboxId, sshResult, "SSH delete succeeded, but "); } + /** + * Force ("cancel creation" or an explicit force delete): the sandbox may not be reachable + * yet, so delete it without SSH. A kept sandbox only loses this workspace's checkout. + * Undefined when the forced removal still needs SSH (a started sandbox that stays). + */ + private async forcedRemovalWithoutSsh( + sandboxId: string, + deletedPath: string + ): Promise { + const deleted = await this.forceDeleteLastReference(sandboxId, deletedPath); + if (deleted) return deleted; + // A forced removal skips the checks, so it never starts a stopped (billed) sandbox for + // them: its checkout stays on the sandbox, which this removal keeps or releases. + if (await this.isSandboxStarted(sandboxId)) return undefined; + return this.settleSandbox(sandboxId, { success: true, deletedPath }, undefined); + } + /** The force path's sandbox delete, when this is the last workspace on a sandbox Xum created. */ private forceDeleteLastReference( sandboxId: string, From c800ffadf9cdbea82d760a9a0fb76d45dd60a91e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:40:08 +0000 Subject: [PATCH 059/231] fix(daytona): never delete an attached sandbox, even one Xum created; say so when its owner goes Item E deleted a sandbox Xum created when its last workspace had attached it. That broke the existing-sandbox contract (Xum never deletes an attached sandbox), and the delete checks only cover that workspace's checkout, so other files on the sandbox could be lost. An attached workspace's removal now always only releases the sandbox. When removing the owner keeps the sandbox only for workspaces that attached it, the removal returns a notice: the runtime result's new notice field becomes a removal warning, which the archived-workspaces delete and the command palette now show on a plain removal too. The service's isCreatedByThisInstall is gone; sandboxReferencesElsewhere tells attached-only references apart. The removal warning mapping moves to runtimeRemovalWarnings, and the UI sites share showWorkspaceRemoveWarnings. --- docs/runtime/daytona.mdx | 2 +- src/browser/App.tsx | 5 +- .../ArchivedWorkspaces.test.tsx | 22 ++++++ .../ArchivedWorkspaces/ArchivedWorkspaces.tsx | 18 +++-- src/browser/utils/workspace.ts | 10 +++ src/common/orpc/schemas/workspace.ts | 5 +- src/node/runtime/DaytonaSSHRuntime.test.ts | 27 +++++--- src/node/runtime/DaytonaSSHRuntime.ts | 37 ++++++---- src/node/runtime/Runtime.ts | 7 +- .../builtInSkillContent.generated.ts | 2 +- src/node/services/daytona/daytonaService.ts | 67 ++++++++++++------- .../workspaceService.removePlanFiles.test.ts | 29 ++++++++ src/node/services/workspaceService.ts | 57 ++++++++++------ 13 files changed, 203 insertions(+), 85 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index 143fdff6953..80391134675 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -63,7 +63,7 @@ Select **Existing** and pick a sandbox from your organization. Xum treats an att | Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs | | Sandbox deleted outside Xum | The workspace reports that the sandbox no longer exists; Xum does not recreate it | -A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses. When you delete the last workspaces on a sandbox at the same time, Xum deletes the sandbox once, after the last of them passes its check for uncommitted work. +A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses. When you delete the last workspaces on a sandbox at the same time, Xum deletes the sandbox once, after the last of them passes its check for uncommitted work. If you attach another workspace to a sandbox Xum created and delete the workspaces that Xum created it for first, the sandbox becomes yours like any attached one: Xum keeps it, says so when it removes the last of those workspaces, and never deletes it later. If the sandbox is still being destroyed (for example after a failed setup), delete waits up to a minute for Daytona to finish. A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard. diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 3cd0515cf2a..dff14e92732 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -111,6 +111,7 @@ import { SettingsProvider, useSettings } from "./contexts/SettingsContext"; import { AboutDialogProvider, useAboutDialog } from "./contexts/AboutDialogContext"; import { ConfirmDialogProvider, useConfirmDialog } from "./contexts/ConfirmDialogContext"; import { confirmAndRemoveSubagent } from "@/browser/utils/subagentRemoval"; +import { showWorkspaceRemoveWarnings } from "@/browser/utils/workspace"; import { AboutDialog } from "./features/About/AboutDialog"; import { LazySettingsPage } from "@/browser/features/Settings/LazySettingsPage"; import { LazyAnalyticsDashboard } from "@/browser/features/Analytics/LazyAnalyticsDashboard"; @@ -956,10 +957,12 @@ function AppInner() { const result = await removeWorkspace(workspaceId); if (!result.success) { paletteRemoveError.showError(workspaceId, result.error ?? "Failed to remove workspace"); + } else { + showWorkspaceRemoveWarnings(paletteRemoveWarning, workspaceId, result.warnings); } return result; }, - [removeWorkspace, paletteRemoveError] + [removeWorkspace, paletteRemoveError, paletteRemoveWarning] ); const removeSubagentFromPalette = async (workspaceId: string, title: string) => { const notice = await confirmAndRemoveSubagent({ diff --git a/src/browser/components/ArchivedWorkspaces/ArchivedWorkspaces.test.tsx b/src/browser/components/ArchivedWorkspaces/ArchivedWorkspaces.test.tsx index 54ea8588e68..86fa906b247 100644 --- a/src/browser/components/ArchivedWorkspaces/ArchivedWorkspaces.test.tsx +++ b/src/browser/components/ArchivedWorkspaces/ArchivedWorkspaces.test.tsx @@ -664,6 +664,28 @@ describe("ArchivedWorkspaces", () => { expect(status.textContent).toContain(workspace.name); }); + // #7100: a plain removal can keep something on purpose (a Daytona sandbox another workspace + // attached), which the user then owns. + test("a plain delete shows what the removal kept", async () => { + const workspace = createWorkspace({ id: "parent", name: "parent" }); + removeWorkspaceMock.mockResolvedValueOnce(successWithLeftover); + const view = render( + + ); + fireEvent.click(view.getByLabelText("Expand archived workspaces")); + fireEvent.click(await waitFor(() => view.getByLabelText("Delete workspace parent"))); + + const alert = await view.findByRole("alert"); + expect(alert.textContent).toContain(leftover); + expect(removeWorkspaceMock).toHaveBeenCalledTimes(1); + expect(onWorkspacesChangedMock).toHaveBeenCalledTimes(1); + }); + test("Shift-click shows what the forced removal left behind", async () => { const workspace = createWorkspace({ id: "parent", name: "parent" }); removeWorkspaceMock.mockResolvedValueOnce({ diff --git a/src/browser/components/ArchivedWorkspaces/ArchivedWorkspaces.tsx b/src/browser/components/ArchivedWorkspaces/ArchivedWorkspaces.tsx index 1f71144a7a5..7185591aff4 100644 --- a/src/browser/components/ArchivedWorkspaces/ArchivedWorkspaces.tsx +++ b/src/browser/components/ArchivedWorkspaces/ArchivedWorkspaces.tsx @@ -14,7 +14,10 @@ import { } from "@/browser/contexts/WorkspaceContext"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { usePopoverError } from "@/browser/hooks/usePopoverError"; -import { formatWorkspaceRemoveWarnings } from "@/browser/utils/workspace"; +import { + formatWorkspaceRemoveWarnings, + showWorkspaceRemoveWarnings, +} from "@/browser/utils/workspace"; import { ChevronDown, ChevronRight, FolderX, Loader2, Search, Trash2 } from "lucide-react"; import { ArchiveIcon, ArchiveRestoreIcon } from "../icons/ArchiveIcon/ArchiveIcon"; import { Tooltip, TooltipTrigger, TooltipContent } from "../Tooltip/Tooltip"; @@ -736,6 +739,8 @@ export const ArchivedWorkspaces: React.FC = ({ try { const result = await removeWorkspace(workspaceId); if (result.success) { + // What the removal kept for the user to own, e.g. a Daytona sandbox (#7100). + showWorkspaceRemoveWarnings(removeWarning, workspaceId, result.warnings); onWorkspacesChanged?.(); return; } @@ -756,9 +761,7 @@ export const ArchivedWorkspaces: React.FC = ({ : {}), }); if (forced.success) { - if (forced.warnings?.length) { - removeWarning.showError(workspaceId, formatWorkspaceRemoveWarnings(forced.warnings)); - } + showWorkspaceRemoveWarnings(removeWarning, workspaceId, forced.warnings); onWorkspacesChanged?.(); return; } @@ -817,12 +820,7 @@ export const ArchivedWorkspaces: React.FC = ({ }); if (result.success) { // The dialog showed the non-forced error; the forced retry can leave more behind. - if (result.warnings?.length) { - removeWarning.showError( - workspaceId, - formatWorkspaceRemoveWarnings(result.warnings) - ); - } + showWorkspaceRemoveWarnings(removeWarning, workspaceId, result.warnings); onWorkspacesChanged?.(); } return result; diff --git a/src/browser/utils/workspace.ts b/src/browser/utils/workspace.ts index 8dd72142e1d..5c3b8afc8b7 100644 --- a/src/browser/utils/workspace.ts +++ b/src/browser/utils/workspace.ts @@ -1,3 +1,4 @@ +import type { UsePopoverErrorResult } from "@/browser/hooks/usePopoverError"; import type { FrontendWorkspaceMetadata, WorkspaceRemoveWarning } from "@/common/types/workspace"; /** @@ -41,3 +42,12 @@ export function formatWorkspaceRemoveWarnings(warnings: readonly WorkspaceRemove }) .join("\n"); } + +/** Show the warnings a successful removal returned, if any (#5143, #7100). */ +export function showWorkspaceRemoveWarnings( + popover: Pick, + workspaceId: string, + warnings: readonly WorkspaceRemoveWarning[] | undefined +): void { + if (warnings?.length) popover.showError(workspaceId, formatWorkspaceRemoveWarnings(warnings)); +} diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index 989029f86a5..c6c94ba8111 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -519,8 +519,9 @@ export const WorkspaceRemovalDescendantSchema = z.object({ active: z.boolean(), }); /** - * Something a successful (forced) removal could not clean up (#5143). Forced paths that skip the - * Force Delete dialog (bulk delete, cancel-creation, shift-click) show these to the user. + * Something a successful (forced) removal could not clean up (#5143), or kept on purpose for the + * user to own (#7100). Forced paths that skip the Force Delete dialog (bulk delete, + * cancel-creation, shift-click) show these to the user. */ export const WorkspaceRemoveWarningSchema = z.discriminatedUnion("kind", [ z.object({ diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 3dbfab4d78a..5b3f7c73fcd 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -706,7 +706,7 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { expect(result).toMatchObject({ success: false, leftoverPaths: ['Daytona sandbox "sb-stuck"'] }); }); - it("deletes a sandbox Xum created with its last workspace, even one that attached it", async () => { + it("never deletes a sandbox Xum created through a workspace that attached it, and says so", async () => { const labels = { [DAYTONA_LABEL_INSTANCE]: await rig.service.getInstanceId() }; const owner = (sandboxId: string) => ({ sandboxId, shared: false, ...SMALL }); const attached = (sandboxId: string) => ({ sandboxId, shared: true }); @@ -718,6 +718,8 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { ); const deletes = () => rig.plane.calls.filter((call) => /^DELETE \/sandbox\/[^/]+$/.test(call)).length; + const notice = (result: Awaited>) => + result.success ? result.notice : result.error; // Each sandbox: the owner stores its name, another workspace attached it by its id. rig.plane.addSandbox({ id: "sb-1", name: "xum-owner-first", labels }); rig.plane.addSandbox({ id: "sb-2", name: "xum-attached-first", labels }); @@ -728,19 +730,24 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { { workspaceId: "ws-t2", runtimeConfig: sshDaytonaConfig(attached("sb-2")) } ); - // The owner goes first: the attached workspace's removal deletes it. - expect((await remove("ws-o1", owner("xum-owner-first"))).success).toBe(true); + // The owner goes first: its removal says the sandbox now outlives Xum's workspaces, and the + // attached workspace's removal only releases it. + const ownerFirst = await remove("ws-o1", owner("xum-owner-first")); + expect(ownerFirst.success).toBe(true); + expect(notice(ownerFirst)).toContain('"xum-owner-first"'); + const attachedLast = await remove("ws-t1", attached("sb-1")); + expect(attachedLast).toEqual({ success: true, deletedPath: expect.any(String) as string }); expect(rig.plane.find("sb-1")).toBeDefined(); - expect((await remove("ws-t1", attached("sb-1"))).success).toBe(true); - expect(rig.plane.find("sb-1")).toBeUndefined(); - expect(deletes()).toBe(1); + expect(deletes()).toBe(0); - // The attached workspace goes first: the owner's removal deletes it. - expect((await remove("ws-t2", attached("sb-2"))).success).toBe(true); + // The attached workspace goes first: the owner's removal deletes it, with nothing to say. + const attachedFirst = await remove("ws-t2", attached("sb-2")); + expect(attachedFirst).toEqual({ success: true, deletedPath: expect.any(String) as string }); expect(rig.plane.find("sb-2")).toBeDefined(); - expect((await remove("ws-o2", owner("xum-attached-first"))).success).toBe(true); + const ownerLast = await remove("ws-o2", owner("xum-attached-first")); + expect(ownerLast).toEqual({ success: true, deletedPath: expect.any(String) as string }); expect(rig.plane.find("sb-2")).toBeUndefined(); - expect(deletes()).toBe(2); + expect(deletes()).toBe(1); }); it("never starts a stopped sandbox for the checks a forced removal skips", async () => { diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 1be313c3c42..d244fd82290 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -82,8 +82,7 @@ export type DaytonaRuntimeService = Pick< | "markSandboxReady" | "invalidateSandboxReady" | "replaceRejectedSshToken" - | "isSandboxReferencedElsewhere" - | "isCreatedByThisInstall" + | "sandboxReferencesElsewhere" | "isAliasStoredElsewhere" | "withSandboxRemovalLock" | "waitUntilDestroyed" @@ -185,17 +184,18 @@ function raceAbort(promise: Promise, signal: AbortSignal | undefined): Pro } type DeleteResult = - | { success: true; deletedPath: string } + | { success: true; deletedPath: string; notice?: string } | { success: false; error: string; leftoverPaths?: string[] }; type StatusEmitter = (phase: RuntimeStatusEvent["phase"], detail?: string) => void; /** * What deleting a workspace does to its sandbox: "keep" it (another workspace, archived ones - * included, still references it), "release" only Xum's ssh access (the last workspace on an - * attached sandbox), or "delete" it (the last workspace on a sandbox Xum created). + * included, still references it), "handOff" (keep it for workspaces that only attached it, so + * Xum no longer deletes it), "release" only Xum's ssh access (the last workspace on an attached + * sandbox), or "delete" it (the last workspace on a sandbox Xum created). */ -type SandboxFate = "keep" | "release" | "delete"; +type SandboxFate = "keep" | "handOff" | "release" | "delete"; const HOST_KEY_FAILURE = /Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/; @@ -1025,13 +1025,16 @@ export class DaytonaSSHRuntime extends SSHRuntime { } private async sandboxFateOnDelete(sandboxId: string): Promise { - if (await this.daytonaService.isSandboxReferencedElsewhere(sandboxId, this.workspaceId)) { - return "keep"; - } - if (this.daytonaConfig.shared !== true) return "delete"; - // Attached to a sandbox Xum created for another workspace, which was removed first: this - // is its last workspace, so it goes as it would with its owner. - return (await this.daytonaService.isCreatedByThisInstall(sandboxId)) ? "delete" : "release"; + const owned = this.daytonaConfig.shared !== true; + const others = await this.daytonaService.sandboxReferencesElsewhere( + sandboxId, + this.workspaceId + ); + // An attached sandbox is the user's, even one Xum created for another workspace: Xum never + // deletes it, since its checks cover only this workspace's checkout. + if (others === "attached" && owned) return "handOff"; + if (others !== "none") return "keep"; + return owned ? "delete" : "release"; } /** @@ -1149,7 +1152,13 @@ export class DaytonaSSHRuntime extends SSHRuntime { }); } } - return result; + if (fate !== "handOff") return result; + const notice = + `Daytona sandbox "${sandboxId}" was kept for a workspace that attached it. Xum never ` + + "deletes an attached sandbox, so it stays when that workspace is removed too; delete it " + + "in Daytona when you no longer need it."; + log.info(notice, { workspaceId: this.workspaceId }); + return { ...result, notice }; } /** Start the sandbox (if stopped or archived) so the dirty and unpushed checks can run. */ diff --git a/src/node/runtime/Runtime.ts b/src/node/runtime/Runtime.ts index d41fcdbe9b8..1a959873e90 100644 --- a/src/node/runtime/Runtime.ts +++ b/src/node/runtime/Runtime.ts @@ -736,7 +736,12 @@ export interface Runtime { /** keepBranch: remove only the checkout, e.g. a rollback on a branch it did not create (#4775). */ options?: { keepBranch?: boolean } ): Promise< - | { success: true; deletedPath: string } + | { + success: true; + deletedPath: string; + /** For the user: something the removal kept on purpose that they now own (#7100). */ + notice?: string; + } | { success: false; error: string; diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index fe58d5a1ef3..2db7ada2a16 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10065,7 +10065,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "| Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs |", "| Sandbox deleted outside Xum | The workspace reports that the sandbox no longer exists; Xum does not recreate it |", "", - "A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses. When you delete the last workspaces on a sandbox at the same time, Xum deletes the sandbox once, after the last of them passes its check for uncommitted work.", + "A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses. When you delete the last workspaces on a sandbox at the same time, Xum deletes the sandbox once, after the last of them passes its check for uncommitted work. If you attach another workspace to a sandbox Xum created and delete the workspaces that Xum created it for first, the sandbox becomes yours like any attached one: Xum keeps it, says so when it removes the last of those workspaces, and never deletes it later.", "", "If the sandbox is still being destroyed (for example after a failed setup), delete waits up to a minute for Daytona to finish. A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard.", "", diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index d5420cc0c98..e064269b7b9 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -19,7 +19,7 @@ import { loadOrCreateInstallationId } from "@/node/config/installationIdentity"; import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; import { retireSshConnections } from "@/node/runtime/transports"; import { redactDaytonaTokens, rememberDaytonaToken } from "@/node/runtime/daytonaTokenRedaction"; -import { getDaytonaSandboxId } from "@/common/types/runtime"; +import { getDaytonaSandboxId, isDedicatedDaytonaSandbox } from "@/common/types/runtime"; import { log } from "@/node/services/log"; import { sleepWithAbort } from "@/node/utils/abort"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; @@ -35,6 +35,12 @@ import { } from "./daytonaClient"; import type { DaytonaWorkspaceRef } from "./daytonaReconciler"; +/** What another workspace stores as its sandbox, and whether it attached it. */ +interface StoredSandboxRef { + stored: string; + attached: boolean; +} + export const DAYTONA_LABEL_INSTANCE = "xum.instance"; export const DAYTONA_LABEL_WORKSPACE_ID = "xum.workspaceId"; export const DAYTONA_LABEL_PROJECT = "xum.project"; @@ -406,30 +412,36 @@ export class DaytonaService { sandboxId: string, workspaceId: string | undefined ): Promise { - if (!this.listWorkspaces || !workspaceId) return true; - const others = this.sandboxIdsStoredElsewhere(this.listWorkspaces(), workspaceId); - if (others.includes(sandboxId)) return true; - if (others.length === 0) return false; - const aliases = await this.sandboxAliases(sandboxId); - return aliases === undefined || others.some((stored) => aliases.has(stored)); + return (await this.sandboxReferencesElsewhere(sandboxId, workspaceId)) !== "none"; } /** - * Whether the sandbox carries this install's label, so Xum created it. False when it is gone - * or the lookup fails: then it is only released, never deleted. + * Who else references the sandbox, matched as in isSandboxReferencedElsewhere: "none", + * "attached" (only workspaces that attached it, through which Xum never deletes it), or + * "other" (a workspace that may delete it, or a failed lookup). */ - async isCreatedByThisInstall(sandboxId: string): Promise { - try { - const { sandbox } = await this.getSandboxState(sandboxId); - const instance = sandbox?.labels?.[DAYTONA_LABEL_INSTANCE]; - return instance !== undefined && instance === (await this.getInstanceId()); - } catch (error) { - log.warn("Could not look up a Daytona sandbox's labels; keeping it", { - sandboxId, - error: getErrorMessage(error), - }); - return false; - } + async sandboxReferencesElsewhere( + sandboxId: string, + workspaceId: string | undefined + ): Promise<"none" | "attached" | "other"> { + if (!this.listWorkspaces || !workspaceId) return "other"; + const others = this.refsStoredElsewhere(this.listWorkspaces(), workspaceId); + if (others.length === 0) return "none"; + const matched = await this.refsMatchingSandbox(sandboxId, others); + if (!matched) return "other"; + if (matched.length === 0) return "none"; + return matched.every((ref) => ref.attached) ? "attached" : "other"; + } + + /** The refs that store this sandbox by its name or its id; undefined when the lookup fails. */ + private async refsMatchingSandbox( + sandboxId: string, + refs: StoredSandboxRef[] + ): Promise { + const aliases = refs.every((ref) => ref.stored === sandboxId) + ? new Set([sandboxId]) + : await this.sandboxAliases(sandboxId); + return aliases && refs.filter((ref) => ref.stored === sandboxId || aliases.has(ref.stored)); } /** @@ -439,16 +451,23 @@ export class DaytonaService { */ isAliasStoredElsewhere(alias: string, workspaceId: string | undefined): boolean { if (!this.listWorkspaces || !workspaceId) return true; - return this.sandboxIdsStoredElsewhere(this.listWorkspaces(), workspaceId).includes(alias); + return this.refsStoredElsewhere(this.listWorkspaces(), workspaceId).some( + (ref) => ref.stored === alias + ); } /** What the other workspaces (removals in flight excluded) store as their sandbox. */ - private sandboxIdsStoredElsewhere(refs: DaytonaWorkspaceRef[], workspaceId: string): string[] { + private refsStoredElsewhere( + refs: DaytonaWorkspaceRef[], + workspaceId: string + ): StoredSandboxRef[] { return refs.flatMap((ref) => { const stored = getDaytonaSandboxId(ref.runtimeConfig); const counts = ref.workspaceId !== workspaceId && !this.removedWorkspaces.has(ref.workspaceId); - return counts && stored ? [stored] : []; + return counts && stored + ? [{ stored, attached: !isDedicatedDaytonaSandbox(ref.runtimeConfig) }] + : []; }); } diff --git a/src/node/services/workspaceService.removePlanFiles.test.ts b/src/node/services/workspaceService.removePlanFiles.test.ts index 4e3eee01c07..ec18920a8e0 100644 --- a/src/node/services/workspaceService.removePlanFiles.test.ts +++ b/src/node/services/workspaceService.removePlanFiles.test.ts @@ -348,6 +348,35 @@ describe("WorkspaceService removal deletes plan files (#5019)", () => { expect(sharesPlanStorage(coder("alpha"), coder("alpha"))).toBe(true); }); + // #7100: a removal can keep something on purpose that the user then owns (a Daytona sandbox + // kept for a workspace that attached it, which Xum never deletes). The IPC result names it. + test("a removal's notice reaches the IPC result as a warning", async () => { + await withTempMuxRoot(async () => { + await addWorkspaceIn(projectPath, { + id: "ffffffff23", + name: "kept", + path: "~/xum/project/kept", + runtimeConfig: { type: "ssh", host: "box", srcBaseDir: "~/xum" }, + }); + // SSHRuntime's own result type has no notice; DaytonaSSHRuntime's does. + const kept = { + success: true as const, + deletedPath: "~/xum/project/kept", + notice: "sandbox kept", + }; + spyOn(SSHRuntime.prototype, "deleteWorkspace").mockResolvedValue(kept); + spyRemotePlanDeletion(); + const context = { workspaceService: service } as unknown as ORPCContext; + + const result = WorkspaceRemoveResultSchema.parse( + await removeWorkspace(context, { workspaceId: "ffffffff23", options: {} }) + ); + + expect(result.success ? "" : result.error).toBe(""); + expect(result.warnings).toEqual([{ kind: "leftover", description: "sandbox kept" }]); + }); + }); + // #5043 item 2: a devcontainer's plan is inside its container. A removal that confirmed the // container gone has nothing left to delete; otherwise the container survives with the plan, // and a later workspace at this path would reconnect to it, so the plan is deleted in there. diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index cab664af396..70a0f6eebcc 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -637,6 +637,37 @@ export function nameRestartBlockerWorkspaces( * whose registration write does not re-check it (#5181 and the rename race), registers between * the registry read and the delete. Their locked re-checks close it. */ +/** + * What a runtime's removal reports to the user: what it kept on purpose for them to own + * (#7100), or what a forced removal left behind (#5143). A container left behind still holds + * this workspace's plan: the plan step cannot reach it once the worktree is gone. Forced paths + * that skip the Force Delete dialog (bulk delete, cancel-creation, shift-click) never saw the + * non-forced error that names it. + */ +function runtimeRemovalWarnings( + deleteResult: Awaited>, + planContainer: string | undefined, + workspaceId: string +): WorkspaceRemoveWarning[] { + if (deleteResult.success) { + return deleteResult.notice ? [{ kind: "leftover", description: deleteResult.notice }] : []; + } + const leftovers = deleteResult.leftoverPaths ?? []; + if (leftovers.length > 0) { + log.warn("Forced removal left these behind; a container may still hold the plan", { + workspaceId, + leftovers, + }); + } + return leftovers.map((leftover) => ({ + kind: "leftover", + description: + leftover === planContainer + ? `The ${leftover} was left behind and may still hold this workspace's plan file; remove the container.` + : `${leftover} was left behind; remove it manually.`, + })); +} + function findWorkspaceSharingPlanPath( registry: readonly FrontendWorkspaceMetadata[], workspaceId: string, @@ -8617,28 +8648,12 @@ export class WorkspaceService log.error( `Failed to delete workspace from disk, but force=true. Removing from config. Error: ${deleteResult.error}` ); - // A container left behind still holds this workspace's plan (#5143): the plan step - // below cannot reach it once the worktree is gone. Name it for the user's logs, and - // return it as a warning: forced paths that skip the Force Delete dialog (bulk delete, - // cancel-creation, shift-click) never saw the non-forced error that names it. - if (deleteResult.leftoverPaths?.length) { - log.warn("Forced removal left these behind; a container may still hold the plan", { - workspaceId, - leftovers: deleteResult.leftoverPaths, - }); - const planContainer = - runtime instanceof DevcontainerRuntime - ? containerLabel(runtime.getWorkspacePath(projectPath, metadata.name)) - : undefined; - removalWarnings = deleteResult.leftoverPaths.map((leftover) => ({ - kind: "leftover", - description: - leftover === planContainer - ? `The ${leftover} was left behind and may still hold this workspace's plan file; remove the container.` - : `${leftover} was left behind; remove it manually.`, - })); - } } + const planContainer = + runtime instanceof DevcontainerRuntime + ? containerLabel(runtime.getWorkspacePath(projectPath, metadata.name)) + : undefined; + removalWarnings = runtimeRemovalWarnings(deleteResult, planContainer, workspaceId); // Note: Coder workspace deletion is handled by CoderSSHRuntime.deleteWorkspace() } From c038ca08c4862db2dda3495247bb99466e76dcac Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:42:06 +0000 Subject: [PATCH 060/231] fix(ssh): expand an Include's wildcards in every path segment, as OpenSSH does OpenSSH globs an Include's whole pathname, so `Include conf.d/*/hosts.conf` reads every matching file. The SSH2 transport's parser only globbed the file name and read a wildcard directory as a literal path, so it included nothing. It now matches each segment against the directory entries, returns the existing matches sorted like glob(3), and includes nothing when no path matches. --- src/node/runtime/sshConfigParser.test.ts | 62 ++++++++++++++++++++++++ src/node/runtime/sshConfigParser.ts | 55 +++++++++++++-------- 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/src/node/runtime/sshConfigParser.test.ts b/src/node/runtime/sshConfigParser.test.ts index 49cd49f2363..e8d940e3ebe 100644 --- a/src/node/runtime/sshConfigParser.test.ts +++ b/src/node/runtime/sshConfigParser.test.ts @@ -160,6 +160,68 @@ describe("resolveSSHConfig", () => { }); }); + // OpenSSH globs an Include's whole pathname (glob(3), sorted), not only its file name. + test("reads every file an Include's directory wildcard matches, in sorted order", async () => { + await withTempHome(async (homeDir) => { + // Written out of order: the sorted first match sets HostName (the first value wins). + for (const [dir, hostName] of [ + ["b", "10.0.0.2"], + ["a", "10.0.0.1"], + ]) { + await fs.mkdir(path.join(homeDir, ".ssh", "conf.d", dir), { recursive: true }); + await fs.writeFile( + path.join(homeDir, ".ssh", "conf.d", dir, "hosts.conf"), + `Host box\n HostName ${hostName}\nHost only-${dir}\n User user-${dir}\n`, + "utf8" + ); + } + await fs.writeFile( + path.join(homeDir, ".ssh", "config"), + "Include conf.d/*/hosts.conf\n", + "utf8" + ); + + expect((await resolveSSHConfig("box")).hostName).toBe("10.0.0.1"); + expect((await resolveSSHConfig("only-a")).user).toBe("user-a"); + expect((await resolveSSHConfig("only-b")).user).toBe("user-b"); + }); + }); + + test("expands wildcards in several segments of an absolute Include path", async () => { + await withTempHome(async (homeDir) => { + const dir = path.join(homeDir, "teams", "team-1", "nested"); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, "box.conf"), "Host box\n User teamuser\n", "utf8"); + await fs.writeFile( + path.join(homeDir, ".ssh", "config"), + `Include ${path.join(homeDir, "te?ms", "team-*", "nested", "*.conf")}\n`, + "utf8" + ); + + expect((await resolveSSHConfig("box")).user).toBe("teamuser"); + }); + }); + + test("includes nothing for an Include pattern no path matches", async () => { + await withTempHome(async (homeDir) => { + await fs.mkdir(path.join(homeDir, ".ssh", "conf.d", "a"), { recursive: true }); + await fs.writeFile( + path.join(homeDir, ".ssh", "conf.d", "a", "hosts.conf"), + "Host box\n User wrong\n", + "utf8" + ); + await fs.writeFile( + path.join(homeDir, ".ssh", "config"), + ["Include conf.d/*/other.conf missing/*/hosts.conf", "Host box", " User right", ""].join( + "\n" + ), + "utf8" + ); + + expect((await resolveSSHConfig("box")).user).toBe("right"); + }); + }); + test("applies an Include inside a Host block only to the hosts that block matches", async () => { await withTempHome(async (homeDir) => { const included = path.join(homeDir, ".ssh", "gated.conf"); diff --git a/src/node/runtime/sshConfigParser.ts b/src/node/runtime/sshConfigParser.ts index 6d33f8a1bb8..3917d395752 100644 --- a/src/node/runtime/sshConfigParser.ts +++ b/src/node/runtime/sshConfigParser.ts @@ -275,28 +275,45 @@ async function resolveIncludePaths(value: string, homeDir: string): Promise { + // Normalized so a Windows path split on path.sep keeps its "/" separators apart too. + const normalized = path.normalize(pattern); + const { root } = path.parse(normalized); + let matches = [root]; + for (const segment of normalized.slice(root.length).split(path.sep).filter(Boolean)) { + if (!INCLUDE_WILDCARD.test(segment)) { + matches = matches.map((dir) => path.join(dir, segment)); continue; } - // Wildcards are supported in the file name only (OpenSSH globs the whole path). - try { - const entries = await fs.readdir(path.dirname(absolute)); - files.push( - ...entries - .filter((entry) => glob([baseName], entry)) - .sort() - .map((entry) => path.join(path.dirname(absolute), entry)) - ); - } catch (error) { - log.debug("Failed to expand SSH config Include", { - include: absolute, - error: getErrorMessage(error), - }); - } + const next: string[][] = []; + for (const dir of matches) next.push(await matchingEntries(dir, segment)); + matches = next.flat(); + } + return matches.sort(); +} + +async function matchingEntries(dir: string, segment: string): Promise { + try { + const entries = await fs.readdir(dir); + return entries.filter((entry) => glob([segment], entry)).map((entry) => path.join(dir, entry)); + } catch (error) { + log.debug("Failed to expand SSH config Include", { + include: path.join(dir, segment), + error: getErrorMessage(error), + }); + return []; } - return files; } /** From bcb192b15648ef45338a0cfde787e15d1eee4018 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:08:49 +0000 Subject: [PATCH 061/231] fix(daytona): refuse a saved API URL that no longer validates instead of falling back A saved URL that failed validation was ignored, so Xum used DAYTONA_API_URL or Daytona's hosted API with the saved key. Since plain http to a non-loopback host is now refused, a URL saved before that change sent its key to app.daytona.io after an upgrade. A saved URL names the deployment its key belongs to: one that does not validate now makes Daytona unavailable with a clear reason, and Settings shows it as written so the user can fix or clear it. --- docs/runtime/daytona.mdx | 2 +- .../config/daytonaCredentialsStore.test.ts | 42 ++++++++++--------- src/node/config/daytonaCredentialsStore.ts | 32 ++++++++++++-- .../builtInSkillContent.generated.ts | 2 +- 4 files changed, 52 insertions(+), 26 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index 80391134675..dff91866577 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -14,7 +14,7 @@ Xum can run workspaces in [Daytona](https://www.daytona.io/docs) sandboxes. Each ## Set up access 1. Open **Settings > Runtimes** and find the **Daytona** row. -2. Paste your API key and click **Save**. Change **API URL** only for a self-hosted (BYOC) Daytona install. It must use `https://`, so that no one on the network can read the API key; `http://` works only for a loopback host (`localhost`, `127.0.0.1` or `::1`). **Target** picks a region; leave it empty for Daytona's default. +2. Paste your API key and click **Save**. Change **API URL** only for a self-hosted (BYOC) Daytona install. It must use `https://`, so that no one on the network can read the API key; `http://` works only for a loopback host (`localhost`, `127.0.0.1` or `::1`). A saved URL that is no longer accepted keeps Daytona unavailable until you fix or clear it; Xum never sends the key to another deployment instead. **Target** picks a region; leave it empty for Daytona's default. 3. Click **Test connection**. Xum stores the key in `~/.xum/daytona.json` (under `XUM_ROOT` when it is set) with file mode `0600`. The settings page never shows a saved key again: to change it, enter a new one, or click **Remove key**. diff --git a/src/node/config/daytonaCredentialsStore.test.ts b/src/node/config/daytonaCredentialsStore.test.ts index 962c54b5cf0..2954c70f047 100644 --- a/src/node/config/daytonaCredentialsStore.test.ts +++ b/src/node/config/daytonaCredentialsStore.test.ts @@ -71,20 +71,26 @@ describe("DaytonaCredentialsStore", () => { expect((await store.resolve())?.apiKey).toBe(KEY); }); - it("ignores a stored API URL that does not validate, like an invalid DAYTONA_API_URL", async () => { - await fs.writeFile( - path.join(rootDir, "daytona.json"), - JSON.stringify({ apiKey: KEY, apiUrl: "not a url" }), - "utf-8" - ); - const withEnvUrl = new DaytonaCredentialsStore(rootDir, { - DAYTONA_API_URL: "https://env.example.com/api/", - }); - expect((await withEnvUrl.resolve())?.apiUrl).toBe("https://env.example.com/api"); - expect((await withEnvUrl.getPublicConfig()).apiUrl).toBe("https://env.example.com/api"); - expect((await new DaytonaCredentialsStore(rootDir, {}).resolve())?.apiUrl).toBe( - DAYTONA_DEFAULT_API_URL - ); + it("refuses a stored API URL that does not validate instead of sending the key elsewhere", async () => { + for (const apiUrl of ["not a url", "http://byoc.example.com/api"]) { + await fs.writeFile( + path.join(rootDir, "daytona.json"), + JSON.stringify({ apiKey: KEY, apiUrl }), + "utf-8" + ); + for (const env of [{}, { DAYTONA_API_URL: "https://env.example.com/api" }]) { + const store = new DaytonaCredentialsStore(rootDir, env); + // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime + await expect(store.resolve()).rejects.toThrow(/saved in Settings/); + // Shown as written, so the user sees what to fix. + expect((await store.getPublicConfig()).apiUrl).toBe(apiUrl); + } + } + + // Clearing it is the user's choice of the default deployment. + const store = new DaytonaCredentialsStore(rootDir, {}); + await store.update({ apiUrl: "" }); + expect((await store.resolve())?.apiUrl).toBe(DAYTONA_DEFAULT_API_URL); }); it("accepts plain http only for a loopback host, where no network sees the key", async () => { @@ -103,16 +109,12 @@ describe("DaytonaCredentialsStore", () => { } }); - it("ignores a stored non-loopback http URL, and refuses one from DAYTONA_API_URL", async () => { + it("refuses a non-loopback http DAYTONA_API_URL", async () => { await fs.writeFile( path.join(rootDir, "daytona.json"), - JSON.stringify({ apiKey: KEY, apiUrl: "http://byoc.example.com/api" }), + JSON.stringify({ apiKey: KEY }), "utf-8" ); - expect((await new DaytonaCredentialsStore(rootDir, {}).resolve())?.apiUrl).toBe( - DAYTONA_DEFAULT_API_URL - ); - // The environment names the deployment, so another one must not get the key instead. const fromEnv = new DaytonaCredentialsStore(rootDir, { DAYTONA_API_URL: "http://byoc.example.com/api", diff --git a/src/node/config/daytonaCredentialsStore.ts b/src/node/config/daytonaCredentialsStore.ts index 363b5e89fa3..39006e817ce 100644 --- a/src/node/config/daytonaCredentialsStore.ts +++ b/src/node/config/daytonaCredentialsStore.ts @@ -89,6 +89,17 @@ function normalizeApiUrl(value: string): string { return parsed.toString().replace(/\/+$/, ""); } +/** Why a stored API URL cannot be used; undefined when it is unset or valid. */ +function storedApiUrlProblem(value: string | undefined): string | undefined { + if (!value) return undefined; + try { + normalizeApiUrl(value); + return undefined; + } catch (error) { + return getErrorMessage(error); + } +} + function validApiUrl(value: string | undefined, source: string): string | undefined { if (!value) return undefined; try { @@ -191,6 +202,16 @@ export class DaytonaCredentialsStore { const envKey = nonEmpty(this.env.DAYTONA_API_KEY); const apiKey = stored.apiKey ?? envKey; if (!apiKey) return null; + // A saved URL names the deployment the key belongs to (an http one saved before http was + // refused, or a hand edit): falling back to DAYTONA_API_URL or the default would send the + // key elsewhere, so Daytona stays unusable until the user fixes or clears it. + const storedProblem = storedApiUrlProblem(stored.apiUrl); + if (storedProblem) { + throw new DaytonaConfigError( + `The Daytona API URL saved in Settings cannot be used (${storedProblem}); ` + + "fix or clear it in Settings > Runtimes" + ); + } // The environment names the deployment to use: falling back to the default one would send // the key to a deployment the operator did not pick, so it stays unusable until fixed. if (this.insecureEnvApiUrl(stored)) { @@ -220,12 +241,15 @@ export class DaytonaCredentialsStore { } /** - * A stored or env URL that does not validate is ignored (self-healing), as if unset, except - * an env URL that only fails for plain http: that one is shown, and resolve refuses it. + * A saved URL wins, shown as written when it does not validate so the user can fix it (resolve + * refuses it). An env URL that does not validate is ignored (self-healing), as if unset, except + * one that only fails for plain http: that one is shown, and resolve refuses it. */ private resolveApiUrl(stored: DaytonaStoredCredentials): string { + if (stored.apiUrl) { + return storedApiUrlProblem(stored.apiUrl) ? stored.apiUrl : normalizeApiUrl(stored.apiUrl); + } return ( - validApiUrl(stored.apiUrl, "the stored Daytona API URL") ?? this.insecureEnvApiUrl(stored) ?? validApiUrl(nonEmpty(this.env.DAYTONA_API_URL), "DAYTONA_API_URL") ?? DAYTONA_DEFAULT_API_URL @@ -235,7 +259,7 @@ export class DaytonaCredentialsStore { /** DAYTONA_API_URL when it would be used and is http to a host that is not loopback. */ private insecureEnvApiUrl(stored: DaytonaStoredCredentials): string | undefined { const envUrl = nonEmpty(this.env.DAYTONA_API_URL); - if (!envUrl || validApiUrl(stored.apiUrl, "the stored Daytona API URL")) return undefined; + if (!envUrl || stored.apiUrl) return undefined; const parsed = URL.canParse(envUrl) ? new URL(envUrl) : undefined; return parsed?.protocol === "http:" && !isLoopbackHost(parsed.hostname) ? envUrl : undefined; } diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 2db7ada2a16..53a93252de6 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10016,7 +10016,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "## Set up access", "", "1. Open **Settings > Runtimes** and find the **Daytona** row.", - "2. Paste your API key and click **Save**. Change **API URL** only for a self-hosted (BYOC) Daytona install. It must use `https://`, so that no one on the network can read the API key; `http://` works only for a loopback host (`localhost`, `127.0.0.1` or `::1`). **Target** picks a region; leave it empty for Daytona's default.", + "2. Paste your API key and click **Save**. Change **API URL** only for a self-hosted (BYOC) Daytona install. It must use `https://`, so that no one on the network can read the API key; `http://` works only for a loopback host (`localhost`, `127.0.0.1` or `::1`). A saved URL that is no longer accepted keeps Daytona unavailable until you fix or clear it; Xum never sends the key to another deployment instead. **Target** picks a region; leave it empty for Daytona's default.", "3. Click **Test connection**.", "", "Xum stores the key in `~/.xum/daytona.json` (under `XUM_ROOT` when it is set) with file mode `0600`. The settings page never shows a saved key again: to change it, enter a new one, or click **Remove key**.", From 791881378a1a0a01cad9481a3b90774c69ad407e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:54:27 +0000 Subject: [PATCH 062/231] fix(ssh): expand bracket expressions in an Include path, and match it as glob(3) does OpenSSH globs an Include with glob(3), which also expands bracket expressions such as conf.d/[ab].conf; the parser read them as literal paths. Each path segment now compiles to its own glob(3) matcher: *, ? (exactly one character), [abc], [a-z], [!a] and [^a] (an unclosed [ is literal), and backslash escapes. As in glob(3), a wildcard no longer matches a leading dot, and ? no longer matches an empty string, which the ssh-config host-pattern matcher did. --- src/node/runtime/sshConfigParser.test.ts | 57 ++++++++++++++++ src/node/runtime/sshConfigParser.ts | 87 +++++++++++++++++++++--- 2 files changed, 135 insertions(+), 9 deletions(-) diff --git a/src/node/runtime/sshConfigParser.test.ts b/src/node/runtime/sshConfigParser.test.ts index e8d940e3ebe..b38bcfcc93f 100644 --- a/src/node/runtime/sshConfigParser.test.ts +++ b/src/node/runtime/sshConfigParser.test.ts @@ -222,6 +222,63 @@ describe("resolveSSHConfig", () => { }); }); + test("expands glob(3) bracket expressions in an Include path", async () => { + await withTempHome(async (homeDir) => { + const confDir = path.join(homeDir, ".ssh", "conf.d"); + await fs.mkdir(confDir); + // Each file sets User for its own host; the pattern decides which ones are read. + for (const name of ["a", "b", "c", "m", "x", "[odd"]) { + await fs.writeFile( + path.join(confDir, `${name}.conf`), + `Host host-${name}\n User user-${name}\n`, + "utf8" + ); + } + await fs.writeFile( + path.join(homeDir, ".ssh", "config"), + [ + "Include conf.d/[ab].conf conf.d/[k-n].conf conf.d/[!a-m].conf conf.d/[odd.conf", + "Host *", + " User none", + "", + ].join("\n"), + "utf8" + ); + + const user = async (name: string) => (await resolveSSHConfig(`host-${name}`)).user; + expect(await user("a")).toBe("user-a"); + expect(await user("b")).toBe("user-b"); + expect(await user("m")).toBe("user-m"); + expect(await user("x")).toBe("user-x"); + // An unclosed "[" is a literal character. + expect(await user("[odd")).toBe("user-[odd"); + expect(await user("c")).toBe("none"); + }); + }); + + test("matches an Include wildcard as glob(3) does: ? is one character, * skips dot files", async () => { + await withTempHome(async (homeDir) => { + const confDir = path.join(homeDir, ".ssh", "conf.d"); + await fs.mkdir(confDir); + for (const [name, host] of [ + ["1.conf", "one"], + ["12.conf", "two"], + [".hidden.conf", "hidden"], + ]) { + await fs.writeFile(path.join(confDir, name), `Host ${host}\n User user-${host}\n`, "utf8"); + } + await fs.writeFile( + path.join(homeDir, ".ssh", "config"), + ["Include conf.d/1?.conf conf.d/*hidden.conf", "Host *", " User none", ""].join("\n"), + "utf8" + ); + + expect((await resolveSSHConfig("one")).user).toBe("none"); + expect((await resolveSSHConfig("two")).user).toBe("user-two"); + expect((await resolveSSHConfig("hidden")).user).toBe("none"); + }); + }); + test("applies an Include inside a Host block only to the hosts that block matches", async () => { await withTempHome(async (homeDir) => { const included = path.join(homeDir, ".ssh", "gated.conf"); diff --git a/src/node/runtime/sshConfigParser.ts b/src/node/runtime/sshConfigParser.ts index 3917d395752..16f829a5fab 100644 --- a/src/node/runtime/sshConfigParser.ts +++ b/src/node/runtime/sshConfigParser.ts @@ -275,16 +275,15 @@ async function resolveIncludePaths(value: string, homeDir: string): Promise { // Normalized so a Windows path split on path.sep keeps its "/" separators apart too. @@ -292,21 +291,26 @@ async function globPath(pattern: string): Promise { const { root } = path.parse(normalized); let matches = [root]; for (const segment of normalized.slice(root.length).split(path.sep).filter(Boolean)) { - if (!INCLUDE_WILDCARD.test(segment)) { + const matcher = segmentMatcher(segment); + if (!matcher) { matches = matches.map((dir) => path.join(dir, segment)); continue; } const next: string[][] = []; - for (const dir of matches) next.push(await matchingEntries(dir, segment)); + for (const dir of matches) next.push(await matchingEntries(dir, segment, matcher)); matches = next.flat(); } return matches.sort(); } -async function matchingEntries(dir: string, segment: string): Promise { +async function matchingEntries(dir: string, segment: string, matcher: RegExp): Promise { try { const entries = await fs.readdir(dir); - return entries.filter((entry) => glob([segment], entry)).map((entry) => path.join(dir, entry)); + // glob(3) matches a leading "." only with a literal one. + const dotFiles = segment.startsWith("."); + return entries + .filter((entry) => (dotFiles || !entry.startsWith(".")) && matcher.test(entry)) + .map((entry) => path.join(dir, entry)); } catch (error) { log.debug("Failed to expand SSH config Include", { include: path.join(dir, segment), @@ -316,6 +320,71 @@ async function matchingEntries(dir: string, segment: string): Promise } } +const REGEXP_SPECIAL = /[\\^$.*+?()[\]{}|/-]/g; +const escapeRegExp = (text: string) => text.replace(REGEXP_SPECIAL, "\\$&"); + +/** + * One path segment of a glob(3) pattern as a RegExp: `*`, `?` (one character), bracket + * expressions (`[ab]`, `[a-z]`, negated by a leading `!` or `^`; an unclosed `[` is literal) + * and `\` escapes. Undefined when the segment has no wildcard. + */ +function segmentMatcher(segment: string): RegExp | undefined { + let source = ""; + let wildcard = false; + for (let i = 0; i < segment.length; i++) { + const token = segmentToken(segment, i); + source += token.source; + wildcard ||= token.wildcard; + i = token.end; + } + return wildcard ? new RegExp(`^${source}$`, "s") : undefined; +} + +const WILDCARD_SOURCES = new Map([ + ["*", ".*"], + ["?", "."], +]); + +/** The RegExp source of the pattern element at `i`, and the index of its last character. */ +function segmentToken( + segment: string, + i: number +): { source: string; end: number; wildcard: boolean } { + const wildcardSource = WILDCARD_SOURCES.get(segment[i]); + if (wildcardSource !== undefined) return { source: wildcardSource, end: i, wildcard: true }; + const bracket = segment[i] === "[" ? bracketExpression(segment, i) : undefined; + if (bracket) return { ...bracket, wildcard: true }; + const end = segment[i] === "\\" && i + 1 < segment.length ? i + 1 : i; + return { source: escapeRegExp(segment[end]), end, wildcard: false }; +} + +/** The bracket expression opening at `start`, or undefined when it never closes. */ +function bracketExpression( + segment: string, + start: number +): { source: string; end: number } | undefined { + const negated = segment[start + 1] === "!" || segment[start + 1] === "^"; + let i = start + (negated ? 2 : 1); + let items = ""; + // A "]" right after the opening (and its negation) is a member, not the close. + for (let first = true; i < segment.length && (first || segment[i] !== "]"); first = false) { + const member = bracketMember(segment, i); + items += member.source; + i = member.next; + } + if (i >= segment.length) return undefined; + return { source: `[${negated ? "^" : ""}${items}]`, end: i }; +} + +/** One character or range of a bracket expression, and the index after it. */ +function bracketMember(segment: string, i: number): { source: string; next: number } { + const range = segment[i + 1] === "-" && i + 2 < segment.length && segment[i + 2] !== "]"; + if (!range) return { source: escapeRegExp(segment[i]), next: i + 1 }; + const [low, high] = [segment[i], segment[i + 2]]; + // A reversed range matches nothing, as in glob(3); a RegExp would refuse it. + return { source: low <= high ? `${escapeRegExp(low)}-${escapeRegExp(high)}` : "", next: i + 3 }; +} + /** * Whether the section `header` (undefined: before any Host/Match) applies to `host`. OpenSSH * reads an Include inside an inactive block as matching nothing (readconf.c's From 9d0214fd1cc705e7453af602f82657836b3dc463 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:56:07 +0000 Subject: [PATCH 063/231] fix(daytona): probe for a surviving job before a model-driven archive deletes a sandbox A model-driven archive refuses to stop a sandbox under a detached job it cannot rule out, but the delete policy skipped that probe: it deleted the sandbox when the checks passed, or stopped it when they failed, either way ending the job. The delete path now runs the same fail-closed probe first, on a running sandbox, and refuses the archive on a job or an unverifiable probe. --- .../daytona/daytonaLifecycleHooks.test.ts | 40 ++++++++++++++++++- .../services/daytona/daytonaLifecycleHooks.ts | 25 ++++++++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/node/services/daytona/daytonaLifecycleHooks.test.ts b/src/node/services/daytona/daytonaLifecycleHooks.test.ts index 0af16e97492..fa6faaacc8f 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.test.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.test.ts @@ -3,7 +3,7 @@ import { execFileSync } from "child_process"; import * as fs from "fs/promises"; import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; -import { Ok, type Result } from "@/common/types/result"; +import { Err, Ok, type Result } from "@/common/types/result"; import type { WorkspaceMetadata } from "@/common/types/workspace"; import { DaytonaSSHRuntime } from "@/node/runtime/DaytonaSSHRuntime"; import { SSHRuntime } from "@/node/runtime/SSHRuntime"; @@ -147,6 +147,44 @@ describe("createDaytonaArchiveHook", () => { expect(rig.plane.find("sb-parent")?.state).toBe("started"); }); + it("never deletes or stops a sandbox under an unverified job on a model-driven archive delete", async () => { + const modelArchive = ( + sandboxId: string, + check: DaytonaArchiveHookOptions["checkSandboxDeletable"], + probe?: DaytonaArchiveHookOptions["hasUnsettledRemoteBackgroundJobs"] + ) => + createDaytonaArchiveHook({ + daytonaService: rig.service, + getArchiveBehavior: () => "delete", + checkSandboxDeletable: check, + hasUnsettledRemoteBackgroundJobs: probe, + })({ + workspaceId: "ws", + workspaceMetadata: metadata({ sandboxId, shared: false }), + refuseStopUnderUnverifiedRemoteJobs: true, + }); + const dirtyCheck = (): Promise> => + Promise.resolve(Err("Workspace contains uncommitted changes.")); + const jobRuns = () => Promise.resolve(Ok(true)); + for (const id of ["sb-job", "sb-job-dirty", "sb-unprobed", "sb-idle"]) { + rig.plane.addSandbox({ id }); + } + + // A surviving job refuses both outcomes: the delete (clean checks) and the stop (dirty). + expect((await modelArchive("sb-job", cleanCheck, jobRuns)).success).toBe(false); + expect((await modelArchive("sb-job-dirty", dirtyCheck, jobRuns)).success).toBe(false); + // No probe: Xum cannot verify, so it refuses too. + expect((await modelArchive("sb-unprobed", cleanCheck)).success).toBe(false); + for (const id of ["sb-job", "sb-job-dirty", "sb-unprobed"]) { + expect(rig.plane.find(id)?.state).toBe("started"); + } + + // Verified idle: the delete goes ahead. + const idle = () => Promise.resolve(Ok(false)); + expect((await modelArchive("sb-idle", cleanCheck, idle)).success).toBe(true); + expect(rig.plane.find("sb-idle")).toBeUndefined(); + }); + it("keeps a dirty sandbox on archive delete: starts it for the check, then stops it and warns", async () => { rig.plane.addSandbox({ id: "sb-dirty", state: "stopped" }); const sshCheck = spyOn( diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index 7f28f24f66d..b4b74794847 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -116,6 +116,9 @@ async function deleteForArchive( args: Parameters[0], sandboxId: string ): Promise> { + // Both outcomes (delete, or stop when the checks fail) end a surviving job. + const refusal = await refuseUnderRemoteJobs(options, args, sandboxId); + if (refusal) return Err(refusal); const check = options.checkSandboxDeletable ? await options.checkSandboxDeletable(args.workspaceMetadata) : Err("its worktree could not be checked for uncommitted work"); @@ -153,15 +156,29 @@ async function stopForArchive( if (!isRunning(state)) { return Ok(undefined); } - if (args.refuseStopUnderUnverifiedRemoteJobs === true) { - const refusal = await probeRemoteJobs(options, args.workspaceMetadata, sandboxId); - if (refusal) return Err(refusal); - } + const refusal = await refuseUnderRemoteJobs(options, args, sandboxId, state); + if (refusal) return Err(refusal); log.debug("Stopping Daytona sandbox before archive", { sandboxId, state }); await stopSandbox(options, sandboxId); return Ok(undefined); } +/** + * Why a model-driven archive must not stop or delete the sandbox: a detached job may survive + * in it. A sandbox that is not running has no job to end. + */ +async function refuseUnderRemoteJobs( + options: DaytonaArchiveHookOptions, + args: Parameters[0], + sandboxId: string, + knownState?: string +): Promise { + if (args.refuseStopUnderUnverifiedRemoteJobs !== true) return undefined; + const state = knownState ?? (await options.daytonaService.getSandboxState(sandboxId)).state; + if (!isRunning(state)) return undefined; + return probeRemoteJobs(options, args.workspaceMetadata, sandboxId); +} + async function probeRemoteJobs( options: DaytonaArchiveHookOptions, workspaceMetadata: WorkspaceMetadata, From 18eb45fb6411ddb381197d9f4ec7681c5a7452a8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:01:19 +0000 Subject: [PATCH 064/231] fix(daytona): tie a recorded workspace removal to its pendingRemoval marker A removal that passed its sandbox step recorded its workspace as no longer referencing the sandbox. If deregistration then failed, the workspace stayed registered but excluded from every later reference check, so removing another workspace could delete the sandbox with the retained workspace's checkout on it (a forced removal of a stopped sandbox leaves that checkout unchecked). The record now holds the removal's pendingRemoval marker, which every removal claims before its first destructive step, and applies only while the listed workspace still carries that marker. A failed removal releases its marker and a retry claims a new one, so the still registered workspace counts again. A listed workspace without a marker is not recorded, which can only keep a sandbox. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 51 ++++++++++++++++++- .../services/daytona/daytonaReconciler.ts | 12 +++-- src/node/services/daytona/daytonaService.ts | 22 +++++--- 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 5b3f7c73fcd..cc460ee1fec 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -40,6 +40,14 @@ afterEach(async () => { await rig.cleanup(); }); +/** + * Give every listed workspace a pendingRemoval marker, as each removal claims one: these + * tests remove workspaces one after another without deregistering them. + */ +function claimRemovals(testRig = rig): void { + for (const ref of testRig.workspaces) ref.pendingRemovalId ??= `rm-${ref.workspaceId}`; +} + function makeRuntime( daytona: DaytonaSandboxConfig, testRig = rig, @@ -641,8 +649,8 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { const source = { sandboxId: "sb-pair", shared: false, ...SMALL }; const fork = { ...source, forked: true }; rig.workspaces.push( - { workspaceId: "ws-1", runtimeConfig: sshDaytonaConfig(source) }, - { workspaceId: "ws-fork", runtimeConfig: sshDaytonaConfig(fork) } + { workspaceId: "ws-1", runtimeConfig: sshDaytonaConfig(source), pendingRemovalId: "rm-1" }, + { workspaceId: "ws-fork", runtimeConfig: sshDaytonaConfig(fork), pendingRemovalId: "rm-2" } ); return Promise.all([ makeRuntime(source).deleteWorkspace("/home/me/project", "feature", false), @@ -729,6 +737,7 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { { workspaceId: "ws-o2", runtimeConfig: sshDaytonaConfig(owner("xum-attached-first")) }, { workspaceId: "ws-t2", runtimeConfig: sshDaytonaConfig(attached("sb-2")) } ); + claimRemovals(); // The owner goes first: its removal says the sandbox now outlives Xum's workspaces, and the // attached workspace's removal only releases it. @@ -750,6 +759,43 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { expect(deletes()).toBe(1); }); + it("counts a workspace whose removal failed after its checkout step as a reference again", async () => { + rig.plane.addSandbox({ id: "sb-kept", state: "stopped" }); + const owner = { sandboxId: "sb-kept", shared: false, ...SMALL }; + const fork = { ...owner, forked: true }; + // Each removal claims its workspace's pendingRemoval marker before any destructive step. + const forkRef = { + workspaceId: "ws-fork", + runtimeConfig: sshDaytonaConfig(fork), + pendingRemovalId: "rm-1" as string | undefined, + }; + rig.workspaces.push( + { workspaceId: "ws-owner", runtimeConfig: sshDaytonaConfig(owner), pendingRemovalId: "rm-o" }, + forkRef + ); + + // Forced on a stopped sandbox: the fork's checkout stays on it, unchecked. + const forced = await makeRuntime(fork, rig, { workspaceId: "ws-fork" }).deleteWorkspace( + "/home/me/project", + "feature-fork", + true + ); + expect(forced.success).toBe(true); + // Deregistration then fails: the fork stays registered and its marker is released, or a + // retry claims a new one. + for (const pendingRemovalId of [undefined, "rm-2"]) { + forkRef.pendingRemovalId = pendingRemovalId; + const removal = await makeRuntime(owner, rig, { workspaceId: "ws-owner" }).deleteWorkspace( + "/home/me/project", + "feature", + false + ); + expect(removal.success).toBe(true); + expect(rig.plane.find("sb-kept")).toBeDefined(); + } + expect(rig.plane.countCalls("DELETE /sandbox/sb-kept")).toBe(0); + }); + it("never starts a stopped sandbox for the checks a forced removal skips", async () => { rig.plane.addSandbox({ id: "sb-users", state: "stopped" }); rig.workspaces.push({ @@ -781,6 +827,7 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { { workspaceId: "ws-fork", runtimeConfig: sshDaytonaConfig({ ...owner, forked: true }) }, { workspaceId: "ws-attached", runtimeConfig: sshDaytonaConfig(attached) } ); + claimRemovals(); // Tokens and include blocks are per stored string: the name and the id. await rig.service.ensureSshAccess("xum-owner-abc123"); await rig.service.ensureSshAccess("sb-7"); diff --git a/src/node/services/daytona/daytonaReconciler.ts b/src/node/services/daytona/daytonaReconciler.ts index b419dc0b70a..c6816279cbb 100644 --- a/src/node/services/daytona/daytonaReconciler.ts +++ b/src/node/services/daytona/daytonaReconciler.ts @@ -15,6 +15,8 @@ export const DAYTONA_JOB_PROBE_FAILURE_LIMIT = 3; export interface DaytonaWorkspaceRef { workspaceId: string; runtimeConfig: RuntimeConfig | undefined; + /** The marker of a removal in flight (WorkspaceConfigSchema.pendingRemoval). */ + pendingRemovalId?: string; } export interface DaytonaReconcilerOptions { @@ -52,14 +54,18 @@ export interface DaytonaReconcilerOptions { export function listDaytonaWorkspaceRefs(config: { projects: ReadonlyMap< string, - { workspaces: ReadonlyArray> } + { workspaces: ReadonlyArray> } >; }): DaytonaWorkspaceRef[] { const refs: DaytonaWorkspaceRef[] = []; for (const project of config.projects.values()) { for (const workspace of project.workspaces) { - if (workspace.id) - refs.push({ workspaceId: workspace.id, runtimeConfig: workspace.runtimeConfig }); + if (!workspace.id) continue; + refs.push({ + workspaceId: workspace.id, + runtimeConfig: workspace.runtimeConfig, + pendingRemovalId: workspace.pendingRemoval?.removalId, + }); } } return refs; diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index e064269b7b9..17403df08bd 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -299,9 +299,12 @@ export class DaytonaService { private readonly removalLocks = new MutexMap(); /** * Workspaces whose removal passed its checks and deleted its checkout, but that the - * workspace list may still show until the removal is persisted. + * workspace list may still show until the removal is persisted, by that removal's + * pendingRemoval marker. A removal that then fails (deregistration rejects) releases its + * marker, and a retry claims a new one: either way the record no longer applies, so the + * still registered workspace counts as a reference again. */ - private readonly removedWorkspaces = new Set(); + private readonly removedWorkspaces = new Map(); constructor(options: DaytonaServiceOptions) { this.credentialsStore = options.credentialsStore; @@ -463,8 +466,10 @@ export class DaytonaService { ): StoredSandboxRef[] { return refs.flatMap((ref) => { const stored = getDaytonaSandboxId(ref.runtimeConfig); - const counts = - ref.workspaceId !== workspaceId && !this.removedWorkspaces.has(ref.workspaceId); + const removed = + ref.pendingRemovalId !== undefined && + this.removedWorkspaces.get(ref.workspaceId) === ref.pendingRemovalId; + const counts = ref.workspaceId !== workspaceId && !removed; return counts && stored ? [{ stored, attached: !isDedicatedDaytonaSandbox(ref.runtimeConfig) }] : []; @@ -490,9 +495,14 @@ export class DaytonaService { return this.removalLocks.withLock("daytona-sandbox-removal", fn); } - /** The workspace no longer references its sandbox, even while the workspace list shows it. */ + /** + * The workspace no longer references its sandbox while its current removal is in flight, + * even though the workspace list still shows it. A listed workspace without a removal + * marker is not recorded: it keeps counting, which can only keep a sandbox. + */ recordWorkspaceRemoval(workspaceId: string): void { - this.removedWorkspaces.add(workspaceId); + const ref = this.listWorkspaces?.().find((entry) => entry.workspaceId === workspaceId); + if (ref?.pendingRemovalId) this.removedWorkspaces.set(workspaceId, ref.pendingRemovalId); } recordSetupFailure(sandboxId: string, reason: string): void { From 764b086927dda52c4acc42b8286b57e75d50de15 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:04:07 +0000 Subject: [PATCH 065/231] fix(daytona): replace a refused ssh token only if the command used the current one Two commands on one sandbox can both be refused on its old token. The first replaces it; the second's replacement was guarded by looking for the cached token in the ssh error. The OpenSSH pool redacts the token from its errors, so that guard could not tell a stale refusal apart and revoked the valid token the first command had just minted. The service now bumps a per-sandbox token generation whenever its cached token becomes another one. The runtime captures it before each attempt, and replaceRejectedSshToken replaces the token only while it is still that generation. The error-text inference is gone. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 43 +++++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 32 ++++++++++----- src/node/services/daytona/daytonaService.ts | 36 ++++++++++++----- 3 files changed, 92 insertions(+), 19 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index cc460ee1fec..afc083a796a 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -1041,6 +1041,49 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { expect(rig.plane.countCalls("POST /sandbox/sb-gone/ssh-access")).toBe(1); }); + it("never replaces a token that a concurrent command already replaced", async () => { + rig.plane.addSandbox({ id: "sb-race" }); + await rig.service.ensureSshAccess("sb-race"); + const first = rig.writer.tokens.get("sb-race"); + if (!first) throw new Error("expected a minted token"); + const local = localShellTransport(rig.rootDir); + const refuse = (user: string) => + Promise.reject( + new RuntimeError(`${user}@127.0.0.1: Permission denied (publickey).`, "network") + ); + // Command A: the gateway refuses the first token until the include file holds another. + const runtimeA = makeRuntime({ sandboxId: "sb-race" }, rig, { + transport: { + ...local, + spawnRemoteProcess: (command, options) => + rig.writer.tokens.get("sb-race") === first + ? refuse(first) + : local.spawnRemoteProcess(command, options), + }, + }); + // Command B ran on the first token too, but its refusal arrives once A has replaced it, with + // the token already redacted (as the OpenSSH pool reports a probe failure). + let attemptsB = 0; + const runtimeB = makeRuntime({ sandboxId: "sb-race" }, rig, { + transport: { + ...local, + spawnRemoteProcess: async (command, options) => { + if (attemptsB++ > 0) return local.spawnRemoteProcess(command, options); + const streamA = await runtimeA.exec("true", { cwd: "/tmp", timeout: 10 }); + expect(await streamA.exitCode).toBe(0); + return refuse(DAYTONA_TOKEN_PLACEHOLDER); + }, + }, + }); + + const stream = await runtimeB.exec("echo ok", { cwd: "/tmp", timeout: 10 }); + + expect(await new Response(stream.stdout).text()).toBe("ok\n"); + // Only A's refused token was revoked; the one A minted stays live. + expect(rig.plane.revokedTokens).toEqual([first]); + expect(rig.plane.countCalls("POST /sandbox/sb-race/ssh-access")).toBe(2); + }); + it("replaces a token the gateway refused and retries the command once", async () => { rig.plane.addSandbox({ id: "sb-rev" }); await rig.service.ensureSshAccess("sb-rev"); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index d244fd82290..73649c7e129 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -82,6 +82,7 @@ export type DaytonaRuntimeService = Pick< | "markSandboxReady" | "invalidateSandboxReady" | "replaceRejectedSshToken" + | "sshTokenGeneration" | "sandboxReferencesElsewhere" | "isAliasStoredElsewhere" | "withSandboxRemovalLock" @@ -391,10 +392,13 @@ export class DaytonaSSHRuntime extends SSHRuntime { options: ExecOptions & { deadlineMs?: number } ): Promise { let result: SpawnResult; + const sandboxId = this.daytonaConfig.sandboxId; + // Before the attempt: a refusal replaces only the token this command used. + const tokenGeneration = sandboxId ? this.daytonaService.sshTokenGeneration(sandboxId) : 0; try { result = await super.spawnRemoteProcess(fullCommand, options); } catch (error) { - if (!(await this.recoverForRetry(error, options.abortSignal))) { + if (!(await this.recoverForRetry(error, tokenGeneration, options.abortSignal))) { throw redactDaytonaError(error); } try { @@ -409,7 +413,6 @@ export class DaytonaSSHRuntime extends SSHRuntime { ...result, onExit: (exitCode, stderr) => { // The ready cache must not hide a sandbox that stopped: the next ensureReady checks. - const sandboxId = this.daytonaConfig.sandboxId; if (sandboxId && this.isTransportFailureExit(exitCode, stderr)) { this.daytonaService.invalidateSandboxReady(sandboxId); } @@ -424,7 +427,11 @@ export class DaytonaSSHRuntime extends SSHRuntime { * "Permission denied", so only a started sandbox gets a new token. Throws a clear error for * a sandbox deleted outside Xum. */ - private recoverForRetry(error: unknown, abortSignal: AbortSignal | undefined): Promise { + private recoverForRetry( + error: unknown, + tokenGeneration: number, + abortSignal: AbortSignal | undefined + ): Promise { return this.daytonaService.withAccountLease(async () => { const sandboxId = this.daytonaConfig.sandboxId; if (!sandboxId || !isRuntimeTransportError(error) || abortSignal?.aborted) return false; @@ -433,17 +440,22 @@ export class DaytonaSSHRuntime extends SSHRuntime { if (!tokenRejected && !this.restartsStoppedSandbox) return false; this.daytonaService.invalidateSandboxReady(sandboxId); const state = await this.readStateAfterFailure(sandboxId); - return this.recoverInState(sandboxId, state, { error, tokenRejected }, abortSignal); + return this.recoverInState( + sandboxId, + state, + { error, tokenRejected, tokenGeneration }, + abortSignal + ); }); } private recoverInState( sandboxId: string, state: DaytonaSandboxState | undefined, - failure: { error: unknown; tokenRejected: boolean }, + failure: { error: unknown; tokenRejected: boolean; tokenGeneration: number }, abortSignal: AbortSignal | undefined ): Promise { - const { error, tokenRejected } = failure; + const { error, tokenRejected, tokenGeneration } = failure; if (state === "missing") { throw new RuntimeError( sandboxMissingMessage(sandboxId), @@ -452,18 +464,20 @@ export class DaytonaSSHRuntime extends SSHRuntime { ); } if (state === "started") { - return tokenRejected ? this.replaceRejectedToken(sandboxId, error) : Promise.resolve(false); + return tokenRejected + ? this.replaceRejectedToken(sandboxId, tokenGeneration) + : Promise.resolve(false); } if (state === undefined || !this.restartsStoppedSandbox) return Promise.resolve(false); return this.restartSandbox(sandboxId, abortSignal); } - private async replaceRejectedToken(sandboxId: string, error: unknown): Promise { + private async replaceRejectedToken(sandboxId: string, tokenGeneration: number): Promise { log.info("The Daytona gateway refused the ssh token; replacing it and retrying once", { sandboxId, }); try { - await this.daytonaService.replaceRejectedSshToken(sandboxId, errorChainText(error)); + await this.daytonaService.replaceRejectedSshToken(sandboxId, tokenGeneration); } catch (replaceError) { log.warn("Could not replace the refused Daytona ssh token", { sandboxId, diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 17403df08bd..ac7c62d8344 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -18,7 +18,7 @@ import { import { loadOrCreateInstallationId } from "@/node/config/installationIdentity"; import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; import { retireSshConnections } from "@/node/runtime/transports"; -import { redactDaytonaTokens, rememberDaytonaToken } from "@/node/runtime/daytonaTokenRedaction"; +import { rememberDaytonaToken } from "@/node/runtime/daytonaTokenRedaction"; import { getDaytonaSandboxId, isDedicatedDaytonaSandbox } from "@/common/types/runtime"; import { log } from "@/node/services/log"; import { sleepWithAbort } from "@/node/utils/abort"; @@ -274,6 +274,9 @@ export class DaytonaService { config: DaytonaGatewayConfig; }; private readonly tokens = new Map(); + /** Bumped (unique per process) whenever a sandbox's cached token becomes another one. */ + private readonly tokenGenerations = new Map(); + private lastTokenGeneration = 0; private readonly tokenRequests = new Map>(); /** Bumped by every settings change, so a mint that spans one can tell. */ private credentialsGeneration = 0; @@ -330,7 +333,7 @@ export class DaytonaService { for (const { sandboxId, token } of await this.sshConfigWriter.readTokens()) { rememberDaytonaToken(token); if (!this.tokens.has(sandboxId)) { - this.tokens.set(sandboxId, { token, expiresAt: new Date(0).toISOString() }); + this.cacheNewToken(sandboxId, { token, expiresAt: new Date(0).toISOString() }); } } } @@ -921,15 +924,28 @@ export class DaytonaService { } /** - * The gateway refused the sandbox's token (revoked outside Xum, or one an earlier process - * left): mint a new one and rewrite the include file. `failure` is the raw ssh error; when it - * names a token other than the cached one, a concurrent command already replaced it. + * Which token the sandbox's ssh commands use now. Capture it before a command: if the gateway + * refuses that command's token, pass it to replaceRejectedSshToken. */ - async replaceRejectedSshToken(sandboxId: string, failure: string): Promise { + sshTokenGeneration(sandboxId: string): number { + return this.tokenGenerations.get(sandboxId) ?? 0; + } + + private cacheNewToken(sandboxId: string, access: DaytonaSshAccess): void { + this.tokens.set(sandboxId, access); + this.tokenGenerations.set(sandboxId, ++this.lastTokenGeneration); + } + + /** + * The gateway refused the token of a command that started at `attemptGeneration` (revoked + * outside Xum, or one an earlier process left): mint a new one and rewrite the include file. + * Nothing to do when the token changed since: a concurrent command already replaced it, and + * the current one must stay live. The generation, not the ssh error, decides: the OpenSSH + * pool redacts the token from its errors. + */ + async replaceRejectedSshToken(sandboxId: string, attemptGeneration: number): Promise { + if (this.sshTokenGeneration(sandboxId) !== attemptGeneration) return; const cached = this.tokens.get(sandboxId); - if (cached && !failure.includes(cached.token) && redactDaytonaTokens(failure) !== failure) { - return; - } this.readyAt.delete(sandboxId); // Due at once, so the next ensureSshAccess mints, writes, and revokes the refused one. if (cached) this.tokens.set(sandboxId, { ...cached, expiresAt: new Date(0).toISOString() }); @@ -1004,7 +1020,7 @@ export class DaytonaService { } const outdatedAfterWrite = this.settleOutdatedMint(mint, true); if (outdatedAfterWrite) return outdatedAfterWrite; - this.tokens.set(sandboxId, access); + this.cacheNewToken(sandboxId, access); if (previous && previous.token !== access.token) { await this.revokeQuietly(client, sandboxId, previous.token); } From 6b8324fc494d89f46e1c66c3278fae798154eaca Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:54:12 +0000 Subject: [PATCH 066/231] fix(daytona): refuse every DAYTONA_API_URL that does not validate Only a plain-http non-loopback DAYTONA_API_URL was refused; a malformed one or one with another scheme was ignored, so the key went to Daytona's hosted API instead of the deployment the operator named. The URL in use (saved, else the environment) is now refused whenever it does not validate, and shown as written so the user can fix it. --- docs/runtime/daytona.mdx | 2 +- .../config/daytonaCredentialsStore.test.ts | 14 ++-- src/node/config/daytonaCredentialsStore.ts | 74 +++++++------------ .../builtInSkillContent.generated.ts | 2 +- 4 files changed, 36 insertions(+), 56 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index dff91866577..f78a6179a4b 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -14,7 +14,7 @@ Xum can run workspaces in [Daytona](https://www.daytona.io/docs) sandboxes. Each ## Set up access 1. Open **Settings > Runtimes** and find the **Daytona** row. -2. Paste your API key and click **Save**. Change **API URL** only for a self-hosted (BYOC) Daytona install. It must use `https://`, so that no one on the network can read the API key; `http://` works only for a loopback host (`localhost`, `127.0.0.1` or `::1`). A saved URL that is no longer accepted keeps Daytona unavailable until you fix or clear it; Xum never sends the key to another deployment instead. **Target** picks a region; leave it empty for Daytona's default. +2. Paste your API key and click **Save**. Change **API URL** only for a self-hosted (BYOC) Daytona install. It must use `https://`, so that no one on the network can read the API key; `http://` works only for a loopback host (`localhost`, `127.0.0.1` or `::1`). A saved URL (or a `DAYTONA_API_URL`) that is not accepted keeps Daytona unavailable until you fix it; Xum never sends the key to another deployment instead. **Target** picks a region; leave it empty for Daytona's default. 3. Click **Test connection**. Xum stores the key in `~/.xum/daytona.json` (under `XUM_ROOT` when it is set) with file mode `0600`. The settings page never shows a saved key again: to change it, enter a new one, or click **Remove key**. diff --git a/src/node/config/daytonaCredentialsStore.test.ts b/src/node/config/daytonaCredentialsStore.test.ts index 2954c70f047..04eef59cb64 100644 --- a/src/node/config/daytonaCredentialsStore.test.ts +++ b/src/node/config/daytonaCredentialsStore.test.ts @@ -109,19 +109,19 @@ describe("DaytonaCredentialsStore", () => { } }); - it("refuses a non-loopback http DAYTONA_API_URL", async () => { + it("refuses every DAYTONA_API_URL that does not validate instead of using the default", async () => { await fs.writeFile( path.join(rootDir, "daytona.json"), JSON.stringify({ apiKey: KEY }), "utf-8" ); // The environment names the deployment, so another one must not get the key instead. - const fromEnv = new DaytonaCredentialsStore(rootDir, { - DAYTONA_API_URL: "http://byoc.example.com/api", - }); - // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime - await expect(fromEnv.resolve()).rejects.toThrow(/DAYTONA_API_URL/); - expect((await fromEnv.getPublicConfig()).apiUrl).toBe("http://byoc.example.com/api"); + for (const envUrl of ["http://byoc.example.com/api", "not a url", "ftp://byoc.example.com"]) { + const fromEnv = new DaytonaCredentialsStore(rootDir, { DAYTONA_API_URL: envUrl }); + // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime + await expect(fromEnv.resolve()).rejects.toThrow(/DAYTONA_API_URL/); + expect((await fromEnv.getPublicConfig()).apiUrl).toBe(envUrl); + } }); it("rejects an API URL that is not plain http(s)", async () => { diff --git a/src/node/config/daytonaCredentialsStore.ts b/src/node/config/daytonaCredentialsStore.ts index 39006e817ce..743bef18636 100644 --- a/src/node/config/daytonaCredentialsStore.ts +++ b/src/node/config/daytonaCredentialsStore.ts @@ -89,8 +89,8 @@ function normalizeApiUrl(value: string): string { return parsed.toString().replace(/\/+$/, ""); } -/** Why a stored API URL cannot be used; undefined when it is unset or valid. */ -function storedApiUrlProblem(value: string | undefined): string | undefined { +/** Why an API URL cannot be used; undefined when it is unset or valid. */ +function apiUrlProblem(value: string | undefined): string | undefined { if (!value) return undefined; try { normalizeApiUrl(value); @@ -100,16 +100,6 @@ function storedApiUrlProblem(value: string | undefined): string | undefined { } } -function validApiUrl(value: string | undefined, source: string): string | undefined { - if (!value) return undefined; - try { - return normalizeApiUrl(value); - } catch (error) { - log.warn(`Ignoring invalid ${source}`, { error: getErrorMessage(error) }); - return undefined; - } -} - const CREDENTIAL_FIELDS = ["apiKey", "apiUrl", "target"] as const; /** The non-empty string fields of a credentials file, or undefined when it is not an object. */ @@ -202,22 +192,19 @@ export class DaytonaCredentialsStore { const envKey = nonEmpty(this.env.DAYTONA_API_KEY); const apiKey = stored.apiKey ?? envKey; if (!apiKey) return null; - // A saved URL names the deployment the key belongs to (an http one saved before http was - // refused, or a hand edit): falling back to DAYTONA_API_URL or the default would send the - // key elsewhere, so Daytona stays unusable until the user fixes or clears it. - const storedProblem = storedApiUrlProblem(stored.apiUrl); - if (storedProblem) { - throw new DaytonaConfigError( - `The Daytona API URL saved in Settings cannot be used (${storedProblem}); ` + - "fix or clear it in Settings > Runtimes" - ); - } - // The environment names the deployment to use: falling back to the default one would send - // the key to a deployment the operator did not pick, so it stays unusable until fixed. - if (this.insecureEnvApiUrl(stored)) { + // The URL in use (saved, else DAYTONA_API_URL) names the deployment the key belongs to: an + // invalid one (an http URL saved before http was refused, a hand edit, a typo in the env) + // must not fall back to another URL or the default, which would send the key elsewhere. + // So Daytona stays unusable until the user fixes it. + const { source, value } = this.configuredApiUrl(stored); + const problem = apiUrlProblem(value); + if (problem) { throw new DaytonaConfigError( - "DAYTONA_API_URL must use https (http is accepted only for localhost and 127.0.0.1); " + - "fix it, or set the API URL in Settings > Runtimes" + source === "settings" + ? `The Daytona API URL saved in Settings cannot be used (${problem}); ` + + "fix or clear it in Settings > Runtimes" + : `DAYTONA_API_URL cannot be used (${problem}); ` + + "fix it, or set the API URL in Settings > Runtimes" ); } return { @@ -240,27 +227,20 @@ export class DaytonaCredentialsStore { }; } - /** - * A saved URL wins, shown as written when it does not validate so the user can fix it (resolve - * refuses it). An env URL that does not validate is ignored (self-healing), as if unset, except - * one that only fails for plain http: that one is shown, and resolve refuses it. - */ - private resolveApiUrl(stored: DaytonaStoredCredentials): string { - if (stored.apiUrl) { - return storedApiUrlProblem(stored.apiUrl) ? stored.apiUrl : normalizeApiUrl(stored.apiUrl); - } - return ( - this.insecureEnvApiUrl(stored) ?? - validApiUrl(nonEmpty(this.env.DAYTONA_API_URL), "DAYTONA_API_URL") ?? - DAYTONA_DEFAULT_API_URL - ); + /** The saved URL wins over DAYTONA_API_URL; neither set means Daytona's default. */ + private configuredApiUrl(stored: DaytonaStoredCredentials): { + source: "settings" | "env" | "default"; + value?: string; + } { + if (stored.apiUrl) return { source: "settings", value: stored.apiUrl }; + const envUrl = nonEmpty(this.env.DAYTONA_API_URL); + return envUrl ? { source: "env", value: envUrl } : { source: "default" }; } - /** DAYTONA_API_URL when it would be used and is http to a host that is not loopback. */ - private insecureEnvApiUrl(stored: DaytonaStoredCredentials): string | undefined { - const envUrl = nonEmpty(this.env.DAYTONA_API_URL); - if (!envUrl || stored.apiUrl) return undefined; - const parsed = URL.canParse(envUrl) ? new URL(envUrl) : undefined; - return parsed?.protocol === "http:" && !isLoopbackHost(parsed.hostname) ? envUrl : undefined; + /** An invalid URL is shown as written, so the user sees what to fix (resolve refuses it). */ + private resolveApiUrl(stored: DaytonaStoredCredentials): string { + const { value } = this.configuredApiUrl(stored); + if (!value) return DAYTONA_DEFAULT_API_URL; + return apiUrlProblem(value) ? value : normalizeApiUrl(value); } } diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 53a93252de6..276d513fdcb 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10016,7 +10016,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "## Set up access", "", "1. Open **Settings > Runtimes** and find the **Daytona** row.", - "2. Paste your API key and click **Save**. Change **API URL** only for a self-hosted (BYOC) Daytona install. It must use `https://`, so that no one on the network can read the API key; `http://` works only for a loopback host (`localhost`, `127.0.0.1` or `::1`). A saved URL that is no longer accepted keeps Daytona unavailable until you fix or clear it; Xum never sends the key to another deployment instead. **Target** picks a region; leave it empty for Daytona's default.", + "2. Paste your API key and click **Save**. Change **API URL** only for a self-hosted (BYOC) Daytona install. It must use `https://`, so that no one on the network can read the API key; `http://` works only for a loopback host (`localhost`, `127.0.0.1` or `::1`). A saved URL (or a `DAYTONA_API_URL`) that is not accepted keeps Daytona unavailable until you fix it; Xum never sends the key to another deployment instead. **Target** picks a region; leave it empty for Daytona's default.", "3. Click **Test connection**.", "", "Xum stores the key in `~/.xum/daytona.json` (under `XUM_ROOT` when it is set) with file mode `0600`. The settings page never shows a saved key again: to change it, enter a new one, or click **Remove key**.", From 275925a358ab72138b35deff4f894c9580fba1d9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:25:40 +0000 Subject: [PATCH 067/231] docs(daytona): say that archive-delete does not protect files git ignores --- docs/runtime/daytona.mdx | 2 +- src/node/services/agentSkills/builtInSkillContent.generated.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index f78a6179a4b..d3fe45bbbb8 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -67,7 +67,7 @@ A sandbox Xum created can serve several workspaces when you fork one. Xum delete If the sandbox is still being destroyed (for example after a failed setup), delete waits up to a minute for Daytona to finish. A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard. -With **Remote box on archive** set to delete, Xum first checks the workspace for uncommitted or unpushed work. If it finds some, or cannot run the check, it stops the sandbox instead and shows a warning. +With **Remote box on archive** set to delete, Xum first checks the workspace for uncommitted or unpushed work and for untracked files. If it finds some, or cannot run the check, it stops the sandbox instead and shows a warning. Files git ignores (for example `node_modules`, build output or `.env`) do not count, so copy any you need before you archive with this setting. Once a minute, Xum also stops running sandboxes that carry this install's label but that no workspace uses. It never deletes them. diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 276d513fdcb..51bf065b40e 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10069,7 +10069,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "If the sandbox is still being destroyed (for example after a failed setup), delete waits up to a minute for Daytona to finish. A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard.", "", - "With **Remote box on archive** set to delete, Xum first checks the workspace for uncommitted or unpushed work. If it finds some, or cannot run the check, it stops the sandbox instead and shows a warning.", + "With **Remote box on archive** set to delete, Xum first checks the workspace for uncommitted or unpushed work and for untracked files. If it finds some, or cannot run the check, it stops the sandbox instead and shows a warning. Files git ignores (for example `node_modules`, build output or `.env`) do not count, so copy any you need before you archive with this setting.", "", "Once a minute, Xum also stops running sandboxes that carry this install's label but that no workspace uses. It never deletes them.", "", From 2d0f64427baa6ba3dfc0679a77c637abb77240f4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:28:00 +0000 Subject: [PATCH 068/231] fix(daytona): drop a cancelled readiness waiter's starting callback Concurrent readiness checks of one sandbox share a request, and each added its onStarting callback to it. A check cancelled while the state request was pending returned an error, but its callback stayed: when the shared request then found the sandbox stopped, it reported "starting" for that cancelled check, and nothing ever reported its end. ensureSandboxReady now takes the caller's signal, races it per waiter, and removes the waiter's callback once its wait settles. raceAbort moves to node/utils/abort for both the service and the runtime. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 30 +++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 42 +++++---------------- src/node/services/daytona/daytonaService.ts | 29 ++++++++------ src/node/utils/abort.ts | 22 +++++++++++ 4 files changed, 78 insertions(+), 45 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index afc083a796a..f6ee33cb835 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -147,6 +147,36 @@ describe("DaytonaSSHRuntime.ensureReady", () => { expect(rig.writer.tokens.get("sb-stopped")).toBeDefined(); }); + it("never reports a start to a check cancelled while it waited for the shared one", async () => { + rig.plane.addSandbox({ id: "sb-slow", state: "stopped" }); + let releaseState!: () => void; + const stateHeld = new Promise((resolve) => (releaseState = resolve)); + rig.plane.onRequest.set("GET /sandbox/sb-slow", () => { + rig.plane.onRequest.delete("GET /sandbox/sb-slow"); + return stateHeld; + }); + const cancelled: string[] = []; + const other: string[] = []; + const controller = new AbortController(); + const cancelledCheck = makeRuntime({ sandboxId: "sb-slow" }).ensureReady({ + signal: controller.signal, + statusSink: (event) => cancelled.push(event.phase), + }); + const otherCheck = makeRuntime({ sandboxId: "sb-slow" }).ensureReady({ + statusSink: (event) => other.push(event.phase), + }); + + // Cancelled while the state request is pending; the shared start then finds it stopped. + controller.abort(); + expect((await cancelledCheck).ready).toBe(false); + releaseState(); + expect(await otherCheck).toEqual({ ready: true }); + + expect(other).toContain("starting"); + expect(cancelled).not.toContain("starting"); + expect(cancelled.at(-1)).toBe("error"); + }); + it("shares one start between concurrent checks", async () => { rig.plane.addSandbox({ id: "sb-archived", state: "archived" }); const phases: string[][] = [[], []]; diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 73649c7e129..1264d5dd217 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -164,26 +164,6 @@ interface CreateAttempt { account?: DaytonaAccount; } -/** Resolve with `promise`, or reject as soon as `signal` aborts (the shared work keeps going). */ -function raceAbort(promise: Promise, signal: AbortSignal | undefined): Promise { - if (!signal) return promise; - if (signal.aborted) return Promise.reject(new Error("Aborted")); - return new Promise((resolve, reject) => { - const onAbort = () => reject(new Error("Aborted")); - signal.addEventListener("abort", onAbort, { once: true }); - promise.then( - (value) => { - signal.removeEventListener("abort", onAbort); - resolve(value); - }, - (error: unknown) => { - signal.removeEventListener("abort", onAbort); - reject(error instanceof Error ? error : new Error(getErrorMessage(error))); - } - ); - }); -} - type DeleteResult = | { success: true; deletedPath: string; notice?: string } | { success: false; error: string; leftoverPaths?: string[] }; @@ -548,12 +528,10 @@ export class DaytonaSSHRuntime extends SSHRuntime { signal: AbortSignal | undefined ): Promise { try { - await raceAbort( - this.daytonaService.ensureSandboxReady(sandboxId, { - onStarting: () => emitStatus("starting", "Starting sandbox..."), - }), - signal - ); + await this.daytonaService.ensureSandboxReady(sandboxId, { + onStarting: () => emitStatus("starting", "Starting sandbox..."), + signal, + }); return undefined; } catch (error) { emitStatus("error"); @@ -779,12 +757,10 @@ export class DaytonaSSHRuntime extends SSHRuntime { ): Promise { const { initLogger, abortSignal } = params; initLogger.logStep("Waiting for the sandbox to start and configuring SSH..."); - await raceAbort( - this.daytonaService.ensureSandboxReady(sandboxId, { - onStarting: () => initLogger.logStep("Starting sandbox..."), - }), - abortSignal - ); + await this.daytonaService.ensureSandboxReady(sandboxId, { + onStarting: () => initLogger.logStep("Starting sandbox..."), + signal: abortSignal, + }); if (!seed) return; initLogger.logStep("Copying agent hooks and global skills into the sandbox..."); const count = await seedAgentHome( @@ -1181,7 +1157,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { abortSignal: AbortSignal | undefined ): Promise<"ready" | "missing" | { error: string }> { try { - await raceAbort(this.daytonaService.ensureSandboxReady(sandboxId), abortSignal); + await this.daytonaService.ensureSandboxReady(sandboxId, { signal: abortSignal }); return "ready"; } catch (error) { if (error instanceof DaytonaApiError && error.kind === "not_found") return "missing"; diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index ac7c62d8344..0c5f9365ee1 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -21,7 +21,7 @@ import { retireSshConnections } from "@/node/runtime/transports"; import { rememberDaytonaToken } from "@/node/runtime/daytonaTokenRedaction"; import { getDaytonaSandboxId, isDedicatedDaytonaSandbox } from "@/common/types/runtime"; import { log } from "@/node/services/log"; -import { sleepWithAbort } from "@/node/utils/abort"; +import { raceAbort, sleepWithAbort } from "@/node/utils/abort"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; import { DaytonaApiError, @@ -201,6 +201,8 @@ export interface DaytonaStartAndWaitOptions { export interface DaytonaEnsureReadyOptions { /** Called when the sandbox has to be started (it was stopped or archived). */ onStarting?: () => void; + /** Stops this caller's wait (and its onStarting calls); the shared request goes on. */ + signal?: AbortSignal; } interface SandboxReadyRequest { @@ -864,18 +866,21 @@ export class DaytonaService { * `onStarting` call. Throws a DaytonaApiError (`not_found` when the sandbox is gone). */ ensureSandboxReady(sandboxId: string, options: DaytonaEnsureReadyOptions = {}): Promise { - const existing = this.readyRequests.get(sandboxId); - if (existing) { - if (options.onStarting) { - existing.onStarting.add(options.onStarting); - if (existing.starting) options.onStarting(); - } - return existing.promise; - } - + const request = this.readyRequests.get(sandboxId) ?? this.startReadyRequest(sandboxId); + const { onStarting, signal } = options; + if (!onStarting || signal?.aborted) return raceAbort(request.promise, signal); + // Per waiter, so the same callback passed twice is two waiters. + const listener = () => onStarting(); + request.onStarting.add(listener); + if (request.starting) listener(); + // A waiter that stopped waiting (cancelled) hears no later start: nothing would report its end. + return raceAbort(request.promise, signal).finally(() => request.onStarting.delete(listener)); + } + + private startReadyRequest(sandboxId: string): SandboxReadyRequest { const request: SandboxReadyRequest = { promise: Promise.resolve(), - onStarting: new Set(options.onStarting ? [options.onStarting] : []), + onStarting: new Set(), starting: false, }; request.promise = this.withAccountLease(async () => { @@ -890,7 +895,7 @@ export class DaytonaService { this.readyRequests.delete(sandboxId); }); this.readyRequests.set(sandboxId, request); - return request.promise; + return request; } /** diff --git a/src/node/utils/abort.ts b/src/node/utils/abort.ts index d55c36094ce..20d2ae5ed51 100644 --- a/src/node/utils/abort.ts +++ b/src/node/utils/abort.ts @@ -1,3 +1,5 @@ +import { getErrorMessage } from "@/common/utils/errors"; + /** * AbortSignal helpers. * @@ -67,3 +69,23 @@ export async function sleepWithAbort(ms: number, abortSignal?: AbortSignal): Pro abortSignal?.addEventListener("abort", onAbort, { once: true }); }); } + +/** Resolve with `promise`, or reject as soon as `signal` aborts (the shared work keeps going). */ +export function raceAbort(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return promise; + if (signal.aborted) return Promise.reject(new Error("Aborted")); + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error("Aborted")); + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error instanceof Error ? error : new Error(getErrorMessage(error))); + } + ); + }); +} From 7a748b0447326f3a3f120fc382bfb9d3df637f6a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:32:58 +0000 Subject: [PATCH 069/231] fix(daytona): never offer, create or attach a Windows sandbox Daytona's API reports each snapshot's and sandbox's sandboxClass (linux-vm, container, windows). Xum dropped it, so the pickers offered Windows snapshots and sandboxes, whose shell is cmd.exe or PowerShell, while Xum sets up and runs a workspace over ssh with POSIX commands: a Windows snapshot created a billed sandbox that setup then failed on and deleted. The client now keeps sandboxClass. The snapshot and sandbox lists leave Windows ones out, and validateBeforePersist refuses attaching a Windows sandbox or creating from a Windows snapshot (looked up by name) before anything is created or persisted. A snapshot Daytona does not find by name is left to Daytona's create, as before. --- docs/runtime/daytona.mdx | 2 +- src/node/runtime/DaytonaSSHRuntime.test.ts | 49 +++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 46 ++++++++++++++--- .../builtInSkillContent.generated.ts | 2 +- .../services/daytona/daytona.testHarness.ts | 19 +++++++ src/node/services/daytona/daytonaClient.ts | 32 ++++++++++-- src/node/services/daytona/daytonaService.ts | 31 +++++++++++- 7 files changed, 165 insertions(+), 16 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index d3fe45bbbb8..a2ce0af1dd4 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -9,7 +9,7 @@ Xum can run workspaces in [Daytona](https://www.daytona.io/docs) sandboxes. Each - A Daytona account and an API key from the Daytona dashboard - `ssh` installed on the machine running Xum -- A snapshot (or Daytona's default) with git and your toolchain +- A Linux snapshot (or Daytona's default) with git and your toolchain. Xum runs Linux shell commands over SSH, so it does not offer Windows snapshots or Windows sandboxes, and refuses one before it creates or attaches a sandbox. ## Set up access diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index f6ee33cb835..d6d56cc1a87 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -292,6 +292,55 @@ describe("DaytonaSSHRuntime.finalizeConfig and validateBeforePersist", () => { }); }); +describe("DaytonaSSHRuntime Windows sandboxes", () => { + // Xum provisions over ssh with POSIX commands; a Windows sandbox runs cmd.exe or PowerShell. + it("refuses to attach a Windows sandbox or create from a Windows snapshot before persisting", async () => { + rig.plane.addSandbox({ id: "sb-win", sandboxClass: "windows" }); + rig.plane.addSandbox({ id: "sb-linux", sandboxClass: "linux-vm" }); + rig.plane.snapshots.push( + { name: "windows-small", sandboxClass: "windows" }, + { name: "daytona-small", sandboxClass: "container" } + ); + const runtime = makeRuntime({}); + const validate = async (daytona: DaytonaSandboxConfig) => { + const finalized = await runtime.finalizeConfig("b", sshDaytonaConfig(daytona)); + if (!finalized.success) return finalized; + return runtime.validateBeforePersist("b", finalized.data); + }; + + const attached = await validate({ sandboxId: "sb-win" }); + expect(attached.success ? "" : attached.error).toContain("Windows"); + const created = await validate({ snapshot: "windows-small" }); + expect(created.success ? "" : created.error).toContain("Windows"); + + expect((await validate({ sandboxId: "sb-linux" })).success).toBe(true); + expect((await validate({ snapshot: "daytona-small" })).success).toBe(true); + // A snapshot Daytona does not list by that name: Daytona's create decides, as before. + expect((await validate({ snapshot: "unlisted" })).success).toBe(true); + expect(rig.plane.countCalls("POST /sandbox")).toBe(0); + }); + + it("offers neither Windows snapshots nor Windows sandboxes", async () => { + rig.plane.addSandbox({ id: "sb-win", sandboxClass: "windows" }); + rig.plane.addSandbox({ id: "sb-linux", sandboxClass: "linux-vm" }); + rig.plane.addSandbox({ id: "sb-older" }); + rig.plane.snapshots.push( + { name: "windows-small", sandboxClass: "windows" }, + { name: "daytona-small", sandboxClass: "container" }, + { name: "older" } + ); + + const snapshots = await rig.service.listSnapshots(); + const sandboxes = await rig.service.listSandboxes(); + + expect(snapshots.ok && snapshots.snapshots.map((s) => s.name)).toEqual([ + "daytona-small", + "older", + ]); + expect(sandboxes.ok && sandboxes.sandboxes.map((s) => s.id)).toEqual(["sb-linux", "sb-older"]); + }); +}); + describe("DaytonaSSHRuntime.postCreateSetup", () => { it("creates the sandbox with preset resources, labels and lifecycle intervals", async () => { await makeRuntime({ sandboxId: "xum-new", shared: false, ...SMALL }).postCreateSetup( diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 1264d5dd217..0291d9c7343 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -49,6 +49,7 @@ import { DAYTONA_LABEL_INSTANCE, DAYTONA_LABEL_WORKSPACE_ID, DAYTONA_START_TIMEOUT_MS, + DAYTONA_WINDOWS_SANDBOX_CLASS, type DaytonaAccount, type DaytonaService, } from "@/node/services/daytona/daytonaService"; @@ -83,6 +84,7 @@ export type DaytonaRuntimeService = Pick< | "invalidateSandboxReady" | "replaceRejectedSshToken" | "sshTokenGeneration" + | "isWindowsSnapshot" | "sandboxReferencesElsewhere" | "isAliasStoredElsewhere" | "withSandboxRemovalLock" @@ -115,6 +117,8 @@ const DAYTONA_AUTO_DELETE_DISABLED = -1; const SANDBOX_NAME_MAX_BRANCH_LENGTH = 40; const SETTINGS_CHANGED_DURING_SETUP = "Daytona settings changed while the sandbox was set up; create the workspace again"; +const LINUX_ONLY = + "Xum sets up and runs a workspace over SSH with Linux shell commands, so it needs a Linux sandbox."; /** `xum--`: unique per creation, and readable in the Daytona dashboard. */ export function buildDaytonaSandboxName(branchName: string): string { @@ -655,24 +659,40 @@ export class DaytonaSSHRuntime extends SSHRuntime { if (!sandboxId) { return Err("Daytona sandbox id is required (should be set by finalizeConfig)"); } - const attaching = daytona.shared === true; - if (!attaching && !findDaytonaSizePreset(daytona)) { - return Err(invalidSizeError()); - } - return this.checkSandboxIdentity(sandboxId, attaching); + return daytona.shared === true + ? this.checkSandboxIdentity(sandboxId, true) + : this.checkNewSandbox(sandboxId, daytona); + } + + /** A sandbox Xum creates: a preset size, a free name, and a snapshot that is not Windows. */ + private async checkNewSandbox( + sandboxId: string, + daytona: DaytonaSandboxConfig + ): Promise> { + if (!findDaytonaSizePreset(daytona)) return Err(invalidSizeError()); + const identity = await this.checkSandboxIdentity(sandboxId, false); + if (!identity.success || !daytona.snapshot) return identity; + return this.checkSnapshotClass(daytona.snapshot); } - /** The sandbox to attach must exist; the name for a new one must be free. */ + /** + * The sandbox to attach must exist and run Linux (Xum's setup and commands are POSIX shell + * over ssh); the name for a new one must be free. + */ private async checkSandboxIdentity( sandboxId: string, attaching: boolean ): Promise> { - let exists: boolean; + let found: Awaited>; try { - exists = (await this.daytonaService.getSandboxState(sandboxId)).state !== "missing"; + found = await this.daytonaService.getSandboxState(sandboxId); } catch (error) { return Err(`Could not reach Daytona: ${getErrorMessage(error)}`); } + const exists = found.state !== "missing"; + if (attaching && found.sandbox?.sandboxClass === DAYTONA_WINDOWS_SANDBOX_CLASS) { + return Err(`Daytona sandbox "${sandboxId}" is a Windows sandbox. ${LINUX_ONLY}`); + } if (attaching === exists) return Ok(undefined); return Err( attaching @@ -681,6 +701,16 @@ export class DaytonaSSHRuntime extends SSHRuntime { ); } + /** Refused before any sandbox is created: a Windows snapshot makes a billed box setup fails on. */ + private async checkSnapshotClass(snapshot: string): Promise> { + try { + if (!(await this.daytonaService.isWindowsSnapshot(snapshot))) return Ok(undefined); + } catch (error) { + return Err(`Could not reach Daytona: ${getErrorMessage(error)}`); + } + return Err(`Daytona snapshot "${snapshot}" creates Windows sandboxes. ${LINUX_ONLY}`); + } + /** The sandbox may not exist yet, so only compute the path (as Coder does). */ override createWorkspace(params: WorkspaceCreationParams): Promise { const workspacePath = this.getWorkspacePath(params.projectPath, params.directoryName); diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 51bf065b40e..eca4e077963 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10011,7 +10011,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "- A Daytona account and an API key from the Daytona dashboard", "- `ssh` installed on the machine running Xum", - "- A snapshot (or Daytona's default) with git and your toolchain", + "- A Linux snapshot (or Daytona's default) with git and your toolchain. Xum runs Linux shell commands over SSH, so it does not offer Windows snapshots or Windows sandboxes, and refuses one before it creates or attaches a sandbox.", "", "## Set up access", "", diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index cd7f8ab63f5..0c803399ef8 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -28,6 +28,8 @@ export interface FakeSandbox { labels: Record; /** GETs left before a transitional state settles (creating/starting -> started, destroying -> gone). */ pendingPolls: number; + /** Daytona's sandboxClass ("linux-vm", "container", "windows"); unset like an older API. */ + sandboxClass?: string; } export class FakeDaytonaControlPlane { @@ -36,6 +38,8 @@ export class FakeDaytonaControlPlane { readonly calls: string[] = []; readonly createBodies: Array> = []; readonly revokedTokens: string[] = []; + /** The organization's snapshots, by name. */ + readonly snapshots: Array<{ name: string; sandboxClass?: string }> = []; /** Answer POST /sandbox with 400 when it carries cpu/memory/disk (fixed-resource snapshot). */ rejectCreateResources = false; /** Resources a sandbox gets when created without any. */ @@ -138,6 +142,20 @@ export class FakeDaytonaControlPlane { return json(200, this.view(sandbox)); } + if (route === "GET /snapshots") { + return json(200, { + items: this.snapshots, + total: this.snapshots.length, + page: 1, + totalPages: 1, + }); + } + const snapshotMatch = /^GET \/snapshots\/([^/]+)$/.exec(route); + if (snapshotMatch) { + const snapshot = this.snapshots.find((s) => s.name === decodeURIComponent(snapshotMatch[1])); + return snapshot ? json(200, snapshot) : fail(404, "Snapshot not found"); + } + const match = /^(GET|POST|DELETE) \/sandbox\/([^/]+)(?:\/(.+))?$/.exec(route); if (!match) return fail(500, `unexpected ${route}`); const [, method, rawId, action] = match; @@ -218,6 +236,7 @@ export class FakeDaytonaControlPlane { memory: sandbox.memory, disk: sandbox.disk, labels: sandbox.labels, + ...(sandbox.sandboxClass ? { sandboxClass: sandbox.sandboxClass } : {}), }; } } diff --git a/src/node/services/daytona/daytonaClient.ts b/src/node/services/daytona/daytonaClient.ts index 30c1e8cfca0..f55061cff39 100644 --- a/src/node/services/daytona/daytonaClient.ts +++ b/src/node/services/daytona/daytonaClient.ts @@ -58,6 +58,8 @@ const SandboxSchema = z.object({ labels: z.record(z.string(), z.string()).nullish(), lastActivityAt: z.string().nullish(), errorReason: z.string().nullish(), + /** "linux-vm", "container" or "windows"; older control planes omit it. */ + sandboxClass: z.string().nullish(), }); export type DaytonaSandbox = z.infer; @@ -67,8 +69,25 @@ const SandboxPageSchema = z.object({ nextCursor: z.string().nullish(), }); +const SnapshotSchema = z.object({ + name: z.string(), + /** The class of the sandboxes the snapshot creates, as on a sandbox. */ + sandboxClass: z.string().nullish(), +}); + +export interface DaytonaSnapshot { + name: string; + sandboxClass?: string; +} + +function toSnapshot(item: z.infer): DaytonaSnapshot { + return item.sandboxClass + ? { name: item.name, sandboxClass: item.sandboxClass } + : { name: item.name }; +} + const SnapshotPageSchema = z.object({ - items: z.array(z.object({ name: z.string() })), + items: z.array(SnapshotSchema), page: z.number().optional(), totalPages: z.number().optional(), }); @@ -437,15 +456,20 @@ export class DaytonaClient { await this.request("POST", `/sandbox/${encodeURIComponent(id)}/last-activity`, { signal }); } - async listSnapshots(signal?: AbortSignal): Promise> { - const snapshots: Array<{ name: string }> = []; + async getSnapshot(name: string, signal?: AbortSignal): Promise { + const raw = await this.request("GET", `/snapshots/${encodeURIComponent(name)}`, { signal }); + return toSnapshot(this.parse(SnapshotSchema, raw, "GET /snapshots/{id}")); + } + + async listSnapshots(signal?: AbortSignal): Promise { + const snapshots: DaytonaSnapshot[] = []; for (let page = 1; page <= MAX_PAGES; page++) { const raw = await this.request("GET", "/snapshots", { query: { page, limit: PAGE_LIMIT }, signal, }); const parsed = this.parse(SnapshotPageSchema, raw, "GET /snapshots"); - snapshots.push(...parsed.items.map((item) => ({ name: item.name }))); + snapshots.push(...parsed.items.map(toSnapshot)); if (parsed.items.length === 0 || parsed.totalPages === undefined) return snapshots; if (page >= parsed.totalPages) return snapshots; } diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 0c5f9365ee1..73d9d79230f 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -41,6 +41,16 @@ interface StoredSandboxRef { attached: boolean; } +/** + * Daytona's sandboxClass of a Windows sandbox (and of the snapshots that create one). Xum + * provisions and runs workspaces over ssh with POSIX shell commands, which cmd.exe and + * PowerShell do not run, so it never offers, creates or attaches one. + */ +export const DAYTONA_WINDOWS_SANDBOX_CLASS = "windows"; + +const isWindowsClass = (item: { sandboxClass?: string | null }) => + item.sandboxClass === DAYTONA_WINDOWS_SANDBOX_CLASS; + export const DAYTONA_LABEL_INSTANCE = "xum.instance"; export const DAYTONA_LABEL_WORKSPACE_ID = "xum.workspaceId"; export const DAYTONA_LABEL_PROJECT = "xum.project"; @@ -661,7 +671,9 @@ export class DaytonaService { return { ok: true, sandboxes: sandboxes - .filter((sandbox) => !DESTROYING_STATES.has(sandbox.state ?? "")) + .filter( + (sandbox) => !DESTROYING_STATES.has(sandbox.state ?? "") && !isWindowsClass(sandbox) + ) .map(toSummary), }; } catch (error) { @@ -672,12 +684,27 @@ export class DaytonaService { async listSnapshots(): Promise { try { const { client } = await this.getClient(); - return { ok: true, snapshots: await client.listSnapshots() }; + const snapshots = await client.listSnapshots(); + return { + ok: true, + snapshots: snapshots.filter((s) => !isWindowsClass(s)).map(({ name }) => ({ name })), + }; } catch (error) { return toResultError(error); } } + /** Whether the snapshot creates Windows sandboxes; false for one Daytona does not find. */ + async isWindowsSnapshot(name: string): Promise { + const { client } = await this.getClient(); + try { + return isWindowsClass(await client.getSnapshot(name)); + } catch (error) { + if (isDaytonaNotFound(error)) return false; + throw error; + } + } + /** Stable per Xum install, so the reconciler only ever touches this install's sandboxes. */ getInstanceId(): Promise { return loadOrCreateInstallationId(this.rootDir); From d38d5a2efb52126ca799542be08c10181b7fc15f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:41:43 +0000 Subject: [PATCH 070/231] fix(ssh): never reuse a connection that was still connecting when its host was retired Retiring a host (a Daytona settings change) stopped only the connections already up. An ssh2 connect in flight could authenticate with the old account's token, finish afterwards, and join the pool for later commands; an OpenSSH ControlMaster still connecting had no socket for the -O stop pass to find, and later commands multiplexed onto it. The ssh2 pool counts retirements per host: a connect that started before one ends its client instead of pooling it, and the shared connect connects once more for its waiters. On the OpenSSH side a retirement moves the host to new ControlPaths before stopping the old masters, so a master that comes up late at an old path is never used and exits after ControlPersist. --- src/node/runtime/SSH2ConnectionPool.test.ts | 38 ++++++++++ src/node/runtime/SSH2ConnectionPool.ts | 71 +++++++++++++++---- src/node/runtime/sshConnectionPool.ts | 17 ++++- .../transports/OpenSSHTransport.test.ts | 37 ++++++++++ .../runtime/transports/OpenSSHTransport.ts | 5 +- 5 files changed, 151 insertions(+), 17 deletions(-) diff --git a/src/node/runtime/SSH2ConnectionPool.test.ts b/src/node/runtime/SSH2ConnectionPool.test.ts index 789916411e1..230bb9e5299 100644 --- a/src/node/runtime/SSH2ConnectionPool.test.ts +++ b/src/node/runtime/SSH2ConnectionPool.test.ts @@ -258,6 +258,44 @@ describe.skipIf(process.platform === "win32")( second.client.end(); }); + it("never pools a connection that was still connecting when its host was retired", async () => { + // The first connection's authentication waits until the test lets it through. + let releaseFirst!: () => void; + const firstAuth = new Promise((resolve) => (releaseFirst = resolve)); + const serverConns: Array<{ closed: boolean }> = []; + const { config } = await startServer((conn) => { + const record = { closed: false }; + serverConns.push(record); + const held = serverConns.length === 1 ? firstAuth : Promise.resolve(); + conn.on("authentication", (ctx) => void held.then(() => ctx.accept())); + conn.on("close", () => (record.closed = true)); + conn.on("error", () => undefined); + }); + const pool = new SSH2ConnectionPool(); + + // Joined by both waiters, then retired (a settings change) before it authenticates. + const waiters = [ + pool.acquireConnection(config), + pool.acquireConnection(config, { maxWaitMs: 0 }), + ]; + const settle = async (done: () => boolean) => { + for (let i = 0; i < 400 && !done(); i++) await new Promise((r) => setTimeout(r, 5)); + }; + await settle(() => serverConns.length > 0); + pool.retireConnection(config); + releaseFirst(); + const [a, b] = await Promise.all(waiters); + + // Both got the connection made after the retirement; the old one was closed. + expect(a).toBe(b); + expect(serverConns).toHaveLength(2); + await settle(() => serverConns[0].closed); + expect(serverConns[0].closed).toBe(true); + expect(serverConns[1].closed).toBe(false); + expect(await pool.acquireConnection(config)).toBe(a); + a.client.end(); + }); + it("a retired client's late error does not put the host into backoff", async () => { const { config } = await startServer((conn) => { conn.on("authentication", (ctx) => ctx.accept()); diff --git a/src/node/runtime/SSH2ConnectionPool.ts b/src/node/runtime/SSH2ConnectionPool.ts index c89ce4fc22e..fd6274e0f92 100644 --- a/src/node/runtime/SSH2ConnectionPool.ts +++ b/src/node/runtime/SSH2ConnectionPool.ts @@ -363,10 +363,20 @@ async function resolvePrivateKeys(identityFiles: string[]): Promise { return keys; } +/** A pooled connect whose host was retired (retireConnection) before it finished. */ +class RetiredConnectError extends Error { + constructor(key: string) { + super(`SSH connection ${key} was retired while it connected`); + this.name = "RetiredConnectError"; + } +} + export class SSH2ConnectionPool { private health = new Map(); private inflight = new Map>(); private connections = new Map(); + /** Bumped by retireConnection: a connect that started before then is never pooled. */ + private retirements = new Map(); private idleTimeoutMs = IDLE_TIMEOUT_MS; async acquireConnection( @@ -430,20 +440,11 @@ export class SSH2ConnectionPool { throw budgetExceeded; } - let inflight = this.inflight.get(key); - if (!inflight) { - // The connect is shared, so it takes no caller's abort signal: one caller aborting - // must not fail every waiter (#5101). Each caller races only its own wait against its - // abort and budget below. A connect nobody waits for any more still finishes (bounded - // by readyTimeout) and is cached, then closes after the idle timeout like any other. - inflight = this.connect(config, timeoutMs); - this.inflight.set(key, inflight); - // Attach no-op catch to prevent unhandled rejection when singleflighted - // promise rejects before any caller awaits it. Actual errors are - // propagated to callers via the await below. - // eslint-disable-next-line @typescript-eslint/no-empty-function - void inflight.catch(() => {}).finally(() => this.inflight.delete(key)); - } + // The connect is shared, so it takes no caller's abort signal: one caller aborting + // must not fail every waiter (#5101). Each caller races only its own wait against its + // abort and budget below. A connect nobody waits for any more still finishes (bounded + // by readyTimeout) and is cached, then closes after the idle timeout like any other. + const inflight = this.inflight.get(key) ?? this.startConnect(key, config, timeoutMs); try { const entry = await waitForAbortable( @@ -607,6 +608,9 @@ export class SSH2ConnectionPool { retireConnection(config: SSHConnectionConfig): void { const key = makeConnectionKey(config); this.health.delete(key); + // A connect in flight may authenticate with the old credentials: it closes instead of + // joining the pool, and its waiters get a fresh one (startConnect). + this.retirements.set(key, this.retirementOf(key) + 1); const entry = this.connections.get(key); if (!entry) return; this.connections.delete(key); @@ -615,6 +619,41 @@ export class SSH2ConnectionPool { if (entry.openChannels === 0) this.endClient(entry); } + /** The shared connect of `key`, connecting once more for its waiters if a retirement overtook it. */ + private startConnect( + key: string, + config: SSHConnectionConfig, + timeoutMs: number + ): Promise { + const started = this.connect(config, timeoutMs).catch((error: unknown) => { + if (error instanceof RetiredConnectError) return this.connect(config, timeoutMs); + throw error; + }); + this.inflight.set(key, started); + // Attach no-op catch to prevent unhandled rejection when singleflighted + // promise rejects before any caller awaits it. Actual errors are + // propagated to callers via the await in acquireConnection. + // eslint-disable-next-line @typescript-eslint/no-empty-function + void started.catch(() => {}).finally(() => this.inflight.delete(key)); + return started; + } + + private retirementOf(key: string): number { + return this.retirements.get(key) ?? 0; + } + + /** A connect that started before its host was retired closes instead of joining the pool. */ + private throwIfRetiredSince(key: string, retirement: number, entry: SSH2ConnectionEntry): void { + if (this.retirementOf(key) === retirement) return; + this.endClient(entry); + throw new RetiredConnectError(key); + } + + /** A retired connect says nothing about the host's health. */ + private reportConnectFailure(config: SSHConnectionConfig, error: unknown): void { + if (!(error instanceof RetiredConnectError)) this.reportFailure(config, getErrorMessage(error)); + } + private endClient(entry: SSH2ConnectionEntry): void { try { entry.client.end(); @@ -655,6 +694,7 @@ export class SSH2ConnectionPool { mode: "pooled" | "dedicated" = "pooled" ): Promise { const key = makeConnectionKey(config); + const retirement = this.retirementOf(key); try { const resolved = await resolveSSHConfig(config.host); const resolvedConfig: ResolvedSSHConfig = { @@ -860,6 +900,7 @@ export class SSH2ConnectionPool { // `registered` also keeps a dedicated client's later errors out of the host health. registered = true; if (mode === "dedicated") return entry; + this.throwIfRetiredSince(key, retirement, entry); this.connections.set(key, entry); entry.idleTimer = setTimeout(() => { this.closeIdleConnection(key, entry); @@ -896,7 +937,7 @@ export class SSH2ConnectionPool { const agentForFallback = shouldTryAgentOnly ? undefined : agent; return await attemptConnection(fallbackIdentityFiles, agentForFallback); } catch (error) { - this.reportFailure(config, getErrorMessage(error)); + this.reportConnectFailure(config, error); throw error; } } diff --git a/src/node/runtime/sshConnectionPool.ts b/src/node/runtime/sshConnectionPool.ts index 9e2473ff17d..1cb34361be9 100644 --- a/src/node/runtime/sshConnectionPool.ts +++ b/src/node/runtime/sshConnectionPool.ts @@ -675,7 +675,22 @@ export const sshConnectionPool = new SSHConnectionPool(); export function getControlPath(config: SSHConnectionConfig): string { const key = makeConnectionKey(config); const hash = hashKey(key); - return path.join(os.tmpdir(), `mux-ssh-${hash}`); + const retirements = controlPathRetirements.get(key) ?? 0; + return path.join(os.tmpdir(), `mux-ssh-${hash}${retirements > 0 ? `-r${retirements}` : ""}`); +} + +/** Per connection key: how often its ControlPaths were retired in this process. */ +const controlPathRetirements = new Map(); + +/** + * Move the host's later commands to new ControlPaths (credentials or gateway changed). A + * ControlMaster still connecting at an old path when its host was retired listens there once + * it is up, after the retirement stopped the masters it could see; no command uses it, and it + * exits after ControlPersist. + */ +export function retireControlPath(config: SSHConnectionConfig): void { + const key = makeConnectionKey(config); + controlPathRetirements.set(key, (controlPathRetirements.get(key) ?? 0) + 1); } /** diff --git a/src/node/runtime/transports/OpenSSHTransport.test.ts b/src/node/runtime/transports/OpenSSHTransport.test.ts index 00bef4b3648..3585f21f159 100644 --- a/src/node/runtime/transports/OpenSSHTransport.test.ts +++ b/src/node/runtime/transports/OpenSSHTransport.test.ts @@ -105,4 +105,41 @@ describe("retireOpenSSHMasters", () => { await fs.rm(dir, { recursive: true, force: true }); } }); + + it("never sends a later command to a master that was still connecting at the retirement", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-fake-ssh-")); + const calls = path.join(dir, "calls"); + await fs.writeFile(path.join(dir, "ssh"), `#!/bin/sh\necho "$@" >> ${calls}\n`, { + mode: 0o755, + }); + const originalPath = process.env.PATH; + process.env.PATH = `${dir}:${originalPath ?? ""}`; + spyOn(sshConnectionPool, "acquireConnection").mockResolvedValue(undefined); + const config = { host: toXumDaytonaHost("sb-connecting") }; + const base = sshConnectionPool.getControlPath(config); + const oldPaths = [base, ...Array.from({ length: 8 }, (_, shard) => `${base}-${shard}`)]; + try { + // No socket yet: a master is still authenticating with the old credentials. + await retireOpenSSHMasters(config); + // It finishes after the retirement and listens at its old path. + for (const oldPath of oldPaths) await fs.writeFile(oldPath, ""); + + const transport = new OpenSSHTransport(config); + for (let i = 0; i < 8; i++) { + const { process: child } = await transport.spawnRemoteProcess("true", {}); + await new Promise((resolve) => child.once("close", resolve)); + } + + const used = (await fs.readFile(calls, "utf-8")) + .trim() + .split("\n") + .map((line) => /ControlPath=(\S+)/.exec(line)?.[1]); + expect(used).toHaveLength(8); + for (const controlPath of used) expect(oldPaths).not.toContain(controlPath); + } finally { + process.env.PATH = originalPath; + for (const oldPath of oldPaths) await fs.rm(oldPath, { force: true }); + await fs.rm(dir, { recursive: true, force: true }); + } + }); }); diff --git a/src/node/runtime/transports/OpenSSHTransport.ts b/src/node/runtime/transports/OpenSSHTransport.ts index 5995830905c..c21fba85ced 100644 --- a/src/node/runtime/transports/OpenSSHTransport.ts +++ b/src/node/runtime/transports/OpenSSHTransport.ts @@ -11,6 +11,7 @@ import { redactDaytonaHostStderr } from "../daytonaTokenRedaction"; import { appendDaytonaHostKeyPinArgs, appendOpenSSHHostKeyPolicyArgs, + retireControlPath, sshConnectionPool, type SSHConnectionConfig, } from "../sshConnectionPool"; @@ -61,8 +62,10 @@ function getShardedControlPath(config: SSHConnectionConfig): string { * ones finish. Reverse forwards keep their own connections. */ export async function retireOpenSSHMasters(config: SSHConnectionConfig): Promise { - sshConnectionPool.forgetHealth(config); const base = sshConnectionPool.getControlPath(config); + // Before stopping: a command spawned meanwhile, or a master still connecting, is never shared. + retireControlPath(config); + sshConnectionPool.forgetHealth(config); const shards = Array.from({ length: OPENSSH_EXEC_SHARD_COUNT }, (_, shard) => `${base}-${shard}`); await Promise.all( [base, ...shards].map(async (controlPath) => { From 51c5a48a25c761cf3b506bf0e60e9d3b40516686 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:04:38 +0000 Subject: [PATCH 071/231] fix(daytona): refuse a snapshot Daytona no longer has before persisting A selected snapshot that Daytona does not find (deleted after it was saved as a project default) counted as a valid non-Windows one, so the workspace was persisted and every setup retry failed. The snapshot check now looks it up by name, falls back to the snapshot list (which serves the picker), and refuses one found in neither. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 23 +++++++++++++++++-- src/node/runtime/DaytonaSSHRuntime.ts | 8 +++++-- .../services/daytona/daytona.testHarness.ts | 8 ++++--- src/node/services/daytona/daytonaService.ts | 17 ++++++++++---- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index d6d56cc1a87..09d8eb797a2 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -315,8 +315,27 @@ describe("DaytonaSSHRuntime Windows sandboxes", () => { expect((await validate({ sandboxId: "sb-linux" })).success).toBe(true); expect((await validate({ snapshot: "daytona-small" })).success).toBe(true); - // A snapshot Daytona does not list by that name: Daytona's create decides, as before. - expect((await validate({ snapshot: "unlisted" })).success).toBe(true); + expect(rig.plane.countCalls("POST /sandbox")).toBe(0); + }); + + it("refuses a snapshot Daytona no longer has before persisting", async () => { + // A general snapshot may come only with the list, not by name. + rig.plane.snapshots.push({ + name: "general-small", + sandboxClass: "container", + listedOnly: true, + }); + const runtime = makeRuntime({}); + const validate = async (snapshot: string) => { + const finalized = await runtime.finalizeConfig("b", sshDaytonaConfig({ snapshot })); + if (!finalized.success) return finalized; + return runtime.validateBeforePersist("b", finalized.data); + }; + + // A stale project default: deleted after it was saved. + const stale = await validate("deleted-snapshot"); + expect(stale.success ? "" : stale.error).toContain('"deleted-snapshot" was not found'); + expect((await validate("general-small")).success).toBe(true); expect(rig.plane.countCalls("POST /sandbox")).toBe(0); }); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 0291d9c7343..ba2ccf1f301 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -84,7 +84,7 @@ export type DaytonaRuntimeService = Pick< | "invalidateSandboxReady" | "replaceRejectedSshToken" | "sshTokenGeneration" - | "isWindowsSnapshot" + | "findSnapshot" | "sandboxReferencesElsewhere" | "isAliasStoredElsewhere" | "withSandboxRemovalLock" @@ -703,11 +703,15 @@ export class DaytonaSSHRuntime extends SSHRuntime { /** Refused before any sandbox is created: a Windows snapshot makes a billed box setup fails on. */ private async checkSnapshotClass(snapshot: string): Promise> { + let found: { windows: boolean } | undefined; try { - if (!(await this.daytonaService.isWindowsSnapshot(snapshot))) return Ok(undefined); + found = await this.daytonaService.findSnapshot(snapshot); } catch (error) { return Err(`Could not reach Daytona: ${getErrorMessage(error)}`); } + // A stale choice (deleted after it was saved as a default) would fail every setup retry. + if (!found) return Err(`Daytona snapshot "${snapshot}" was not found; pick another snapshot`); + if (!found.windows) return Ok(undefined); return Err(`Daytona snapshot "${snapshot}" creates Windows sandboxes. ${LINUX_ONLY}`); } diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 0c803399ef8..3895755e578 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -38,8 +38,8 @@ export class FakeDaytonaControlPlane { readonly calls: string[] = []; readonly createBodies: Array> = []; readonly revokedTokens: string[] = []; - /** The organization's snapshots, by name. */ - readonly snapshots: Array<{ name: string; sandboxClass?: string }> = []; + /** The organization's snapshots; `listedOnly` ones only come with the list, not by name. */ + readonly snapshots: Array<{ name: string; sandboxClass?: string; listedOnly?: boolean }> = []; /** Answer POST /sandbox with 400 when it carries cpu/memory/disk (fixed-resource snapshot). */ rejectCreateResources = false; /** Resources a sandbox gets when created without any. */ @@ -152,7 +152,9 @@ export class FakeDaytonaControlPlane { } const snapshotMatch = /^GET \/snapshots\/([^/]+)$/.exec(route); if (snapshotMatch) { - const snapshot = this.snapshots.find((s) => s.name === decodeURIComponent(snapshotMatch[1])); + const snapshot = this.snapshots.find( + (s) => s.name === decodeURIComponent(snapshotMatch[1]) && !s.listedOnly + ); return snapshot ? json(200, snapshot) : fail(404, "Snapshot not found"); } diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 73d9d79230f..6799ee8d49b 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -31,6 +31,7 @@ import { type DaytonaGatewayConfig, type DaytonaResizeRequest, type DaytonaSandbox, + type DaytonaSnapshot, type DaytonaSshAccess, } from "./daytonaClient"; import type { DaytonaWorkspaceRef } from "./daytonaReconciler"; @@ -694,15 +695,21 @@ export class DaytonaService { } } - /** Whether the snapshot creates Windows sandboxes; false for one Daytona does not find. */ - async isWindowsSnapshot(name: string): Promise { + /** + * The snapshot named `name`, with whether it creates Windows sandboxes; undefined when + * Daytona has none by that name. The list is the fallback for one the lookup does not find + * by name (it serves the snapshot picker). + */ + async findSnapshot(name: string): Promise<{ windows: boolean } | undefined> { const { client } = await this.getClient(); + let snapshot: DaytonaSnapshot | undefined; try { - return isWindowsClass(await client.getSnapshot(name)); + snapshot = await client.getSnapshot(name); } catch (error) { - if (isDaytonaNotFound(error)) return false; - throw error; + if (!isDaytonaNotFound(error)) throw error; + snapshot = (await client.listSnapshots()).find((listed) => listed.name === name); } + return snapshot && { windows: isWindowsClass(snapshot) }; } /** Stable per Xum install, so the reconciler only ever touches this install's sandboxes. */ From 9054b6b128c1b803377af17239a7dbf6f955c60a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:06:39 +0000 Subject: [PATCH 072/231] fix(daytona): refuse a Windows deployment default snapshot before creating --- src/node/runtime/DaytonaSSHRuntime.test.ts | 11 ++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 25 +++++++++++++++++-- .../services/daytona/daytona.testHarness.ts | 3 +++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 09d8eb797a2..bfcede5f20b 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -339,6 +339,17 @@ describe("DaytonaSSHRuntime Windows sandboxes", () => { expect(rig.plane.countCalls("POST /sandbox")).toBe(0); }); + it("refuses a Windows deployment default when no snapshot is chosen", async () => { + rig.plane.snapshots.push({ name: "windows-default", sandboxClass: "windows" }); + rig.plane.defaultSnapshot = "windows-default"; + const runtime = makeRuntime({}); + const finalized = await runtime.finalizeConfig("b", sshDaytonaConfig({})); + if (!finalized.success) throw new Error(finalized.error); + const refused = await runtime.validateBeforePersist("b", finalized.data); + expect(refused.success ? "" : refused.error).toContain('"windows-default" creates Windows'); + expect(rig.plane.countCalls("POST /sandbox")).toBe(0); + }); + it("offers neither Windows snapshots nor Windows sandboxes", async () => { rig.plane.addSandbox({ id: "sb-win", sandboxClass: "windows" }); rig.plane.addSandbox({ id: "sb-linux", sandboxClass: "linux-vm" }); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index ba2ccf1f301..16d68a5f5bf 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -85,6 +85,7 @@ export type DaytonaRuntimeService = Pick< | "replaceRejectedSshToken" | "sshTokenGeneration" | "findSnapshot" + | "getGatewayConfig" | "sandboxReferencesElsewhere" | "isAliasStoredElsewhere" | "withSandboxRemovalLock" @@ -671,8 +672,28 @@ export class DaytonaSSHRuntime extends SSHRuntime { ): Promise> { if (!findDaytonaSizePreset(daytona)) return Err(invalidSizeError()); const identity = await this.checkSandboxIdentity(sandboxId, false); - if (!identity.success || !daytona.snapshot) return identity; - return this.checkSnapshotClass(daytona.snapshot); + if (!identity.success) return identity; + return daytona.snapshot + ? this.checkSnapshotClass(daytona.snapshot) + : this.checkDefaultSnapshotClass(); + } + + /** + * "Daytona default" creates from the snapshot the deployment advertises, so a Windows default + * is refused like a chosen one. A default the organization cannot look up is Daytona's to + * resolve at create time, as when none is advertised. + */ + private async checkDefaultSnapshotClass(): Promise> { + let snapshot: string | undefined; + let found: { windows: boolean } | undefined; + try { + snapshot = (await this.daytonaService.getGatewayConfig()).defaultSnapshot; + found = snapshot ? await this.daytonaService.findSnapshot(snapshot) : undefined; + } catch (error) { + return Err(`Could not reach Daytona: ${getErrorMessage(error)}`); + } + if (!found?.windows) return Ok(undefined); + return Err(`Daytona snapshot "${snapshot}" creates Windows sandboxes. ${LINUX_ONLY}`); } /** diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 3895755e578..01f6e6c98f7 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -40,6 +40,8 @@ export class FakeDaytonaControlPlane { readonly revokedTokens: string[] = []; /** The organization's snapshots; `listedOnly` ones only come with the list, not by name. */ readonly snapshots: Array<{ name: string; sandboxClass?: string; listedOnly?: boolean }> = []; + /** The snapshot GET /config advertises as the deployment default; unset advertises none. */ + defaultSnapshot?: string; /** Answer POST /sandbox with 400 when it carries cpu/memory/disk (fixed-resource snapshot). */ rejectCreateResources = false; /** Resources a sandbox gets when created without any. */ @@ -106,6 +108,7 @@ export class FakeDaytonaControlPlane { sshGatewayHost: "ssh.daytona.test", sshGatewayPort: 22, sshGatewayHostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey"], + ...(this.defaultSnapshot ? { defaultSnapshot: this.defaultSnapshot } : {}), }); } if (route === "GET /sandbox") { From 979ce24b70c4d7e236944afdb66d52189dd6f958 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:07:54 +0000 Subject: [PATCH 073/231] fix(daytona): repoint every sandbox alias when the gateway changes --- src/node/runtime/daytonaSshConfig.test.ts | 29 ++++++++++++++ src/node/runtime/daytonaSshConfig.ts | 46 ++++++++++++++++------- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index 31b1200544a..345fe7bec4e 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -94,6 +94,35 @@ describe("DaytonaSshConfigWriter", () => { expect(afterRemove).toContain("Host sb-2.xum--daytona"); }); + it("repoints every sandbox at a new gateway in the write that replaces its host keys", async () => { + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxId: "sb-2", token: "tokenB", gateway: GATEWAY }); + const moved = { + host: "ssh2.daytona.test", + port: 2222, + hostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINewGatewayKey"], + }; + + // sb-1 is refreshed first; sb-2 still has a turn on the shared known_hosts file. + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA2", gateway: moved }); + + const include = await fs.readFile(writer.paths.includePath, "utf8"); + expect(include).not.toContain("ssh.daytona.test"); + expect(include).toContain( + [ + "Host sb-2.xum--daytona", + " HostName ssh2.daytona.test", + " Port 2222", + " User tokenB", + " HostKeyAlias ssh2.daytona.test", + ].join("\n") + ); + expect(await writer.readTokens()).toEqual([ + { sandboxId: "sb-1", token: "tokenA2" }, + { sandboxId: "sb-2", token: "tokenB" }, + ]); + }); + it("reads back the tokens the include file holds, for a later Xum process", async () => { const later = new DaytonaSshConfigWriter({ xumHome, sshConfigPath }); expect(await later.readTokens()).toEqual([]); diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 6819525af40..ebd57f9c964 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -174,6 +174,18 @@ function renderIncludeFile(blocks: Map): string { return `${[INCLUDE_FILE_HEADER, ...blocks.values()].join("\n\n")}\n`; } +function tokensIn(blocks: Map): Array<{ sandboxId: string; token: string }> { + const suffix = `.${XUM_DAYTONA_HOST_SUFFIX}`; + const tokens: Array<{ sandboxId: string; token: string }> = []; + for (const [alias, block] of blocks) { + const token = /^\s*User\s+(\S+)\s*$/m.exec(block)?.[1]; + if (alias.endsWith(suffix) && token) { + tokens.push({ sandboxId: alias.slice(0, -suffix.length), token }); + } + } + return tokens; +} + // One lock per include file across writer instances: blocks of different sandboxes share it. const includeFileLocks = new MutexMap(); @@ -202,11 +214,21 @@ export class DaytonaSshConfigWriter { gateway: DaytonaSshGateway; }): Promise { await ensurePrivateDir(this.paths.dir); - await writeFileAtomic(this.paths.knownHostsPath, renderDaytonaKnownHosts(input.gateway), { - encoding: "utf-8", - mode: 0o600, + const knownHosts = renderDaytonaKnownHosts(input.gateway); + await includeFileLocks.withLock(this.paths.includePath, async () => { + await writeFileAtomic(this.paths.knownHostsPath, knownHosts, { + encoding: "utf-8", + mode: 0o600, + }); + // known_hosts is shared by every alias: a gateway change repoints them all (each keeps its + // token) in the same step, or another sandbox's turn would dial the old host with new pins. + const blocks = await this.readBlocks(); + for (const other of tokensIn(blocks)) { + blocks.set(toXumDaytonaHost(other.sandboxId), this.renderBlock({ ...input, ...other })); + } + blocks.set(toXumDaytonaHost(input.sandboxId), this.renderBlock(input)); + await this.writeBlocks(blocks); }); - await this.upsertBlock(input); await ensureManagedSSHConfigBlock({ markers: { start: XUM_DAYTONA_SSH_BLOCK_START, @@ -239,15 +261,11 @@ export class DaytonaSshConfigWriter { /** The sandbox ids and tokens the include file holds (written by this or an earlier process). */ async readTokens(): Promise> { - const suffix = `.${XUM_DAYTONA_HOST_SUFFIX}`; - const tokens: Array<{ sandboxId: string; token: string }> = []; - for (const [alias, block] of await this.readBlocks()) { - const token = /^\s*User\s+(\S+)\s*$/m.exec(block)?.[1]; - if (alias.endsWith(suffix) && token) { - tokens.push({ sandboxId: alias.slice(0, -suffix.length), token }); - } - } - return tokens; + return tokensIn(await this.readBlocks()); + } + + private renderBlock(input: { sandboxId: string; token: string; gateway: DaytonaSshGateway }) { + return renderDaytonaHostBlock({ ...input, knownHostsPath: this.paths.knownHostsPath }); } private upsertBlock(input: { @@ -255,7 +273,7 @@ export class DaytonaSshConfigWriter { token: string; gateway: DaytonaSshGateway; }): Promise { - const block = renderDaytonaHostBlock({ ...input, knownHostsPath: this.paths.knownHostsPath }); + const block = this.renderBlock(input); const alias = toXumDaytonaHost(input.sandboxId); return includeFileLocks.withLock(this.paths.includePath, async () => { const blocks = await this.readBlocks(); From 25f384639a5885ab3a45b55d6fb2a35a7fe6e387 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:10:59 +0000 Subject: [PATCH 074/231] fix(daytona): drop the install label from a handed-off sandbox so no sweep stops it --- src/node/runtime/DaytonaSSHRuntime.test.ts | 35 +++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 27 ++++++++++++++ .../services/daytona/daytona.testHarness.ts | 7 +++- src/node/services/daytona/daytonaClient.ts | 12 +++++++ src/node/services/daytona/daytonaService.ts | 12 +++++++ 5 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index bfcede5f20b..d04983195bc 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -10,6 +10,7 @@ import { localShellTransport, type DaytonaTestRig, } from "@/node/services/daytona/daytona.testHarness"; +import { DaytonaReconciler } from "@/node/services/daytona/daytonaReconciler"; import { DAYTONA_LABEL_INSTANCE, DAYTONA_READY_CACHE_MS, @@ -857,6 +858,14 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { expect(attachedLast).toEqual({ success: true, deletedPath: expect.any(String) as string }); expect(rig.plane.find("sb-1")).toBeDefined(); expect(deletes()).toBe(0); + // Handed to the user: no longer labeled as this install's, so no sweep stops it as an orphan. + expect(rig.plane.find("sb-1")?.labels).toEqual({}); + await new DaytonaReconciler({ + daytonaService: rig.service, + listWorkspaces: () => [], + isWorkspaceActive: () => Promise.resolve(false), + }).sweep(); + expect(rig.plane.find("sb-1")?.state).toBe("started"); // The attached workspace goes first: the owner's removal deletes it, with nothing to say. const attachedFirst = await remove("ws-t2", attached("sb-2")); @@ -868,6 +877,32 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { expect(deletes()).toBe(1); }); + it("keeps the owner's workspace when its sandbox cannot be handed off", async () => { + const labels = { [DAYTONA_LABEL_INSTANCE]: await rig.service.getInstanceId() }; + rig.plane.addSandbox({ id: "sb-1", name: "xum-owner", labels }); + const owner = { sandboxId: "xum-owner", shared: false, ...SMALL }; + rig.workspaces.push( + { workspaceId: "ws-o", runtimeConfig: sshDaytonaConfig(owner) }, + { workspaceId: "ws-t", runtimeConfig: sshDaytonaConfig({ sandboxId: "sb-1", shared: true }) } + ); + claimRemovals(); + const remove = () => + makeRuntime(owner, rig, { workspaceId: "ws-o" }).deleteWorkspace( + "/home/me/project", + "ws-o", + false + ); + + rig.plane.failingRoutes.add("PUT /sandbox/xum-owner/labels"); + const refused = await remove(); + expect(refused.success ? "" : refused.error).toContain("Xum-owned"); + expect(rig.plane.find("sb-1")?.labels).toEqual(labels); + + rig.plane.failingRoutes.clear(); + expect((await remove()).success).toBe(true); + expect(rig.plane.find("sb-1")?.labels).toEqual({}); + }); + it("counts a workspace whose removal failed after its checkout step as a reference again", async () => { rig.plane.addSandbox({ id: "sb-kept", state: "stopped" }); const owner = { sandboxId: "sb-kept", shared: false, ...SMALL }; diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 16d68a5f5bf..6c541a41423 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -92,6 +92,7 @@ export type DaytonaRuntimeService = Pick< | "waitUntilDestroyed" | "recordWorkspaceRemoval" | "releaseSshAccess" + | "disownSandbox" | "recordSetupFailure" | "getSetupFailure" >; @@ -1181,6 +1182,10 @@ export class DaytonaSSHRuntime extends SSHRuntime { if (fate === "delete") { return this.deleteOwnedSandbox(sandboxId, result.deletedPath, errorPrefix); } + if (fate === "handOff") { + const disowned = await this.disownSandbox(sandboxId, errorPrefix); + if (disowned) return disowned; + } // Access is per stored string (a sandbox's name or its id): a kept sandbox keeps it only // while another workspace stores the same one. const releases = @@ -1206,6 +1211,28 @@ export class DaytonaSSHRuntime extends SSHRuntime { return { ...result, notice }; } + /** + * A handed-off sandbox still labeled as this install's would be stopped by the orphan sweep + * once its last workspace goes, so a failed relabel fails the removal (a retry relabels it). + */ + private async disownSandbox( + sandboxId: string, + errorPrefix: string | undefined + ): Promise { + try { + await this.daytonaService.disownSandbox(sandboxId); + return undefined; + } catch (error) { + const message = getErrorMessage(error); + log.error("Failed to hand off a Daytona sandbox", { sandboxId, error: message }); + const failure = `could not mark Daytona sandbox "${sandboxId}" as no longer Xum-owned: ${message}`; + return { + success: false, + error: errorPrefix ? `${errorPrefix}${failure}` : `Xum ${failure}`, + }; + } + } + /** Start the sandbox (if stopped or archived) so the dirty and unpushed checks can run. */ private async startForDeleteChecks( sandboxId: string, diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 01f6e6c98f7..1ba8008e49f 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -161,7 +161,7 @@ export class FakeDaytonaControlPlane { return snapshot ? json(200, snapshot) : fail(404, "Snapshot not found"); } - const match = /^(GET|POST|DELETE) \/sandbox\/([^/]+)(?:\/(.+))?$/.exec(route); + const match = /^(GET|POST|PUT|DELETE) \/sandbox\/([^/]+)(?:\/(.+))?$/.exec(route); if (!match) return fail(500, `unexpected ${route}`); const [, method, rawId, action] = match; const idOrName = decodeURIComponent(rawId); @@ -206,6 +206,11 @@ export class FakeDaytonaControlPlane { return json(200, {}); case "POST last-activity": return json(200, {}); + case "PUT labels": { + const body = JSON.parse(requestBody(init)) as { labels: Record }; + sandbox.labels = body.labels; + return json(200, { labels: sandbox.labels }); + } default: return fail(500, `unexpected ${route}`); } diff --git a/src/node/services/daytona/daytonaClient.ts b/src/node/services/daytona/daytonaClient.ts index f55061cff39..34d38b99cd7 100644 --- a/src/node/services/daytona/daytonaClient.ts +++ b/src/node/services/daytona/daytonaClient.ts @@ -451,6 +451,18 @@ export class DaytonaClient { }); } + /** Replaces every label: Daytona has no call that removes a single one. */ + async replaceLabels( + id: string, + labels: Record, + signal?: AbortSignal + ): Promise { + await this.request("PUT", `/sandbox/${encodeURIComponent(id)}/labels`, { + body: { labels }, + signal, + }); + } + /** Daytona counts only SSH and API traffic as activity; this keeps a busy box from stopping. */ async refreshActivity(id: string, signal?: AbortSignal): Promise { await this.request("POST", `/sandbox/${encodeURIComponent(id)}/last-activity`, { signal }); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 6799ee8d49b..7ef1adf0e14 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -812,6 +812,18 @@ export class DaytonaService { await client.resizeSandbox(sandboxId, request); } + /** + * Hand a sandbox Xum created to the user: drop this install's label, which is what makes the + * reconciler stop it once no workspace references it. + */ + async disownSandbox(sandboxId: string): Promise { + const { client } = await this.getClient(); + const labels = (await client.getSandbox(sandboxId)).labels ?? {}; + if (!(DAYTONA_LABEL_INSTANCE in labels)) return; + const kept = Object.entries(labels).filter(([key]) => key !== DAYTONA_LABEL_INSTANCE); + await client.replaceLabels(sandboxId, Object.fromEntries(kept)); + } + async stopSandbox(sandboxId: string): Promise { const { client } = await this.getClient(); this.readyAt.delete(sandboxId); From a539e2321c5e2e2f7866aab028b45b1eca2e842f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:13:39 +0000 Subject: [PATCH 075/231] fix(daytona): close the bash AI proxy forward when a sandbox alias is retired --- src/node/runtime/transports/index.ts | 14 ++++++++- .../bashAiProxy/bashAiProxyService.test.ts | 26 +++++++++++++++- .../bashAiProxy/bashAiProxyService.ts | 5 +++ .../services/bashAiProxy/reverseForwards.ts | 31 +++++++++++++++++-- .../services/bashAiProxy/sshForwardTarget.ts | 4 +-- 5 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/node/runtime/transports/index.ts b/src/node/runtime/transports/index.ts index 2026beada18..c89a17473fd 100644 --- a/src/node/runtime/transports/index.ts +++ b/src/node/runtime/transports/index.ts @@ -12,8 +12,20 @@ export function createSSHTransport(config: SSHConnectionConfig, useSSH2: boolean return useSSH2 ? new SSH2Transport(config) : new OpenSSHTransport(config); } -/** Retire the host's pooled connections on both transports (credentials or gateway changed). */ +const retiredHostListeners = new Set<(host: string) => void>(); + +/** + * Connections the pools do not hold (a reverse forward opens its own) close through these when + * their host is retired. Returns the unsubscribe. + */ +export function onSshHostRetired(listener: (host: string) => void): () => void { + retiredHostListeners.add(listener); + return () => retiredHostListeners.delete(listener); +} + +/** Retire the host's connections on both transports (credentials or gateway changed). */ export async function retireSshConnections(config: SSHConnectionConfig): Promise { ssh2ConnectionPool.retireConnection(config); + for (const listener of retiredHostListeners) listener(config.host); await retireOpenSSHMasters(config); } diff --git a/src/node/services/bashAiProxy/bashAiProxyService.test.ts b/src/node/services/bashAiProxy/bashAiProxyService.test.ts index 5bb0e27e4d4..84f628084cc 100644 --- a/src/node/services/bashAiProxy/bashAiProxyService.test.ts +++ b/src/node/services/bashAiProxy/bashAiProxyService.test.ts @@ -11,7 +11,11 @@ import type { AiSdkUsageLike } from "@/common/utils/tokens/usageHelpers"; import { log } from "@/node/services/log"; import type { ProviderConfigRaw } from "@/node/utils/providerRequirements"; -import { ReverseForwardRefusedError, type ReverseForward } from "@/node/runtime/transports"; +import { + ReverseForwardRefusedError, + retireSshConnections, + type ReverseForward, +} from "@/node/runtime/transports"; import { BashAiProxyService } from "./bashAiProxyService"; import type { ForwardTarget } from "./reverseForwards"; @@ -712,6 +716,26 @@ describe("BashAiProxyService", () => { await res.text(); }); + test("retiring a host's ssh connections closes its forward, and the next turn opens one", async () => { + // Daytona retires a sandbox alias when its account or gateway changes: the forward's own + // connection would still end in the old sandbox. + const retired = fakeSshHost("sb-1.xum--daytona|22|"); + const other = fakeSshHost("sb-2.xum--daytona|22|"); + sshTargets.set("ws-1", retired.target); + sshTargets.set("ws-2", other.target); + await proxy.envFor("ws-1", "ssh", []); + await proxy.envFor("ws-2", "ssh", []); + + await retireSshConnections({ host: "sb-1.xum--daytona" }); + + expect(retired.closes()).toBe(1); + expect(other.closes()).toBe(0); + await proxy.envFor("ws-1", "ssh", []); + expect(retired.opened).toHaveLength(2); + await proxy.envFor("ws-2", "ssh", []); + expect(other.opened).toHaveLength(1); + }); + test("a forward that outlived a restart is adopted, not replaced", async () => { const host = fakeSshHost("ssh-host-4", { survivesClose: true }); sshTargets.set("ssh-ws", host.target); diff --git a/src/node/services/bashAiProxy/bashAiProxyService.ts b/src/node/services/bashAiProxy/bashAiProxyService.ts index 81937946174..4ca79286fa4 100644 --- a/src/node/services/bashAiProxy/bashAiProxyService.ts +++ b/src/node/services/bashAiProxy/bashAiProxyService.ts @@ -33,6 +33,7 @@ import { import type { RuntimeMode } from "@/common/types/runtime"; import type { ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator"; import type { AiSdkUsageLike } from "@/common/utils/tokens/usageHelpers"; +import { onSshHostRetired } from "@/node/runtime/transports"; import { log } from "@/node/services/log"; import { resolveProviderCredentials, @@ -263,6 +264,7 @@ export class BashAiProxyService { private stopped = false; private readonly state: ProxyStateStore; private readonly forwards: ReverseForwardManager; + private readonly stopRetireListener: () => void; /** The forward last saved per SSH host, to skip redundant writes. */ private readonly savedForwards = new Map(); private restoring: Promise | undefined; @@ -282,6 +284,8 @@ export class BashAiProxyService { savedRemotePort: (hostKey) => this.savedForwards.get(hostKey)?.remotePort, onEstablished: () => undefined, // envFor() records the workspace that uses it }); + // Daytona retires a sandbox alias when its account or gateway changes. + this.stopRetireListener = onSshHostRetired((host) => this.forwards.closeHost(host)); } /** @@ -438,6 +442,7 @@ export class BashAiProxyService { async stop(): Promise { this.stopped = true; + this.stopRetireListener(); const forwardsClosed = this.forwards.closeAll(); const server = this.server; this.server = undefined; diff --git a/src/node/services/bashAiProxy/reverseForwards.ts b/src/node/services/bashAiProxy/reverseForwards.ts index 63d65330cd8..d5a51b03ea0 100644 --- a/src/node/services/bashAiProxy/reverseForwards.ts +++ b/src/node/services/bashAiProxy/reverseForwards.ts @@ -26,6 +26,17 @@ export interface ForwardTarget { remoteHealth(remotePort: number, nonce: string): Promise; } +const HOST_KEY_SEPARATOR = "|"; + +/** ForwardTarget.hostKey of an SSH host; the host comes first so closeHost() can find it. */ +export function sshForwardHostKey(config: { + host: string; + port?: number; + identityFile?: string; +}): string { + return [config.host, config.port ?? 22, config.identityFile ?? ""].join(HOST_KEY_SEPARATOR); +} + export interface ReverseForwardManagerOptions { /** Seeds the remote port candidates (the Xum root dir). */ seed: string; @@ -117,12 +128,23 @@ export class ReverseForwardManager { await Promise.allSettled(this.cancelAll()); } + /** + * Closes the forwards to `host` (its ssh connections were retired), as closeOwned() does for + * every host: a forward rides its own connection, which would still reach the old machine. + */ + closeHost(host: string): void { + for (const [hostKey, state] of this.hosts) { + if (hostKey.split(HOST_KEY_SEPARATOR)[0] !== host) continue; + this.cancel(state); + this.hosts.delete(hostKey); + } + } + /** Closes owned forwards and cancels setups in flight; returns those setups. */ private cancelAll(): Array> { const pending: Array> = []; for (const state of this.hosts.values()) { - state.cancelled = true; - state.owned?.close(); + this.cancel(state); if (state.establishing) pending.push(state.establishing); } // A setup in flight keeps its detached state; the next ensure() starts a fresh one. @@ -130,6 +152,11 @@ export class ReverseForwardManager { return pending; } + private cancel(state: HostState): void { + state.cancelled = true; + state.owned?.close(); + } + private stopped(state: HostState): boolean { return this.closed || state.cancelled === true; } diff --git a/src/node/services/bashAiProxy/sshForwardTarget.ts b/src/node/services/bashAiProxy/sshForwardTarget.ts index 30722842cfb..547e87c3f09 100644 --- a/src/node/services/bashAiProxy/sshForwardTarget.ts +++ b/src/node/services/bashAiProxy/sshForwardTarget.ts @@ -9,7 +9,7 @@ import { SSHRuntime } from "@/node/runtime/SSHRuntime"; import { ServerLockfile } from "@/node/services/serverLockfile"; import { execBuffered } from "@/node/utils/runtime/helpers"; -import type { ForwardTarget } from "./reverseForwards"; +import { sshForwardHostKey, type ForwardTarget } from "./reverseForwards"; import { BASH_AI_PROXY_HEALTH_PATH, isHealthNonce } from "./stableIdentity"; /** @@ -31,7 +31,7 @@ export function createSshForwardTarget(runtime: Runtime): ForwardTarget | undefi if (!(runtime instanceof SSHRuntime)) return undefined; const config = runtime.getConfig(); return { - hostKey: [config.host, config.port ?? 22, config.identityFile ?? ""].join("|"), + hostKey: sshForwardHostKey(config), // Managed boxes: restoring a forward at startup would start a stopped (billed) box. restoreAtStartup: !(runtime instanceof CoderSSHRuntime || runtime instanceof DaytonaSSHRuntime), openReverseForward: (remotePort, localPort) => From 83ec9fe0f82852fb7161fafcaa9358dd63cf450b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:30:25 +0000 Subject: [PATCH 076/231] fix(daytona): narrow the default snapshot name before reporting it --- src/node/runtime/DaytonaSSHRuntime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 6c541a41423..768f9168f2c 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -693,7 +693,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { } catch (error) { return Err(`Could not reach Daytona: ${getErrorMessage(error)}`); } - if (!found?.windows) return Ok(undefined); + if (!snapshot || !found?.windows) return Ok(undefined); return Err(`Daytona snapshot "${snapshot}" creates Windows sandboxes. ${LINUX_ONLY}`); } From 762550443b2061d2e775db775928bf98e59bf178 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:04:54 +0000 Subject: [PATCH 077/231] fix(daytona): retire a released sandbox's ssh connections and forwards --- .../services/daytona/daytonaService.test.ts | 21 +++++++++++++++++-- src/node/services/daytona/daytonaService.ts | 4 ++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index d613ab3a031..5381635ffb7 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -283,6 +283,8 @@ describe("DaytonaService", () => { const writes: string[] = []; /** Sandboxes whose pooled ssh connections were retired. */ const retired: string[] = []; + /** Include block removals and retirements, in order. */ + const order: string[] = []; const record = (input: { token: string; gateway: { host: string; hostKeys: string[] } }) => { writes.push(`${input.token}@${input.gateway.host} ${input.gateway.hostKeys.join(",")}`); return Promise.resolve(); @@ -295,16 +297,20 @@ describe("DaytonaService", () => { sshConfigWriter: { writeSandbox: record, updateToken: record, - removeSandbox: () => Promise.resolve(), + removeSandbox: (sandboxId) => { + order.push(`remove ${sandboxId}`); + return Promise.resolve(); + }, readTokens: () => Promise.resolve([]), verifyResolvedConfig: () => Promise.resolve(), }, retireSshConnections: (sandboxId) => { retired.push(sandboxId); + order.push(`retire ${sandboxId}`); return Promise.resolve(); }, }); - return { service: recording, writes, retired }; + return { service: recording, writes, retired, order }; } it("refuses a settings change while it looks up the gateway for a token", async () => { @@ -378,6 +384,17 @@ describe("DaytonaService", () => { expect(retired).toEqual(["sb-1"]); }); + it("retires a released sandbox's ssh connections once its include block is gone", async () => { + const { service: releasing, order } = serviceRecordingWrites(); + await releasing.ensureSshAccess("sb-1"); + + await releasing.releaseSshAccess("sb-1"); + + // Pooled sessions and reverse forwards stay authenticated after the token is revoked; a + // retirement before the block is gone could reconnect from it. + expect(order).toEqual(["remove sb-1", "retire sb-1"]); + }); + it("revokes a token minted across a release, keeping the access released", async () => { const held = serviceWithHeldMint(); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 7ef1adf0e14..bc5d0515994 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -1016,6 +1016,10 @@ export class DaytonaService { this.readyAt.delete(sandboxId); await this.forgetSshToken(sandboxId); await this.sshConfigWriter.removeSandbox(sandboxId); + // A revoked token does not end sessions it opened: pooled connections and the bash AI + // proxy's reverse forward would keep reaching the sandbox. Retired only once the include + // block is gone, so nothing reconnects from it. + await this.retirePooledSsh([sandboxId]); } tokenNeedsRefresh(sandboxId: string): boolean { From c47225f8a4bfa5121ed178db5994f5ccad83203f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:06:14 +0000 Subject: [PATCH 078/231] fix(daytona): release an archived workspace's alias that no other workspace stores --- .../daytona/daytonaLifecycleHooks.test.ts | 21 ++++++++++++++ .../services/daytona/daytonaLifecycleHooks.ts | 28 ++++++++++--------- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/node/services/daytona/daytonaLifecycleHooks.test.ts b/src/node/services/daytona/daytonaLifecycleHooks.test.ts index fa6faaacc8f..925f04a9673 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.test.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.test.ts @@ -126,6 +126,27 @@ describe("createDaytonaArchiveHook", () => { expect(rig.plane.find("sb-9")?.state).toBe("started"); }); + it("releases an alias no other workspace stores, keeping the sandbox another one uses", async () => { + // The owner stores the generated name, the attached workspace the real id: ssh access + // (token, include block, pooled sessions, forwards) is per stored string. + rig.plane.addSandbox({ id: "sb-9", name: "xum-owner-def456" }); + rig.workspaces.push({ + workspaceId: "attached", + runtimeConfig: metadata({ sandboxId: "sb-9", shared: true }).runtimeConfig, + }); + await rig.service.ensureSshAccess("xum-owner-def456"); // tok1 + await rig.service.ensureSshAccess("sb-9"); + + expect((await archive({ sandboxId: "xum-owner-def456", shared: false }, "stop")).success).toBe( + true + ); + + expect(rig.plane.find("sb-9")?.state).toBe("started"); + expect(rig.plane.revokedTokens).toEqual(["tok1"]); + expect(rig.writer.removed).toEqual(["xum-owner-def456"]); + expect(rig.service.tokenNeedsRefresh("sb-9")).toBe(false); + }); + it("leaves the parent's sandbox alone when a shared-checkout sub-agent is archived", async () => { rig.plane.addSandbox({ id: "sb-parent" }); const hook = createDaytonaArchiveHook({ diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index b4b74794847..0bf7d8d92fb 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -23,6 +23,7 @@ export interface DaytonaArchiveHookOptions { | "stopAndWait" | "deleteSandbox" | "isSandboxReferencedElsewhere" + | "isAliasStoredElsewhere" | "releaseSshAccess" | "withAccountLease" >; @@ -48,8 +49,17 @@ async function archiveSandbox( options: DaytonaArchiveHookOptions, args: Parameters[0] ): Promise> { - const sandboxId = await unsharedSandboxId(options, args.workspaceMetadata); + const sandboxId = storedSandboxId(args.workspaceMetadata); if (!sandboxId) return Ok(undefined); + const workspaceId = args.workspaceMetadata.id; + if (await options.daytonaService.isSandboxReferencedElsewhere(sandboxId, workspaceId)) { + // The sandbox stays as it is, but ssh access is per stored string: another workspace that + // stores the same sandbox under its other identifier (name or id) never uses this alias. + if (!options.daytonaService.isAliasStoredElsewhere(sandboxId, workspaceId)) { + await releaseSshAccess(options, sandboxId); + } + return Ok(undefined); + } const result = isDedicatedDaytonaSandbox(args.workspaceMetadata.runtimeConfig) ? await applyArchiveBehavior(options, args, sandboxId) @@ -76,20 +86,12 @@ async function applyArchiveBehavior( } } -/** The archived workspace's sandbox, unless another workspace (archived ones too) uses it. */ -async function unsharedSandboxId( - options: DaytonaArchiveHookOptions, - workspaceMetadata: WorkspaceMetadata -): Promise { +/** The sandbox as the archived workspace stores it (its name or its id). */ +function storedSandboxId(workspaceMetadata: WorkspaceMetadata): string | undefined { // A shared-checkout sub-agent copies its parent's runtime config, sandbox ID included: - // the sandbox belongs to the parent. + // the sandbox and its access belong to the parent. if (workspaceMetadata.taskIsolation === "none") return undefined; - const sandboxId = getDaytonaSandboxId(workspaceMetadata.runtimeConfig); - if (!sandboxId) return undefined; - if (await options.daytonaService.isSandboxReferencedElsewhere(sandboxId, workspaceMetadata.id)) { - return undefined; - } - return sandboxId; + return getDaytonaSandboxId(workspaceMetadata.runtimeConfig); } /** The archived workspace needs no token; unarchive mints a new one. */ From 3eb8fa487a67e25f83c3807d22ebd03068d7c8a0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:07:45 +0000 Subject: [PATCH 079/231] fix(daytona): register saved ssh tokens before restoring bash AI proxy forwards --- src/node/services/serviceContainer.test.ts | 23 +++++++++++++++++++++- src/node/services/serviceContainer.ts | 11 ++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index 093198390e1..98a954ff235 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -925,8 +925,8 @@ describe("ServiceContainer", () => { "telemetryService.initialize", "coderOauthService.separateDiscoveredModels", "experimentsService.initialize", - "bashAiProxy.restore", "daytonaService.loadPersistedTokens", + "bashAiProxy.restore", "agentResourceLimits.start", "taskService.recoverInterruptedTasks", ]; @@ -1219,6 +1219,27 @@ describe("ServiceContainer", () => { expect(recoverTasks).not.toHaveBeenCalled(); }); + it("initializeCore registers Daytona's saved ssh tokens before the bash AI proxy restores forwards", async () => { + services = new ServiceContainer(stores); + spyOn(services.taskService, "recoverInterruptedTasks").mockResolvedValue(undefined); + // Forward restoration starts ssh in the background; its errors may quote a token. + const restore = spyOn(services.bashAiProxy, "restore").mockResolvedValue(undefined); + let tokensRequested!: () => void; + const requested = new Promise((resolve) => (tokensRequested = resolve)); + let releaseTokens!: () => void; + spyOn(services.daytonaService, "loadPersistedTokens").mockImplementation(() => { + tokensRequested(); + return new Promise((resolve) => (releaseTokens = resolve)); + }); + + const core = services.initializeCore(); + await requested; + expect(restore).not.toHaveBeenCalled(); + releaseTokens(); + await core; + expect(restore).toHaveBeenCalledTimes(1); + }); + it("initializeCore records the eight core steps and re-runs them when called again", async () => { services = new ServiceContainer(stores); const recoverTasks = spyOn(services.taskService, "recoverInterruptedTasks").mockResolvedValue( diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index de05a5b5d1e..58e511325c0 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -532,16 +532,17 @@ export class ServiceContainer { this.syncPerfFlightRecorder(); }, }, - // Bash commands that outlived a restart still hold the proxy URL and key: bind the saved - // port again before clients connect. Best-effort: a proxy problem never blocks startup. - { name: "bashAiProxy.restore", run: () => this.bashAiProxy.restore(), bestEffort: true }, - // Before any ssh (skills listing, workspace open, task recovery): an earlier process's - // Daytona tokens still sit in the ssh include file, and ssh errors quote them. + // Before any ssh (forward restoration, skills listing, workspace open, task recovery): an + // earlier process's Daytona tokens still sit in the ssh include file, and ssh errors quote + // them. { name: "daytonaService.loadPersistedTokens", run: () => this.daytonaService.loadPersistedTokens(), bestEffort: true, }, + // Bash commands that outlived a restart still hold the proxy URL and key: bind the saved + // port again before clients connect. Best-effort: a proxy problem never blocks startup. + { name: "bashAiProxy.restore", run: () => this.bashAiProxy.restore(), bestEffort: true }, // Before task recovery, so commands of recovered tasks start under the agent resource limit. // Best-effort: a limit that cannot apply is reported in Settings and never blocks startup. { From f24a5171f3b42e7ad26a63f209c0d321fed59e79 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:09:28 +0000 Subject: [PATCH 080/231] fix(daytona): install a moved gateway's ssh setup before retiring old connections --- src/node/runtime/daytonaSshConfig.test.ts | 6 +++ src/node/runtime/daytonaSshConfig.ts | 44 +++++++++++++------ .../services/daytona/daytona.testHarness.ts | 11 ++++- .../services/daytona/daytonaService.test.ts | 26 ++++++++++- src/node/services/daytona/daytonaService.ts | 29 +++++++++--- 5 files changed, 93 insertions(+), 23 deletions(-) diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index 345fe7bec4e..9d6c44505b8 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -121,6 +121,12 @@ describe("DaytonaSshConfigWriter", () => { { sandboxId: "sb-1", token: "tokenA2" }, { sandboxId: "sb-2", token: "tokenB" }, ]); + + // Moved back without a token refresh: every block follows, tokens kept. + await writer.repointGateway(GATEWAY); + expect(await fs.readFile(writer.paths.includePath, "utf8")).not.toContain("ssh2.daytona.test"); + expect(await writer.readTokens()).toHaveLength(2); + expect(await fs.readFile(writer.paths.knownHostsPath, "utf8")).toContain("GatewayKey"); }); it("reads back the tokens the include file holds, for a later Xum process", async () => { diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index ebd57f9c964..889afa848fa 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -213,31 +213,47 @@ export class DaytonaSshConfigWriter { token: string; gateway: DaytonaSshGateway; }): Promise { + await this.installGateway(input.gateway, (blocks) => + blocks.set(toXumDaytonaHost(input.sandboxId), this.renderBlock(input)) + ); + await ensureManagedSSHConfigBlock({ + markers: { + start: XUM_DAYTONA_SSH_BLOCK_START, + end: XUM_DAYTONA_SSH_BLOCK_END, + label: "Daytona", + }, + block: renderDaytonaManagedBlock(this.paths.includePath), + sshConfigPath: this.sshConfigPath, + }); + } + + /** Daytona moved its gateway: repoint every sandbox's block (keeping its token) and pins. */ + async repointGateway(gateway: DaytonaSshGateway): Promise { + await this.installGateway(gateway, () => undefined); + } + + /** + * known_hosts is shared by every alias: a gateway change repoints them all (each keeps its + * token) in the same step, or another sandbox's turn would dial the old host with new pins. + */ + private async installGateway( + gateway: DaytonaSshGateway, + edit: (blocks: Map) => void + ): Promise { await ensurePrivateDir(this.paths.dir); - const knownHosts = renderDaytonaKnownHosts(input.gateway); + const knownHosts = renderDaytonaKnownHosts(gateway); await includeFileLocks.withLock(this.paths.includePath, async () => { await writeFileAtomic(this.paths.knownHostsPath, knownHosts, { encoding: "utf-8", mode: 0o600, }); - // known_hosts is shared by every alias: a gateway change repoints them all (each keeps its - // token) in the same step, or another sandbox's turn would dial the old host with new pins. const blocks = await this.readBlocks(); for (const other of tokensIn(blocks)) { - blocks.set(toXumDaytonaHost(other.sandboxId), this.renderBlock({ ...input, ...other })); + blocks.set(toXumDaytonaHost(other.sandboxId), this.renderBlock({ ...other, gateway })); } - blocks.set(toXumDaytonaHost(input.sandboxId), this.renderBlock(input)); + edit(blocks); await this.writeBlocks(blocks); }); - await ensureManagedSSHConfigBlock({ - markers: { - start: XUM_DAYTONA_SSH_BLOCK_START, - end: XUM_DAYTONA_SSH_BLOCK_END, - label: "Daytona", - }, - block: renderDaytonaManagedBlock(this.paths.includePath), - sshConfigPath: this.sshConfigPath, - }); } /** Token refresh: rewrites only the include file, never `~/.ssh/config` or known_hosts. */ diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 1ba8008e49f..6cf1d4170b3 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -254,7 +254,12 @@ export class FakeDaytonaControlPlane { /** Records ssh config writes instead of touching `~/.ssh/config`. */ export class FakeDaytonaSshConfigWriter implements Pick< DaytonaSshConfigWriter, - "writeSandbox" | "updateToken" | "removeSandbox" | "verifyResolvedConfig" | "readTokens" + | "writeSandbox" + | "updateToken" + | "repointGateway" + | "removeSandbox" + | "verifyResolvedConfig" + | "readTokens" > { /** Current token per sandbox alias, as the include file would hold it. */ readonly tokens = new Map(); @@ -273,6 +278,10 @@ export class FakeDaytonaSshConfigWriter implements Pick< return Promise.resolve(); } + repointGateway(): Promise { + return Promise.resolve(); + } + removeSandbox(sandboxId: string): Promise { this.removed.push(sandboxId); this.tokens.delete(sandboxId); diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 5381635ffb7..ed7b09a3744 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -248,6 +248,7 @@ describe("DaytonaService", () => { sshConfigWriter: { writeSandbox: store, updateToken: store, + repointGateway: () => Promise.resolve(), removeSandbox: (sandboxId: string) => { includeFile.delete(sandboxId); return Promise.resolve(); @@ -283,8 +284,10 @@ describe("DaytonaService", () => { const writes: string[] = []; /** Sandboxes whose pooled ssh connections were retired. */ const retired: string[] = []; - /** Include block removals and retirements, in order. */ + /** Include block removals, gateway repoints and retirements, in order. */ const order: string[] = []; + /** Per retirement: whether the sandbox's next ssh access would set it up again. */ + const staleAtRetire: boolean[] = []; const record = (input: { token: string; gateway: { host: string; hostKeys: string[] } }) => { writes.push(`${input.token}@${input.gateway.host} ${input.gateway.hostKeys.join(",")}`); return Promise.resolve(); @@ -301,16 +304,21 @@ describe("DaytonaService", () => { order.push(`remove ${sandboxId}`); return Promise.resolve(); }, + repointGateway: (gateway) => { + order.push(`repoint ${gateway.host} ${gateway.hostKeys.join(",")}`); + return Promise.resolve(); + }, readTokens: () => Promise.resolve([]), verifyResolvedConfig: () => Promise.resolve(), }, retireSshConnections: (sandboxId) => { retired.push(sandboxId); order.push(`retire ${sandboxId}`); + staleAtRetire.push(recording.tokenNeedsRefresh(sandboxId)); return Promise.resolve(); }, }); - return { service: recording, writes, retired, order }; + return { service: recording, writes, retired, order, staleAtRetire }; } it("refuses a settings change while it looks up the gateway for a token", async () => { @@ -375,6 +383,20 @@ describe("DaytonaService", () => { expect(retired).toEqual(["sb-1"]); }); + it("installs a moved gateway before retiring the connections to the old one", async () => { + const { service: watching, order, staleAtRetire } = serviceRecordingWrites(); + await watching.ensureSshAccess("sb-1"); + + plane.gatewayHostKeys = ["ssh-ed25519 BBBB"]; + clock += 6 * 60_000; + await watching.getGatewayConfig(); + + // A connection opened while the old ones are retired reads the new files, and the next + // access sets the sandbox up again: nothing reaches the old gateway afterwards. + expect(order).toEqual(["repoint ssh.test ssh-ed25519 BBBB", "retire sb-1"]); + expect(staleAtRetire).toEqual([true]); + }); + it("retires the pooled ssh connections of its sandboxes on a settings change", async () => { const { service: changing, retired } = serviceRecordingWrites(); await changing.ensureSshAccess("sb-1"); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index bc5d0515994..25be49b2676 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -189,7 +189,12 @@ export interface DaytonaServiceOptions { /** Defaults to a writer for the real `~/.ssh/config`; tests pass one aimed at a temp dir. */ sshConfigWriter?: Pick< DaytonaSshConfigWriter, - "writeSandbox" | "updateToken" | "removeSandbox" | "verifyResolvedConfig" | "readTokens" + | "writeSandbox" + | "updateToken" + | "repointGateway" + | "removeSandbox" + | "verifyResolvedConfig" + | "readTokens" >; fetch?: typeof fetch; now?: () => number; @@ -603,10 +608,9 @@ export class DaytonaService { const config = await client.getConfig(); // Fetched across a settings change: never cache it for lookups under the new settings. if (this.credentialsGeneration !== generation) return config; - if (cached && gatewayIdentity(cached.config) !== gatewayIdentity(config)) { - await this.retireSshSetup(); - } + const moved = cached && gatewayIdentity(cached.config) !== gatewayIdentity(config); this.gatewayCache = { apiUrl: credentials.apiUrl, generation, fetchedAt: this.now(), config }; + if (moved) await this.retireSshSetup(config); return config; } @@ -620,14 +624,27 @@ export class DaytonaService { * Daytona moved its gateway or rotated its host keys: every include block and the * known_hosts pins are stale. Each sandbox's next ssh access mints a token, which rewrites * its whole ssh setup and revokes the old token. + * + * Install first, retire last: a command connecting while the old connections are retired + * then reads the new files and caches, so no connection to the old gateway outlives this. + * Retiring first left a window in which a turn reconnected from the stale setup. */ - private async retireSshSetup(): Promise { - await this.retirePooledSsh(this.sandboxesWithSsh()); + private async retireSshSetup(config: DaytonaGatewayConfig): Promise { + const sandboxIds = this.sandboxesWithSsh(); this.sshConfigured.clear(); this.readyAt.clear(); for (const [sandboxId, access] of this.tokens) { this.tokens.set(sandboxId, { ...access, expiresAt: new Date(0).toISOString() }); } + try { + await this.sshConfigWriter.repointGateway(toSshGateway(config)); + } catch (error) { + // Each sandbox's next ssh access rewrites its setup anyway. + log.warn("Could not repoint the Daytona ssh setup at the moved gateway", { + error: getErrorMessage(error), + }); + } + await this.retirePooledSsh(sandboxIds); } private sandboxesWithSsh(): string[] { From d4f4e779bca9ba14beda266617b0ccb28c303296 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:10:36 +0000 Subject: [PATCH 081/231] fix(daytona): say where a forced removal left the checkout on a stopped sandbox --- src/node/runtime/DaytonaSSHRuntime.test.ts | 17 ++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 26 +++++++++++++++------- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index d04983195bc..5f74450fc00 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -961,6 +961,23 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { expect(rig.writer.removed).toContain("sb-users"); }); + it("says where a forced removal left the checkout of a sandbox that was not running", async () => { + rig.plane.addSandbox({ id: "sb-users", state: "archived" }); + const attached = { sandboxId: "sb-users", shared: true }; + const forced = await makeRuntime(attached).deleteWorkspace("/home/me/project", "feature", true); + if (!forced.success) throw new Error(forced.error); + expect(forced.notice).toContain(forced.deletedPath); + expect(forced.notice).toContain('"sb-users"'); + + // Gone: no checkout was left anywhere. + const gone = await makeRuntime({ sandboxId: "sb-gone", shared: true }).deleteWorkspace( + "/home/me/project", + "feature", + true + ); + expect(gone).toEqual({ success: true, deletedPath: expect.any(String) as string }); + }); + it("drops the removed workspace's ssh access to a sandbox another workspace keeps", async () => { const labels = { [DAYTONA_LABEL_INSTANCE]: await rig.service.getInstanceId() }; rig.plane.addSandbox({ id: "sb-7", name: "xum-owner-abc123", labels }); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 768f9168f2c..2f1ff96381b 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -300,15 +300,19 @@ export class DaytonaSSHRuntime extends SSHRuntime { } private async isSandboxStarted(sandboxId: string): Promise { + return (await this.readSandboxState(sandboxId)) === "started"; + } + + /** Undefined when the state cannot be read: callers treat that as not running. */ + private async readSandboxState(sandboxId: string): Promise { try { - const { state } = await this.daytonaService.getSandboxState(sandboxId); - return state === "started"; + return (await this.daytonaService.getSandboxState(sandboxId)).state; } catch (error) { log.warn("Could not read Daytona sandbox state; treating it as not running", { sandboxId, error: getErrorMessage(error), }); - return false; + return undefined; } } @@ -1048,8 +1052,14 @@ export class DaytonaSSHRuntime extends SSHRuntime { if (deleted) return deleted; // A forced removal skips the checks, so it never starts a stopped (billed) sandbox for // them: its checkout stays on the sandbox, which this removal keeps or releases. - if (await this.isSandboxStarted(sandboxId)) return undefined; - return this.settleSandbox(sandboxId, { success: true, deletedPath }, undefined); + const state = await this.readSandboxState(sandboxId); + if (state === "started") return undefined; + const notice = + state === "missing" + ? undefined + : `Daytona sandbox "${sandboxId}" was not running, so the checkout ${deletedPath} ` + + "was left on it; delete it there if you no longer need it."; + return this.settleSandbox(sandboxId, { success: true, deletedPath, notice }, undefined); } /** The force path's sandbox delete, when this is the last workspace on a sandbox Xum created. */ @@ -1158,7 +1168,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { */ private settleSandbox( sandboxId: string, - result: { success: true; deletedPath: string }, + result: { success: true; deletedPath: string; notice?: string }, errorPrefix: string | undefined ): Promise { return this.daytonaService.withSandboxRemovalLock(async () => { @@ -1175,7 +1185,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { private async applySandboxFate( sandboxId: string, - result: { success: true; deletedPath: string }, + result: { success: true; deletedPath: string; notice?: string }, fate: SandboxFate, errorPrefix: string | undefined ): Promise { @@ -1208,7 +1218,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { "deletes an attached sandbox, so it stays when that workspace is removed too; delete it " + "in Daytona when you no longer need it."; log.info(notice, { workspaceId: this.workspaceId }); - return { ...result, notice }; + return { ...result, notice: result.notice ? `${result.notice} ${notice}` : notice }; } /** From b67bda7e9412e161a108e09647a024f4cdcb5d7a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:11:36 +0000 Subject: [PATCH 082/231] fix(daytona): leave an unreported sandbox disk size alone when resizing --- src/node/runtime/DaytonaSSHRuntime.test.ts | 23 +++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 6 ++++- .../services/daytona/daytona.testHarness.ts | 4 +++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 5f74450fc00..48e99cc1e94 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -414,6 +414,29 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { }); }); + it("never sends a preset disk over a disk size Daytona did not report", async () => { + rig.plane.rejectCreateResources = true; + rig.plane.omitDisk = true; + // The snapshot's disk is larger than the preset's: sending the preset would shrink it. + rig.plane.snapshotResources = { cpu: 1, memory: 1, disk: 30 }; + + await makeRuntime({ + sandboxId: "xum-unknown-disk", + shared: false, + snapshot: "fixed-snap", + cpu: 4, + memoryGiB: 8, + diskGiB: 20, + }).postCreateSetup(initParams()); + + expect(rig.plane.find("xum-unknown-disk")).toMatchObject({ + state: "started", + cpu: 4, + memory: 8, + disk: 30, + }); + }); + it("deletes the sandbox it created when setup fails afterwards", async () => { rig.writer.verifyError = new Error("Your ssh config sets User"); const params = initParams(); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 2f1ff96381b..d12ab1ee6fc 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -224,8 +224,12 @@ function makeStatusEmitter(statusSink: EnsureReadyOptions["statusSink"]): Status return (phase, detail) => statusSink?.({ phase, runtimeType: "ssh", detail }); } +/** + * An unreported disk is left as it is: the snapshot's may be larger than the preset's, and + * sending the preset would then be a shrink Daytona refuses, failing (and deleting) the sandbox. + */ function growsDisk(sandbox: DaytonaSandbox, preset: DaytonaSizePreset): boolean { - return sandbox.disk === undefined || sandbox.disk < preset.diskGiB; + return sandbox.disk !== undefined && sandbox.disk < preset.diskGiB; } /** A running sandbox can only grow cpu and memory. */ diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 6cf1d4170b3..2941918a484 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -44,6 +44,8 @@ export class FakeDaytonaControlPlane { defaultSnapshot?: string; /** Answer POST /sandbox with 400 when it carries cpu/memory/disk (fixed-resource snapshot). */ rejectCreateResources = false; + /** Leave `disk` out of sandbox responses, which Daytona's schema allows. */ + omitDisk = false; /** Resources a sandbox gets when created without any. */ snapshotResources = { cpu: 1, memory: 1, disk: 3 }; /** Routes (`METHOD /path`) that never answer, for timeout tests. */ @@ -244,7 +246,7 @@ export class FakeDaytonaControlPlane { state: sandbox.state, cpu: sandbox.cpu, memory: sandbox.memory, - disk: sandbox.disk, + ...(this.omitDisk ? {} : { disk: sandbox.disk }), labels: sandbox.labels, ...(sandbox.sandboxClass ? { sandboxClass: sandbox.sandboxClass } : {}), }; From ff1f14fd73f9356470ebe96e9ce07a452469aab1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:13:47 +0000 Subject: [PATCH 083/231] test(daytona): give the restart test's writer stub repointGateway --- src/node/services/daytona/daytonaService.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index ed7b09a3744..825b1949f52 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -447,6 +447,7 @@ describe("DaytonaService", () => { readTokens: () => Promise.resolve([...includeFile].map(([sandboxId, token]) => ({ sandboxId, token }))), verifyResolvedConfig: () => Promise.resolve(), + repointGateway: () => Promise.resolve(), }; const startProcess = async () => { const started = new DaytonaService({ From 9b3350069187e4753831f81bd8d29768b9e76671 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:14:58 +0000 Subject: [PATCH 084/231] test(daytona): give the shadowed-config test's writer stub repointGateway --- src/node/services/daytona/daytonaService.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 825b1949f52..ae64758c3c3 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -198,6 +198,7 @@ describe("DaytonaService", () => { readTokens: () => Promise.resolve([]), verifyResolvedConfig: () => shadowed ? Promise.reject(new Error("ssh config shadows User")) : Promise.resolve(), + repointGateway: () => Promise.resolve(), }; const withWriter = new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), From 3bca3158341974b8907e498cb8fd285e2a374e83 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:18:16 +0000 Subject: [PATCH 085/231] refactor(daytona): split the access release out of applySandboxFate --- src/node/runtime/DaytonaSSHRuntime.ts | 37 +++++++++++++++------------ 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index d12ab1ee6fc..4ef525026b9 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -1200,22 +1200,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { const disowned = await this.disownSandbox(sandboxId, errorPrefix); if (disowned) return disowned; } - // Access is per stored string (a sandbox's name or its id): a kept sandbox keeps it only - // while another workspace stores the same one. - const releases = - fate === "release" || - !this.daytonaService.isAliasStoredElsewhere(sandboxId, this.workspaceId); - if (releases) { - try { - await this.daytonaService.releaseSshAccess(sandboxId); - } catch (error) { - // The token expires within the hour; the workspace is gone either way. - log.warn("Failed to release Daytona ssh access after deleting a workspace", { - sandboxId, - error: getErrorMessage(error), - }); - } - } + await this.releaseAccessOnDelete(sandboxId, fate); if (fate !== "handOff") return result; const notice = `Daytona sandbox "${sandboxId}" was kept for a workspace that attached it. Xum never ` + @@ -1225,6 +1210,26 @@ export class DaytonaSSHRuntime extends SSHRuntime { return { ...result, notice: result.notice ? `${result.notice} ${notice}` : notice }; } + /** + * Access is per stored string (a sandbox's name or its id): a kept sandbox keeps it only + * while another workspace stores the same one. + */ + private async releaseAccessOnDelete(sandboxId: string, fate: SandboxFate): Promise { + const releases = + fate === "release" || + !this.daytonaService.isAliasStoredElsewhere(sandboxId, this.workspaceId); + if (!releases) return; + try { + await this.daytonaService.releaseSshAccess(sandboxId); + } catch (error) { + // The token expires within the hour; the workspace is gone either way. + log.warn("Failed to release Daytona ssh access after deleting a workspace", { + sandboxId, + error: getErrorMessage(error), + }); + } + } + /** * A handed-off sandbox still labeled as this install's would be stopped by the orphan sweep * once its last workspace goes, so a failed relabel fails the removal (a retry relabels it). From 17ac60228c79f8c3c39fea5e2d3e6afab719140f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:21:11 +0000 Subject: [PATCH 086/231] fix(daytona): resize unreported cpu and memory on a stopped sandbox --- src/node/runtime/DaytonaSSHRuntime.test.ts | 23 +++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 7 ++++-- .../services/daytona/daytona.testHarness.ts | 5 ++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 48e99cc1e94..f545373c501 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -437,6 +437,29 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { }); }); + it("resizes a sandbox whose cpu and memory Daytona did not report while it is stopped", async () => { + rig.plane.rejectCreateResources = true; + rig.plane.omitCompute = true; + // Above the preset: resizing the running sandbox to it would be a shrink Daytona refuses. + rig.plane.snapshotResources = { cpu: 8, memory: 16, disk: 30 }; + + await makeRuntime({ + sandboxId: "xum-unknown-compute", + shared: false, + snapshot: "fixed-snap", + cpu: 4, + memoryGiB: 8, + diskGiB: 20, + }).postCreateSetup(initParams()); + + expect(rig.plane.find("xum-unknown-compute")).toMatchObject({ + state: "started", + cpu: 4, + memory: 8, + disk: 30, + }); + }); + it("deletes the sandbox it created when setup fails afterwards", async () => { rig.writer.verifyError = new Error("Your ssh config sets User"); const params = initParams(); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 4ef525026b9..d2c97e96bbc 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -232,9 +232,12 @@ function growsDisk(sandbox: DaytonaSandbox, preset: DaytonaSizePreset): boolean return sandbox.disk !== undefined && sandbox.disk < preset.diskGiB; } -/** A running sandbox can only grow cpu and memory. */ +/** + * A running sandbox can only grow cpu and memory. An unreported value may be above the preset, + * so it is resized stopped, where either direction is allowed. + */ function shrinksCompute(sandbox: DaytonaSandbox, preset: DaytonaSizePreset): boolean { - return (sandbox.cpu ?? 0) > preset.cpu || (sandbox.memory ?? 0) > preset.memoryGiB; + return (sandbox.cpu ?? Infinity) > preset.cpu || (sandbox.memory ?? Infinity) > preset.memoryGiB; } function changesCompute(sandbox: DaytonaSandbox, preset: DaytonaSizePreset): boolean { diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 2941918a484..a3c875e613b 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -46,6 +46,8 @@ export class FakeDaytonaControlPlane { rejectCreateResources = false; /** Leave `disk` out of sandbox responses, which Daytona's schema allows. */ omitDisk = false; + /** Leave `cpu` and `memory` out of sandbox responses, which Daytona's schema allows. */ + omitCompute = false; /** Resources a sandbox gets when created without any. */ snapshotResources = { cpu: 1, memory: 1, disk: 3 }; /** Routes (`METHOD /path`) that never answer, for timeout tests. */ @@ -244,8 +246,7 @@ export class FakeDaytonaControlPlane { id: sandbox.id, name: sandbox.name, state: sandbox.state, - cpu: sandbox.cpu, - memory: sandbox.memory, + ...(this.omitCompute ? {} : { cpu: sandbox.cpu, memory: sandbox.memory }), ...(this.omitDisk ? {} : { disk: sandbox.disk }), labels: sandbox.labels, ...(sandbox.sandboxClass ? { sandboxClass: sandbox.sandboxClass } : {}), From bf08d89c6bb6a0345ac63774fabb2df8f1da4def Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:02:32 +0000 Subject: [PATCH 087/231] fix(daytona): warn and release access when a forced removal cannot hand off its sandbox --- src/node/runtime/DaytonaSSHRuntime.test.ts | 23 ++++++ src/node/runtime/DaytonaSSHRuntime.ts | 87 ++++++++++++++-------- 2 files changed, 80 insertions(+), 30 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index f545373c501..35ff6b10860 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -949,6 +949,29 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { expect(rig.plane.find("sb-1")?.labels).toEqual({}); }); + it("warns a forced removal that could not hand off its sandbox, and releases its access", async () => { + const labels = { [DAYTONA_LABEL_INSTANCE]: await rig.service.getInstanceId() }; + rig.plane.addSandbox({ id: "sb-1", name: "xum-owner", labels, state: "stopped" }); + const owner = { sandboxId: "xum-owner", shared: false, ...SMALL }; + rig.workspaces.push( + { workspaceId: "ws-o", runtimeConfig: sshDaytonaConfig(owner) }, + { workspaceId: "ws-t", runtimeConfig: sshDaytonaConfig({ sandboxId: "sb-1", shared: true }) } + ); + claimRemovals(); + rig.plane.failingRoutes.add("PUT /sandbox/xum-owner/labels"); + + const forced = await makeRuntime(owner, rig, { workspaceId: "ws-o" }).deleteWorkspace( + "/home/me/project", + "ws-o", + true + ); + + // A forced removal goes on, so the warning is the user's only cue to drop the label. + expect(forced.success ? forced.notice : forced.error).toContain(DAYTONA_LABEL_INSTANCE); + expect(forced.success).toBe(true); + expect(rig.writer.removed).toContain("xum-owner"); + }); + it("counts a workspace whose removal failed after its checkout step as a reference again", async () => { rig.plane.addSandbox({ id: "sb-kept", state: "stopped" }); const owner = { sandboxId: "sb-kept", shared: false, ...SMALL }; diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index d2c97e96bbc..a736670feda 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -184,6 +184,19 @@ type StatusEmitter = (phase: RuntimeStatusEvent["phase"], detail?: string) => vo */ type SandboxFate = "keep" | "handOff" | "release" | "delete"; +/** A removal whose checkout step is done (or skipped), before its sandbox's fate is applied. */ +interface RemovedCheckout { + success: true; + deletedPath: string; + notice?: string; +} + +/** `errorPrefix` leads a failure reported after the checkout was already deleted over SSH. */ +interface SandboxRemoval { + force: boolean; + errorPrefix?: string; +} + const HOST_KEY_FAILURE = /Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/; function errorChainText(error: unknown): string { @@ -1034,7 +1047,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { if (prepared === "missing") { log.debug("Daytona sandbox already gone, skipping SSH cleanup", { sandboxId }); // Still drop the token and include block of a sandbox no other workspace uses. - return this.settleSandbox(sandboxId, { success: true, deletedPath }, undefined); + return this.settleSandbox(sandboxId, { success: true, deletedPath }, { force }); } // A forced delete still tries the SSH cleanup; a normal one never skips the checks. if (prepared !== "ready" && !force) { @@ -1043,7 +1056,10 @@ export class DaytonaSSHRuntime extends SSHRuntime { // A failed check (dirty or unpushed work) keeps the workspace and its sandbox. const sshResult = await sshDelete(); if (!sshResult.success) return sshResult; - return this.settleSandbox(sandboxId, sshResult, "SSH delete succeeded, but "); + return this.settleSandbox(sandboxId, sshResult, { + force, + errorPrefix: "SSH delete succeeded, but ", + }); } /** @@ -1066,7 +1082,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { ? undefined : `Daytona sandbox "${sandboxId}" was not running, so the checkout ${deletedPath} ` + "was left on it; delete it there if you no longer need it."; - return this.settleSandbox(sandboxId, { success: true, deletedPath, notice }, undefined); + return this.settleSandbox(sandboxId, { success: true, deletedPath, notice }, { force: true }); } /** The force path's sandbox delete, when this is the last workspace on a sandbox Xum created. */ @@ -1175,15 +1191,15 @@ export class DaytonaSSHRuntime extends SSHRuntime { */ private settleSandbox( sandboxId: string, - result: { success: true; deletedPath: string; notice?: string }, - errorPrefix: string | undefined + result: RemovedCheckout, + removal: SandboxRemoval ): Promise { return this.daytonaService.withSandboxRemovalLock(async () => { const settled = await this.applySandboxFate( sandboxId, result, await this.sandboxFateOnDelete(sandboxId), - errorPrefix + removal ); if (settled.success) this.recordRemoval(); return settled; @@ -1192,25 +1208,46 @@ export class DaytonaSSHRuntime extends SSHRuntime { private async applySandboxFate( sandboxId: string, - result: { success: true; deletedPath: string; notice?: string }, + result: RemovedCheckout, fate: SandboxFate, - errorPrefix: string | undefined + removal: SandboxRemoval ): Promise { if (fate === "delete") { - return this.deleteOwnedSandbox(sandboxId, result.deletedPath, errorPrefix); - } - if (fate === "handOff") { - const disowned = await this.disownSandbox(sandboxId, errorPrefix); - if (disowned) return disowned; + return this.deleteOwnedSandbox(sandboxId, result.deletedPath, removal.errorPrefix); } + if (fate === "handOff") return this.handOffSandbox(sandboxId, result, removal); await this.releaseAccessOnDelete(sandboxId, fate); - if (fate !== "handOff") return result; - const notice = + return result; + } + + /** + * Leave the sandbox to the workspace that attached it, no longer labeled as this install's: + * still labeled, the orphan sweep would stop it once its last workspace goes. A failed + * relabel fails a normal removal (a retry relabels it); a forced one goes on, so its notice + * tells the user to drop the label. + */ + private async handOffSandbox( + sandboxId: string, + result: RemovedCheckout, + removal: SandboxRemoval + ): Promise { + const failure = await this.disownSandbox(sandboxId); + if (failure && !removal.force) { + const error = removal.errorPrefix ? `${removal.errorPrefix}${failure}` : `Xum ${failure}`; + return { success: false, error }; + } + await this.releaseAccessOnDelete(sandboxId, "handOff"); + const handOff = `Daytona sandbox "${sandboxId}" was kept for a workspace that attached it. Xum never ` + "deletes an attached sandbox, so it stays when that workspace is removed too; delete it " + "in Daytona when you no longer need it."; - log.info(notice, { workspaceId: this.workspaceId }); - return { ...result, notice: result.notice ? `${result.notice} ${notice}` : notice }; + log.info(handOff, { workspaceId: this.workspaceId }); + const relabel = + failure && + `Xum ${failure}. Remove its "${DAYTONA_LABEL_INSTANCE}" label in Daytona, or Xum stops ` + + "the sandbox once no workspace uses it."; + const notice = [result.notice, handOff, relabel].filter(Boolean).join(" "); + return { ...result, notice }; } /** @@ -1233,25 +1270,15 @@ export class DaytonaSSHRuntime extends SSHRuntime { } } - /** - * A handed-off sandbox still labeled as this install's would be stopped by the orphan sweep - * once its last workspace goes, so a failed relabel fails the removal (a retry relabels it). - */ - private async disownSandbox( - sandboxId: string, - errorPrefix: string | undefined - ): Promise { + /** Why Daytona would not drop this install's label from the sandbox; undefined once it did. */ + private async disownSandbox(sandboxId: string): Promise { try { await this.daytonaService.disownSandbox(sandboxId); return undefined; } catch (error) { const message = getErrorMessage(error); log.error("Failed to hand off a Daytona sandbox", { sandboxId, error: message }); - const failure = `could not mark Daytona sandbox "${sandboxId}" as no longer Xum-owned: ${message}`; - return { - success: false, - error: errorPrefix ? `${errorPrefix}${failure}` : `Xum ${failure}`, - }; + return `could not mark Daytona sandbox "${sandboxId}" as no longer Xum-owned: ${message}`; } } From 8c91a35152461bded8bff17e3a0228fe06316bf9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:53:14 +0000 Subject: [PATCH 088/231] fix(ssh): expand variables, local tokens and named glob classes in config Include paths --- src/node/runtime/sshConfigParser.test.ts | 70 +++++++++++++++++++ src/node/runtime/sshConfigParser.ts | 85 ++++++++++++++++++++++-- 2 files changed, 149 insertions(+), 6 deletions(-) diff --git a/src/node/runtime/sshConfigParser.test.ts b/src/node/runtime/sshConfigParser.test.ts index b38bcfcc93f..bdaef185863 100644 --- a/src/node/runtime/sshConfigParser.test.ts +++ b/src/node/runtime/sshConfigParser.test.ts @@ -279,6 +279,76 @@ describe("resolveSSHConfig", () => { }); }); + test("matches glob(3) named classes in an Include path; an unknown class matches nothing", async () => { + await withTempHome(async (homeDir) => { + const confDir = path.join(homeDir, ".ssh", "conf.d"); + await fs.mkdir(confDir); + for (const [name, host] of [ + ["1", "one"], + ["a", "letter"], + ]) { + await fs.writeFile( + path.join(confDir, `${name}.conf`), + `Host ${host}\n User user-${host}\n` + ); + } + await fs.writeFile( + path.join(homeDir, ".ssh", "config"), + [ + "Include conf.d/[[:digit:]].conf conf.d/[[:bogus:]a].conf", + "Host *", + " User none", + "", + ].join("\n") + ); + + expect((await resolveSSHConfig("one")).user).toBe("user-one"); + expect((await resolveSSHConfig("letter")).user).toBe("none"); + }); + }); + + test("expands environment variables and local %-tokens in an Include path (OpenSSH 9.9+)", async () => { + const variable = "XUM_SSH_CONFIG_TEST_DIR"; + await withTempHome(async (homeDir) => { + const confDir = path.join(homeDir, ".ssh", "conf.d"); + await fs.mkdir(confDir); + process.env[variable] = confDir; + const unset = `\${XUM_SSH_CONFIG_TEST_UNSET}`; + await fs.mkdir(path.join(confDir, unset)); + const files = [ + ["env.conf", "env-host"], + ["home.conf", "home-host"], + ["named-host.conf", "named-host"], + // Only a literal reading of an unset variable or the port token finds these files. + [`${unset}/unset.conf`, "unset-host"], + ["%p.conf", "port-host"], + ]; + for (const [file, host] of files) { + await fs.writeFile(path.join(confDir, file), `Host ${host}\n User user-${host}\n`); + } + await fs.writeFile( + path.join(homeDir, ".ssh", "config"), + [ + `Include \${${variable}}/env.conf %d/.ssh/conf.d/home.conf conf.d/%n.conf`, + `Include conf.d/${unset}/unset.conf conf.d/%p.conf`, + "Host *", + " User none", + "", + ].join("\n") + ); + + try { + expect((await resolveSSHConfig("env-host")).user).toBe("user-env-host"); + expect((await resolveSSHConfig("home-host")).user).toBe("user-home-host"); + expect((await resolveSSHConfig("named-host")).user).toBe("user-named-host"); + expect((await resolveSSHConfig("unset-host")).user).toBe("none"); + expect((await resolveSSHConfig("port-host")).user).toBe("none"); + } finally { + Reflect.deleteProperty(process.env, variable); + } + }); + }); + test("applies an Include inside a Host block only to the hosts that block matches", async () => { await withTempHome(async (homeDir) => { const included = path.join(homeDir, ".ssh", "gated.conf"); diff --git a/src/node/runtime/sshConfigParser.ts b/src/node/runtime/sshConfigParser.ts index 16f829a5fab..58043d6d595 100644 --- a/src/node/runtime/sshConfigParser.ts +++ b/src/node/runtime/sshConfigParser.ts @@ -269,10 +269,53 @@ function splitIncludeArgs(value: string): string[] { return args; } -async function resolveIncludePaths(value: string, homeDir: string): Promise { +/** + * An Include argument with its `${VAR}` references and %-tokens expanded, as OpenSSH 9.9+ + * does (readconf.c's percent_dollar_expand). Undefined skips the argument, as OpenSSH does + * on an expansion error: an unset variable or an unknown token. The tokens that depend on + * options set earlier in the parse (%h after a HostName, %k, %p, %r, %C, %j) are not tracked + * by this pass and skip it too: skipping can only miss an option, where a guess could read + * another host's file. + */ +function expandIncludeArg(arg: string, homeDir: string, host: string): string | undefined { + const localHost = os.hostname(); + const tokens: Record = { + "%": "%", + d: homeDir, + i: process.getuid?.().toString(), + L: localHost.split(".")[0], + l: localHost, + n: host, + u: getDefaultUsername(), + }; + let failed = false; + // `${NAME}` (an unclosed `${` fails) or `%` and the character after it. + const expanded = arg.replace( + /\$\{([^}]*)(\})?|%(.?)/gs, + (match, name: string | undefined, close: string | undefined, token: string | undefined) => { + const variable = close ? process.env[name ?? ""] : undefined; + const value = token === undefined ? variable : tokens[token]; + if (value === undefined) failed = true; + return value ?? match; + } + ); + if (failed) { + log.debug("Skipping an SSH config Include argument that does not expand", { include: arg }); + return undefined; + } + return expanded; +} + +async function resolveIncludePaths( + value: string, + homeDir: string, + host: string +): Promise { const files: string[] = []; for (const arg of splitIncludeArgs(value)) { - const expanded = expandHomePath(arg, homeDir); + const tokenized = expandIncludeArg(arg, homeDir, host); + if (tokenized === undefined) continue; + const expanded = expandHomePath(tokenized, homeDir); // OpenSSH resolves relative user-config Include paths against ~/.ssh. const absolute = path.isAbsolute(expanded) ? expanded : path.join(homeDir, ".ssh", expanded); files.push(...(await globPath(absolute))); @@ -366,18 +409,48 @@ function bracketExpression( const negated = segment[start + 1] === "!" || segment[start + 1] === "^"; let i = start + (negated ? 2 : 1); let items = ""; + let invalid = false; // A "]" right after the opening (and its negation) is a member, not the close. for (let first = true; i < segment.length && (first || segment[i] !== "]"); first = false) { const member = bracketMember(segment, i); items += member.source; + invalid ||= member.invalid === true; i = member.next; } if (i >= segment.length) return undefined; - return { source: `[${negated ? "^" : ""}${items}]`, end: i }; + return { source: invalid ? "(?!)" : `[${negated ? "^" : ""}${items}]`, end: i }; } -/** One character or range of a bracket expression, and the index after it. */ -function bracketMember(segment: string, i: number): { source: string; next: number } { +/** glob(3)'s named classes (`[[:digit:]]`) as RegExp class contents, for the C locale. */ +const NAMED_CLASSES = new Map([ + ["alnum", "0-9A-Za-z"], + ["alpha", "A-Za-z"], + ["blank", " \\t"], + ["cntrl", "\\x00-\\x1f\\x7f"], + ["digit", "0-9"], + ["graph", "\\x21-\\x7e"], + ["lower", "a-z"], + ["print", "\\x20-\\x7e"], + ["punct", "!-/:-@\\[-`{-~"], + ["space", "\\t-\\r "], + ["upper", "A-Z"], + ["xdigit", "0-9A-Fa-f"], +]); + +/** + * One character, range or named class of a bracket expression, and the index after it. As in + * glob(3), `[:` opens a class only when its first `:` is followed by `]`; an unknown class + * makes the whole pattern match nothing. + */ +function bracketMember( + segment: string, + i: number +): { source: string; next: number; invalid?: boolean } { + const colon = segment.startsWith("[:", i) ? segment.indexOf(":", i + 2) : -1; + if (colon !== -1 && segment[colon + 1] === "]") { + const source = NAMED_CLASSES.get(segment.slice(i + 2, colon)); + return { source: source ?? "", next: colon + 2, invalid: source === undefined }; + } const range = segment[i + 1] === "-" && i + 2 < segment.length && segment[i + 2] !== "]"; if (!range) return { source: escapeRegExp(segment[i]), next: i + 1 }; const [low, high] = [segment[i], segment[i + 2]]; @@ -409,7 +482,7 @@ async function readIncludedFiles( host: string ): Promise { const contents: string[] = []; - for (const file of await resolveIncludePaths(value, homeDir)) { + for (const file of await resolveIncludePaths(value, homeDir, host)) { try { contents.push(await expandIncludes(await fs.readFile(file, "utf8"), homeDir, depth, host)); } catch (error) { From d5f856f8d15027ac0a836cde9806a4fed02b48fa Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:56:01 +0000 Subject: [PATCH 089/231] refactor(ssh): split named-class parsing out of the Include bracket matcher --- src/node/runtime/sshConfigParser.ts | 38 +++++++++++++++++------------ 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/node/runtime/sshConfigParser.ts b/src/node/runtime/sshConfigParser.ts index 58043d6d595..16cab858bf6 100644 --- a/src/node/runtime/sshConfigParser.ts +++ b/src/node/runtime/sshConfigParser.ts @@ -408,17 +408,21 @@ function bracketExpression( ): { source: string; end: number } | undefined { const negated = segment[start + 1] === "!" || segment[start + 1] === "^"; let i = start + (negated ? 2 : 1); - let items = ""; - let invalid = false; + const items: Array = []; // A "]" right after the opening (and its negation) is a member, not the close. for (let first = true; i < segment.length && (first || segment[i] !== "]"); first = false) { const member = bracketMember(segment, i); - items += member.source; - invalid ||= member.invalid === true; + items.push(member.source); i = member.next; } if (i >= segment.length) return undefined; - return { source: invalid ? "(?!)" : `[${negated ? "^" : ""}${items}]`, end: i }; + return { source: bracketSource(items, negated), end: i }; +} + +/** An unknown named class makes the whole pattern match nothing, as glob(3) refuses it. */ +function bracketSource(items: Array, negated: boolean): string { + if (items.includes(undefined)) return "(?!)"; + return `[${negated ? "^" : ""}${items.join("")}]`; } /** glob(3)'s named classes (`[[:digit:]]`) as RegExp class contents, for the C locale. */ @@ -438,19 +442,23 @@ const NAMED_CLASSES = new Map([ ]); /** - * One character, range or named class of a bracket expression, and the index after it. As in - * glob(3), `[:` opens a class only when its first `:` is followed by `]`; an unknown class - * makes the whole pattern match nothing. + * The named class (`[:digit:]`) at `i`: as in glob(3), `[:` opens one only when its first `:` + * is followed by `]`. Its source is undefined for an unknown class name. */ -function bracketMember( +function namedClass( segment: string, i: number -): { source: string; next: number; invalid?: boolean } { - const colon = segment.startsWith("[:", i) ? segment.indexOf(":", i + 2) : -1; - if (colon !== -1 && segment[colon + 1] === "]") { - const source = NAMED_CLASSES.get(segment.slice(i + 2, colon)); - return { source: source ?? "", next: colon + 2, invalid: source === undefined }; - } +): { source: string | undefined; next: number } | undefined { + if (!segment.startsWith("[:", i)) return undefined; + const colon = segment.indexOf(":", i + 2); + if (colon === -1 || segment[colon + 1] !== "]") return undefined; + return { source: NAMED_CLASSES.get(segment.slice(i + 2, colon)), next: colon + 2 }; +} + +/** One character, range or named class of a bracket expression, and the index after it. */ +function bracketMember(segment: string, i: number): { source: string | undefined; next: number } { + const named = namedClass(segment, i); + if (named) return named; const range = segment[i + 1] === "-" && i + 2 < segment.length && segment[i + 2] !== "]"; if (!range) return { source: escapeRegExp(segment[i]), next: i + 1 }; const [low, high] = [segment[i], segment[i + 2]]; From 026a9ae238873b92c9a63788fdfd562a91b724a8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:12:47 +0000 Subject: [PATCH 090/231] fix(daytona): never forward the local agent, X11 or config port forwards to a sandbox --- src/node/runtime/daytonaSshConfig.ts | 2 +- src/node/runtime/sshConnectionPool.test.ts | 41 ++++++++++++++++++- src/node/runtime/sshConnectionPool.ts | 19 +++++++-- .../runtime/transports/OpenSSHTransport.ts | 4 +- src/node/services/terminalService.ts | 4 +- 5 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 889afa848fa..f91488e9fb6 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -319,7 +319,7 @@ export class DaytonaSshConfigWriter { * silently replaces the token (`User`) or the pinned known_hosts file (`UserKnownHostsFile`). * Resolve the alias with `ssh -G` and fail with a clear message instead of an opaque auth or * host key failure. The token is never printed. A shadowed StrictHostKeyChecking needs no check - * here: every OpenSSH invocation forces it on the command line (appendDaytonaHostKeyPinArgs). + * here: every OpenSSH invocation forces it on the command line (appendDaytonaOpenSSHArgs). * Skipped when no OpenSSH client is installed (the ssh2 path parses the config itself). */ async verifyResolvedConfig(sandboxId: string, token: string): Promise { diff --git a/src/node/runtime/sshConnectionPool.test.ts b/src/node/runtime/sshConnectionPool.test.ts index b8bc4a9b23f..1f8581d81ef 100644 --- a/src/node/runtime/sshConnectionPool.test.ts +++ b/src/node/runtime/sshConnectionPool.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from "child_process"; import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; @@ -168,7 +169,45 @@ describe("appendOpenSSHHostKeyPolicyArgs", () => { appendOpenSSHHostKeyPolicyArgs(args, "xum-feature-a1b2c3.xum--daytona"); - expect(args).toEqual(["-T", "-o", "StrictHostKeyChecking=yes"]); + expect(args).toContain("StrictHostKeyChecking=yes"); + expect(args).not.toContain("UserKnownHostsFile=/dev/null"); + } + ); + + // The sandbox is not trusted: a user's `Host *` forwards would hand it the local agent, the + // X display or local ports. Command-line options beat every config file. + test.skipIf(spawnSync("ssh", ["-V"]).status !== 0)( + "keeps a user's agent, X11 and port forwards off a Daytona alias", + async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-daytona-fwd-")); + try { + const configPath = path.join(dir, "config"); + await fs.writeFile( + configPath, + [ + "Host *", + " ForwardAgent yes", + " ForwardX11 yes", + " LocalForward 5000 localhost:5000", + " RemoteForward 6000 localhost:6000", + " DynamicForward 7000", + "", + ].join("\n") + ); + const host = "xum-feature-a1b2c3.xum--daytona"; + const args: string[] = []; + appendOpenSSHHostKeyPolicyArgs(args, host); + + const resolved = spawnSync("ssh", ["-G", "-F", configPath, ...args, host], { + encoding: "utf8", + }).stdout.split("\n"); + + expect(resolved).toContain("forwardagent no"); + expect(resolved).toContain("forwardx11 no"); + expect(resolved.filter((line) => /^(local|remote|dynamic)forward /.test(line))).toEqual([]); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } } ); diff --git a/src/node/runtime/sshConnectionPool.ts b/src/node/runtime/sshConnectionPool.ts index 1cb34361be9..9fd76ca1c40 100644 --- a/src/node/runtime/sshConnectionPool.ts +++ b/src/node/runtime/sshConnectionPool.ts @@ -57,17 +57,30 @@ export function isInteractiveHostKeyApprovalAvailable(): boolean { * hand the token (the ssh user name) to any server. Command-line options beat every config file, * so force strict checking: if an earlier entry also points UserKnownHostsFile elsewhere, the * gateway key is unknown and ssh refuses before it sends the user name. + * + * The sandbox is not trusted either: a user `Host *` entry's agent, X11 or port forwards would + * let its processes use the local agent, display or ports, so those are forced off too. The + * bash AI proxy's reverse forward adds its `-R` through a control client that skips this. */ -export function appendDaytonaHostKeyPinArgs(args: string[], host: string): void { +export function appendDaytonaOpenSSHArgs(args: string[], host: string): void { if (isXumDaytonaHost(host)) { - args.push("-o", "StrictHostKeyChecking=yes"); + args.push( + "-o", + "StrictHostKeyChecking=yes", + "-o", + "ForwardAgent=no", + "-o", + "ForwardX11=no", + "-o", + "ClearAllForwardings=yes" + ); } } /** Daytona aliases never get the headless fallback, which would accept any gateway key. */ export function appendOpenSSHHostKeyPolicyArgs(args: string[], host: string): void { if (isXumDaytonaHost(host)) { - appendDaytonaHostKeyPinArgs(args, host); + appendDaytonaOpenSSHArgs(args, host); return; } if (hostKeyPolicyMode === "strict") { diff --git a/src/node/runtime/transports/OpenSSHTransport.ts b/src/node/runtime/transports/OpenSSHTransport.ts index c21fba85ced..c10517eb3d1 100644 --- a/src/node/runtime/transports/OpenSSHTransport.ts +++ b/src/node/runtime/transports/OpenSSHTransport.ts @@ -9,7 +9,7 @@ import { cdThenExecShell, runInPosixShell } from "../streamUtils"; import { expandTildeForSSH } from "../tildeExpansion"; import { redactDaytonaHostStderr } from "../daytonaTokenRedaction"; import { - appendDaytonaHostKeyPinArgs, + appendDaytonaOpenSSHArgs, appendOpenSSHHostKeyPolicyArgs, retireControlPath, sshConnectionPool, @@ -192,7 +192,7 @@ export class OpenSSHTransport implements SSHTransport { args.push("-o", "ServerAliveCountMax=2"); args.push("-t"); // Not the full host key policy: a terminal on another host may ask about an unknown key. - appendDaytonaHostKeyPinArgs(args, this.config.host); + appendDaytonaOpenSSHArgs(args, this.config.host); args.push(this.config.host); // expandTildeForSSH already returns a quoted string (e.g., "$HOME/path") diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index f1ae5a4afa3..6e4148bdedf 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -34,7 +34,7 @@ import { workspaceUseLeasesFor, type WorkspaceUseLease } from "@/node/services/w import { isCommandAvailable, findAvailableCommand } from "@/node/utils/commandDiscovery"; import { resolveContainerCli } from "@/node/runtime/containerCli"; import { sanitizeXumChildEnv } from "@/node/runtime/childProcessEnv"; -import { appendDaytonaHostKeyPinArgs } from "@/node/runtime/sshConnectionPool"; +import { appendDaytonaOpenSSHArgs } from "@/node/runtime/sshConnectionPool"; import { Terminal } from "@xterm/headless"; import { SerializeAddon } from "@xterm/addon-serialize"; import { NO_OSC_IDLE_FALLBACK_MS } from "@/constants/terminalActivity"; @@ -1007,7 +1007,7 @@ export class TerminalService { } // Force pseudo-terminal allocation sshArgs.push("-t"); - appendDaytonaHostKeyPinArgs(sshArgs, config.sshConfig.host); + appendDaytonaOpenSSHArgs(sshArgs, config.sshConfig.host); // Add host sshArgs.push(config.sshConfig.host); // Add remote command to cd into directory and start shell From fe4e5b400e23bc3e5de131110bc9b10749b4528d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:37:11 +0000 Subject: [PATCH 091/231] fix(ssh): split Include and Host arguments as OpenSSH's argv_split does --- src/node/runtime/sshConfigParser.test.ts | 38 ++++++++++++++++ src/node/runtime/sshConfigParser.ts | 58 ++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/node/runtime/sshConfigParser.test.ts b/src/node/runtime/sshConfigParser.test.ts index bdaef185863..a5022a82bd8 100644 --- a/src/node/runtime/sshConfigParser.test.ts +++ b/src/node/runtime/sshConfigParser.test.ts @@ -349,6 +349,44 @@ describe("resolveSSHConfig", () => { }); }); + // OpenSSH 8.6+ splits config arguments with argv_split (misc.c): backslash escapes, quotes + // anywhere in an argument, and a # that starts an argument ends the line. + test("reads Include paths with escaped spaces and quotes as OpenSSH does", async () => { + await withTempHome(async (homeDir) => { + const sshDir = path.join(homeDir, ".ssh"); + const files = { + a: path.join(sshDir, "conf dir", "a.conf"), + b: path.join(sshDir, 'say "hi"', "b.conf"), + c: path.join(sshDir, "single dir", "c.conf"), + }; + for (const [host, file] of Object.entries(files)) { + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, `Host ${host}\n User ${host}user\n`, "utf8"); + } + const configPath = path.join(sshDir, "config"); + await fs.writeFile( + configPath, + [ + // Absolute: ssh -G resolves a relative Include against the real ~/.ssh. + `Include ${sshDir}/conf\\ dir/a.conf`, + `Include "${sshDir}/say \\"hi\\"/b.conf"`, + `Include ${sshDir}/'single dir'/c.conf # not a path`, + "Host *", + " User fallback", + "", + ].join("\n"), + "utf8" + ); + + for (const host of ["a", "b", "c"]) { + expect((await resolveSSHConfig(host)).user).toBe(`${host}user`); + if (hasOpenSsh) { + expect(opensshUserAndProxy(configPath, host).user).toBe(`${host}user`); + } + } + }); + }); + test("applies an Include inside a Host block only to the hosts that block matches", async () => { await withTempHome(async (homeDir) => { const included = path.join(homeDir, ".ssh", "gated.conf"); diff --git a/src/node/runtime/sshConfigParser.ts b/src/node/runtime/sshConfigParser.ts index 16cab858bf6..817ab2200f9 100644 --- a/src/node/runtime/sshConfigParser.ts +++ b/src/node/runtime/sshConfigParser.ts @@ -258,17 +258,65 @@ const SECTION_LINE_PATTERN = /^\s*(?:host|match)(?:\s*=\s*|\s+)\S/i; const HOST_LINE_PATTERN = /^\s*host(?:\s*=\s*|\s+)(.+?)\s*$/i; const MATCH_ALL_LINE_PATTERN = /^\s*match(?:\s*=\s*|\s+)all\s*$/i; +/** + * A config line's arguments, split as OpenSSH 8.6+ does (misc.c's argv_split): quotes may + * appear anywhere in an argument, a backslash escapes a quote, a backslash or (unquoted) a + * space, and an argument that starts with # ends the line. An unclosed quote makes OpenSSH + * reject the line: it yields no arguments here. + */ function splitIncludeArgs(value: string): string[] { const args: string[] = []; - const pattern = /"([^"]*)"|(\S+)/g; - let match: RegExpExecArray | null; - while ((match = pattern.exec(value)) !== null) { - const arg = match[1] ?? match[2]; - if (arg) args.push(arg); + let index = 0; + while (index < value.length) { + if (isConfigBlank(value[index])) { + index++; + continue; + } + if (value[index] === "#") break; + const read = readConfigArg(value, index); + if (!read) return []; + if (read.arg) args.push(read.arg); + index = read.end; } return args; } +function readConfigArg(value: string, start: number): { arg: string; end: number } | undefined { + let arg = ""; + let quote = ""; + let index = start; + while (index < value.length && (quote || !isConfigBlank(value[index]))) { + const step = configArgStep(value, index, quote); + arg += step.text; + quote = step.quote; + index += step.length; + } + return quote ? undefined : { arg, end: index }; +} + +/** One character (or escape pair) of an argument: the text it adds and the quote after it. */ +function configArgStep( + value: string, + index: number, + quote: string +): { text: string; quote: string; length: number } { + const char = value[index]; + const next = value[index + 1]; + if (char === "\\" && isConfigEscape(next, quote)) return { text: next, quote, length: 2 }; + if (quote) + return { text: char === quote ? "" : char, quote: char === quote ? "" : quote, length: 1 }; + if (char === '"' || char === "'") return { text: "", quote: char, length: 1 }; + return { text: char, quote, length: 1 }; +} + +function isConfigBlank(char: string): boolean { + return char === " " || char === "\t"; +} + +function isConfigEscape(next: string | undefined, quote: string): boolean { + return next === "'" || next === '"' || next === "\\" || (!quote && next === " "); +} + /** * An Include argument with its `${VAR}` references and %-tokens expanded, as OpenSSH 9.9+ * does (readconf.c's percent_dollar_expand). Undefined skips the argument, as OpenSSH does From 98072c69309baa730c425fcb5eb786cd74e9ce2c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:39:26 +0000 Subject: [PATCH 092/231] fix(daytona): accept an IPv6 SSH gateway, refusing zone ids --- src/node/runtime/daytonaSshConfig.test.ts | 31 +++++++++++++++++++++++ src/node/runtime/daytonaSshConfig.ts | 16 +++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index 9d6c44505b8..dab82856149 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -156,6 +156,37 @@ describe("DaytonaSshConfigWriter", () => { expect(await Bun.file(sshConfigPath).exists()).toBe(false); }); + it("writes a self-hosted IPv6 gateway as OpenSSH resolves it, refusing zone ids", async () => { + const gateway = { ...GATEWAY, host: "2001:db8::10", port: 2222 }; + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway }); + + expect(await fs.readFile(writer.paths.knownHostsPath, "utf8")).toBe( + [ + "2001:db8::10 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGatewayKey", + "[2001:db8::10]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGatewayKey", + "", + ].join("\n") + ); + if (hasOpenSsh) { + const resolved = spawnSync("ssh", ["-G", "-F", sshConfigPath, "sb-1.xum--daytona"], { + encoding: "utf8", + }).stdout; + for (const line of ["hostname 2001:db8::10", "port 2222", "hostkeyalias 2001:db8::10"]) { + expect(resolved.split("\n")).toContain(line); + } + } + + // A zone id carries `%`, which ssh config expands as a token. + // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime + await expect( + writer.writeSandbox({ + sandboxId: "sb-2", + token: "t", + gateway: { ...gateway, host: "fe80::1%eth0" }, + }) + ).rejects.toThrow(/Refusing to write an invalid Daytona gateway host/); + }); + it("port-qualifies known_hosts entries for a gateway off port 22", () => { expect(renderDaytonaKnownHosts({ ...GATEWAY, port: 2222 })).toBe( [ diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index f91488e9fb6..4cfc8124aa7 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import * as fs from "node:fs/promises"; +import * as net from "node:net"; import * as path from "node:path"; import { promisify } from "node:util"; import { @@ -69,8 +70,21 @@ function assertValid(value: string, pattern: RegExp, what: string): void { } } +/** + * A DNS name or an IP literal: self-hosted deployments may advertise an IPv6 gateway, which + * OpenSSH takes in HostName and HostKeyAlias. A zone id (`%eth0`) is refused: ssh config + * would expand its `%` as a token. + */ +function isGatewayHost(host: string): boolean { + return HOSTNAME_PATTERN.test(host) || (net.isIPv6(host) && !host.includes("%")); +} + function validateGateway(gateway: DaytonaSshGateway): void { - assertValid(gateway.host, HOSTNAME_PATTERN, "gateway host"); + if (!isGatewayHost(gateway.host)) { + throw new DaytonaSshConfigError( + "Refusing to write an invalid Daytona gateway host to ssh config" + ); + } if (!Number.isInteger(gateway.port) || gateway.port < 1 || gateway.port > 65535) { throw new DaytonaSshConfigError("Refusing to write an invalid Daytona gateway port"); } From b10c05fd59ade9b2b2a2e457e857dc8815121eb5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:40:44 +0000 Subject: [PATCH 093/231] fix(daytona): keep walking full snapshot pages when Daytona omits the totals --- .../services/daytona/daytonaClient.test.ts | 26 +++++++++++++++++++ src/node/services/daytona/daytonaClient.ts | 16 +++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/node/services/daytona/daytonaClient.test.ts b/src/node/services/daytona/daytonaClient.test.ts index 36816c7fa37..41108404b99 100644 --- a/src/node/services/daytona/daytonaClient.test.ts +++ b/src/node/services/daytona/daytonaClient.test.ts @@ -180,4 +180,30 @@ describe("DaytonaClient", () => { expect(await client.listSnapshots()).toEqual([{ name: "snap-1" }, { name: "snap-2" }]); expect(requests).toHaveLength(2); }); + + it("walks full snapshot pages when the totals are omitted, stopping on a short page", async () => { + // 100 per page (the limit Xum asks for), then 3; totalPages is optional in the response. + const { fetch, requests } = fakeFetch((request) => { + const page = Number(request.url.searchParams.get("page")); + const count = page === 1 ? 100 : page === 2 ? 3 : 0; + const items = Array.from({ length: count }, (_, i) => ({ name: `snap-${page}-${i}` })); + return jsonResponse(200, { items }); + }); + const client = new DaytonaClient({ apiUrl: API_URL, apiKey: API_KEY, fetch }); + const snapshots = await client.listSnapshots(); + expect(snapshots).toHaveLength(103); + expect(snapshots.at(-1)).toEqual({ name: "snap-2-2" }); + expect(requests).toHaveLength(2); + }); + + it("stops at a full page that repeats the last one (a server that ignores page)", async () => { + const { fetch, requests } = fakeFetch(() => + jsonResponse(200, { + items: Array.from({ length: 100 }, (_, i) => ({ name: `snap-${i}` })), + }) + ); + const client = new DaytonaClient({ apiUrl: API_URL, apiKey: API_KEY, fetch }); + expect(await client.listSnapshots()).toHaveLength(100); + expect(requests).toHaveLength(2); + }); }); diff --git a/src/node/services/daytona/daytonaClient.ts b/src/node/services/daytona/daytonaClient.ts index 34d38b99cd7..afc19a5df18 100644 --- a/src/node/services/daytona/daytonaClient.ts +++ b/src/node/services/daytona/daytonaClient.ts @@ -475,16 +475,26 @@ export class DaytonaClient { async listSnapshots(signal?: AbortSignal): Promise { const snapshots: DaytonaSnapshot[] = []; + const seen = new Set(); for (let page = 1; page <= MAX_PAGES; page++) { const raw = await this.request("GET", "/snapshots", { query: { page, limit: PAGE_LIMIT }, signal, }); const parsed = this.parse(SnapshotPageSchema, raw, "GET /snapshots"); - snapshots.push(...parsed.items.map(toSnapshot)); - if (parsed.items.length === 0 || parsed.totalPages === undefined) return snapshots; - if (page >= parsed.totalPages) return snapshots; + // A page with nothing new ends the walk: a server that ignores `page` repeats page 1. + const fresh = parsed.items.map(toSnapshot).filter((snapshot) => !seen.has(snapshot.name)); + for (const snapshot of fresh) seen.add(snapshot.name); + snapshots.push(...fresh); + if (fresh.length === 0 || isLastPage(parsed, page)) return snapshots; } return snapshots; } } + +/** totalPages is optional: without it, a page shorter than the limit is the last one. */ +function isLastPage(parsed: { items: unknown[]; totalPages?: number }, page: number): boolean { + return parsed.totalPages === undefined + ? parsed.items.length < PAGE_LIMIT + : page >= parsed.totalPages; +} From 41a39a27dcd1a57c818a78c0940770b6529b76d9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:42:57 +0000 Subject: [PATCH 094/231] fix(daytona): hold a cross-process lock across a credentials save --- .../config/daytonaCredentialsStore.test.ts | 39 ++++++++++++++ src/node/config/daytonaCredentialsStore.ts | 54 ++++++++++++++----- 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/src/node/config/daytonaCredentialsStore.test.ts b/src/node/config/daytonaCredentialsStore.test.ts index 04eef59cb64..fec5dd6cfb1 100644 --- a/src/node/config/daytonaCredentialsStore.test.ts +++ b/src/node/config/daytonaCredentialsStore.test.ts @@ -3,6 +3,7 @@ import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; import { DAYTONA_DEFAULT_API_URL } from "@/common/constants/daytona"; +import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import { DaytonaCredentialsStore } from "./daytonaCredentialsStore"; const KEY = "test-key-not-real"; @@ -36,6 +37,44 @@ describe("DaytonaCredentialsStore", () => { expect((await store.resolve())?.apiKey).toBe(KEY); }); + // Two backends on one Xum root (a desktop app next to `xum server`) each have their own store. + it("keeps both of two backends' concurrent saves", async () => { + const desktop = new DaytonaCredentialsStore(rootDir, {}); + const server = new DaytonaCredentialsStore(rootDir, {}); + await desktop.update({ apiKey: "old-key", apiUrl: "https://old.example.com/api" }); + + await Promise.all([ + desktop.update({ apiKey: KEY }), + server.update({ apiUrl: "https://new.example.com/api" }), + ]); + + expect(await desktop.read()).toEqual({ apiKey: KEY, apiUrl: "https://new.example.com/api" }); + }); + + it("waits for another process's save before reading the file", async () => { + // Held by this process, the file lock looks to the store like another backend mid-save. + const release = await acquireCrossProcessLock({ + lockPath: path.join(rootDir, "daytona.json.lock"), + acquireTimeoutMs: 1_000, + staleMs: 60_000, + timeoutMessage: "test holder", + }); + const store = new DaytonaCredentialsStore(rootDir, {}); + let settled = false; + const saving = store.update({ apiKey: KEY }).finally(() => { + settled = true; + }); + try { + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(settled).toBe(false); + expect(await store.read()).toEqual({}); + } finally { + await release(); + } + await saving; + expect(await store.read()).toEqual({ apiKey: KEY }); + }); + it("leaves omitted fields alone and clears fields set to an empty string", async () => { const store = new DaytonaCredentialsStore(rootDir, {}); await store.update({ apiKey: KEY, target: "us" }); diff --git a/src/node/config/daytonaCredentialsStore.ts b/src/node/config/daytonaCredentialsStore.ts index 743bef18636..721dd554740 100644 --- a/src/node/config/daytonaCredentialsStore.ts +++ b/src/node/config/daytonaCredentialsStore.ts @@ -8,6 +8,7 @@ import { isPlainObject } from "@/common/utils/isPlainObject"; import { isLoopbackHost } from "@/common/utils/loopbackHost"; import { log } from "@/node/services/log"; import { ensurePrivateDir, isErrnoWithCode } from "@/node/utils/fs"; +import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import writeFileAtomic from "@/node/utils/writeFileAtomic"; /** @@ -119,6 +120,25 @@ function parseStoredCredentials(raw: string): DaytonaStoredCredentials | undefin return stored; } +/** `stored` with the patch's fields set; an empty string clears a field. */ +function applyPatch( + stored: DaytonaStoredCredentials, + patch: DaytonaCredentialsPatch +): DaytonaStoredCredentials { + const next = { ...stored }; + for (const field of CREDENTIAL_FIELDS) { + const value = patch[field]; + if (value === undefined) continue; + const trimmed = nonEmpty(value); + if (trimmed === undefined) { + delete next[field]; + } else { + next[field] = field === "apiUrl" ? normalizeApiUrl(trimmed) : trimmed; + } + } + return next; +} + export class DaytonaCredentialsStore { readonly filePath: string; private readonly rootDir: string; @@ -164,23 +184,29 @@ export class DaytonaCredentialsStore { return run; } + /** + * Another backend on this Xum root (a desktop app next to `xum server`) has its own + * updateChain: the file lock spans the whole read-modify-write, or one backend's save of the + * key could be overwritten by the other's save of the URL, pairing the old key with the new + * deployment. + */ private async applyUpdate(patch: DaytonaCredentialsPatch): Promise { - const next = await this.read(); - for (const field of CREDENTIAL_FIELDS) { - const value = patch[field]; - if (value === undefined) continue; - const trimmed = nonEmpty(value); - if (trimmed === undefined) { - delete next[field]; - } else { - next[field] = field === "apiUrl" ? normalizeApiUrl(trimmed) : trimmed; - } - } await ensurePrivateDir(this.rootDir); - await writeFileAtomic(this.filePath, JSON.stringify(next, null, 2), { - encoding: "utf-8", - mode: 0o600, + const release = await acquireCrossProcessLock({ + lockPath: `${this.filePath}.lock`, + acquireTimeoutMs: 30_000, + staleMs: 60_000, + timeoutMessage: "Another Xum process is saving Daytona settings.", }); + try { + const next = applyPatch(await this.read(), patch); + await writeFileAtomic(this.filePath, JSON.stringify(next, null, 2), { + encoding: "utf-8", + mode: 0o600, + }); + } finally { + await release(); + } } /** From 51e08042496a1b0b99e86ee94f6947cc0fdf56e5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:04:52 +0000 Subject: [PATCH 095/231] fix(daytona): hold a cross-process lock across every Daytona ssh config write --- src/node/runtime/daytonaSshConfig.test.ts | 34 ++++++++++++++++++++++ src/node/runtime/daytonaSshConfig.ts | 35 +++++++++++++++++++---- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index dab82856149..f52ff38127e 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -8,6 +8,7 @@ import { XUM_DAYTONA_SSH_BLOCK_START, toXumDaytonaHost, } from "@/common/constants/daytona"; +import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import { DaytonaSshConfigWriter, renderDaytonaKnownHosts } from "./daytonaSshConfig"; import { appendOpenSSHHostKeyPolicyArgs } from "./sshConnectionPool"; @@ -129,6 +130,39 @@ describe("DaytonaSshConfigWriter", () => { expect(await fs.readFile(writer.paths.knownHostsPath, "utf8")).toContain("GatewayKey"); }); + it("waits for another backend's include file update before reading the file", async () => { + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + const updates = [ + () => writer.writeSandbox({ sandboxId: "sb-2", token: "tokenB", gateway: GATEWAY }), + () => writer.updateToken({ sandboxId: "sb-1", token: "tokenA2", gateway: GATEWAY }), + () => writer.repointGateway(GATEWAY), + () => writer.removeSandbox("sb-1"), + ]; + for (const update of updates) { + // Held by this process, the file lock looks to the writer like another backend mid-write. + const release = await acquireCrossProcessLock({ + lockPath: `${writer.paths.includePath}.lock`, + acquireTimeoutMs: 1_000, + staleMs: 60_000, + timeoutMessage: "test holder", + }); + const before = await fs.readFile(writer.paths.includePath, "utf8"); + let settled = false; + const running = update().finally(() => { + settled = true; + }); + try { + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(settled).toBe(false); + expect(await fs.readFile(writer.paths.includePath, "utf8")).toBe(before); + } finally { + await release(); + } + await running; + } + expect(await writer.readTokens()).toEqual([{ sandboxId: "sb-2", token: "tokenB" }]); + }); + it("reads back the tokens the include file holds, for a later Xum process", async () => { const later = new DaytonaSshConfigWriter({ xumHome, sshConfigPath }); expect(await later.readTokens()).toEqual([]); diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 4cfc8124aa7..c4bcc0f8da4 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -14,6 +14,7 @@ import { ensureManagedSSHConfigBlock } from "@/node/runtime/muxSshConfigWriter"; import { log } from "@/node/services/log"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; import { ensurePrivateDir, isErrnoWithCode } from "@/node/utils/fs"; +import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import writeFileAtomic from "@/node/utils/writeFileAtomic"; const execFileAsync = promisify(execFile); @@ -200,7 +201,8 @@ function tokensIn(blocks: Map): Array<{ sandboxId: string; token return tokens; } -// One lock per include file across writer instances: blocks of different sandboxes share it. +// One lock per include file across writer instances in this process (withIncludeFileLock adds +// the cross-process one): blocks of different sandboxes share it. const includeFileLocks = new MutexMap(); export interface DaytonaSshConfigWriterOptions { @@ -254,9 +256,8 @@ export class DaytonaSshConfigWriter { gateway: DaytonaSshGateway, edit: (blocks: Map) => void ): Promise { - await ensurePrivateDir(this.paths.dir); const knownHosts = renderDaytonaKnownHosts(gateway); - await includeFileLocks.withLock(this.paths.includePath, async () => { + await this.withIncludeFileLock(async () => { await writeFileAtomic(this.paths.knownHostsPath, knownHosts, { encoding: "utf-8", mode: 0o600, @@ -276,13 +277,12 @@ export class DaytonaSshConfigWriter { token: string; gateway: DaytonaSshGateway; }): Promise { - await ensurePrivateDir(this.paths.dir); await this.upsertBlock(input); } async removeSandbox(sandboxId: string): Promise { const alias = toXumDaytonaHost(sandboxId); - await includeFileLocks.withLock(this.paths.includePath, async () => { + await this.withIncludeFileLock(async () => { const blocks = await this.readBlocks(); if (!blocks.delete(alias)) return; await this.writeBlocks(blocks); @@ -305,13 +305,36 @@ export class DaytonaSshConfigWriter { }): Promise { const block = this.renderBlock(input); const alias = toXumDaytonaHost(input.sandboxId); - return includeFileLocks.withLock(this.paths.includePath, async () => { + return this.withIncludeFileLock(async () => { const blocks = await this.readBlocks(); blocks.set(alias, block); await this.writeBlocks(blocks); }); } + /** + * Every read-modify-write of the include file (and known_hosts) runs under this. Another + * backend on the same Xum root (a desktop app next to `xum server`) has its own MutexMap: + * without the file lock, two writers read the same file and the later rename drops the + * other's alias. Callers never nest it. + */ + private async withIncludeFileLock(fn: () => Promise): Promise { + await ensurePrivateDir(this.paths.dir); + await includeFileLocks.withLock(this.paths.includePath, async () => { + const release = await acquireCrossProcessLock({ + lockPath: `${this.paths.includePath}.lock`, + acquireTimeoutMs: 30_000, + staleMs: 60_000, + timeoutMessage: "Another Xum process is updating the Daytona ssh config.", + }); + try { + await fn(); + } finally { + await release(); + } + }); + } + private async readBlocks(): Promise> { try { return parseIncludeBlocks(await fs.readFile(this.paths.includePath, "utf-8")); From 43299a17ff71178e3539450a726dfedd83f678ec Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:08:15 +0000 Subject: [PATCH 096/231] fix(daytona): apply the archive policy once only archived workspaces use the sandbox --- .../daytona/daytonaLifecycleHooks.test.ts | 28 ++++++++++++++++ .../services/daytona/daytonaLifecycleHooks.ts | 33 ++++++++++++++++--- .../services/daytona/daytonaReconciler.ts | 10 +++++- src/node/services/daytona/daytonaService.ts | 27 ++++++++++++++- 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/src/node/services/daytona/daytonaLifecycleHooks.test.ts b/src/node/services/daytona/daytonaLifecycleHooks.test.ts index 925f04a9673..6670b351f1b 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.test.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.test.ts @@ -104,6 +104,34 @@ describe("createDaytonaArchiveHook", () => { expect(rig.writer.removed).toEqual(["sb-shared"]); }); + it("applies the archive policy once the last active workspace on a sandbox is archived", async () => { + rig.plane.addSandbox({ id: "sb-shared" }); + const owner = { + workspaceId: "owner", + runtimeConfig: metadata({ sandboxId: "sb-shared", shared: false }).runtimeConfig, + archived: false, + }; + rig.workspaces.push(owner); + const fork = { sandboxId: "sb-shared", forked: true, shared: false }; + + // The owner still works in the sandbox: archiving the fork leaves it running. + expect((await archive(fork, "stop")).success).toBe(true); + expect(rig.plane.find("sb-shared")?.state).toBe("started"); + + // Archived first, the owner no longer uses it: archiving the fork applies the policy. + owner.archived = true; + expect((await archive(fork, "stop")).success).toBe(true); + expect(rig.plane.find("sb-shared")?.state).toBe("stopped"); + + // Delete stops instead: the owner's checkout on it was never checked, and it may come back. + rig.plane.find("sb-shared")!.state = "started"; + const warnings: string[] = []; + expect((await archive(fork, "delete", cleanCheck, (w) => warnings.push(w))).success).toBe(true); + expect(rig.plane.find("sb-shared")?.state).toBe("stopped"); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("archived workspace"); + }); + it("leaves a sandbox alone that another workspace attached by its id, or that cannot be looked up", async () => { // Created by Xum under a name; the attach picker stored the same sandbox's real id. rig.plane.addSandbox({ id: "sb-9", name: "xum-owner-def456" }); diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index 0bf7d8d92fb..61838f07172 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -22,7 +22,7 @@ export interface DaytonaArchiveHookOptions { | "getSandboxState" | "stopAndWait" | "deleteSandbox" - | "isSandboxReferencedElsewhere" + | "sandboxUseElsewhere" | "isAliasStoredElsewhere" | "releaseSshAccess" | "withAccountLease" @@ -52,7 +52,8 @@ async function archiveSandbox( const sandboxId = storedSandboxId(args.workspaceMetadata); if (!sandboxId) return Ok(undefined); const workspaceId = args.workspaceMetadata.id; - if (await options.daytonaService.isSandboxReferencedElsewhere(sandboxId, workspaceId)) { + const others = await options.daytonaService.sandboxUseElsewhere(sandboxId, workspaceId); + if (others === "active") { // The sandbox stays as it is, but ssh access is per stored string: another workspace that // stores the same sandbox under its other identifier (name or id) never uses this alias. if (!options.daytonaService.isAliasStoredElsewhere(sandboxId, workspaceId)) { @@ -61,8 +62,9 @@ async function archiveSandbox( return Ok(undefined); } + // Only archived workspaces left on it: nobody works there now, so the policy applies. const result = isDedicatedDaytonaSandbox(args.workspaceMetadata.runtimeConfig) - ? await applyArchiveBehavior(options, args, sandboxId) + ? await applyArchiveBehavior(options, args, sandboxId, others === "archived") : Ok(undefined); if (result.success) await releaseSshAccess(options, sandboxId); return result; @@ -71,14 +73,17 @@ async function archiveSandbox( async function applyArchiveBehavior( options: DaytonaArchiveHookOptions, args: Parameters[0], - sandboxId: string + sandboxId: string, + archivedOthers: boolean ): Promise> { // The archive operation's policy snapshot wins (see the Coder hook). const behavior = args.coderWorkspaceArchiveBehavior ?? options.getArchiveBehavior(); if (behavior === "keep") return Ok(undefined); try { if (behavior === "delete") { - return await deleteForArchive(options, args, sandboxId); + return archivedOthers + ? await stopInsteadOfDelete(options, args, sandboxId) + : await deleteForArchive(options, args, sandboxId); } return await stopForArchive(options, args, sandboxId); } catch (error) { @@ -86,6 +91,24 @@ async function applyArchiveBehavior( } } +/** + * Archived workspaces still have checkouts on the sandbox: they were never checked for + * unsaved work, and unarchiving one needs the sandbox back. So it is stopped, not deleted. + */ +async function stopInsteadOfDelete( + options: DaytonaArchiveHookOptions, + args: Parameters[0], + sandboxId: string +): Promise> { + const stopped = await stopForArchive(options, args, sandboxId); + if (stopped.success) { + args.reportWarning?.( + `Daytona sandbox "${sandboxId}" was stopped, not deleted: an archived workspace still has its checkout on it.` + ); + } + return stopped; +} + /** The sandbox as the archived workspace stores it (its name or its id). */ function storedSandboxId(workspaceMetadata: WorkspaceMetadata): string | undefined { // A shared-checkout sub-agent copies its parent's runtime config, sandbox ID included: diff --git a/src/node/services/daytona/daytonaReconciler.ts b/src/node/services/daytona/daytonaReconciler.ts index c6816279cbb..ff00b8545c3 100644 --- a/src/node/services/daytona/daytonaReconciler.ts +++ b/src/node/services/daytona/daytonaReconciler.ts @@ -1,3 +1,4 @@ +import { isWorkspaceArchived } from "@/common/utils/archive"; import type { Workspace } from "@/common/types/project"; import { getDaytonaSandboxId, type RuntimeConfig } from "@/common/types/runtime"; import { Err, type Result } from "@/common/types/result"; @@ -17,6 +18,8 @@ export interface DaytonaWorkspaceRef { runtimeConfig: RuntimeConfig | undefined; /** The marker of a removal in flight (WorkspaceConfigSchema.pendingRemoval). */ pendingRemovalId?: string; + /** Archived now: archive applies its sandbox policy once only archived workspaces use it. */ + archived?: boolean; } export interface DaytonaReconcilerOptions { @@ -54,7 +57,11 @@ export interface DaytonaReconcilerOptions { export function listDaytonaWorkspaceRefs(config: { projects: ReadonlyMap< string, - { workspaces: ReadonlyArray> } + { + workspaces: ReadonlyArray< + Pick + >; + } >; }): DaytonaWorkspaceRef[] { const refs: DaytonaWorkspaceRef[] = []; @@ -65,6 +72,7 @@ export function listDaytonaWorkspaceRefs(config: { workspaceId: workspace.id, runtimeConfig: workspace.runtimeConfig, pendingRemovalId: workspace.pendingRemoval?.removalId, + archived: isWorkspaceArchived(workspace.archivedAt, workspace.unarchivedAt), }); } } diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 25be49b2676..1786afaed5b 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -40,6 +40,7 @@ import type { DaytonaWorkspaceRef } from "./daytonaReconciler"; interface StoredSandboxRef { stored: string; attached: boolean; + archived: boolean; } /** @@ -457,6 +458,24 @@ export class DaytonaService { return matched.every((ref) => ref.attached) ? "attached" : "other"; } + /** + * For archive: whether another workspace still works in the sandbox ("active"), only + * archived ones reference it ("archived"), or none does. Matched and failing closed (as + * "active") like sandboxReferencesElsewhere; removal still counts archived workspaces. + */ + async sandboxUseElsewhere( + sandboxId: string, + workspaceId: string | undefined + ): Promise<"none" | "archived" | "active"> { + if (!this.listWorkspaces || !workspaceId) return "active"; + const others = this.refsStoredElsewhere(this.listWorkspaces(), workspaceId); + if (others.length === 0) return "none"; + const matched = await this.refsMatchingSandbox(sandboxId, others); + if (!matched) return "active"; + if (matched.length === 0) return "none"; + return matched.every((ref) => ref.archived) ? "archived" : "active"; + } + /** The refs that store this sandbox by its name or its id; undefined when the lookup fails. */ private async refsMatchingSandbox( sandboxId: string, @@ -492,7 +511,13 @@ export class DaytonaService { this.removedWorkspaces.get(ref.workspaceId) === ref.pendingRemovalId; const counts = ref.workspaceId !== workspaceId && !removed; return counts && stored - ? [{ stored, attached: !isDedicatedDaytonaSandbox(ref.runtimeConfig) }] + ? [ + { + stored, + attached: !isDedicatedDaytonaSandbox(ref.runtimeConfig), + archived: ref.archived === true, + }, + ] : []; }); } From 7c7be5771796c8a759aa76f3ea123712b8f69bb5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:09:33 +0000 Subject: [PATCH 097/231] fix(daytona): neither offer nor attach a sandbox in an error state --- src/node/runtime/DaytonaSSHRuntime.test.ts | 17 +++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 23 +++++++++++++++++++-- src/node/services/daytona/daytonaService.ts | 4 +++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 35ff6b10860..7a8d86b0d40 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -351,6 +351,23 @@ describe("DaytonaSSHRuntime Windows sandboxes", () => { expect(rig.plane.countCalls("POST /sandbox")).toBe(0); }); + it("neither offers nor attaches a sandbox in an error state", async () => { + rig.plane.addSandbox({ id: "sb-broken", state: "build_failed" }); + rig.plane.addSandbox({ id: "sb-ok" }); + const runtime = makeRuntime({}); + const finalized = await runtime.finalizeConfig( + "b", + sshDaytonaConfig({ sandboxId: "sb-broken" }) + ); + if (!finalized.success) throw new Error(finalized.error); + + // startAndWait never brings such a sandbox up: setup would fail on every retry. + const refused = await runtime.validateBeforePersist("b", finalized.data); + expect(refused.success ? "" : refused.error).toContain("build_failed"); + const listed = await rig.service.listSandboxes(); + expect(listed.ok && listed.sandboxes.map((s) => s.id)).toEqual(["sb-ok"]); + }); + it("offers neither Windows snapshots nor Windows sandboxes", async () => { rig.plane.addSandbox({ id: "sb-win", sandboxClass: "windows" }); rig.plane.addSandbox({ id: "sb-linux", sandboxClass: "linux-vm" }); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index a736670feda..22fea133e0f 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -241,6 +241,24 @@ function makeStatusEmitter(statusSink: EnsureReadyOptions["statusSink"]): Status * An unreported disk is left as it is: the snapshot's may be larger than the preset's, and * sending the preset would then be a shrink Daytona refuses, failing (and deleting) the sandbox. */ +/** Why an existing sandbox cannot be attached, before the workspace is persisted. */ +function attachRefusal( + sandboxId: string, + state: DaytonaSandboxState, + sandbox: DaytonaSandbox +): string | undefined { + if (sandbox.sandboxClass === DAYTONA_WINDOWS_SANDBOX_CLASS) { + return `Daytona sandbox "${sandboxId}" is a Windows sandbox. ${LINUX_ONLY}`; + } + // Setup's start never brings an error state up, so every retry would fail. + if (state !== "error") return undefined; + const reason = sandbox.errorReason ? ` (${sandbox.errorReason})` : ""; + return ( + `Daytona sandbox "${sandboxId}" is in state ${sandbox.state ?? "unknown"}${reason}, ` + + "which Xum cannot start; pick another sandbox" + ); +} + function growsDisk(sandbox: DaytonaSandbox, preset: DaytonaSizePreset): boolean { return sandbox.disk !== undefined && sandbox.disk < preset.diskGiB; } @@ -736,8 +754,9 @@ export class DaytonaSSHRuntime extends SSHRuntime { return Err(`Could not reach Daytona: ${getErrorMessage(error)}`); } const exists = found.state !== "missing"; - if (attaching && found.sandbox?.sandboxClass === DAYTONA_WINDOWS_SANDBOX_CLASS) { - return Err(`Daytona sandbox "${sandboxId}" is a Windows sandbox. ${LINUX_ONLY}`); + if (attaching && found.sandbox) { + const refusal = attachRefusal(sandboxId, found.state, found.sandbox); + if (refusal) return Err(refusal); } if (attaching === exists) return Ok(undefined); return Err( diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 1786afaed5b..4142cf48ab3 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -714,8 +714,10 @@ export class DaytonaService { return { ok: true, sandboxes: sandboxes + // Error states (destroyed and destroying included) cannot be started or attached. .filter( - (sandbox) => !DESTROYING_STATES.has(sandbox.state ?? "") && !isWindowsClass(sandbox) + (sandbox) => + mapDaytonaSandboxState(sandbox.state) !== "error" && !isWindowsClass(sandbox) ) .map(toSummary), }; From de10c3e0343b97c582a2b29329f27635d65f34c6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:11:36 +0000 Subject: [PATCH 098/231] fix(daytona): neither offer nor accept a snapshot that is not active --- src/node/runtime/DaytonaSSHRuntime.test.ts | 31 +++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 22 +++++++++++-- .../services/daytona/daytona.testHarness.ts | 7 ++++- src/node/services/daytona/daytonaClient.ts | 11 +++++-- src/node/services/daytona/daytonaService.ts | 20 +++++++++--- 5 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 7a8d86b0d40..771226dcd82 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -368,6 +368,37 @@ describe("DaytonaSSHRuntime Windows sandboxes", () => { expect(listed.ok && listed.sandboxes.map((s) => s.id)).toEqual(["sb-ok"]); }); + it("neither offers nor accepts a snapshot that is not active", async () => { + rig.plane.snapshots.push( + { name: "snap-building", state: "building" }, + { name: "snap-inactive", state: "inactive" }, + { name: "snap-active", state: "active" }, + // No state reported: accepted, as before Daytona reported one. + { name: "snap-unknown" } + ); + const runtime = makeRuntime({}); + const validate = async (snapshot: string) => { + const finalized = await runtime.finalizeConfig("b", sshDaytonaConfig({ snapshot })); + if (!finalized.success) return finalized; + return runtime.validateBeforePersist("b", finalized.data); + }; + const error = (result: Awaited>) => + result.success ? "" : result.error; + + expect(error(await validate("snap-building"))).toContain("building"); + const inactive = error(await validate("snap-inactive")); + expect(inactive).toContain("inactive"); + expect(inactive).toContain("activate"); + expect((await validate("snap-active")).success).toBe(true); + expect((await validate("snap-unknown")).success).toBe(true); + const listed = await rig.service.listSnapshots(); + expect(listed.ok && listed.snapshots.map((s) => s.name)).toEqual([ + "snap-active", + "snap-unknown", + ]); + expect(rig.plane.countCalls("POST /sandbox")).toBe(0); + }); + it("offers neither Windows snapshots nor Windows sandboxes", async () => { rig.plane.addSandbox({ id: "sb-win", sandboxClass: "windows" }); rig.plane.addSandbox({ id: "sb-linux", sandboxClass: "linux-vm" }); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 22fea133e0f..208818b46c5 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -241,6 +241,19 @@ function makeStatusEmitter(statusSink: EnsureReadyOptions["statusSink"]): Status * An unreported disk is left as it is: the snapshot's may be larger than the preset's, and * sending the preset would then be a shrink Daytona refuses, failing (and deleting) the sandbox. */ +/** + * Only an active snapshot creates sandboxes (SnapshotState: building, pending, pulling, + * snapshotting, inactive, error, build_failed, removing do not); none reported passes. + */ +function snapshotStateRefusal(snapshot: string, state: string | undefined): string | undefined { + if (!state || state === "active") return undefined; + const next = + state === "inactive" + ? "activate it in Daytona or pick another snapshot" + : "pick another snapshot"; + return `Daytona snapshot "${snapshot}" is ${state}, not active; ${next}`; +} + /** Why an existing sandbox cannot be attached, before the workspace is persisted. */ function attachRefusal( sandboxId: string, @@ -768,7 +781,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { /** Refused before any sandbox is created: a Windows snapshot makes a billed box setup fails on. */ private async checkSnapshotClass(snapshot: string): Promise> { - let found: { windows: boolean } | undefined; + let found: Awaited>; try { found = await this.daytonaService.findSnapshot(snapshot); } catch (error) { @@ -776,8 +789,11 @@ export class DaytonaSSHRuntime extends SSHRuntime { } // A stale choice (deleted after it was saved as a default) would fail every setup retry. if (!found) return Err(`Daytona snapshot "${snapshot}" was not found; pick another snapshot`); - if (!found.windows) return Ok(undefined); - return Err(`Daytona snapshot "${snapshot}" creates Windows sandboxes. ${LINUX_ONLY}`); + if (found.windows) { + return Err(`Daytona snapshot "${snapshot}" creates Windows sandboxes. ${LINUX_ONLY}`); + } + const refusal = snapshotStateRefusal(snapshot, found.state); + return refusal ? Err(refusal) : Ok(undefined); } /** The sandbox may not exist yet, so only compute the path (as Coder does). */ diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index a3c875e613b..21b2afd39d2 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -39,7 +39,12 @@ export class FakeDaytonaControlPlane { readonly createBodies: Array> = []; readonly revokedTokens: string[] = []; /** The organization's snapshots; `listedOnly` ones only come with the list, not by name. */ - readonly snapshots: Array<{ name: string; sandboxClass?: string; listedOnly?: boolean }> = []; + readonly snapshots: Array<{ + name: string; + sandboxClass?: string; + listedOnly?: boolean; + state?: string; + }> = []; /** The snapshot GET /config advertises as the deployment default; unset advertises none. */ defaultSnapshot?: string; /** Answer POST /sandbox with 400 when it carries cpu/memory/disk (fixed-resource snapshot). */ diff --git a/src/node/services/daytona/daytonaClient.ts b/src/node/services/daytona/daytonaClient.ts index afc19a5df18..55f41dd4061 100644 --- a/src/node/services/daytona/daytonaClient.ts +++ b/src/node/services/daytona/daytonaClient.ts @@ -73,17 +73,22 @@ const SnapshotSchema = z.object({ name: z.string(), /** The class of the sandboxes the snapshot creates, as on a sandbox. */ sandboxClass: z.string().nullish(), + /** SnapshotState: only "active" creates sandboxes (building, inactive, error, ...). */ + state: z.string().nullish(), }); export interface DaytonaSnapshot { name: string; sandboxClass?: string; + state?: string; } function toSnapshot(item: z.infer): DaytonaSnapshot { - return item.sandboxClass - ? { name: item.name, sandboxClass: item.sandboxClass } - : { name: item.name }; + return { + name: item.name, + ...(item.sandboxClass ? { sandboxClass: item.sandboxClass } : {}), + ...(item.state ? { state: item.state } : {}), + }; } const SnapshotPageSchema = z.object({ diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 4142cf48ab3..cfbb5035f45 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -110,6 +110,11 @@ const RAW_STATE_MAP: Record = { unknown: "error", }; +/** Only an active snapshot creates sandboxes; one with no reported state is taken as active. */ +function isActiveSnapshot(snapshot: DaytonaSnapshot): boolean { + return !snapshot.state || snapshot.state === "active"; +} + /** Collapse Daytona's ~20 sandbox states to the five Xum acts on; unknown values are errors. */ export function mapDaytonaSandboxState(raw: string | undefined): ObservedSandboxState { return (raw ? RAW_STATE_MAP[raw] : undefined) ?? "error"; @@ -732,7 +737,9 @@ export class DaytonaService { const snapshots = await client.listSnapshots(); return { ok: true, - snapshots: snapshots.filter((s) => !isWindowsClass(s)).map(({ name }) => ({ name })), + snapshots: snapshots + .filter((s) => !isWindowsClass(s) && isActiveSnapshot(s)) + .map(({ name }) => ({ name })), }; } catch (error) { return toResultError(error); @@ -740,11 +747,11 @@ export class DaytonaService { } /** - * The snapshot named `name`, with whether it creates Windows sandboxes; undefined when + * The snapshot named `name`, with whether it creates Windows sandboxes and its state; undefined when * Daytona has none by that name. The list is the fallback for one the lookup does not find * by name (it serves the snapshot picker). */ - async findSnapshot(name: string): Promise<{ windows: boolean } | undefined> { + async findSnapshot(name: string): Promise<{ windows: boolean; state?: string } | undefined> { const { client } = await this.getClient(); let snapshot: DaytonaSnapshot | undefined; try { @@ -753,7 +760,12 @@ export class DaytonaService { if (!isDaytonaNotFound(error)) throw error; snapshot = (await client.listSnapshots()).find((listed) => listed.name === name); } - return snapshot && { windows: isWindowsClass(snapshot) }; + return ( + snapshot && { + windows: isWindowsClass(snapshot), + ...(snapshot.state ? { state: snapshot.state } : {}), + } + ); } /** Stable per Xum install, so the reconciler only ever touches this install's sandboxes. */ From c78e60caafc72df2ddd5932e67682ebd721ffa6d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:16:52 +0000 Subject: [PATCH 099/231] fix(daytona): refuse an inactive deployment default snapshot like a chosen one --- src/node/runtime/DaytonaSSHRuntime.test.ts | 26 +++++++++++++--------- src/node/runtime/DaytonaSSHRuntime.ts | 21 +++++++++-------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 771226dcd82..c10e34a50e8 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -340,16 +340,22 @@ describe("DaytonaSSHRuntime Windows sandboxes", () => { expect(rig.plane.countCalls("POST /sandbox")).toBe(0); }); - it("refuses a Windows deployment default when no snapshot is chosen", async () => { - rig.plane.snapshots.push({ name: "windows-default", sandboxClass: "windows" }); - rig.plane.defaultSnapshot = "windows-default"; - const runtime = makeRuntime({}); - const finalized = await runtime.finalizeConfig("b", sshDaytonaConfig({})); - if (!finalized.success) throw new Error(finalized.error); - const refused = await runtime.validateBeforePersist("b", finalized.data); - expect(refused.success ? "" : refused.error).toContain('"windows-default" creates Windows'); - expect(rig.plane.countCalls("POST /sandbox")).toBe(0); - }); + it.each([ + [{ name: "default-snap", sandboxClass: "windows" }, '"default-snap" creates Windows'], + [{ name: "default-snap", state: "inactive" }, '"default-snap" is inactive'], + ])( + "refuses a deployment default Xum cannot use when no snapshot is chosen (%o)", + async (snapshot, refusal) => { + rig.plane.snapshots.push(snapshot); + rig.plane.defaultSnapshot = "default-snap"; + const runtime = makeRuntime({}); + const finalized = await runtime.finalizeConfig("b", sshDaytonaConfig({})); + if (!finalized.success) throw new Error(finalized.error); + const refused = await runtime.validateBeforePersist("b", finalized.data); + expect(refused.success ? "" : refused.error).toContain(refusal); + expect(rig.plane.countCalls("POST /sandbox")).toBe(0); + } + ); it("neither offers nor attaches a sandbox in an error state", async () => { rig.plane.addSandbox({ id: "sb-broken", state: "build_failed" }); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 208818b46c5..2bc567c6253 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -735,21 +735,18 @@ export class DaytonaSSHRuntime extends SSHRuntime { } /** - * "Daytona default" creates from the snapshot the deployment advertises, so a Windows default - * is refused like a chosen one. A default the organization cannot look up is Daytona's to - * resolve at create time, as when none is advertised. + * "Daytona default" creates from the snapshot the deployment advertises, so a Windows or + * inactive default is refused like a chosen one. A default the organization cannot look up + * is Daytona's to resolve at create time, as when none is advertised. */ private async checkDefaultSnapshotClass(): Promise> { let snapshot: string | undefined; - let found: { windows: boolean } | undefined; try { snapshot = (await this.daytonaService.getGatewayConfig()).defaultSnapshot; - found = snapshot ? await this.daytonaService.findSnapshot(snapshot) : undefined; } catch (error) { return Err(`Could not reach Daytona: ${getErrorMessage(error)}`); } - if (!snapshot || !found?.windows) return Ok(undefined); - return Err(`Daytona snapshot "${snapshot}" creates Windows sandboxes. ${LINUX_ONLY}`); + return snapshot ? this.checkSnapshotClass(snapshot, { missingOk: true }) : Ok(undefined); } /** @@ -780,7 +777,10 @@ export class DaytonaSSHRuntime extends SSHRuntime { } /** Refused before any sandbox is created: a Windows snapshot makes a billed box setup fails on. */ - private async checkSnapshotClass(snapshot: string): Promise> { + private async checkSnapshotClass( + snapshot: string, + options?: { missingOk: boolean } + ): Promise> { let found: Awaited>; try { found = await this.daytonaService.findSnapshot(snapshot); @@ -788,7 +788,10 @@ export class DaytonaSSHRuntime extends SSHRuntime { return Err(`Could not reach Daytona: ${getErrorMessage(error)}`); } // A stale choice (deleted after it was saved as a default) would fail every setup retry. - if (!found) return Err(`Daytona snapshot "${snapshot}" was not found; pick another snapshot`); + if (!found) { + if (options?.missingOk) return Ok(undefined); + return Err(`Daytona snapshot "${snapshot}" was not found; pick another snapshot`); + } if (found.windows) { return Err(`Daytona snapshot "${snapshot}" creates Windows sandboxes. ${LINUX_ONLY}`); } From e98b3d814c0c702121c020572110d985dabf669e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:40:25 +0000 Subject: [PATCH 100/231] fix(daytona): redact Daytona tokens from terminal output OpenSSH can print `@gateway: Permission denied` or a password prompt naming the token into an embedded terminal, which renderer clients see and replay. DaytonaSSHRuntime.createPtySession now wraps the PTY in the same token-aware redactor as ssh stderr. The carry/holdback logic is shared, a held tail is published after a short quiet period so typed echo does not stall, and exit flushes it first. The text is now redacted before the tail is held, so a token ending with its own first char at a chunk end is no longer split past redaction. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 37 ++++++ src/node/runtime/DaytonaSSHRuntime.ts | 3 +- .../runtime/daytonaTokenRedaction.test.ts | 104 +++++++++++++++++ src/node/runtime/daytonaTokenRedaction.ts | 110 ++++++++++++++++-- 4 files changed, 243 insertions(+), 11 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index c10e34a50e8..666f9e99523 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -245,6 +245,43 @@ describe("DaytonaSSHRuntime integrated terminals", () => { ).rejects.toThrow("no longer exists"); expect(opened).toBe(false); }); + + it("keep the token out of what the terminal prints", async () => { + rig.plane.addSandbox({ id: "sb-term-tok" }); + await rig.service.ensureSshAccess("sb-term-tok"); + const token = rig.writer.tokens.get("sb-term-tok"); + if (!token) throw new Error("expected a minted token"); + let emit: (data: string) => void = () => undefined; + const runtime = makeRuntime({ sandboxId: "sb-term-tok" }, rig, { + transport: { + ...localShellTransport(rig.rootDir), + createPtySession: () => + Promise.resolve({ + write: () => undefined, + resize: () => undefined, + kill: () => undefined, + onData: (handler: (data: string) => void) => { + emit = handler; + return { dispose: () => undefined }; + }, + onExit: () => ({ dispose: () => undefined }), + }), + }, + }); + + const pty = await runtime.createPtySession({ + workspacePath: "~/src/project/feature", + cols: 80, + rows: 24, + }); + const printed: string[] = []; + pty.onData((data) => printed.push(data)); + emit(`${token}@gateway: Permission denied (publickey).\r\n`); + + expect(printed.join("")).toBe( + `${DAYTONA_TOKEN_PLACEHOLDER}@gateway: Permission denied (publickey).\r\n` + ); + }); }); describe("DaytonaSSHRuntime.finalizeConfig and validateBeforePersist", () => { diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 2bc567c6253..07cc43f87e6 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -60,6 +60,7 @@ import { expandTildeForSSH } from "./tildeExpansion"; import { redactChildStderr, redactDaytonaError, + redactDaytonaPty, redactDaytonaTokens, } from "./daytonaTokenRedaction"; @@ -566,7 +567,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { override async createPtySession(params: PtySessionParams): Promise { const ready = await this.ensureReady(); if (!ready.ready) throw new Error(ready.error); - return super.createPtySession(params); + return redactDaytonaPty(await super.createPtySession(params)); } /** Init output (sync, git push, hook) can quote ssh's stderr, so it is redacted too. */ diff --git a/src/node/runtime/daytonaTokenRedaction.test.ts b/src/node/runtime/daytonaTokenRedaction.test.ts index 044bb2d9d6b..0b644035d34 100644 --- a/src/node/runtime/daytonaTokenRedaction.test.ts +++ b/src/node/runtime/daytonaTokenRedaction.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "bun:test"; +import type { PtyHandle } from "./transports"; import { DAYTONA_TOKEN_PLACEHOLDER, + redactDaytonaPty, redactDaytonaTokens, rememberDaytonaToken, } from "./daytonaTokenRedaction"; @@ -26,3 +28,105 @@ describe("Daytona token redaction", () => { ); }); }); + +describe("redactDaytonaPty", () => { + const TOKEN = "pty-token-z7w"; + rememberDaytonaToken(TOKEN); + + function setup() { + const dataHandlers = new Set<(data: string) => void>(); + const exitHandlers = new Set<(event: { exitCode: number }) => void>(); + let killed = false; + const pty: PtyHandle = { + write: () => undefined, + resize: () => undefined, + kill: () => { + killed = true; + }, + onData: (handler) => { + dataHandlers.add(handler); + return { dispose: () => dataHandlers.delete(handler) }; + }, + onExit: (handler) => { + exitHandlers.add(handler); + return { dispose: () => exitHandlers.delete(handler) }; + }, + }; + const timers: Array<{ run: () => void; cancelled: boolean }> = []; + const schedule = (run: () => void) => { + const timer = { run, cancelled: false }; + timers.push(timer); + return () => { + timer.cancelled = true; + }; + }; + const redacted = redactDaytonaPty(pty, schedule); + const output: string[] = []; + redacted.onData((data) => output.push(data)); + return { + redacted, + output, + emit: (data: string) => { + for (const handler of dataHandlers) handler(data); + }, + exit: () => { + for (const handler of exitHandlers) handler({ exitCode: 255 }); + }, + runPendingTimers: () => { + for (const timer of timers.splice(0)) if (!timer.cancelled) timer.run(); + }, + pendingTimers: () => timers.filter((timer) => !timer.cancelled).length, + wasKilled: () => killed, + }; + } + + it("redacts a token within one chunk", () => { + const pty = setup(); + pty.emit(`${TOKEN}@gateway: Permission denied\r\n`); + expect(pty.output.join("")).toBe(`${DAYTONA_TOKEN_PLACEHOLDER}@gateway: Permission denied\r\n`); + }); + + it("redacts a token split across chunks", () => { + const pty = setup(); + pty.emit(`login ${TOKEN.slice(0, 5)}`); + pty.emit(`${TOKEN.slice(5)}@gateway's password: `); + expect(pty.output.join("")).toBe(`login ${DAYTONA_TOKEN_PLACEHOLDER}@gateway's password: `); + }); + + it("redacts a token that ends a chunk and ends with its own first char", () => { + const overlapping = "q-overlap-token-q"; + rememberDaytonaToken(overlapping); + const pty = setup(); + pty.emit(`denied for ${overlapping}`); + pty.runPendingTimers(); + expect(pty.output.join("")).toBe(`denied for ${DAYTONA_TOKEN_PLACEHOLDER}`); + }); + + it("emits a held tail that never becomes a token after the bound", () => { + const pty = setup(); + pty.emit("$ p"); + expect(pty.output.join("")).toBe("$ "); + pty.runPendingTimers(); + expect(pty.output.join("")).toBe("$ p"); + }); + + it("flushes a held tail before reporting exit", () => { + const pty = setup(); + const events: string[] = []; + pty.redacted.onExit(() => events.push(`exit:${pty.output.join("")}`)); + pty.emit("bye p"); + pty.exit(); + expect(events).toEqual(["exit:bye p"]); + expect(pty.pendingTimers()).toBe(0); + }); + + it("passes other output through and cancels the timer on kill", () => { + const pty = setup(); + pty.emit("hello world\r\n"); + expect(pty.output).toEqual(["hello world\r\n"]); + pty.emit("p"); + pty.redacted.kill(); + expect(pty.wasKilled()).toBe(true); + expect(pty.pendingTimers()).toBe(0); + }); +}); diff --git a/src/node/runtime/daytonaTokenRedaction.ts b/src/node/runtime/daytonaTokenRedaction.ts index d1d7ff5a64d..cd5e5c0cfe7 100644 --- a/src/node/runtime/daytonaTokenRedaction.ts +++ b/src/node/runtime/daytonaTokenRedaction.ts @@ -4,6 +4,7 @@ import { Transform } from "stream"; import { isXumDaytonaHost } from "@/common/constants/daytona"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; +import type { PtyHandle } from "./transports"; /** * Daytona's SSH gateway takes the access token as the ssh user name, so OpenSSH messages such @@ -62,32 +63,121 @@ function tokenPrefixSuffixLength(text: string): number { return held; } +export interface DaytonaTextRedactor { + /** Redacted text that is safe to publish now; a possible token start stays held. */ + push(text: string): string; + /** Everything still held, redacted. */ + flush(): string; + holding(): boolean; +} + /** - * Redact a byte stream. A token split across chunks is caught by holding back a tail that could - * start one until the next chunk (or the end) arrives. + * Redact text that arrives in pieces. A token split across pieces is caught by holding back a + * tail that could start one until the next piece (or the end) arrives. The text is redacted + * before the tail is chosen, so a whole token whose last chars also start a token is not split. */ +export function createDaytonaTextRedactor(): DaytonaTextRedactor { + let carry = ""; + return { + push(text) { + const redacted = redactDaytonaTokens(carry + text); + const held = tokenPrefixSuffixLength(redacted); + carry = redacted.slice(redacted.length - held); + return redacted.slice(0, redacted.length - held); + }, + flush() { + const rest = carry; + carry = ""; + return rest; + }, + holding: () => carry !== "", + }; +} + +/** Redact a byte stream with createDaytonaTextRedactor. */ export function createDaytonaRedactingStream(): Transform { const decoder = new StringDecoder("utf8"); - let carry = ""; + const redactor = createDaytonaTextRedactor(); const emit = (stream: Transform, text: string) => { - if (text) stream.push(Buffer.from(redactDaytonaTokens(text), "utf8")); + if (text) stream.push(Buffer.from(text, "utf8")); }; return new Transform({ transform(chunk: Buffer, _encoding, callback) { - const text = carry + decoder.write(chunk); - const held = tokenPrefixSuffixLength(text); - carry = text.slice(text.length - held); - emit(this, text.slice(0, text.length - held)); + emit(this, redactor.push(decoder.write(chunk))); callback(); }, flush(callback) { - emit(this, carry + decoder.end()); - carry = ""; + emit(this, redactor.push(decoder.end()) + redactor.flush()); callback(); }, }); } +// A PTY gives no signal that more output is coming, so a held tail is published after this +// quiet period. Otherwise a typed char that happens to start a token would not echo until the +// next output. +const PTY_HOLD_MS = 50; + +type CancelTimer = () => void; + +function scheduleHoldFlush(run: () => void): CancelTimer { + const timer = setTimeout(run, PTY_HOLD_MS); + timer.unref?.(); + return () => clearTimeout(timer); +} + +/** + * Redact a PTY's output: without BatchMode OpenSSH can print `@gateway: Permission + * denied` or a password prompt naming the token into the terminal, which clients replay. + */ +export function redactDaytonaPty( + pty: PtyHandle, + schedule: (run: () => void) => CancelTimer = scheduleHoldFlush +): PtyHandle { + const redactor = createDaytonaTextRedactor(); + const handlers = new Set<(data: string) => void>(); + let subscribed = false; + let cancelTimer: CancelTimer | undefined; + const stopTimer = () => { + cancelTimer?.(); + cancelTimer = undefined; + }; + const publish = (text: string) => { + if (!text) return; + for (const handler of handlers) handler(text); + }; + const flushHeld = () => { + stopTimer(); + publish(redactor.flush()); + }; + const onRawData = (data: string) => { + stopTimer(); + publish(redactor.push(data)); + if (redactor.holding()) cancelTimer = schedule(flushHeld); + }; + return { + write: (data) => pty.write(data), + resize: (cols, rows) => pty.resize(cols, rows), + kill: () => { + stopTimer(); + pty.kill(); + }, + onData(handler) { + handlers.add(handler); + if (!subscribed) { + subscribed = true; + pty.onData(onRawData); + } + return { dispose: () => handlers.delete(handler) }; + }, + onExit: (handler) => + pty.onExit((event) => { + flushHeld(); + handler(event); + }), + }; +} + /** redactChildStderr for an ssh process to a Daytona alias; other hosts are left alone. */ export function redactDaytonaHostStderr(child: ChildProcess, host: string): void { if (isXumDaytonaHost(host)) redactChildStderr(child); From 5f7ec5a60eed0d91547a1ad8a32c979c3ce0f0fb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:42:32 +0000 Subject: [PATCH 101/231] fix(ssh): restore the includer's scope after an Include returns OpenSSH restores the including file's scope when an Include returns. A top-level Include of a file ending in a Host block left the following top-level lines scoped to that block, so `Port 2222` after it was lost for other hosts. Each expanded Include is now followed by the current header, or `Host *` at the top level, and a nested file starts in its includer's scope. --- src/node/runtime/sshConfigParser.test.ts | 46 ++++++++++++++++++++++-- src/node/runtime/sshConfigParser.ts | 27 ++++++++------ 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/node/runtime/sshConfigParser.test.ts b/src/node/runtime/sshConfigParser.test.ts index a5022a82bd8..4e83380a59f 100644 --- a/src/node/runtime/sshConfigParser.test.ts +++ b/src/node/runtime/sshConfigParser.test.ts @@ -8,13 +8,17 @@ import { resolveSSHConfig } from "./sshConfigParser"; const hasOpenSsh = spawnSync("ssh", ["-V"]).status === 0; /** What OpenSSH resolves for `host` from `configPath` alone: the parity reference. */ -function opensshUserAndProxy(configPath: string, host: string) { +function opensshOptions(configPath: string, host: string) { const output = spawnSync("ssh", ["-G", "-F", configPath, host], { encoding: "utf8" }).stdout; - const value = (key: string) => + return (key: string) => output .split("\n") .find((line) => line.startsWith(`${key} `)) ?.slice(key.length + 1); +} + +function opensshUserAndProxy(configPath: string, host: string) { + const value = opensshOptions(configPath, host); return { user: value("user"), proxyCommand: value("proxycommand") }; } @@ -415,4 +419,42 @@ describe("resolveSSHConfig", () => { } }); }); + + // OpenSSH restores the including file's scope when an Include returns. + test("keeps lines after a top-level Include global when the included file ends in a Host block", async () => { + await withTempHome(async (homeDir) => { + const included = path.join(homeDir, ".ssh", "inc.conf"); + await fs.writeFile(included, "Host other\n User otheruser\n", "utf8"); + const configPath = path.join(homeDir, ".ssh", "config"); + await fs.writeFile(configPath, `Include ${included}\nPort 2222\n`, "utf8"); + + expect((await resolveSSHConfig("target")).port).toBe(2222); + if (hasOpenSsh) expect(opensshOptions(configPath, "target")("port")).toBe("2222"); + }); + }); + + test("restores the includer's block after an Include nested in an included file", async () => { + await withTempHome(async (homeDir) => { + const inner = path.join(homeDir, ".ssh", "b.conf"); + const outer = path.join(homeDir, ".ssh", "a.conf"); + await fs.writeFile(inner, "Host other\n User otheruser\n", "utf8"); + await fs.writeFile(outer, `Include ${inner}\nUser x\n`, "utf8"); + const configPath = path.join(homeDir, ".ssh", "config"); + await fs.writeFile( + configPath, + ["Host target", ` Include ${outer}`, "Host *", " User fallback", ""].join("\n"), + "utf8" + ); + + expect((await resolveSSHConfig("target")).user).toBe("x"); + expect((await resolveSSHConfig("elsewhere")).user).toBe("fallback"); + if (hasOpenSsh) { + for (const host of ["target", "elsewhere"]) { + expect((await resolveSSHConfig(host)).user).toBe( + opensshUserAndProxy(configPath, host).user + ); + } + } + }); + }); }); diff --git a/src/node/runtime/sshConfigParser.ts b/src/node/runtime/sshConfigParser.ts index 817ab2200f9..758527ff796 100644 --- a/src/node/runtime/sshConfigParser.ts +++ b/src/node/runtime/sshConfigParser.ts @@ -257,6 +257,8 @@ const INCLUDE_LINE_PATTERN = /^\s*include(?:\s*=\s*|\s+)(.+?)\s*$/i; const SECTION_LINE_PATTERN = /^\s*(?:host|match)(?:\s*=\s*|\s+)\S/i; const HOST_LINE_PATTERN = /^\s*host(?:\s*=\s*|\s+)(.+?)\s*$/i; const MATCH_ALL_LINE_PATTERN = /^\s*match(?:\s*=\s*|\s+)all\s*$/i; +// Matches every host, so lines after it apply as top-level lines do. +const GLOBAL_SCOPE_HEADER = "Host *"; /** * A config line's arguments, split as OpenSSH 8.6+ does (misc.c's argv_split): quotes may @@ -535,12 +537,14 @@ async function readIncludedFiles( value: string, homeDir: string, depth: number, - host: string + host: string, + scope: string | undefined ): Promise { const contents: string[] = []; for (const file of await resolveIncludePaths(value, homeDir, host)) { try { - contents.push(await expandIncludes(await fs.readFile(file, "utf8"), homeDir, depth, host)); + const content = await fs.readFile(file, "utf8"); + contents.push(await expandIncludes(content, homeDir, depth, host, scope)); } catch (error) { // OpenSSH skips missing include files silently. if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { @@ -557,17 +561,20 @@ async function readIncludedFiles( /** * Inline `Include` directives for resolving `host`, which the ssh-config package does not * resolve (Xum's Daytona block relies on one). An Include inside a block that does not apply - * to `host` is skipped, as OpenSSH does. One inside a block that applies is followed by that - * block's header again, so later lines of the block stay scoped to it as in OpenSSH. + * to `host` is skipped, as OpenSSH does. OpenSSH restores the includer's scope when an Include + * returns, so each expanded Include is followed by the current block's header again, or by + * `Host *` at the top level, and later lines stay where they were written. `scope` is the + * header a nested file is read in (undefined at the top level). */ async function expandIncludes( content: string, homeDir: string, depth: number, - host: string + host: string, + scope?: string ): Promise { const out: string[] = []; - let sectionHeader: string | undefined; + let sectionHeader = scope; for (const line of content.split(/\r?\n/)) { if (SECTION_LINE_PATTERN.test(line)) { sectionHeader = line.trim(); @@ -584,10 +591,10 @@ async function expandIncludes( continue; } if (!sectionAppliesTo(sectionHeader, host)) continue; - out.push(...(await readIncludedFiles(includeMatch[1], homeDir, depth + 1, host))); - if (sectionHeader) { - out.push(sectionHeader); - } + out.push( + ...(await readIncludedFiles(includeMatch[1], homeDir, depth + 1, host, sectionHeader)), + sectionHeader ?? GLOBAL_SCOPE_HEADER + ); } return out.join("\n"); } From 3e443077a2ee7b08287376a2056db773dd490ee0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:44:49 +0000 Subject: [PATCH 102/231] fix(daytona): seed only skill directories of a skills root A skills root entry linked to a directory such as ~/.ssh became its own copy boundary, so every file in it was copied into the sandbox. A directory of a skills root is now seeded only when it is a skill as discovery sees one: a valid skill name and a regular SKILL.md that stays inside its real directory. Other directories are skipped with a warning. --- src/node/runtime/daytonaAgentHomeSeed.test.ts | 31 +++++++++++++++++++ src/node/runtime/daytonaAgentHomeSeed.ts | 29 ++++++++++++++--- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/node/runtime/daytonaAgentHomeSeed.test.ts b/src/node/runtime/daytonaAgentHomeSeed.test.ts index e96ad436395..a0a6cba1121 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.test.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.test.ts @@ -75,6 +75,7 @@ describe("agent-home seeding", () => { it("skips a file larger than the remaining seed budget, unread, and copies the rest", async () => { const huge = path.join(hostXum, "skills", "data", "model.bin"); await fs.mkdir(path.dirname(huge), { recursive: true }); + await fs.writeFile(path.join(hostXum, "skills", "data", "SKILL.md"), "# data\n"); // Sparse: 64 MiB on paper, no disk used. await fs.writeFile(huge, ""); await fs.truncate(huge, 64 * 1024 * 1024); @@ -88,6 +89,7 @@ describe("agent-home seeding", () => { const files = await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux"); expect(files.map((file) => file.remotePath)).toEqual([ + "~/.mux/skills/data/SKILL.md", "~/.mux/skills/deploy/SKILL.md", "~/.agents/skills/lint/SKILL.md", ]); @@ -135,4 +137,33 @@ describe("agent-home seeding", () => { ]); expect(files.some((file) => file.content.toString().includes("PRIVATE KEY"))).toBe(false); }); + + it("copies a directory of a skills root only when it is a skill", async () => { + const secrets = path.join(hostHome, ".ssh"); + await fs.mkdir(secrets, { recursive: true }); + await fs.writeFile(path.join(secrets, "id_rsa"), "PRIVATE KEY\n"); + const skills = path.join(hostXum, "skills"); + await fs.mkdir(skills, { recursive: true }); + // A root entry linked to a directory that is no skill. + await fs.symlink(secrets, path.join(skills, "keys")); + // A plain directory without SKILL.md. + await fs.mkdir(path.join(skills, "notes")); + await fs.writeFile(path.join(skills, "notes", "todo.txt"), "todo\n"); + // A SKILL.md that is a link out of its directory does not make it a skill. + await fs.mkdir(path.join(skills, "borrowed")); + await fs.symlink(path.join(secrets, "id_rsa"), path.join(skills, "borrowed", "SKILL.md")); + await fs.writeFile(path.join(skills, "borrowed", "extra.txt"), "extra\n"); + // A skill whose name discovery refuses. + await fs.mkdir(path.join(skills, "Not_A_Skill")); + await fs.writeFile(path.join(skills, "Not_A_Skill", "SKILL.md"), "# no\n"); + // A linked real skill is still copied. + const linkedTarget = path.join(root, "plugins", "lint"); + await fs.mkdir(linkedTarget, { recursive: true }); + await fs.writeFile(path.join(linkedTarget, "SKILL.md"), "# lint\n"); + await fs.symlink(linkedTarget, path.join(skills, "lint")); + + const files = await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux"); + + expect(files.map((file) => file.remotePath)).toEqual(["~/.mux/skills/lint/SKILL.md"]); + }); }); diff --git a/src/node/runtime/daytonaAgentHomeSeed.ts b/src/node/runtime/daytonaAgentHomeSeed.ts index c5f22c13175..21c89052a80 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.ts @@ -1,5 +1,6 @@ import * as fs from "fs/promises"; import * as path from "path"; +import { SkillNameSchema } from "@/common/orpc/schemas"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; import { isErrnoWithCode } from "@/node/utils/fs"; @@ -129,10 +130,10 @@ function isInside(realPath: string, realRoot: string): boolean { } /** - * A skills root (`~/.xum/skills`, `~/.agents/skills`). Each skill directory in it may be a - * symlink (users and plugin installs link skills, and skill discovery follows them); inside a - * skill, links are followed only while they stay within that skill's real directory, so a - * link to `~/.ssh` is never copied into the sandbox. + * A skills root (`~/.xum/skills`, `~/.agents/skills`). Only its skill directories are copied + * (see isSkillDirectory). Each may be a symlink (users and plugin installs link skills, and + * skill discovery follows them); inside a skill, links are followed only while they stay + * within that skill's real directory, so a link to `~/.ssh` is never copied into the sandbox. */ async function collectSkillsRoot( files: AgentHomeSeedFile[], @@ -154,6 +155,17 @@ async function collectSkillsRoot( } } +/** + * Whether a directory of a skills root is a skill as discovery sees one: a valid skill name and + * a regular SKILL.md. SKILL.md must also stay inside the real directory, so a root entry linked + * to `~/.ssh` (or holding a SKILL.md link to elsewhere) does not become a copy boundary. + */ +async function isSkillDirectory(name: string, realDir: string): Promise { + if (!SkillNameSchema.safeParse(name).success) return false; + const skillFile = await resolveEntry(path.join(realDir, "SKILL.md")); + return skillFile !== null && skillFile.stat.isFile() && isInside(skillFile.real, realDir); +} + /** A skill directory (its real path bounds what it may link to), or a file of the root. */ async function collectSkillsRootEntry( files: AgentHomeSeedFile[], @@ -164,6 +176,15 @@ async function collectSkillsRootEntry( const resolved = await resolveEntry(entry.hostPath); if (!resolved) return; if (resolved.stat.isDirectory()) { + if (!(await isSkillDirectory(path.basename(entry.hostPath), resolved.real))) { + log.warn( + "Skipping a skills directory entry that is not a skill while seeding a Daytona sandbox", + { + hostPath: entry.hostPath, + } + ); + return; + } await collectDirectory(files, budget, resolved.real, entry.remotePath, 1, resolved.real); } else if (isInside(resolved.real, realRoot)) { await collectFile(files, budget, resolved, entry.remotePath); From ff9c9b31886343ad29514fd0930a0976c45dd250 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:14:04 +0000 Subject: [PATCH 103/231] fix(daytona): keep a possible token start masked after the quiet period The PTY quiet-period flush published a held token prefix verbatim, and the rest of the token, arriving later, no longer matched, so the screen showed the whole token. The shared redactor now keeps the published part of a possible token start and redacts it again with what follows, so the rest is masked. The quiet period releases a held tail only while it is shorter than four chars; a longer one waits for more output or exit. --- .../runtime/daytonaTokenRedaction.test.ts | 23 +++++++ src/node/runtime/daytonaTokenRedaction.ts | 60 ++++++++++++++----- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/node/runtime/daytonaTokenRedaction.test.ts b/src/node/runtime/daytonaTokenRedaction.test.ts index 0b644035d34..9f96709efd1 100644 --- a/src/node/runtime/daytonaTokenRedaction.test.ts +++ b/src/node/runtime/daytonaTokenRedaction.test.ts @@ -110,6 +110,29 @@ describe("redactDaytonaPty", () => { expect(pty.output.join("")).toBe("$ p"); }); + it("masks the rest of a token whose short start was echoed after the quiet period", () => { + const pty = setup(); + pty.emit(`login ${TOKEN.slice(0, 2)}`); + pty.runPendingTimers(); + pty.emit(`${TOKEN.slice(2)}@gateway's password: `); + const screen = pty.output.join(""); + expect(screen).not.toContain(TOKEN); + expect(screen).toBe( + `login ${TOKEN.slice(0, 2)}${DAYTONA_TOKEN_PLACEHOLDER}@gateway's password: ` + ); + }); + + it("keeps a long possible token start held past the quiet period", () => { + const pty = setup(); + pty.emit(`login ${TOKEN.slice(0, 6)}`); + pty.runPendingTimers(); + expect(pty.output.join("")).toBe("login "); + pty.emit(`${TOKEN.slice(6)}@gateway: Permission denied\r\n`); + expect(pty.output.join("")).toBe( + `login ${DAYTONA_TOKEN_PLACEHOLDER}@gateway: Permission denied\r\n` + ); + }); + it("flushes a held tail before reporting exit", () => { const pty = setup(); const events: string[] = []; diff --git a/src/node/runtime/daytonaTokenRedaction.ts b/src/node/runtime/daytonaTokenRedaction.ts index cd5e5c0cfe7..72b1289b1cf 100644 --- a/src/node/runtime/daytonaTokenRedaction.ts +++ b/src/node/runtime/daytonaTokenRedaction.ts @@ -68,29 +68,55 @@ export interface DaytonaTextRedactor { push(text: string): string; /** Everything still held, redacted. */ flush(): string; + /** The held text, only while the whole possible token start is shorter than the bound. */ + releaseShortTail(): string; holding(): boolean; } +// An accidental match of a token's first chars this short is negligible, and a leaked prefix +// this short is useless, so it may echo; a longer possible token start waits for more text. +const RELEASABLE_TAIL_LENGTH = 4; + +function commonPrefixLength(a: string, b: string): number { + let length = 0; + while (length < a.length && a[length] === b[length]) length++; + return length; +} + /** - * Redact text that arrives in pieces. A token split across pieces is caught by holding back a - * tail that could start one until the next piece (or the end) arrives. The text is redacted - * before the tail is chosen, so a whole token whose last chars also start a token is not split. + * Redact text that arrives in pieces. A token split across pieces is caught by keeping the tail + * that could start one: its held part waits for the next piece (or the end), and its released + * part (already published) is redacted again with what follows, so a token whose start was + * released is still masked from there on. The text is redacted before the tail is chosen, so a + * whole token whose last chars also start a token is not split. */ export function createDaytonaTextRedactor(): DaytonaTextRedactor { - let carry = ""; + let released = ""; + let held = ""; return { push(text) { - const redacted = redactDaytonaTokens(carry + text); - const held = tokenPrefixSuffixLength(redacted); - carry = redacted.slice(redacted.length - held); - return redacted.slice(0, redacted.length - held); + const redacted = redactDaytonaTokens(released + held + text); + const published = commonPrefixLength(released, redacted); + const tailStart = redacted.length - tokenPrefixSuffixLength(redacted); + const cut = Math.max(published, tailStart); + released = redacted.slice(tailStart, cut); + held = redacted.slice(cut); + return redacted.slice(published, cut); }, flush() { - const rest = carry; - carry = ""; + const rest = held; + released = ""; + held = ""; return rest; }, - holding: () => carry !== "", + releaseShortTail() { + if (released.length + held.length >= RELEASABLE_TAIL_LENGTH) return ""; + const rest = held; + released += held; + held = ""; + return rest; + }, + holding: () => held !== "", }; } @@ -113,9 +139,9 @@ export function createDaytonaRedactingStream(): Transform { }); } -// A PTY gives no signal that more output is coming, so a held tail is published after this -// quiet period. Otherwise a typed char that happens to start a token would not echo until the -// next output. +// A PTY gives no signal that more output is coming, so a short held tail is published after +// this quiet period. Otherwise a typed char that happens to start a token would not echo until +// the next output. const PTY_HOLD_MS = 50; type CancelTimer = () => void; @@ -150,10 +176,14 @@ export function redactDaytonaPty( stopTimer(); publish(redactor.flush()); }; + const releaseShortTail = () => { + cancelTimer = undefined; + publish(redactor.releaseShortTail()); + }; const onRawData = (data: string) => { stopTimer(); publish(redactor.push(data)); - if (redactor.holding()) cancelTimer = schedule(flushHeld); + if (redactor.holding()) cancelTimer = schedule(releaseShortTail); }; return { write: (data) => pty.write(data), From a09221642a60370e66dcfb9f352c66688bfc8b91 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:16:26 +0000 Subject: [PATCH 104/231] fix(daytona): settle the sandbox when a forced removal cannot delete its checkout A forced removal whose SSH checkout delete failed returned early, but the workspace is deregistered anyway, so the alias token, include block and sessions stayed, and an owned sandbox handed to an attaching workspace kept its xum.instance label. The forced path now settles the sandbox and reports the checkout left behind as a notice. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 24 ++++++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 9 +++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 666f9e99523..066a56fe6be 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -773,6 +773,30 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { expect(newAccount.find("xum-same")).toBeUndefined(); }); + it("still hands off a started sandbox when a forced removal cannot delete the checkout", async () => { + const labels = { [DAYTONA_LABEL_INSTANCE]: await rig.service.getInstanceId() }; + rig.plane.addSandbox({ id: "sb-1", name: "xum-owner", labels }); + const owner = { sandboxId: "xum-owner", shared: false, ...SMALL }; + rig.workspaces.push( + { workspaceId: "ws-o", runtimeConfig: sshDaytonaConfig(owner) }, + { workspaceId: "ws-t", runtimeConfig: sshDaytonaConfig({ sandboxId: "sb-1", shared: true }) } + ); + claimRemovals(); + await rig.service.ensureSshAccess("xum-owner"); + sshDeleteSpy.mockResolvedValue({ success: false, error: "ssh: host unreachable" }); + + const forced = await makeRuntime(owner, rig, { workspaceId: "ws-o" }).deleteWorkspace( + "/home/me/project", + "ws-o", + true + ); + + expect(forced.success).toBe(true); + expect(forced.success ? forced.notice : forced.error).toContain("ssh: host unreachable"); + expect(rig.plane.find("sb-1")?.labels).toEqual({}); + expect(rig.writer.removed).toContain("xum-owner"); + }); + it("starts a stopped sandbox to run the dirty check and keeps a dirty workspace", async () => { rig.plane.addSandbox({ id: "sb-dirty", state: "stopped" }); sshDeleteSpy.mockResolvedValue({ success: false, error: "uncommitted changes" }); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 07cc43f87e6..27b712aa32f 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -1094,7 +1094,14 @@ export class DaytonaSSHRuntime extends SSHRuntime { } // A failed check (dirty or unpushed work) keeps the workspace and its sandbox. const sshResult = await sshDelete(); - if (!sshResult.success) return sshResult; + if (!sshResult.success) { + if (!force) return sshResult; + // Forced: the workspace goes anyway, so the sandbox is still released or handed off. + const notice = + `The checkout ${deletedPath} could not be deleted on Daytona sandbox "${sandboxId}" ` + + `(${sshResult.error}); delete it there if you no longer need it.`; + return this.settleSandbox(sandboxId, { success: true, deletedPath, notice }, { force }); + } return this.settleSandbox(sandboxId, sshResult, { force, errorPrefix: "SSH delete succeeded, but ", From e326c4154fdebf1084c8dde6963ac48574be1eed Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:17:44 +0000 Subject: [PATCH 105/231] fix(daytona): start a stopped source sandbox before forking WorkspaceService forks with a fresh runtime and no ensureReady, so a fork from an auto-stopped or archived sandbox connected to a stopped box and failed fatally. The user asked for the fork, so forkWorkspace now brings the source sandbox up first, as a terminal does, and fails with its error when it cannot. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 27 ++++++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 5 +++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 066a56fe6be..de8e3a2101d 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -874,6 +874,33 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { } }); + it("starts a stopped source sandbox before forking, and fails a fork it cannot bring up", async () => { + const sandbox = rig.plane.addSandbox({ id: "sb-parent", state: "stopped" }); + let stateWhenForked: string | undefined; + const forkSpy = spyOn(SSHRuntime.prototype, "forkWorkspace").mockImplementation(() => { + stateWhenForked = sandbox.state; + return Promise.resolve({ success: true, workspacePath: "/fork" }); + }); + const forkFrom = (sandboxId: string) => + makeRuntime({ sandboxId, shared: false, ...SMALL }).forkWorkspace({ + projectPath: "/home/me/project", + sourceWorkspaceName: "feature", + newWorkspaceName: "feature-fork", + initLogger: initParams().initLogger, + }); + try { + expect((await forkFrom("sb-parent")).success).toBe(true); + expect(stateWhenForked).toBe("started"); + + const gone = await forkFrom("sb-gone"); + expect(gone).toMatchObject({ success: false, failureIsFatal: true }); + expect(gone.error).toContain("no longer exists"); + expect(forkSpy).toHaveBeenCalledTimes(1); + } finally { + forkSpy.mockRestore(); + } + }); + it("deletes the sandbox with a fork that outlives its source", async () => { rig.plane.addSandbox({ id: "sb-parent" }); const forkSpy = spyOn(SSHRuntime.prototype, "forkWorkspace").mockResolvedValue({ diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 27b712aa32f..e702ba5125d 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -1019,9 +1019,12 @@ export class DaytonaSSHRuntime extends SSHRuntime { /** * Keep the inherited worktree fork: the child reuses the parent's sandbox (`forked`, so its * setup creates none). Neither workspace is marked attached: deleting either keeps the - * sandbox while the other references it, and the last one deletes it. + * sandbox while the other references it, and the last one deletes it. The user asked for + * the fork, so a stopped or archived source sandbox is started first, as for a terminal. */ override async forkWorkspace(params: WorkspaceForkParams): Promise { + const ready = await this.ensureReady(); + if (!ready.ready) return { success: false, error: ready.error, failureIsFatal: true }; const result = await super.forkWorkspace(params); // Sub-agents must share the parent's sandbox: never fall back to creating a new one. if (!result.success) return { ...result, failureIsFatal: true }; From 27cc140a8bab333ec39d06c9bd9daa7c743d8572 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:21:00 +0000 Subject: [PATCH 106/231] fix(daytona): tell the user a sandbox a failed setup could not delete is kept The cleanup comment claimed the reconciler stops such a sandbox as an unreferenced orphan, but the persisted workspace still names it, so it is kept; removing the workspace deletes it. The init log now says so, and the later ready error no longer claims Xum deleted the sandbox. A test covers removal (normal after a transient error, forced after a persistent one) deleting the sandbox. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 41 ++++++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 12 ++++--- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index de8e3a2101d..bc0f3b9edd2 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -581,6 +581,47 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { } }); + it("keeps a sandbox its failed setup could not delete for the workspace removal to delete", async () => { + const sshDelete = spyOn(SSHRuntime.prototype, "deleteWorkspace").mockResolvedValue({ + success: true, + deletedPath: "/path", + }); + try { + // A setup error that persists blocks the removal's checks, so only a forced one goes. + for (const [sandboxId, force] of [ + ["xum-transient", false], + ["xum-persistent", true], + ] as const) { + rig.writer.verifyError = new Error("Your ssh config sets User"); + rig.plane.failingRoutes.add(`DELETE /sandbox/${sandboxId}`); + const daytona = { sandboxId, shared: false, ...SMALL }; + + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(makeRuntime(daytona).postCreateSetup(initParams())).rejects.toThrow( + "setup failed" + ); + expect(rig.plane.find(sandboxId)).toBeDefined(); + + // The persisted workspace still names the sandbox; removing it deletes the sandbox. + rig.plane.failingRoutes.clear(); + if (!force) rig.writer.verifyError = undefined; + rig.workspaces.splice(0, rig.workspaces.length, { + workspaceId: "ws-1", + runtimeConfig: sshDaytonaConfig(daytona), + }); + const removal = await makeRuntime(daytona).deleteWorkspace( + "/home/me/project", + "feature", + force + ); + expect(removal.success).toBe(true); + expect(rig.plane.find(sandboxId)).toBeUndefined(); + } + } finally { + sshDelete.mockRestore(); + } + }); + it("refuses a settings change while it creates a sandbox, and takes one afterwards", async () => { const newAccount = rig.addAccount("new-key"); // The user saves another account's key while the create request is in flight. diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index e702ba5125d..eeb9a52591b 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -406,8 +406,8 @@ export class DaytonaSSHRuntime extends SSHRuntime { return { ready: false, error: - `Daytona sandbox setup failed: ${setupFailure}. Xum deleted the sandbox it had ` + - `created; delete this workspace and create a new one.`, + `Daytona sandbox setup failed: ${setupFailure}. Delete this workspace (which deletes ` + + `any sandbox its setup left) and create a new one.`, errorType: "runtime_not_ready", }; } @@ -918,13 +918,17 @@ export class DaytonaSSHRuntime extends SSHRuntime { initLogger.logStep(`Deleting the sandbox "${sandboxId}" this setup created...`); await this.daytonaService.deleteSandbox(sandboxId, create.account); } catch (error) { - // The reconciler stops labeled, unreferenced sandboxes, so a leak here is bounded. + // The persisted workspace still names the sandbox, so the reconciler leaves it alone; + // removing the workspace deletes it. const message = getErrorMessage(error); log.error("Failed to delete a Daytona sandbox after setup failed", { sandboxId, error: message, }); - initLogger.logStderr(`Failed to delete Daytona sandbox "${sandboxId}": ${message}`); + initLogger.logStderr( + `Failed to delete Daytona sandbox "${sandboxId}": ${message}. The sandbox is kept; ` + + "removing this workspace deletes it." + ); } } From d96f3dc7b6106da1d83fcec505d2894c312c5e88 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:38:05 +0000 Subject: [PATCH 107/231] fix(daytona): drop cached SSH access when another process changes the account The credentials store lock only serializes writes, so a key saved by another Xum backend sharing the root left this one returning the old account's cached token and keeping its transports. Each resolve now compares a hash of the API URL and key with the last one; another account retires the token, gateway, readiness and transport caches as a settings change does (shared helper, generation bumped), without revoking. getSshToken resolves before its cached fast path. --- .../services/daytona/daytonaService.test.ts | 32 ++++++++ src/node/services/daytona/daytonaService.ts | 73 ++++++++++++++++--- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index ae64758c3c3..5313be3710a 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -189,6 +189,38 @@ describe("DaytonaService", () => { expect((await service.getSshToken("sb-1")).token).not.toBe(held.token); }); + it("mints under credentials another Xum process saved, and not after its own change", async () => { + const mintKeys: string[] = []; + service = new DaytonaService({ + credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), + rootDir, + fetch: ((input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : input.toString(); + if (init?.method === "POST" && url.includes("/ssh-access")) { + mintKeys.push((init.headers as Record).Authorization); + } + return plane.fetch(input, init); + }) as typeof fetch, + now: () => clock, + }); + const before = await service.getAccount(); + const held = await service.getSshToken("sb-1"); + + // Another backend sharing this root saves another key. + await new DaytonaCredentialsStore(rootDir, {}).update({ apiKey: "other" }); + + expect((await service.getSshToken("sb-1")).token).not.toBe(held.token); + expect(mintKeys).toEqual(["Bearer k", "Bearer other"]); + expect(service.isAccountCurrent(before)).toBe(false); + + // This process's own change is not taken for another one. + await service.setConfig({ apiKey: "third" }); + const after = await service.getAccount(); + await service.getSshToken("sb-1"); + expect(service.isAccountCurrent(after)).toBe(true); + expect(mintKeys).toEqual(["Bearer k", "Bearer other", "Bearer third"]); + }); + it("reuses one token while the user fixes an ssh config that shadows it", async () => { let shadowed = true; const writer = { diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index cfbb5035f45..40ad608ce0b 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -1,3 +1,4 @@ +import { createHash } from "crypto"; import * as path from "path"; import type { DaytonaListSandboxesResult, @@ -274,6 +275,14 @@ function toSshGateway(config: DaytonaGatewayConfig) { }; } +/** The account credentials select, hashed so this cache holds no key; "" when none is set. */ +function fingerprintAccount(credentials: DaytonaResolvedCredentials | null): string { + if (!credentials) return ""; + return createHash("sha256") + .update(JSON.stringify([credentials.apiUrl, credentials.apiKey])) + .digest("hex"); +} + /** What the ssh setup depends on; key order from Daytona is not significant. */ function gatewayIdentity(config: DaytonaGatewayConfig): string { const { host, port, hostKeys } = toSshGateway(config); @@ -304,6 +313,12 @@ export class DaytonaService { private readonly tokenRequests = new Map>(); /** Bumped by every settings change, so a mint that spans one can tell. */ private credentialsGeneration = 0; + /** + * A hash of the account (API URL and key) last resolved. Another Xum process sharing this + * root may save other credentials; the store's lock does not tell this one, so a resolve + * that sees another account retires what this process cached for the old one. + */ + private accountFingerprint?: string; /** Operations running under withAccountLease; setConfig refuses while any runs. */ private accountLeases = 0; /** The settings change being saved; new leases wait for it. */ @@ -399,10 +414,27 @@ export class DaytonaService { private async applyConfig(patch: DaytonaCredentialsPatch): Promise { const previousClient = await this.getClientIfConfigured(); await this.credentialsStore.update(patch); - // A mint in flight finishes under the old settings: it sees the bump and starts over. + this.accountFingerprint = await this.readAccountFingerprint(); + // Revoke the old account's tokens with the old credentials, which may not work for the + // new ones. + const tokens = await this.retireAccountCaches(); + if (previousClient) { + await Promise.all( + tokens.map(([sandboxId, access]) => + this.revokeQuietly(previousClient, sandboxId, access.token) + ) + ); + } + return this.credentialsStore.getPublicConfig(); + } + + /** + * Tokens and gateway data belong to the old account or deployment. A mint in flight + * finishes under the old settings: it sees the bump and starts over. Returns the tokens + * dropped, for the caller to revoke. + */ + private async retireAccountCaches(): Promise> { this.credentialsGeneration++; - // Tokens and gateway data belong to the old account or deployment. Revoke the tokens with - // the old credentials, which may not work for the new ones. const tokens = [...this.tokens]; const connected = this.sandboxesWithSsh(); this.gatewayCache = undefined; @@ -410,14 +442,29 @@ export class DaytonaService { this.sshConfigured.clear(); this.readyAt.clear(); await this.retirePooledSsh(connected); - if (previousClient) { - await Promise.all( - tokens.map(([sandboxId, access]) => - this.revokeQuietly(previousClient, sandboxId, access.token) - ) - ); + return tokens; + } + + private async readAccountFingerprint(): Promise { + try { + return fingerprintAccount(await this.credentialsStore.resolve()); + } catch (error) { + log.debug("Could not read the saved Daytona settings", { error: getErrorMessage(error) }); + return undefined; } - return this.credentialsStore.getPublicConfig(); + } + + /** + * Another Xum process saved credentials of another account: drop this process's caches of + * the old one. Its tokens are not revoked: they expire, and the old key may be gone. + */ + private async noticeAccount(credentials: DaytonaResolvedCredentials | null): Promise { + const fingerprint = fingerprintAccount(credentials); + const previous = this.accountFingerprint; + this.accountFingerprint = fingerprint; + if (previous === undefined || previous === fingerprint) return; + log.info("Daytona settings changed in another Xum process; dropping cached SSH access"); + await this.retireAccountCaches(); } private async getClientIfConfigured(): Promise { @@ -584,7 +631,9 @@ export class DaytonaService { /** Unusable settings (an http DAYTONA_API_URL) throw a `not_configured` DaytonaApiError. */ private async resolveCredentials(): Promise { try { - return await this.credentialsStore.resolve(); + const credentials = await this.credentialsStore.resolve(); + await this.noticeAccount(credentials); + return credentials; } catch (error) { if (error instanceof DaytonaConfigError) { throw new DaytonaApiError("not_configured", error.message); @@ -1096,6 +1145,8 @@ export class DaytonaService { sandboxId: string, options: DaytonaSshTokenOptions = {} ): Promise { + // Read first: another Xum process may have saved another account's credentials. + await this.resolveCredentials(); const cached = this.tokens.get(sandboxId); if (cached && !this.tokenNeedsRefresh(sandboxId)) return cached; const inFlight = this.tokenRequests.get(sandboxId); From b6b48c7e0744b93c6f5b8859e7efd51952483cbe Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:39:30 +0000 Subject: [PATCH 108/231] refactor(daytona): split the settlement after the SSH checkout delete Keeps deleteWorkspaceAndSandbox under the CRAP gate after the forced removal fix; no behavior change. --- src/node/runtime/DaytonaSSHRuntime.ts | 34 +++++++++++++++++---------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index eeb9a52591b..64326881da2 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -1099,20 +1099,28 @@ export class DaytonaSSHRuntime extends SSHRuntime { if (prepared !== "ready" && !force) { return { success: false, error: prepared.error }; } - // A failed check (dirty or unpushed work) keeps the workspace and its sandbox. - const sshResult = await sshDelete(); - if (!sshResult.success) { - if (!force) return sshResult; - // Forced: the workspace goes anyway, so the sandbox is still released or handed off. - const notice = - `The checkout ${deletedPath} could not be deleted on Daytona sandbox "${sandboxId}" ` + - `(${sshResult.error}); delete it there if you no longer need it.`; - return this.settleSandbox(sandboxId, { success: true, deletedPath, notice }, { force }); + return this.settleAfterSshDelete(sandboxId, deletedPath, await sshDelete(), force); + } + + /** A failed check (dirty or unpushed work) keeps the workspace and its sandbox. */ + private settleAfterSshDelete( + sandboxId: string, + deletedPath: string, + sshResult: DeleteResult, + force: boolean + ): Promise { + if (sshResult.success) { + return this.settleSandbox(sandboxId, sshResult, { + force, + errorPrefix: "SSH delete succeeded, but ", + }); } - return this.settleSandbox(sandboxId, sshResult, { - force, - errorPrefix: "SSH delete succeeded, but ", - }); + if (!force) return Promise.resolve(sshResult); + // Forced: the workspace goes anyway, so the sandbox is still released or handed off. + const notice = + `The checkout ${deletedPath} could not be deleted on Daytona sandbox "${sandboxId}" ` + + `(${sshResult.error}); delete it there if you no longer need it.`; + return this.settleSandbox(sandboxId, { success: true, deletedPath, notice }, { force }); } /** From c5c17d851dbf605efa00ea69d1fcf017f27b7766 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:13:30 +0000 Subject: [PATCH 109/231] fix(ssh): lock the main ssh config across processes while writing a managed block --- src/node/runtime/muxSshConfigWriter.test.ts | 43 +++++++++++++++++++++ src/node/runtime/muxSshConfigWriter.ts | 22 ++++++++--- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/node/runtime/muxSshConfigWriter.test.ts b/src/node/runtime/muxSshConfigWriter.test.ts index 43b72ac3129..8c8594e9ffa 100644 --- a/src/node/runtime/muxSshConfigWriter.test.ts +++ b/src/node/runtime/muxSshConfigWriter.test.ts @@ -8,6 +8,7 @@ import { MUX_CODER_SSH_BLOCK_END, MUX_CODER_SSH_BLOCK_START, } from "@/constants/coder"; +import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import { ensureManagedSSHConfigBlock, ensureMuxCoderSSHConfigFile } from "./muxSshConfigWriter"; function renderExpectedMuxBlock(coderBinaryPath: string): string { @@ -70,6 +71,48 @@ describe("ensureMuxCoderSSHConfigFile", () => { expect(content).toContain("Host work"); }); + it("waits for another backend's file lock on the resolved config before writing", async () => { + const sshConfigPath = await makeSSHConfigPath(); + const targetPath = path.join(path.dirname(sshConfigPath), "real-config"); + await writeSSHConfig(targetPath, "Host work\n User me\n"); + await fs.symlink(targetPath, sshConfigPath); + const block = (label: string) => ({ + markers: { start: `# >>> ${label} >>>`, end: `# <<< ${label} <<<`, label }, + block: [`# >>> ${label} >>>`, `Host *.${label}`, `# <<< ${label} <<<`].join("\n"), + sshConfigPath, + }); + const lockPath = `${targetPath}.xum.lock`; + + // Held by this process, the file lock looks to the writers like another backend mid-write. + const release = await acquireCrossProcessLock({ + lockPath, + acquireTimeoutMs: 1_000, + staleMs: 60_000, + timeoutMessage: "test holder", + }); + let settled = false; + const running = Promise.all([ + ensureManagedSSHConfigBlock(block("first")), + ensureManagedSSHConfigBlock(block("second")), + ]).finally(() => { + settled = true; + }); + try { + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(settled).toBe(false); + expect(await readSSHConfig(targetPath)).toBe("Host work\n User me\n"); + } finally { + await release(); + } + await running; + + const content = await readSSHConfig(targetPath); + expect(content).toContain("Host *.first"); + expect(content).toContain("Host *.second"); + expect(content).toContain("Host work"); + expect(await fs.readdir(path.dirname(targetPath))).not.toContain(path.basename(lockPath)); + }); + it("creates block from an empty config", async () => { const sshConfigPath = await makeSSHConfigPath(); diff --git a/src/node/runtime/muxSshConfigWriter.ts b/src/node/runtime/muxSshConfigWriter.ts index 9ff5af10f49..3c79efcf8dc 100644 --- a/src/node/runtime/muxSshConfigWriter.ts +++ b/src/node/runtime/muxSshConfigWriter.ts @@ -8,6 +8,7 @@ import { MUX_CODER_SSH_BLOCK_START, } from "@/constants/coder"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; +import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; interface EnsureMuxCoderSSHConfigFileOptions { coderBinaryPath: string; @@ -253,11 +254,22 @@ export async function ensureManagedSSHConfigBlock(opts: { await fs.mkdir(sshConfigDir, { recursive: true, mode: 0o700 }); - // Coder and Daytona setups can run at once: without the lock both read the old file and the - // last rename drops the other's block. (In this process only, like the include file lock.) - await sshConfigFileLocks.withLock(sshConfigPath, () => - writeManagedBlock(sshConfigPath, markers, opts.block) - ); + // Coder and Daytona setups can run at once, in this backend or another one sharing the config + // (even under a different Xum root): without both locks two writers read the old file and the + // last rename drops the other's block. The file lock sits next to the resolved config. + await sshConfigFileLocks.withLock(sshConfigPath, async () => { + const release = await acquireCrossProcessLock({ + lockPath: `${sshConfigPath}.xum.lock`, + acquireTimeoutMs: 30_000, + staleMs: 60_000, + timeoutMessage: "Another Xum process is updating the ssh config.", + }); + try { + await writeManagedBlock(sshConfigPath, markers, opts.block); + } finally { + await release(); + } + }); } async function writeManagedBlock( From 2e25786e360e63c8d84924b920e30f69b37dbff0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:14:51 +0000 Subject: [PATCH 110/231] fix(daytona): refuse a Xum root with % before writing Daytona ssh config --- src/node/runtime/daytonaSshConfig.test.ts | 19 +++++++++++++++++++ src/node/runtime/daytonaSshConfig.ts | 15 +++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index f52ff38127e..cc9252a195c 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -190,6 +190,25 @@ describe("DaytonaSshConfigWriter", () => { expect(await Bun.file(sshConfigPath).exists()).toBe(false); }); + it("refuses a Xum root whose path holds an ssh % token, writing nothing", async () => { + const tokenWriter = new DaytonaSshConfigWriter({ + xumHome: path.join(tempDir, "xum%d"), + sshConfigPath, + }); + const input = { sandboxId: "sb-1", token: "tok", gateway: GATEWAY }; + for (const update of [ + () => tokenWriter.writeSandbox(input), + () => tokenWriter.updateToken(input), + () => tokenWriter.repointGateway(GATEWAY), + ]) { + // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime + await expect(update()).rejects.toThrow( + `Daytona ssh setup cannot use ${tokenWriter.paths.dir}` + ); + } + expect(await fs.readdir(tempDir)).toEqual([]); + }); + it("writes a self-hosted IPv6 gateway as OpenSSH resolves it, refusing zone ids", async () => { const gateway = { ...GATEWAY, host: "2001:db8::10", port: 2222 }; await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway }); diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index c4bcc0f8da4..eae0243ace8 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -257,6 +257,7 @@ export class DaytonaSshConfigWriter { edit: (blocks: Map) => void ): Promise { const knownHosts = renderDaytonaKnownHosts(gateway); + this.assertPathsHaveNoSshTokens(); await this.withIncludeFileLock(async () => { await writeFileAtomic(this.paths.knownHostsPath, knownHosts, { encoding: "utf-8", @@ -303,6 +304,7 @@ export class DaytonaSshConfigWriter { token: string; gateway: DaytonaSshGateway; }): Promise { + this.assertPathsHaveNoSshTokens(); const block = this.renderBlock(input); const alias = toXumDaytonaHost(input.sandboxId); return this.withIncludeFileLock(async () => { @@ -312,6 +314,19 @@ export class DaytonaSshConfigWriter { }); } + /** + * The Include and UserKnownHostsFile paths go into ssh config, where OpenSSH expands `%` + * tokens. Escaping as `%%` is not an option: older OpenSSH reads Include paths literally. + */ + private assertPathsHaveNoSshTokens(): void { + if (this.paths.dir.includes("%")) { + throw new DaytonaSshConfigError( + `Daytona ssh setup cannot use ${this.paths.dir}: ssh treats "%" in config paths as a ` + + `token. Use a Xum root (XUM_ROOT) and home directory without "%".` + ); + } + } + /** * Every read-modify-write of the include file (and known_hosts) runs under this. Another * backend on the same Xum root (a desktop app next to `xum server`) has its own MutexMap: From a67047e4ec049df68b37b5bb95136ad1177cb0a0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:17:29 +0000 Subject: [PATCH 111/231] fix(workspace): report archive warnings from bulk archive of merged workspaces --- src/browser/App.tsx | 62 +++++++++++++------ src/common/orpc/schemas/api.ts | 6 ++ .../services/workspaceService.archive.test.ts | 33 +++++++++- src/node/services/workspaceService.ts | 7 +++ 4 files changed, 89 insertions(+), 19 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 142e0280631..9a1ec9b26a0 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -208,6 +208,47 @@ export function appShellRef(element: HTMLElement | null): void { * `pageKey` names the kind of page: a new kind gets a fresh boundary, so a page that failed to load * or render does not keep its failure state over the next page. */ +const MAX_ARCHIVE_MERGED_ISSUES_TO_SHOW = 5; + +function formatArchiveMergedIssues(heading: string, lines: string[]): string { + if (lines.length === 0) return ""; + const shown = lines + .slice(0, MAX_ARCHIVE_MERGED_ISSUES_TO_SHOW) + .map((line) => `- ${line}`) + .join("\n"); + const remainingCount = Math.max(0, lines.length - MAX_ARCHIVE_MERGED_ISSUES_TO_SHOW); + const remainingSuffix = remainingCount > 0 ? `\n… and ${remainingCount} more.` : ""; + return `\n\n${heading}:\n${shown}${remainingSuffix}`; +} + +/** The alert after archiving merged workspaces, or null when nothing needs the user's attention. */ +function describeArchiveMergedIssues(data: { + archivedWorkspaceIds: string[]; + skippedWorkspaceIds: string[]; + errors: Array<{ workspaceId: string; error: string }>; + warnings: Array<{ workspaceId: string; warning: string }>; +}): string | null { + const errorCount = data.errors.length; + const warningCount = data.warnings.length; + if (errorCount === 0 && warningCount === 0) return null; + const counts = [ + `Archived: ${data.archivedWorkspaceIds.length}`, + `Skipped: ${data.skippedWorkspaceIds.length}`, + ...(errorCount > 0 ? [`Errors: ${errorCount}`] : []), + ...(warningCount > 0 ? [`Warnings: ${warningCount}`] : []), + ].join("\n"); + const title = `Archived merged workspaces with some ${errorCount > 0 ? "errors" : "warnings"}.`; + const errorList = formatArchiveMergedIssues( + "Errors", + data.errors.map((e) => `${e.workspaceId}: ${e.error}`) + ); + const warningList = formatArchiveMergedIssues( + "Warnings", + data.warnings.map((w) => `${w.workspaceId}: ${w.warning}`) + ); + return `${title}\n\n${counts}${errorList}${warningList}`; +} + function PageArea(props: { pageKey: string; children: React.ReactNode }) { const frame = (state: React.ReactNode) => (
@@ -883,24 +924,9 @@ function AppInner() { return; } - const errorCount = result.data.errors.length; - if (errorCount > 0) { - const archivedCount = result.data.archivedWorkspaceIds.length; - const skippedCount = result.data.skippedWorkspaceIds.length; - - const MAX_ERRORS_TO_SHOW = 5; - const shownErrors = result.data.errors - .slice(0, MAX_ERRORS_TO_SHOW) - .map((e) => `- ${e.workspaceId}: ${e.error}`) - .join("\n"); - const remainingCount = Math.max(0, errorCount - MAX_ERRORS_TO_SHOW); - const remainingSuffix = remainingCount > 0 ? `\n… and ${remainingCount} more.` : ""; - - if (typeof window !== "undefined") { - window.alert( - `Archived merged workspaces with some errors.\n\nArchived: ${archivedCount}\nSkipped: ${skippedCount}\nErrors: ${errorCount}\n\nErrors:\n${shownErrors}${remainingSuffix}` - ); - } + const summary = describeArchiveMergedIssues(result.data); + if (summary && typeof window !== "undefined") { + window.alert(summary); } } catch (error) { const message = getErrorMessage(error); diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 35a275d82b8..7234e26d7d5 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -2144,6 +2144,12 @@ export const workspace = { error: z.string(), }) ), + warnings: z.array( + z.object({ + workspaceId: z.string(), + warning: z.string(), + }) + ), }), z.string() ), diff --git a/src/node/services/workspaceService.archive.test.ts b/src/node/services/workspaceService.archive.test.ts index 6cad315e509..a648fe391dc 100644 --- a/src/node/services/workspaceService.archive.test.ts +++ b/src/node/services/workspaceService.archive.test.ts @@ -2363,7 +2363,9 @@ describe("WorkspaceService archiveMergedInProject", () => { } ) => Promise>; - type ArchiveFn = (workspaceId: string) => Promise>; + type ArchiveFn = ( + workspaceId: string + ) => Promise>; function archiveSuccess(): Promise> { return Promise.resolve(Ok({ kind: "archived" })); @@ -2497,6 +2499,35 @@ describe("WorkspaceService archiveMergedInProject", () => { expect(executeBashMock).toHaveBeenCalledTimes(3); }); + test("reports the warnings of archives that kept a sandbox", async () => { + const allMetadata: FrontendWorkspaceMetadata[] = [ + createMetadata("ws-kept"), + createMetadata("ws-clean"), + ]; + + const { workspaceService } = await createServiceHarness( + allMetadata, + () => Promise.resolve(bashOk('{"state":"MERGED"}')), + (workspaceId) => + workspaceId === "ws-kept" + ? Promise.resolve(Ok({ kind: "archived", warnings: ["Sandbox kept: dirty checkout"] })) + : archiveSuccess() + ); + + const result = await workspaceService.archiveMergedInProject(TARGET_PROJECT_PATH); + + expect(result.success).toBe(true); + if (!result.success) { + return; + } + + expect(result.data.archivedWorkspaceIds).toEqual(["ws-clean", "ws-kept"]); + expect(result.data.errors).toEqual([]); + expect(result.data.warnings).toEqual([ + { workspaceId: "ws-kept", warning: "Sandbox kept: dirty checkout" }, + ]); + }); + test("skips no_pr and non-merged states", async () => { const allMetadata: FrontendWorkspaceMetadata[] = [ createMetadata("ws-open"), diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 70a0f6eebcc..c75c389571e 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1339,6 +1339,7 @@ interface ArchiveMergedInProjectResult { archivedWorkspaceIds: string[]; skippedWorkspaceIds: string[]; errors: Array<{ workspaceId: string; error: string }>; + warnings: Array<{ workspaceId: string; warning: string }>; } interface ProjectGitStatusResult { @@ -14043,6 +14044,7 @@ export class WorkspaceService const archivedWorkspaceIds: string[] = []; const skippedWorkspaceIds: string[] = []; const errors: Array<{ workspaceId: string; error: string }> = []; + const warnings: Array<{ workspaceId: string; warning: string }> = []; try { const allMetadata = await this.config.getAllWorkspaceMetadata(); @@ -14137,16 +14139,21 @@ export class WorkspaceService continue; } archivedWorkspaceIds.push(workspaceId); + for (const warning of result.data.warnings ?? []) { + warnings.push({ workspaceId, warning }); + } } archivedWorkspaceIds.sort(); skippedWorkspaceIds.sort(); errors.sort((a, b) => a.workspaceId.localeCompare(b.workspaceId)); + warnings.sort((a, b) => a.workspaceId.localeCompare(b.workspaceId)); return Ok({ archivedWorkspaceIds, skippedWorkspaceIds, errors, + warnings, }); } catch (error) { const message = getErrorMessage(error); From 02b1a9ff25c0c6e8b0d61c75184c228f8fe08b16 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:20:42 +0000 Subject: [PATCH 112/231] refactor(workspace): keep archiveMergedInProject complexity flat --- src/node/services/workspaceService.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index c75c389571e..718f967ceb3 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1163,6 +1163,13 @@ function archivedResult(warnings: string[]): { kind: "archived"; warnings?: stri return warnings.length > 0 ? { kind: "archived", warnings } : { kind: "archived" }; } +function toBulkArchiveWarnings( + workspaceId: string, + warnings: string[] | undefined +): Array<{ workspaceId: string; warning: string }> { + return (warnings ?? []).map((warning) => ({ workspaceId, warning })); +} + /** * A remote box Xum created for this workspace (not an existing one it connected to): a Coder * workspace, or a Daytona sandbox, which reuses the Coder archive behavior (keep, stop, delete). @@ -14139,9 +14146,7 @@ export class WorkspaceService continue; } archivedWorkspaceIds.push(workspaceId); - for (const warning of result.data.warnings ?? []) { - warnings.push({ workspaceId, warning }); - } + warnings.push(...toBulkArchiveWarnings(workspaceId, result.data.warnings)); } archivedWorkspaceIds.sort(); From da146e5846b5b128b66b6ae9426364199d0f5ded Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:41:44 +0000 Subject: [PATCH 113/231] style(app): keep PageArea's doc comment on PageArea --- src/browser/App.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 9a1ec9b26a0..5221a29d3a9 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -203,11 +203,6 @@ export function appShellRef(element: HTMLElement | null): void { ); } -/** - * The page column. While a page chunk loads or after it fails, it holds the page's one `main`. - * `pageKey` names the kind of page: a new kind gets a fresh boundary, so a page that failed to load - * or render does not keep its failure state over the next page. - */ const MAX_ARCHIVE_MERGED_ISSUES_TO_SHOW = 5; function formatArchiveMergedIssues(heading: string, lines: string[]): string { @@ -249,6 +244,11 @@ function describeArchiveMergedIssues(data: { return `${title}\n\n${counts}${errorList}${warningList}`; } +/** + * The page column. While a page chunk loads or after it fails, it holds the page's one `main`. + * `pageKey` names the kind of page: a new kind gets a fresh boundary, so a page that failed to load + * or render does not keep its failure state over the next page. + */ function PageArea(props: { pageKey: string; children: React.ReactNode }) { const frame = (state: React.ReactNode) => (
From 075a8ad2b8ec20eec40f2246c9e3e737caef5d3a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:43:21 +0000 Subject: [PATCH 114/231] fix(daytona): leave a resolve during this process's own settings save to that save A resolve that ran while setConfig was saving (getSshToken's, outside any lease) could see the new credentials before applyConfig recorded their fingerprint, take them for another process's change and retire the caches first. applyConfig then found no tokens to revoke with the old client. noticeAccount now does nothing while a settings change is being saved: that change records the fingerprint and retires itself. --- .../services/daytona/daytonaService.test.ts | 45 ++++++++++++++++++- src/node/services/daytona/daytonaService.ts | 2 + 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 5313be3710a..bbbdf980c08 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; @@ -221,6 +221,49 @@ describe("DaytonaService", () => { expect(mintKeys).toEqual(["Bearer k", "Bearer other", "Bearer third"]); }); + it("leaves a resolve during its own settings save to that save", async () => { + const revokeKeys: string[] = []; + const store = new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }); + service = new DaytonaService({ + credentialsStore: store, + rootDir, + fetch: ((input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : input.toString(); + if (init?.method === "DELETE" && url.includes("/ssh-access")) { + revokeKeys.push((init.headers as Record).Authorization); + } + return plane.fetch(input, init); + }) as typeof fetch, + now: () => clock, + }); + const held = await service.getSshToken("sb-1"); + const before = (await service.getAccount()).generation; + // Hold the save after the new key is on disk, before setConfig goes on. + let releaseSave = () => undefined as void; + const saveGate = new Promise((resolve) => (releaseSave = resolve)); + let saved = () => undefined as void; + const savedOnDisk = new Promise((resolve) => (saved = resolve)); + const realUpdate = store.update.bind(store); + spyOn(store, "update").mockImplementation(async (patch) => { + const result = await realUpdate(patch); + saved(); + await saveGate; + return result; + }); + + const change = service.setConfig({ apiKey: "new-key" }); + await savedOnDisk; + const token = service.getSshToken("sb-1"); + await service.checkAvailability(); + releaseSave(); + await change; + await token; + + expect(plane.revoked).toEqual([held.token]); + expect(revokeKeys).toEqual(["Bearer k"]); + expect((await service.getAccount()).generation).toBe(before + 1); + }); + it("reuses one token while the user fixes an ssh config that shadows it", async () => { let shadowed = true; const writer = { diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 40ad608ce0b..b7a148481ae 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -459,6 +459,8 @@ export class DaytonaService { * the old one. Its tokens are not revoked: they expire, and the old key may be gone. */ private async noticeAccount(credentials: DaytonaResolvedCredentials | null): Promise { + // This process's own save sets the fingerprint and retires (and revokes) itself. + if (this.settingsChange) return; const fingerprint = fingerprintAccount(credentials); const previous = this.accountFingerprint; this.accountFingerprint = fingerprint; From 93a838b0f763afd6e9faff47e1854f90f3f9c34a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:08:00 +0000 Subject: [PATCH 115/231] fix(daytona): seed only skill directories of a skills root, not its files When a skills root was itself a link to a directory such as ~/.ssh, its direct files counted as inside the root and were copied into the sandbox. Discovery reads only skill directories of a skills root, so the seed now skips every other entry, files included. --- src/node/runtime/daytonaAgentHomeSeed.test.ts | 14 ++++++ src/node/runtime/daytonaAgentHomeSeed.ts | 47 +++++++++---------- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/src/node/runtime/daytonaAgentHomeSeed.test.ts b/src/node/runtime/daytonaAgentHomeSeed.test.ts index a0a6cba1121..0cee87baa69 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.test.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.test.ts @@ -166,4 +166,18 @@ describe("agent-home seeding", () => { expect(files.map((file) => file.remotePath)).toEqual(["~/.mux/skills/lint/SKILL.md"]); }); + + it("copies only skill directories of a skills root that is itself a link", async () => { + const secrets = path.join(hostHome, ".ssh"); + await fs.mkdir(path.join(secrets, "keys"), { recursive: true }); + await fs.writeFile(path.join(secrets, "id_rsa"), "PRIVATE KEY\n"); + await fs.writeFile(path.join(secrets, "keys", "id_ed25519"), "PRIVATE KEY\n"); + await fs.mkdir(path.join(secrets, "lint")); + await fs.writeFile(path.join(secrets, "lint", "SKILL.md"), "# lint\n"); + await fs.symlink(secrets, path.join(hostXum, "skills")); + + const files = await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux"); + + expect(files.map((file) => file.remotePath)).toEqual(["~/.mux/skills/lint/SKILL.md"]); + }); }); diff --git a/src/node/runtime/daytonaAgentHomeSeed.ts b/src/node/runtime/daytonaAgentHomeSeed.ts index 21c89052a80..640400236bf 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.ts @@ -142,16 +142,13 @@ async function collectSkillsRoot( remoteRoot: string ): Promise { const entries = await readDirectory(hostRoot); - const root = entries ? await resolveEntry(hostRoot) : null; - if (!entries || !root) return; + if (!entries) return; for (const entry of entries) { if (isSpent(budget)) return; - await collectSkillsRootEntry( - files, - budget, - { hostPath: path.join(hostRoot, entry), remotePath: path.posix.join(remoteRoot, entry) }, - root.real - ); + await collectSkillsRootEntry(files, budget, { + hostPath: path.join(hostRoot, entry), + remotePath: path.posix.join(remoteRoot, entry), + }); } } @@ -166,29 +163,31 @@ async function isSkillDirectory(name: string, realDir: string): Promise return skillFile !== null && skillFile.stat.isFile() && isInside(skillFile.real, realDir); } -/** A skill directory (its real path bounds what it may link to), or a file of the root. */ +/** + * A skill directory (its real path bounds what it may link to). Discovery reads nothing else + * of a skills root, and the root itself may be a link to `~/.ssh`, so other entries (files + * included) are skipped. + */ async function collectSkillsRootEntry( files: AgentHomeSeedFile[], budget: SeedBudget, - entry: { hostPath: string; remotePath: string }, - realRoot: string + entry: { hostPath: string; remotePath: string } ): Promise { const resolved = await resolveEntry(entry.hostPath); if (!resolved) return; - if (resolved.stat.isDirectory()) { - if (!(await isSkillDirectory(path.basename(entry.hostPath), resolved.real))) { - log.warn( - "Skipping a skills directory entry that is not a skill while seeding a Daytona sandbox", - { - hostPath: entry.hostPath, - } - ); - return; - } - await collectDirectory(files, budget, resolved.real, entry.remotePath, 1, resolved.real); - } else if (isInside(resolved.real, realRoot)) { - await collectFile(files, budget, resolved, entry.remotePath); + const isSkill = + resolved.stat.isDirectory() && + (await isSkillDirectory(path.basename(entry.hostPath), resolved.real)); + if (!isSkill) { + log.warn( + "Skipping a skills directory entry that is not a skill while seeding a Daytona sandbox", + { + hostPath: entry.hostPath, + } + ); + return; } + await collectDirectory(files, budget, resolved.real, entry.remotePath, 1, resolved.real); } async function collectDirectory( From dd7be06d57acd58709a6c483e9942a7fd3174207 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:10:22 +0000 Subject: [PATCH 116/231] fix(daytona): never count a borrowed checkout as a sandbox reference A side chat (taskIsolation: none) copies its main chat's runtime config but works in its checkout. Counted as another reference, it made the main chat's archive skip its stop/delete policy, and the side chat's own removal never applies it. Workspace refs now mark such rows, and the ownership checks (archive, delete, ssh access) skip them; the reconciler still counts their activity for keepalive. --- .../daytona/daytonaLifecycleHooks.test.ts | 21 +++++++++++++++++++ .../services/daytona/daytonaReconciler.ts | 17 ++++++++++++++- src/node/services/daytona/daytonaService.ts | 7 +++++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/node/services/daytona/daytonaLifecycleHooks.test.ts b/src/node/services/daytona/daytonaLifecycleHooks.test.ts index 6670b351f1b..f6e187cb577 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.test.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.test.ts @@ -14,6 +14,7 @@ import { type DaytonaTestRig, } from "./daytona.testHarness"; import { createDaytonaArchiveHook, type DaytonaArchiveHookOptions } from "./daytonaLifecycleHooks"; +import { listDaytonaWorkspaceRefs } from "./daytonaReconciler"; let rig: DaytonaTestRig; @@ -196,6 +197,26 @@ describe("createDaytonaArchiveHook", () => { expect(rig.plane.find("sb-parent")?.state).toBe("started"); }); + it("applies the archive policy to a sandbox whose only other user is a side chat", async () => { + rig.plane.addSandbox({ id: "sb-main" }); + const daytona = { sandboxId: "sb-main", shared: false }; + // A side chat copies its main chat's runtime config but borrows its checkout. + const sideChat = { + id: "side", + runtimeConfig: metadata(daytona).runtimeConfig, + taskIsolation: "none" as const, + }; + rig.workspaces.push( + ...listDaytonaWorkspaceRefs({ + projects: new Map([["/tmp/proj", { workspaces: [sideChat] }]]), + }) + ); + + expect((await archive(daytona, "stop")).success).toBe(true); + + expect(rig.plane.find("sb-main")?.state).toBe("stopped"); + }); + it("never deletes or stops a sandbox under an unverified job on a model-driven archive delete", async () => { const modelArchive = ( sandboxId: string, diff --git a/src/node/services/daytona/daytonaReconciler.ts b/src/node/services/daytona/daytonaReconciler.ts index ff00b8545c3..e45f69d5281 100644 --- a/src/node/services/daytona/daytonaReconciler.ts +++ b/src/node/services/daytona/daytonaReconciler.ts @@ -20,6 +20,12 @@ export interface DaytonaWorkspaceRef { pendingRemovalId?: string; /** Archived now: archive applies its sandbox policy once only archived workspaces use it. */ archived?: boolean; + /** + * `taskIsolation: "none"` (a side chat, a shared-checkout sub-agent): it copies another + * workspace's runtime config and works in that one's checkout, so it never owns or holds a + * sandbox. Its activity still keeps the sandbox alive. + */ + borrowsCheckout?: boolean; } export interface DaytonaReconcilerOptions { @@ -59,7 +65,15 @@ export function listDaytonaWorkspaceRefs(config: { string, { workspaces: ReadonlyArray< - Pick + Pick< + Workspace, + | "id" + | "runtimeConfig" + | "pendingRemoval" + | "archivedAt" + | "unarchivedAt" + | "taskIsolation" + > >; } >; @@ -73,6 +87,7 @@ export function listDaytonaWorkspaceRefs(config: { runtimeConfig: workspace.runtimeConfig, pendingRemovalId: workspace.pendingRemoval?.removalId, archived: isWorkspaceArchived(workspace.archivedAt, workspace.unarchivedAt), + borrowsCheckout: workspace.taskIsolation === "none", }); } } diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index b7a148481ae..49d040fa9fc 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -553,7 +553,10 @@ export class DaytonaService { ); } - /** What the other workspaces (removals in flight excluded) store as their sandbox. */ + /** + * What the other workspaces (removals in flight and borrowed checkouts excluded) store as + * their sandbox. + */ private refsStoredElsewhere( refs: DaytonaWorkspaceRef[], workspaceId: string @@ -563,7 +566,7 @@ export class DaytonaService { const removed = ref.pendingRemovalId !== undefined && this.removedWorkspaces.get(ref.workspaceId) === ref.pendingRemovalId; - const counts = ref.workspaceId !== workspaceId && !removed; + const counts = ref.workspaceId !== workspaceId && !removed && ref.borrowsCheckout !== true; return counts && stored ? [ { From b97e9b142233ab7b6969bb4965fbcffea20f718a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:22:13 +0000 Subject: [PATCH 117/231] test(daytona): lock in that multi-project workspaces refuse a Daytona runtime Codex reported that removing a multi-project Daytona workspace never settles its sandbox. Such a workspace cannot exist: createMultiProject and the multi-project fork accept only local and worktree runtimes, and the multi-project create UI sends no runtime. This covers the Daytona config next to the docker case, so the refusal cannot regress silently. --- .../workspaceService.multiProject.test.ts | 88 +++++++++++-------- 1 file changed, 51 insertions(+), 37 deletions(-) diff --git a/src/node/services/workspaceService.multiProject.test.ts b/src/node/services/workspaceService.multiProject.test.ts index 2dfe077d2e7..38de41a0e9c 100644 --- a/src/node/services/workspaceService.multiProject.test.ts +++ b/src/node/services/workspaceService.multiProject.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { tmpdir } from "node:os"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import { getValidUnrelatedWorkspaceConsent } from "@/common/orpc/schemas/workspace"; +import type { RuntimeConfig } from "@/common/types/runtime"; import { Config, type SecretsStore } from "@/node/config"; import { ContainerManager } from "@/node/multiProject/containerManager"; import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime"; @@ -1187,45 +1188,58 @@ describe("WorkspaceService multi-project lifecycle", () => { ); }); }); - test("createMultiProject rejects runtimes that are not local/worktree", async () => { - await withTempMuxRoot(async (rootDir) => { - const projectAPath = path.join(rootDir, "project-a"); - const projectBPath = path.join(rootDir, "project-b"); - const config = new Config(rootDir); - await config.editConfig((snapshot) => { - snapshot.projects.set(projectAPath, { workspaces: [], trusted: true }); - snapshot.projects.set(projectBPath, { workspaces: [], trusted: true }); - return snapshot; - }); - const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime"); - try { - const workspaceService = createWorkspaceServiceForTest({ config, historyService }); - const result = await workspaceService.createMultiProject( - [ - { projectPath: projectAPath, projectName: "project-a" }, - { projectPath: projectBPath, projectName: "project-b" }, - ], - "feature-unsupported-runtime", - "main", - "Unsupported runtime", - { - type: "docker", - image: "ubuntu:22.04", + // Daytona (an ssh runtime) included: its removal settles one sandbox per workspace, which the + // per-project multi-project removal never does. + test.each<[string, RuntimeConfig]>([ + ["docker", { type: "docker", image: "ubuntu:22.04" }], + [ + "ssh", + { + type: "ssh", + host: "daytona://", + srcBaseDir: "~/src", + daytona: { sandboxId: "xum-multi", shared: false }, + }, + ], + ])( + "createMultiProject rejects a %s runtime (not local/worktree)", + async (name, runtimeConfig) => { + await withTempMuxRoot(async (rootDir) => { + const projectAPath = path.join(rootDir, "project-a"); + const projectBPath = path.join(rootDir, "project-b"); + const config = new Config(rootDir); + await config.editConfig((snapshot) => { + snapshot.projects.set(projectAPath, { workspaces: [], trusted: true }); + snapshot.projects.set(projectBPath, { workspaces: [], trusted: true }); + return snapshot; + }); + const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime"); + try { + const workspaceService = createWorkspaceServiceForTest({ config, historyService }); + const result = await workspaceService.createMultiProject( + [ + { projectPath: projectAPath, projectName: "project-a" }, + { projectPath: projectBPath, projectName: "project-b" }, + ], + "feature-unsupported-runtime", + "main", + "Unsupported runtime", + runtimeConfig + ); + expect(result.success).toBe(false); + if (result.success) { + return; } - ); - expect(result.success).toBe(false); - if (result.success) { - return; + expect(result.error).toContain( + `Multi-project workspaces currently require local or worktree runtime, got: ${name}` + ); + expect(createRuntimeSpy).not.toHaveBeenCalled(); + } finally { + createRuntimeSpy.mockRestore(); } - expect(result.error).toContain( - "Multi-project workspaces currently require local or worktree runtime, got: docker" - ); - expect(createRuntimeSpy).not.toHaveBeenCalled(); - } finally { - createRuntimeSpy.mockRestore(); - } - }); - }); + }); + } + ); // Case-insensitive volumes and Windows name trimming alias these to .mux/.xum as well. test.each([".mux", ".XUM", ".Mux."])( "createMultiProject refuses a project named like the workspace metadata directory (%s, #4455)", From 28ca6d8f04bfe2a04a6fa8765f47daa87647fb09 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:41:32 +0000 Subject: [PATCH 118/231] fix(daytona): recognize Daytona's deleting and deleted sandbox states Daytona documents a sandbox going away as deleting/deleted, but Xum only knew destroying/destroyed. A removal that overlapped a failed setup's sandbox delete saw "deleting", did not wait, and failed as if the sandbox could not be started. Both spellings now share one list for the wait and the state map. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 26 ++++++++++++------- .../services/daytona/daytona.testHarness.ts | 5 ++-- src/node/services/daytona/daytonaService.ts | 7 ++--- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index bc0f3b9edd2..51b282052c8 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -1026,18 +1026,24 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { }); }); - it("waits for a sandbox Daytona is already destroying instead of refusing the delete", async () => { + it("waits for a sandbox Daytona is already deleting instead of refusing the delete", async () => { // A failed setup deletes the sandbox it created while the workspace's removal runs. - rig.plane.addSandbox({ id: "sb-going", state: "destroying", pendingPolls: 2 }); - - const result = await makeRuntime({ sandboxId: "sb-going", shared: false }).deleteWorkspace( - "/home/me/project", - "feature", - false - ); + // Daytona's documented state is "deleting"; "destroying" is the older spelling. + for (const [sandboxId, state] of [ + ["sb-going", "deleting"], + ["sb-going-old", "destroying"], + ] as const) { + rig.plane.addSandbox({ id: sandboxId, state, pendingPolls: 2 }); + + const result = await makeRuntime({ sandboxId, shared: false }).deleteWorkspace( + "/home/me/project", + "feature", + false + ); - expect(result.success).toBe(true); - expect(rig.plane.find("sb-going")).toBeUndefined(); + expect(result.success).toBe(true); + expect(rig.plane.find(sandboxId)).toBeUndefined(); + } }); it("names the sandbox left behind when a forced delete cannot delete it", async () => { diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 21b2afd39d2..a3e0afb7269 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -26,7 +26,7 @@ export interface FakeSandbox { memory: number; disk: number; labels: Record; - /** GETs left before a transitional state settles (creating/starting -> started, destroying -> gone). */ + /** GETs left before a transitional state settles (creating/starting -> started, deleting -> gone). */ pendingPolls: number; /** Daytona's sandboxClass ("linux-vm", "container", "windows"); unset like an older API. */ sandboxClass?: string; @@ -179,7 +179,8 @@ export class FakeDaytonaControlPlane { if (!sandbox) return fail(404, "Sandbox not found"); if (method === "GET" && !action) { - if (sandbox.state === "destroying" && sandbox.pendingPolls === 0) { + const goingAway = sandbox.state === "deleting" || sandbox.state === "destroying"; + if (goingAway && sandbox.pendingPolls === 0) { this.sandboxes.delete(sandbox.id); return fail(404, "Sandbox not found"); } diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 49d040fa9fc..80b0d7d37ad 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -81,7 +81,9 @@ export const DAYTONA_GATEWAY_CONFIG_TTL_MS = 5 * 60_000; /** How long a delete waits for a sandbox Daytona is already destroying to go away. */ export const DAYTONA_DESTROY_WAIT_MS = 60_000; -const DESTROYING_STATES = new Set(["destroying", "destroyed"]); +// Daytona documents "deleting" and "deleted"; "destroying" and "destroyed" are older spellings. +const DESTROYING_STATE_NAMES = ["deleting", "deleted", "destroying", "destroyed"] as const; +const DESTROYING_STATES = new Set(DESTROYING_STATE_NAMES); type ObservedSandboxState = Exclude; @@ -106,9 +108,8 @@ const RAW_STATE_MAP: Record = { archived: "archived", error: "error", build_failed: "error", - destroyed: "error", - destroying: "error", unknown: "error", + ...Object.fromEntries(DESTROYING_STATE_NAMES.map((state) => [state, "error" as const])), }; /** Only an active snapshot creates sandboxes; one with no reported state is taken as active. */ From daf83043371e5072b4cc7d630c34eafca4a43e78 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:43:07 +0000 Subject: [PATCH 119/231] fix(daytona): share one gateway refresh between concurrent lookups Two lookups after the gateway cache TTL each fetched /config. During a gateway rollout an earlier request answering last restored the old gateway in the cache and, comparing against its stale capture, skipped retireSshSetup. Concurrent lookups for the same settings now share one in-flight refresh. --- .../services/daytona/daytonaService.test.ts | 35 +++++++++++++++++++ src/node/services/daytona/daytonaService.ts | 28 ++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index bbbdf980c08..8954e179541 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -459,6 +459,41 @@ describe("DaytonaService", () => { expect(retired).toEqual(["sb-1"]); }); + it("shares one gateway refresh between concurrent lookups, so a stale reply cannot win", async () => { + const held: Array<{ run: () => Promise; resolve: (response: Response) => void }> = []; + let holdConfig = false; + const fetchImpl = ((input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : input.toString(); + if (!holdConfig || !url.endsWith("/config")) return plane.fetch(input, init); + return new Promise((resolve) => { + held.push({ run: () => plane.fetch(input, init), resolve }); + }); + }) as typeof fetch; + const { service: watching, retired } = serviceRecordingWrites(fetchImpl); + await watching.ensureSshAccess("sb-1"); + + clock += 6 * 60_000; + holdConfig = true; + const lookups = Promise.all([watching.getGatewayConfig(), watching.getGatewayConfig()]); + while (held.length === 0) await new Promise((resolve) => setTimeout(resolve, 1)); + await new Promise((resolve) => setTimeout(resolve, 20)); + // During a rollout the last request answers first with the new gateway; earlier ones late. + for (let i = held.length - 1; i >= 0; i--) { + plane.gatewayHostKeys = i === held.length - 1 ? ["ssh-ed25519 NEW"] : ["ssh-ed25519 AAAA"]; + held[i].resolve(await held[i].run()); + } + const results = await lookups; + holdConfig = false; + + expect(held).toHaveLength(1); + expect(results.map((config) => config.sshGatewayHostKeys)).toEqual([ + ["ssh-ed25519 NEW"], + ["ssh-ed25519 NEW"], + ]); + expect((await watching.getGatewayConfig()).sshGatewayHostKeys).toEqual(["ssh-ed25519 NEW"]); + expect(retired).toEqual(["sb-1"]); + }); + it("installs a moved gateway before retiring the connections to the old one", async () => { const { service: watching, order, staleAtRetire } = serviceRecordingWrites(); await watching.ensureSshAccess("sb-1"); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 80b0d7d37ad..73591eaee70 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -307,6 +307,11 @@ export class DaytonaService { fetchedAt: number; config: DaytonaGatewayConfig; }; + private gatewayRequest?: { + apiUrl: string; + generation: number; + promise: Promise; + }; private readonly tokens = new Map(); /** Bumped (unique per process) whenever a sandbox's cached token becomes another one. */ private readonly tokenGenerations = new Map(); @@ -690,11 +695,32 @@ export class DaytonaService { if (cached && this.now() - cached.fetchedAt < DAYTONA_GATEWAY_CONFIG_TTL_MS) { return cached.config; } + // Concurrent lookups share one refresh: a reply that started earlier and arrived last would + // otherwise publish the old gateway over the new one. + const inFlight = this.gatewayRequest; + if (inFlight?.apiUrl === credentials.apiUrl && inFlight.generation === generation) { + return inFlight.promise; + } + const promise = this.refreshGateway(client, credentials.apiUrl, generation, cached).finally( + () => { + if (this.gatewayRequest?.promise === promise) this.gatewayRequest = undefined; + } + ); + this.gatewayRequest = { apiUrl: credentials.apiUrl, generation, promise }; + return promise; + } + + private async refreshGateway( + client: DaytonaClient, + apiUrl: string, + generation: number, + cached: ReturnType + ): Promise { const config = await client.getConfig(); // Fetched across a settings change: never cache it for lookups under the new settings. if (this.credentialsGeneration !== generation) return config; const moved = cached && gatewayIdentity(cached.config) !== gatewayIdentity(config); - this.gatewayCache = { apiUrl: credentials.apiUrl, generation, fetchedAt: this.now(), config }; + this.gatewayCache = { apiUrl, generation, fetchedAt: this.now(), config }; if (moved) await this.retireSshSetup(config); return config; } From d0b2996e1a4802f5b22af665e9901d09b74233e3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:46:27 +0000 Subject: [PATCH 120/231] fix(daytona): keep an account lease on the credentials it started with A key saved by another Xum process while this one ran a lease was read by the next nested getClient, so one operation could check or start a sandbox in the old account and then mint, relabel or delete in the new one. Resolves now return one process-level snapshot while a lease or this process's own save runs; only an idle resolve re-reads the store (and notices another account), and one that a lease started during discards what it read. applyConfig sets the snapshot to the saved credentials. --- .../services/daytona/daytonaService.test.ts | 33 +++++++++++++++++++ src/node/services/daytona/daytonaService.ts | 24 ++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 8954e179541..bd37163474a 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -221,6 +221,39 @@ describe("DaytonaService", () => { expect(mintKeys).toEqual(["Bearer k", "Bearer other", "Bearer third"]); }); + it("keeps a lease on its account when another process saves another key", async () => { + const lookupKeys: string[] = []; + service = new DaytonaService({ + credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), + rootDir, + fetch: ((input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : input.toString(); + if ((init?.method ?? "GET") === "GET" && url.endsWith("/sandbox/sb-1")) { + lookupKeys.push((init?.headers as Record).Authorization); + } + return plane.fetch(input, init); + }) as typeof fetch, + now: () => clock, + }); + const generation = async () => (await service.getAccount()).generation; + await service.getSshToken("sb-1"); + const before = await generation(); + + await service.withAccountLease(async () => { + await service.getSandboxState("sb-1"); + await new DaytonaCredentialsStore(rootDir, {}).update({ apiKey: "other" }); + await service.getSandboxState("sb-1"); + expect(await generation()).toBe(before); + }); + expect(lookupKeys).toEqual(["Bearer k", "Bearer k"]); + + // The first resolve after the lease takes the new account and retires the old one once. + await service.getSandboxState("sb-1"); + await service.getSandboxState("sb-1"); + expect(lookupKeys.slice(2)).toEqual(["Bearer other", "Bearer other"]); + expect(await generation()).toBe(before + 1); + }); + it("leaves a resolve during its own settings save to that save", async () => { const revokeKeys: string[] = []; const store = new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 73591eaee70..9cc7cbfd5fb 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -325,6 +325,12 @@ export class DaytonaService { * that sees another account retires what this process cached for the old one. */ private accountFingerprint?: string; + /** + * The credentials last resolved. While an account lease or this process's settings save + * runs, every resolve returns these, so an operation never mixes two accounts when another + * process saves new ones mid-way; the first resolve once idle reads the store again. + */ + private credentialsSnapshot?: { credentials: DaytonaResolvedCredentials | null }; /** Operations running under withAccountLease; setConfig refuses while any runs. */ private accountLeases = 0; /** The settings change being saved; new leases wait for it. */ @@ -420,7 +426,9 @@ export class DaytonaService { private async applyConfig(patch: DaytonaCredentialsPatch): Promise { const previousClient = await this.getClientIfConfigured(); await this.credentialsStore.update(patch); - this.accountFingerprint = await this.readAccountFingerprint(); + this.credentialsSnapshot = await this.readSavedCredentials(); + this.accountFingerprint = + this.credentialsSnapshot && fingerprintAccount(this.credentialsSnapshot.credentials); // Revoke the old account's tokens with the old credentials, which may not work for the // new ones. const tokens = await this.retireAccountCaches(); @@ -451,9 +459,11 @@ export class DaytonaService { return tokens; } - private async readAccountFingerprint(): Promise { + private async readSavedCredentials(): Promise< + { credentials: DaytonaResolvedCredentials | null } | undefined + > { try { - return fingerprintAccount(await this.credentialsStore.resolve()); + return { credentials: await this.credentialsStore.resolve() }; } catch (error) { log.debug("Could not read the saved Daytona settings", { error: getErrorMessage(error) }); return undefined; @@ -641,8 +651,16 @@ export class DaytonaService { /** Unusable settings (an http DAYTONA_API_URL) throw a `not_configured` DaytonaApiError. */ private async resolveCredentials(): Promise { + const pinned = () => + (this.accountLeases > 0 || this.settingsChange !== undefined) && this.credentialsSnapshot; + const held = pinned(); + if (held) return held.credentials; try { const credentials = await this.credentialsStore.resolve(); + // A lease or save that started during the read keeps the account it started with. + const heldNow = pinned(); + if (heldNow) return heldNow.credentials; + this.credentialsSnapshot = { credentials }; await this.noticeAccount(credentials); return credentials; } catch (error) { From 85f375946bacdaa6b5160d0ff97c8686aaf1c491 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:08:00 +0000 Subject: [PATCH 121/231] fix(daytona): neither offer, create nor attach GPU sandboxes Daytona documents GPU sandboxes as ephemeral: it deletes one when it stops. Xum lets a workspace's sandbox auto-stop, and stops one to grow its disk, so the checkout would be lost. Daytona's OpenAPI has no GPU sandboxClass; GPU sandboxes and snapshots carry a positive gpu count. One predicate now covers Windows and GPU: such snapshots and sandboxes are not listed, and creating from or attaching one is refused before the workspace is persisted, with a reason for each kind. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 17 ++++++--- src/node/runtime/DaytonaSSHRuntime.ts | 27 ++++++++++---- .../services/daytona/daytona.testHarness.ts | 4 +++ src/node/services/daytona/daytonaClient.ts | 6 ++++ src/node/services/daytona/daytonaService.ts | 36 ++++++++++++------- 5 files changed, 67 insertions(+), 23 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 51b282052c8..9d5a3129a52 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -330,13 +330,16 @@ describe("DaytonaSSHRuntime.finalizeConfig and validateBeforePersist", () => { }); }); -describe("DaytonaSSHRuntime Windows sandboxes", () => { +describe("DaytonaSSHRuntime Windows and GPU sandboxes", () => { // Xum provisions over ssh with POSIX commands; a Windows sandbox runs cmd.exe or PowerShell. - it("refuses to attach a Windows sandbox or create from a Windows snapshot before persisting", async () => { + // Daytona deletes a GPU sandbox when it stops, and Xum lets workspaces auto-stop. + it("refuses to attach a Windows or GPU sandbox or create from such a snapshot before persisting", async () => { rig.plane.addSandbox({ id: "sb-win", sandboxClass: "windows" }); - rig.plane.addSandbox({ id: "sb-linux", sandboxClass: "linux-vm" }); + rig.plane.addSandbox({ id: "sb-gpu", sandboxClass: "linux-vm", gpu: 1 }); + rig.plane.addSandbox({ id: "sb-linux", sandboxClass: "linux-vm", gpu: 0 }); rig.plane.snapshots.push( { name: "windows-small", sandboxClass: "windows" }, + { name: "daytona-gpu", sandboxClass: "linux-vm", gpu: 1 }, { name: "daytona-small", sandboxClass: "container" } ); const runtime = makeRuntime({}); @@ -350,6 +353,10 @@ describe("DaytonaSSHRuntime Windows sandboxes", () => { expect(attached.success ? "" : attached.error).toContain("Windows"); const created = await validate({ snapshot: "windows-small" }); expect(created.success ? "" : created.error).toContain("Windows"); + const attachedGpu = await validate({ sandboxId: "sb-gpu" }); + expect(attachedGpu.success ? "" : attachedGpu.error).toContain("GPU"); + const createdGpu = await validate({ snapshot: "daytona-gpu" }); + expect(createdGpu.success ? "" : createdGpu.error).toContain("GPU"); expect((await validate({ sandboxId: "sb-linux" })).success).toBe(true); expect((await validate({ snapshot: "daytona-small" })).success).toBe(true); @@ -442,12 +449,14 @@ describe("DaytonaSSHRuntime Windows sandboxes", () => { expect(rig.plane.countCalls("POST /sandbox")).toBe(0); }); - it("offers neither Windows snapshots nor Windows sandboxes", async () => { + it("offers neither Windows nor GPU snapshots and sandboxes", async () => { rig.plane.addSandbox({ id: "sb-win", sandboxClass: "windows" }); + rig.plane.addSandbox({ id: "sb-gpu", sandboxClass: "linux-vm", gpu: 1 }); rig.plane.addSandbox({ id: "sb-linux", sandboxClass: "linux-vm" }); rig.plane.addSandbox({ id: "sb-older" }); rig.plane.snapshots.push( { name: "windows-small", sandboxClass: "windows" }, + { name: "daytona-gpu", sandboxClass: "linux-vm", gpu: 1 }, { name: "daytona-small", sandboxClass: "container" }, { name: "older" } ); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 64326881da2..9292f9a3e47 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -49,7 +49,8 @@ import { DAYTONA_LABEL_INSTANCE, DAYTONA_LABEL_WORKSPACE_ID, DAYTONA_START_TIMEOUT_MS, - DAYTONA_WINDOWS_SANDBOX_CLASS, + unsupportedDaytonaSandboxKind, + type UnsupportedDaytonaSandboxKind, type DaytonaAccount, type DaytonaService, } from "@/node/services/daytona/daytonaService"; @@ -120,8 +121,17 @@ const DAYTONA_AUTO_DELETE_DISABLED = -1; const SANDBOX_NAME_MAX_BRANCH_LENGTH = 40; const SETTINGS_CHANGED_DURING_SETUP = "Daytona settings changed while the sandbox was set up; create the workspace again"; -const LINUX_ONLY = - "Xum sets up and runs a workspace over SSH with Linux shell commands, so it needs a Linux sandbox."; +const UNSUPPORTED_SANDBOXES: Record = + { + windows: { + label: "Windows", + why: "Xum sets up and runs a workspace over SSH with Linux shell commands, so it needs a Linux sandbox.", + }, + gpu: { + label: "GPU", + why: "GPU sandboxes are ephemeral: Daytona deletes one when it stops, and a workspace's sandbox stops when idle, so its checkout would be lost.", + }, + }; /** `xum--`: unique per creation, and readable in the Daytona dashboard. */ export function buildDaytonaSandboxName(branchName: string): string { @@ -261,8 +271,10 @@ function attachRefusal( state: DaytonaSandboxState, sandbox: DaytonaSandbox ): string | undefined { - if (sandbox.sandboxClass === DAYTONA_WINDOWS_SANDBOX_CLASS) { - return `Daytona sandbox "${sandboxId}" is a Windows sandbox. ${LINUX_ONLY}`; + const unsupported = unsupportedDaytonaSandboxKind(sandbox); + if (unsupported) { + const { label, why } = UNSUPPORTED_SANDBOXES[unsupported]; + return `Daytona sandbox "${sandboxId}" is a ${label} sandbox. ${why}`; } // Setup's start never brings an error state up, so every retry would fail. if (state !== "error") return undefined; @@ -793,8 +805,9 @@ export class DaytonaSSHRuntime extends SSHRuntime { if (options?.missingOk) return Ok(undefined); return Err(`Daytona snapshot "${snapshot}" was not found; pick another snapshot`); } - if (found.windows) { - return Err(`Daytona snapshot "${snapshot}" creates Windows sandboxes. ${LINUX_ONLY}`); + if (found.unsupported) { + const { label, why } = UNSUPPORTED_SANDBOXES[found.unsupported]; + return Err(`Daytona snapshot "${snapshot}" creates ${label} sandboxes. ${why}`); } const refusal = snapshotStateRefusal(snapshot, found.state); return refusal ? Err(refusal) : Ok(undefined); diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index a3e0afb7269..360471f3092 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -30,6 +30,8 @@ export interface FakeSandbox { pendingPolls: number; /** Daytona's sandboxClass ("linux-vm", "container", "windows"); unset like an older API. */ sandboxClass?: string; + /** GPU units; Daytona's GPU sandboxes carry a positive count. */ + gpu?: number; } export class FakeDaytonaControlPlane { @@ -42,6 +44,7 @@ export class FakeDaytonaControlPlane { readonly snapshots: Array<{ name: string; sandboxClass?: string; + gpu?: number; listedOnly?: boolean; state?: string; }> = []; @@ -256,6 +259,7 @@ export class FakeDaytonaControlPlane { ...(this.omitDisk ? {} : { disk: sandbox.disk }), labels: sandbox.labels, ...(sandbox.sandboxClass ? { sandboxClass: sandbox.sandboxClass } : {}), + ...(sandbox.gpu !== undefined ? { gpu: sandbox.gpu } : {}), }; } } diff --git a/src/node/services/daytona/daytonaClient.ts b/src/node/services/daytona/daytonaClient.ts index 55f41dd4061..1ae08e1fcd2 100644 --- a/src/node/services/daytona/daytonaClient.ts +++ b/src/node/services/daytona/daytonaClient.ts @@ -60,6 +60,8 @@ const SandboxSchema = z.object({ errorReason: z.string().nullish(), /** "linux-vm", "container" or "windows"; older control planes omit it. */ sandboxClass: z.string().nullish(), + /** GPU units; positive on a GPU sandbox (no sandboxClass marks one). */ + gpu: z.number().nullish(), }); export type DaytonaSandbox = z.infer; @@ -73,6 +75,8 @@ const SnapshotSchema = z.object({ name: z.string(), /** The class of the sandboxes the snapshot creates, as on a sandbox. */ sandboxClass: z.string().nullish(), + /** GPU units of the sandboxes it creates, as on a sandbox. */ + gpu: z.number().nullish(), /** SnapshotState: only "active" creates sandboxes (building, inactive, error, ...). */ state: z.string().nullish(), }); @@ -80,6 +84,7 @@ const SnapshotSchema = z.object({ export interface DaytonaSnapshot { name: string; sandboxClass?: string; + gpu?: number; state?: string; } @@ -87,6 +92,7 @@ function toSnapshot(item: z.infer): DaytonaSnapshot { return { name: item.name, ...(item.sandboxClass ? { sandboxClass: item.sandboxClass } : {}), + ...(item.gpu ? { gpu: item.gpu } : {}), ...(item.state ? { state: item.state } : {}), }; } diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 9cc7cbfd5fb..89132a53841 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -45,14 +45,23 @@ interface StoredSandboxRef { } /** - * Daytona's sandboxClass of a Windows sandbox (and of the snapshots that create one). Xum - * provisions and runs workspaces over ssh with POSIX shell commands, which cmd.exe and - * PowerShell do not run, so it never offers, creates or attaches one. + * Sandboxes (and the snapshots that create them) Xum never offers, creates or attaches: + * - "windows" (Daytona's sandboxClass): Xum provisions and runs workspaces over ssh with POSIX + * shell commands, which cmd.exe and PowerShell do not run. + * - "gpu" (a positive `gpu` count; Daytona's sandboxClass has no GPU value): Daytona deletes a + * GPU sandbox when it stops, and Xum lets workspaces auto-stop and stops one to grow its + * disk, so the checkout would be lost. */ -export const DAYTONA_WINDOWS_SANDBOX_CLASS = "windows"; - -const isWindowsClass = (item: { sandboxClass?: string | null }) => - item.sandboxClass === DAYTONA_WINDOWS_SANDBOX_CLASS; +export type UnsupportedDaytonaSandboxKind = "windows" | "gpu"; + +export function unsupportedDaytonaSandboxKind(item: { + sandboxClass?: string | null; + gpu?: number | null; +}): UnsupportedDaytonaSandboxKind | undefined { + if (item.sandboxClass === "windows") return "windows"; + if ((item.gpu ?? 0) > 0) return "gpu"; + return undefined; +} export const DAYTONA_LABEL_INSTANCE = "xum.instance"; export const DAYTONA_LABEL_WORKSPACE_ID = "xum.workspaceId"; @@ -821,7 +830,8 @@ export class DaytonaService { // Error states (destroyed and destroying included) cannot be started or attached. .filter( (sandbox) => - mapDaytonaSandboxState(sandbox.state) !== "error" && !isWindowsClass(sandbox) + mapDaytonaSandboxState(sandbox.state) !== "error" && + !unsupportedDaytonaSandboxKind(sandbox) ) .map(toSummary), }; @@ -837,7 +847,7 @@ export class DaytonaService { return { ok: true, snapshots: snapshots - .filter((s) => !isWindowsClass(s) && isActiveSnapshot(s)) + .filter((s) => !unsupportedDaytonaSandboxKind(s) && isActiveSnapshot(s)) .map(({ name }) => ({ name })), }; } catch (error) { @@ -846,11 +856,13 @@ export class DaytonaService { } /** - * The snapshot named `name`, with whether it creates Windows sandboxes and its state; undefined when + * The snapshot named `name`, with the unsupported kind of sandbox it creates (if any) and its state; undefined when * Daytona has none by that name. The list is the fallback for one the lookup does not find * by name (it serves the snapshot picker). */ - async findSnapshot(name: string): Promise<{ windows: boolean; state?: string } | undefined> { + async findSnapshot( + name: string + ): Promise<{ unsupported?: UnsupportedDaytonaSandboxKind; state?: string } | undefined> { const { client } = await this.getClient(); let snapshot: DaytonaSnapshot | undefined; try { @@ -861,7 +873,7 @@ export class DaytonaService { } return ( snapshot && { - windows: isWindowsClass(snapshot), + unsupported: unsupportedDaytonaSandboxKind(snapshot), ...(snapshot.state ? { state: snapshot.state } : {}), } ); From 30c2746d2e72e3105de44e64be4a5a1143d46563 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:05:42 +0000 Subject: [PATCH 122/231] fix(daytona): refuse a Xum root with ssh glob characters before writing Daytona ssh config --- src/node/runtime/daytonaSshConfig.test.ts | 37 ++++++++++++----------- src/node/runtime/daytonaSshConfig.ts | 16 +++++----- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index cc9252a195c..15df29ee2c4 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -190,24 +190,27 @@ describe("DaytonaSshConfigWriter", () => { expect(await Bun.file(sshConfigPath).exists()).toBe(false); }); - it("refuses a Xum root whose path holds an ssh % token, writing nothing", async () => { - const tokenWriter = new DaytonaSshConfigWriter({ - xumHome: path.join(tempDir, "xum%d"), - sshConfigPath, - }); - const input = { sandboxId: "sb-1", token: "tok", gateway: GATEWAY }; - for (const update of [ - () => tokenWriter.writeSandbox(input), - () => tokenWriter.updateToken(input), - () => tokenWriter.repointGateway(GATEWAY), - ]) { - // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime - await expect(update()).rejects.toThrow( - `Daytona ssh setup cannot use ${tokenWriter.paths.dir}` - ); + it.each(["xum%d", "xum*", "xum?", "xum[abc]"])( + "refuses a Xum root that ssh would read as a token or glob (%s), writing nothing", + async (root) => { + const tokenWriter = new DaytonaSshConfigWriter({ + xumHome: path.join(tempDir, root), + sshConfigPath, + }); + const input = { sandboxId: "sb-1", token: "tok", gateway: GATEWAY }; + for (const update of [ + () => tokenWriter.writeSandbox(input), + () => tokenWriter.updateToken(input), + () => tokenWriter.repointGateway(GATEWAY), + ]) { + // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime + await expect(update()).rejects.toThrow( + `Daytona ssh setup cannot use ${tokenWriter.paths.dir}` + ); + } + expect(await fs.readdir(tempDir)).toEqual([]); } - expect(await fs.readdir(tempDir)).toEqual([]); - }); + ); it("writes a self-hosted IPv6 gateway as OpenSSH resolves it, refusing zone ids", async () => { const gateway = { ...GATEWAY, host: "2001:db8::10", port: 2222 }; diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index eae0243ace8..7159581d8d5 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -257,7 +257,7 @@ export class DaytonaSshConfigWriter { edit: (blocks: Map) => void ): Promise { const knownHosts = renderDaytonaKnownHosts(gateway); - this.assertPathsHaveNoSshTokens(); + this.assertPathsReadLiterallyBySsh(); await this.withIncludeFileLock(async () => { await writeFileAtomic(this.paths.knownHostsPath, knownHosts, { encoding: "utf-8", @@ -304,7 +304,7 @@ export class DaytonaSshConfigWriter { token: string; gateway: DaytonaSshGateway; }): Promise { - this.assertPathsHaveNoSshTokens(); + this.assertPathsReadLiterallyBySsh(); const block = this.renderBlock(input); const alias = toXumDaytonaHost(input.sandboxId); return this.withIncludeFileLock(async () => { @@ -316,13 +316,15 @@ export class DaytonaSshConfigWriter { /** * The Include and UserKnownHostsFile paths go into ssh config, where OpenSSH expands `%` - * tokens. Escaping as `%%` is not an option: older OpenSSH reads Include paths literally. + * tokens and Include globs `*`, `?` and `[`. Escaping is not an option: older OpenSSH reads + * Include paths literally, so `%%` would break there. */ - private assertPathsHaveNoSshTokens(): void { - if (this.paths.dir.includes("%")) { + private assertPathsReadLiterallyBySsh(): void { + if (/[%*?[]/.test(this.paths.dir)) { throw new DaytonaSshConfigError( - `Daytona ssh setup cannot use ${this.paths.dir}: ssh treats "%" in config paths as a ` + - `token. Use a Xum root (XUM_ROOT) and home directory without "%".` + `Daytona ssh setup cannot use ${this.paths.dir}: ssh reads "%" in config paths as a ` + + `token and "*", "?" or "[" as a pattern. Use a Xum root (XUM_ROOT) and home ` + + `directory without these characters.` ); } } From 1ef4b728fe88ab8948a69048cdb267846614ffa3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:10:49 +0000 Subject: [PATCH 123/231] fix(ssh): keep spaces in a quoted UserKnownHostsFile path --- src/node/runtime/SSH2ConnectionPool.test.ts | 6 +++-- src/node/runtime/sshConfigParser.test.ts | 30 +++++++++++++++++++++ src/node/runtime/sshConfigParser.ts | 9 +++---- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/node/runtime/SSH2ConnectionPool.test.ts b/src/node/runtime/SSH2ConnectionPool.test.ts index 230bb9e5299..f325ea60890 100644 --- a/src/node/runtime/SSH2ConnectionPool.test.ts +++ b/src/node/runtime/SSH2ConnectionPool.test.ts @@ -190,7 +190,9 @@ describe.skipIf(process.platform === "win32")( const originalUserProfile = process.env.USERPROFILE; process.env.USERPROFILE = home; try { - const knownHostsPath = path.join(home, "daytona_known_hosts"); + // A space, as in "/Users/John Smith": daytonaSshConfig quotes the path. + const knownHostsPath = path.join(home, "John Smith", "daytona_known_hosts"); + await fs.mkdir(path.dirname(knownHostsPath)); await fs.mkdir(path.join(home, ".ssh")); await fs.writeFile( path.join(home, ".ssh", "config"), @@ -199,7 +201,7 @@ describe.skipIf(process.platform === "win32")( " HostName 127.0.0.1", ` Port ${String(config.port)}`, " HostKeyAlias gateway.test", - ` UserKnownHostsFile ${knownHostsPath}`, + ` UserKnownHostsFile "${knownHostsPath}"`, " StrictHostKeyChecking yes", ].join("\n") ); diff --git a/src/node/runtime/sshConfigParser.test.ts b/src/node/runtime/sshConfigParser.test.ts index 4e83380a59f..75664d83c97 100644 --- a/src/node/runtime/sshConfigParser.test.ts +++ b/src/node/runtime/sshConfigParser.test.ts @@ -142,6 +142,36 @@ describe("resolveSSHConfig", () => { }); }); + test("keeps a space in a quoted UserKnownHostsFile and splits unquoted files", async () => { + await withTempHome(async (homeDir) => { + const configPath = path.join(homeDir, ".ssh", "config"); + await fs.writeFile( + configPath, + "Host plain\n UserKnownHostsFile ~/kh1 ~/kh2\n" + + 'Host quoted-first\n UserKnownHostsFile "~/J S/kh" ~/kh2\n', + "utf8" + ); + // Rendered as daytonaSshConfig does for a home dir like "/Users/John Smith". + const writer = new DaytonaSshConfigWriter({ + xumHome: path.join(homeDir, "John Smith", ".xum"), + sshConfigPath: configPath, + }); + await writer.writeSandbox({ + sandboxId: "sb-1", + token: "tokenA", + gateway: { host: "ssh.daytona.test", port: 22, hostKeys: ["ssh-ed25519 AAAA"] }, + }); + + expect((await resolveSSHConfig("sb-1.xum--daytona")).userKnownHostsFile).toBe( + writer.paths.knownHostsPath + ); + expect((await resolveSSHConfig("plain")).userKnownHostsFile).toBe(path.join(homeDir, "kh1")); + expect((await resolveSSHConfig("quoted-first")).userKnownHostsFile).toBe( + path.join(homeDir, "J S", "kh") + ); + }); + }); + test("resolves relative and wildcard Includes and keeps later lines in the enclosing block", async () => { await withTempHome(async (homeDir) => { await fs.mkdir(path.join(homeDir, ".ssh", "conf.d")); diff --git a/src/node/runtime/sshConfigParser.ts b/src/node/runtime/sshConfigParser.ts index 758527ff796..2239b7c5826 100644 --- a/src/node/runtime/sshConfigParser.ts +++ b/src/node/runtime/sshConfigParser.ts @@ -667,11 +667,10 @@ function readHostKeyPinning( computed: Parameters[0], homeDir: string ): Pick { - const knownHostsValue = toStringValue(getConfigValue(computed, "UserKnownHostsFile")); - const userKnownHostsFile = knownHostsValue - ? splitIncludeArgs(knownHostsValue) - .map((file) => expandHomePath(file, homeDir)) - .at(0) + // ssh-config already splits the files and strips their quotes: a single value is one file. + const firstKnownHostsFile = toStringValue(getConfigValue(computed, "UserKnownHostsFile")); + const userKnownHostsFile = firstKnownHostsFile + ? expandHomePath(firstKnownHostsFile, homeDir) : undefined; const hostKeyAlias = toStringValue(getConfigValue(computed, "HostKeyAlias")); return { From 194752339bdabb57a9e657e9fa88ade996bd1c7b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:21:03 +0000 Subject: [PATCH 124/231] fix(daytona): allow only ssh-literal characters in the Daytona ssh config dir --- src/node/runtime/daytonaSshConfig.test.ts | 57 +++++++++++++++-------- src/node/runtime/daytonaSshConfig.ts | 22 ++++++--- 2 files changed, 52 insertions(+), 27 deletions(-) diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index 15df29ee2c4..584e5fad016 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -190,27 +190,44 @@ describe("DaytonaSshConfigWriter", () => { expect(await Bun.file(sshConfigPath).exists()).toBe(false); }); - it.each(["xum%d", "xum*", "xum?", "xum[abc]"])( - "refuses a Xum root that ssh would read as a token or glob (%s), writing nothing", - async (root) => { - const tokenWriter = new DaytonaSshConfigWriter({ - xumHome: path.join(tempDir, root), - sshConfigPath, - }); - const input = { sandboxId: "sb-1", token: "tok", gateway: GATEWAY }; - for (const update of [ - () => tokenWriter.writeSandbox(input), - () => tokenWriter.updateToken(input), - () => tokenWriter.repointGateway(GATEWAY), - ]) { - // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime - await expect(update()).rejects.toThrow( - `Daytona ssh setup cannot use ${tokenWriter.paths.dir}` - ); - } - expect(await fs.readdir(tempDir)).toEqual([]); + it.each([ + "xum%d", + "xum*", + "xum?", + "xum[abc]", + "xum's", + 'xum"q', + "xum$HOME", + "xum#1", + // A path separator on Windows. + ...(process.platform === "win32" ? [] : ["xum\\b"]), + ])("refuses a Xum root that ssh would not read literally (%s), writing nothing", async (root) => { + const tokenWriter = new DaytonaSshConfigWriter({ + xumHome: path.join(tempDir, root), + sshConfigPath, + }); + const input = { sandboxId: "sb-1", token: "tok", gateway: GATEWAY }; + for (const update of [ + () => tokenWriter.writeSandbox(input), + () => tokenWriter.updateToken(input), + () => tokenWriter.repointGateway(GATEWAY), + ]) { + // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime + await expect(update()).rejects.toThrow( + `Daytona ssh setup cannot use ${tokenWriter.paths.dir}` + ); } - ); + expect(await fs.readdir(tempDir)).toEqual([]); + }); + + it("accepts a Xum root with a space and non-ASCII letters", async () => { + const spacedWriter = new DaytonaSshConfigWriter({ + xumHome: path.join(tempDir, "José Smith", ".xum"), + sshConfigPath, + }); + await spacedWriter.writeSandbox({ sandboxId: "sb-1", token: "tok", gateway: GATEWAY }); + expect(await spacedWriter.readTokens()).toEqual([{ sandboxId: "sb-1", token: "tok" }]); + }); it("writes a self-hosted IPv6 gateway as OpenSSH resolves it, refusing zone ids", async () => { const gateway = { ...GATEWAY, host: "2001:db8::10", port: 2222 }; diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 7159581d8d5..79d71369866 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -91,6 +91,13 @@ function validateGateway(gateway: DaytonaSshGateway): void { } } +// Letters (with combining marks, as macOS may store them decomposed), digits, a few punctuation +// marks and the platform's separators; Windows paths also carry `\` and the drive's `:`. +const SSH_LITERAL_PATH_CHAR = + process.platform === "win32" + ? /^[\p{L}\p{M}\p{N} ._\-+@,=~/\\:]$/u + : /^[\p{L}\p{M}\p{N} ._\-+@,=~/]$/u; + function quoteConfigPath(filePath: string): string { if (/["\r\n]/.test(filePath)) { throw new DaytonaSshConfigError(`Unsupported characters in path for ssh config: ${filePath}`); @@ -315,16 +322,17 @@ export class DaytonaSshConfigWriter { } /** - * The Include and UserKnownHostsFile paths go into ssh config, where OpenSSH expands `%` - * tokens and Include globs `*`, `?` and `[`. Escaping is not an option: older OpenSSH reads - * Include paths literally, so `%%` would break there. + * The Include and UserKnownHostsFile paths go into ssh config, where OpenSSH reads `%` as a + * token, globs Include paths and parses quotes and backslashes. Escaping is not an option: + * OpenSSH versions disagree on it (older ones read Include paths without token expansion), so + * only characters every version reads literally are allowed. */ private assertPathsReadLiterallyBySsh(): void { - if (/[%*?[]/.test(this.paths.dir)) { + const offending = [...this.paths.dir].find((char) => !SSH_LITERAL_PATH_CHAR.test(char)); + if (offending !== undefined) { throw new DaytonaSshConfigError( - `Daytona ssh setup cannot use ${this.paths.dir}: ssh reads "%" in config paths as a ` + - `token and "*", "?" or "[" as a pattern. Use a Xum root (XUM_ROOT) and home ` + - `directory without these characters.` + `Daytona ssh setup cannot use ${this.paths.dir}: ssh does not read ${JSON.stringify(offending)} ` + + `in config paths literally. Use a Xum root (XUM_ROOT) and home directory without it.` ); } } From c24e86c0c959a73a856680c471819707f8418bfd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:22:00 +0000 Subject: [PATCH 125/231] fix(ssh): restore the includer's scope after every file an Include reads --- src/node/runtime/sshConfigParser.test.ts | 29 ++++++++++++++++++++++++ src/node/runtime/sshConfigParser.ts | 11 ++++----- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/node/runtime/sshConfigParser.test.ts b/src/node/runtime/sshConfigParser.test.ts index 75664d83c97..b6eb3be8bc4 100644 --- a/src/node/runtime/sshConfigParser.test.ts +++ b/src/node/runtime/sshConfigParser.test.ts @@ -463,6 +463,35 @@ describe("resolveSSHConfig", () => { }); }); + test("restores the includer's block after every file an Include reads", async () => { + await withTempHome(async (homeDir) => { + const incDir = path.join(homeDir, ".ssh", "inc"); + await fs.mkdir(incDir); + await fs.writeFile(path.join(incDir, "a"), "Host other\n User otheruser\n", "utf8"); + await fs.writeFile(path.join(incDir, "b"), "Port 2222\n", "utf8"); + const separate = path.join(homeDir, ".ssh", "c.conf"); + await fs.writeFile(separate, "User cuser\n", "utf8"); + const configPath = path.join(homeDir, ".ssh", "config"); + await fs.writeFile( + configPath, + // One glob matching two files, then two arguments on one Include line. + ["Host target", ` Include ${incDir}/*`, ` Include ${incDir}/a ${separate}`, ""].join( + "\n" + ), + "utf8" + ); + + const resolved = await resolveSSHConfig("target"); + expect(resolved.port).toBe(2222); + expect(resolved.user).toBe("cuser"); + if (hasOpenSsh) { + const openssh = opensshOptions(configPath, "target"); + expect(openssh("port")).toBe("2222"); + expect(openssh("user")).toBe("cuser"); + } + }); + }); + test("restores the includer's block after an Include nested in an included file", async () => { await withTempHome(async (homeDir) => { const inner = path.join(homeDir, ".ssh", "b.conf"); diff --git a/src/node/runtime/sshConfigParser.ts b/src/node/runtime/sshConfigParser.ts index 2239b7c5826..31196052d60 100644 --- a/src/node/runtime/sshConfigParser.ts +++ b/src/node/runtime/sshConfigParser.ts @@ -562,8 +562,8 @@ async function readIncludedFiles( * Inline `Include` directives for resolving `host`, which the ssh-config package does not * resolve (Xum's Daytona block relies on one). An Include inside a block that does not apply * to `host` is skipped, as OpenSSH does. OpenSSH restores the includer's scope when an Include - * returns, so each expanded Include is followed by the current block's header again, or by - * `Host *` at the top level, and later lines stay where they were written. `scope` is the + * returns, so each file an Include reads is followed by the current block's header again, or + * by `Host *` at the top level, and later lines stay where they were written. `scope` is the * header a nested file is read in (undefined at the top level). */ async function expandIncludes( @@ -591,10 +591,9 @@ async function expandIncludes( continue; } if (!sectionAppliesTo(sectionHeader, host)) continue; - out.push( - ...(await readIncludedFiles(includeMatch[1], homeDir, depth + 1, host, sectionHeader)), - sectionHeader ?? GLOBAL_SCOPE_HEADER - ); + const files = await readIncludedFiles(includeMatch[1], homeDir, depth + 1, host, sectionHeader); + // OpenSSH restores the scope after each file, so one file's last block never captures the next. + out.push(...files.flatMap((file) => [file, sectionHeader ?? GLOBAL_SCOPE_HEADER])); } return out.join("\n"); } From fbd182357a8bb35c35fc33419b23416f0a21f66f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:23:45 +0000 Subject: [PATCH 126/231] fix(daytona): check every directive of a Daytona host block against ssh -G --- src/node/runtime/daytonaSshConfig.test.ts | 23 +++++++ src/node/runtime/daytonaSshConfig.ts | 82 ++++++++++++++++------- 2 files changed, 82 insertions(+), 23 deletions(-) diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index 584e5fad016..a5db0774e13 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -292,6 +292,29 @@ describe("DaytonaSshConfigWriter", () => { expect((error as Error).message).not.toContain("tokenA"); }); + it.skipIf(!hasOpenSsh)("fails clearly when a user Host * routing setting wins", async () => { + await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); + for (const [setting, line] of [ + ["Port", "Port 2222"], + ["HostName", "HostName elsewhere.example.com"], + ["HostKeyAlias", "HostKeyAlias elsewhere.example.com"], + ]) { + await fs.writeFile(sshConfigPath, `Host *\n ${line}\n`, "utf8"); + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + + const error = await writer.verifyResolvedConfig("sb-1", "tokenA").catch((e: unknown) => e); + expect(String(error)).toContain(`sets ${setting} for`); + } + // The block's own values (a mixed-case gateway host included) pass as ssh -G prints them. + await fs.writeFile(sshConfigPath, "Host other\n Port 2222\n", "utf8"); + await writer.writeSandbox({ + sandboxId: "sb-1", + token: "tokenA", + gateway: { ...GATEWAY, host: "SSH.Daytona.test", port: 2200 }, + }); + await writer.verifyResolvedConfig("sb-1", "tokenA"); + }); + describe("a user Host * entry that weakens host key checking", () => { const WEAKENING_CONFIG = "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n"; diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 79d71369866..6c1df9e4872 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -378,11 +378,11 @@ export class DaytonaSshConfigWriter { /** * OpenSSH keeps the first value it sees, so a user `Host *` entry above Xum's managed block - * silently replaces the token (`User`) or the pinned known_hosts file (`UserKnownHostsFile`). - * Resolve the alias with `ssh -G` and fail with a clear message instead of an opaque auth or - * host key failure. The token is never printed. A shadowed StrictHostKeyChecking needs no check - * here: every OpenSSH invocation forces it on the command line (appendDaytonaOpenSSHArgs). - * Skipped when no OpenSSH client is installed (the ssh2 path parses the config itself). + * silently replaces what the alias's block sets: the token (`User`), the gateway (`HostName`, + * `Port`, `HostKeyAlias`) or the pinned known_hosts file. Resolve the alias with `ssh -G`, + * compare every directive the include file holds for it, and fail with a clear message instead + * of an opaque auth, routing or host key failure. The token is never printed. Skipped when no + * OpenSSH client is installed (the ssh2 path parses the config itself). */ async verifyResolvedConfig(sandboxId: string, token: string): Promise { const alias = toXumDaytonaHost(sandboxId); @@ -399,26 +399,62 @@ export class DaytonaSshConfigWriter { `Could not check the ssh config for ${alias} with ssh -G (${getErrorMessage(error).split("\n")[0]})` ); } - const resolved = (key: string) => - stdout - .split("\n") - .find((line) => line.startsWith(`${key} `)) - ?.slice(key.length + 1) - .trim(); - const shadowedBy = (setting: string) => - new DaytonaSshConfigError( - `Your ssh config sets ${setting} for ${alias} before Xum's Daytona block (often a ` + - `"Host *" entry with ${setting}), and OpenSSH keeps the first value. Move that ` + - `${setting} line below the "${XUM_DAYTONA_SSH_BLOCK_START}" block in ~/.ssh/config or ` + - `scope it to specific hosts.` + const block = (await this.readBlocks()).get(alias); + if (!block) { + throw new DaytonaSshConfigError( + `Xum's ssh settings for ${alias} are missing from ${this.paths.includePath}` ); - if (resolved("user") !== token) { - throw shadowedBy("User"); } - // ssh -G prints every known_hosts file on one line, separated by spaces. - const knownHostsFiles = ` ${resolved("userknownhostsfile") ?? ""} `; - if (!knownHostsFiles.includes(` ${this.paths.knownHostsPath} `)) { - throw shadowedBy("UserKnownHostsFile"); + const shadowed = shadowedDirectives(block, token, stdout); + if (shadowed.length > 0) { + const one = shadowed.length === 1; + throw new DaytonaSshConfigError( + `Your ssh config sets ${shadowed.join(", ")} for ${alias} before Xum's Daytona block ` + + `(often in a "Host *" entry), and OpenSSH keeps the first value. Move ` + + `${one ? "that line" : "those lines"} below the "${XUM_DAYTONA_SSH_BLOCK_START}" ` + + `block in ~/.ssh/config or scope ${one ? "it" : "them"} to specific hosts.` + ); } } } + +// Every OpenSSH call forces it on the command line (appendDaytonaOpenSSHArgs) and the ssh2 +// transport always checks the pinned key, so a user value for it changes nothing. +const UNVERIFIED_DIRECTIVES = new Set(["stricthostkeychecking"]); + +/** The directives of a rendered host block that `ssh -G` (its `stdout`) resolves differently. */ +function shadowedDirectives(block: string, token: string, stdout: string): string[] { + const resolved = new Map(); + for (const line of stdout.split("\n")) { + const space = line.indexOf(" "); + if (space > 0) resolved.set(line.slice(0, space), line.slice(space + 1).trim()); + } + return hostBlockDirectives(block) + .filter(([name]) => !UNVERIFIED_DIRECTIVES.has(name.toLowerCase())) + .filter(([name, value]) => { + const key = name.toLowerCase(); + return !resolvesTo(key, key === "user" ? token : value, resolved.get(key)); + }) + .map(([name]) => name); +} + +/** The `Key value` lines of a rendered host block, in order, with quoted values unquoted. */ +function hostBlockDirectives(block: string): Array<[string, string]> { + return block + .split("\n") + .slice(1) + .flatMap((line): Array<[string, string]> => { + const match = /^\s+(\S+)\s+(.+?)\s*$/.exec(line); + return match ? [[match[1], match[2].replace(/^"(.*)"$/, "$1")]] : []; + }); +} + +/** Whether `ssh -G` printed `expected` for `key`, given how it normalizes the value. */ +function resolvesTo(key: string, expected: string, resolved: string | undefined): boolean { + if (key === "hostname" || key === "hostkeyalias") { + return resolved?.toLowerCase() === expected.toLowerCase(); + } + // ssh -G prints every known_hosts file on one line, separated by spaces. + if (key === "userknownhostsfile") return ` ${resolved ?? ""} `.includes(` ${expected} `); + return resolved === expected; +} From 0b57469800c38b91745802310aa6481b3236ad91 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:21:58 +0000 Subject: [PATCH 127/231] fix(daytona): apply the archive policy when an attached workspace is archived last An owner archived while another workspace was attached to its sandbox deferred the stop/delete policy; when the attached workspace was then archived last, the policy was skipped because that workspace does not own the sandbox, so the sandbox ran until its auto-stop. When only archived workspaces remain and one of them owns the sandbox, the last archive now applies the policy, stopping instead of deleting since the owner's checkout was never checked. --- .../daytona/daytonaLifecycleHooks.test.ts | 28 +++++++++++++++++++ .../services/daytona/daytonaLifecycleHooks.ts | 9 ++++-- src/node/services/daytona/daytonaService.ts | 11 +++++--- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/node/services/daytona/daytonaLifecycleHooks.test.ts b/src/node/services/daytona/daytonaLifecycleHooks.test.ts index f6e187cb577..4a0e7d7c8d4 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.test.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.test.ts @@ -133,6 +133,34 @@ describe("createDaytonaArchiveHook", () => { expect(warnings[0]).toContain("archived workspace"); }); + it("applies the owner's deferred policy when the attached workspace is archived last", async () => { + rig.plane.addSandbox({ id: "sb-shared" }); + const owner = { sandboxId: "sb-shared", shared: false }; + const attached = { sandboxId: "sb-shared", shared: true }; + const ref = (workspaceId: string, daytona: DaytonaSandboxConfig, archived: boolean) => ({ + workspaceId, + runtimeConfig: metadata(daytona).runtimeConfig, + archived, + }); + + // The attached workspace still works there: the owner's archive defers its policy. + rig.workspaces.splice(0, rig.workspaces.length, ref("attached", attached, false)); + expect((await archive(owner, "delete")).success).toBe(true); + expect(rig.plane.find("sb-shared")?.state).toBe("started"); + + // Archived last, the attached workspace applies it; the owner's checkout was never checked, + // so delete stops instead. + rig.workspaces.splice(0, rig.workspaces.length, ref("owner", owner, true)); + expect((await archive(attached, "delete")).success).toBe(true); + expect(rig.plane.find("sb-shared")?.state).toBe("stopped"); + + // Only attached workspaces on it: the sandbox is the user's, never stopped. + rig.plane.find("sb-shared")!.state = "started"; + rig.workspaces.splice(0, rig.workspaces.length, ref("other-attached", attached, true)); + expect((await archive(attached, "stop")).success).toBe(true); + expect(rig.plane.find("sb-shared")?.state).toBe("started"); + }); + it("leaves a sandbox alone that another workspace attached by its id, or that cannot be looked up", async () => { // Created by Xum under a name; the attach picker stored the same sandbox's real id. rig.plane.addSandbox({ id: "sb-9", name: "xum-owner-def456" }); diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index 61838f07172..0875081d586 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -62,9 +62,12 @@ async function archiveSandbox( return Ok(undefined); } - // Only archived workspaces left on it: nobody works there now, so the policy applies. - const result = isDedicatedDaytonaSandbox(args.workspaceMetadata.runtimeConfig) - ? await applyArchiveBehavior(options, args, sandboxId, others === "archived") + // Only archived workspaces left on it: nobody works there now, so the policy applies, for + // this workspace's own sandbox or for one whose archived owner deferred it to this archive. + const owned = + isDedicatedDaytonaSandbox(args.workspaceMetadata.runtimeConfig) || others === "archivedOwner"; + const result = owned + ? await applyArchiveBehavior(options, args, sandboxId, others !== "none") : Ok(undefined); if (result.success) await releaseSshAccess(options, sandboxId); return result; diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 89132a53841..4e3e3db86fd 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -539,20 +539,23 @@ export class DaytonaService { /** * For archive: whether another workspace still works in the sandbox ("active"), only - * archived ones reference it ("archived"), or none does. Matched and failing closed (as - * "active") like sandboxReferencesElsewhere; removal still counts archived workspaces. + * archived ones reference it ("archived", or "archivedOwner" when one of them is a workspace + * whose sandbox Xum created, which deferred its archive policy), or none does. Matched and + * failing closed (as "active") like sandboxReferencesElsewhere; removal still counts + * archived workspaces. */ async sandboxUseElsewhere( sandboxId: string, workspaceId: string | undefined - ): Promise<"none" | "archived" | "active"> { + ): Promise<"none" | "archived" | "archivedOwner" | "active"> { if (!this.listWorkspaces || !workspaceId) return "active"; const others = this.refsStoredElsewhere(this.listWorkspaces(), workspaceId); if (others.length === 0) return "none"; const matched = await this.refsMatchingSandbox(sandboxId, others); if (!matched) return "active"; if (matched.length === 0) return "none"; - return matched.every((ref) => ref.archived) ? "archived" : "active"; + if (!matched.every((ref) => ref.archived)) return "active"; + return matched.some((ref) => !ref.attached) ? "archivedOwner" : "archived"; } /** The refs that store this sandbox by its name or its id; undefined when the lookup fails. */ From 0998dcc183e5e7e700cb2cb331d6fcf24c469023 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:25:52 +0000 Subject: [PATCH 128/231] fix(daytona): let an archive join an in-flight readiness before releasing access --- .../daytona/daytonaLifecycleHooks.test.ts | 23 +++++++++++++++++++ .../services/daytona/daytonaLifecycleHooks.ts | 3 +++ src/node/services/daytona/daytonaService.ts | 19 +++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/src/node/services/daytona/daytonaLifecycleHooks.test.ts b/src/node/services/daytona/daytonaLifecycleHooks.test.ts index 4a0e7d7c8d4..2c0e28daeac 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.test.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.test.ts @@ -75,6 +75,29 @@ describe("createDaytonaArchiveHook", () => { expect(rig.plane.find("sb-delete")).toBeUndefined(); }); + it("lets a start in flight finish before it stops the sandbox and releases access", async () => { + rig.plane.addSandbox({ id: "sb-waking", state: "stopped" }); + let startReached!: () => void; + const reached = new Promise((resolve) => (startReached = resolve)); + let answerStart!: () => void; + const startAnswered = new Promise((resolve) => (answerStart = resolve)); + rig.plane.onRequest.set("POST /sandbox/sb-waking/start", () => { + startReached(); + return startAnswered; + }); + + const ready = rig.service.ensureSandboxReady("sb-waking"); + await reached; + const archived = archive({ sandboxId: "sb-waking", shared: false }, "stop"); + answerStart(); + await ready; + + expect((await archived).success).toBe(true); + // Neither the start nor its token outlives the archive. + expect(rig.plane.find("sb-waking")?.state).toBe("stopped"); + expect(rig.writer.tokens.has("sb-waking")).toBe(false); + }); + it("never stops or deletes an attached or shared sandbox", async () => { rig.plane.addSandbox({ id: "sb-users" }); diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index 0875081d586..b5e04e2e6e2 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -25,6 +25,7 @@ export interface DaytonaArchiveHookOptions { | "sandboxUseElsewhere" | "isAliasStoredElsewhere" | "releaseSshAccess" + | "waitForSandboxReadiness" | "withAccountLease" >; getArchiveBehavior: () => CoderWorkspaceArchiveBehavior; @@ -62,6 +63,8 @@ async function archiveSandbox( return Ok(undefined); } + // A start in flight would restart the sandbox after the stop below. + await options.daytonaService.waitForSandboxReadiness(sandboxId); // Only archived workspaces left on it: nobody works there now, so the policy applies, for // this workspace's own sandbox or for one whose archived owner deferred it to this archive. const owned = diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 4e3e3db86fd..e7820e1754f 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -1178,8 +1178,27 @@ export class DaytonaService { return this.tokens.has(sandboxId) && this.tokenNeedsRefresh(sandboxId); } + /** + * Settle the readiness request in flight for this sandbox, if any. Its start and mint would + * otherwise land after a stop or a release and undo it. Bounded by the start timeout; its + * failure belongs to its own callers, so it is only logged here. + */ + async waitForSandboxReadiness(sandboxId: string): Promise { + const request = this.readyRequests.get(sandboxId); + if (!request) return; + try { + await request.promise; + } catch (error) { + log.warn("Daytona sandbox readiness failed while another operation waited for it", { + sandboxId, + error: getErrorMessage(error), + }); + } + } + /** Revoke the token and remove the sandbox's include block. */ async releaseSshAccess(sandboxId: string): Promise { + await this.waitForSandboxReadiness(sandboxId); // A mint in flight sees the bump and drops its token instead of writing it back. this.accessReleases.set(sandboxId, (this.accessReleases.get(sandboxId) ?? 0) + 1); this.sshConfigured.delete(sandboxId); From 956d64b56515923f6cf6ba58432e614de03e8f79 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:46:38 +0000 Subject: [PATCH 129/231] fix(daytona): redact the request's secrets from Daytona error diagnostics --- .../services/daytona/daytonaClient.test.ts | 42 +++++++++++ src/node/services/daytona/daytonaClient.ts | 73 ++++++++++++++++--- .../services/daytona/daytonaService.test.ts | 22 ++++++ 3 files changed, 125 insertions(+), 12 deletions(-) diff --git a/src/node/services/daytona/daytonaClient.test.ts b/src/node/services/daytona/daytonaClient.test.ts index 41108404b99..8a52e409e2f 100644 --- a/src/node/services/daytona/daytonaClient.test.ts +++ b/src/node/services/daytona/daytonaClient.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { rememberDaytonaToken } from "@/node/runtime/daytonaTokenRedaction"; import { DaytonaApiError, DaytonaClient } from "./daytonaClient"; const API_URL = "https://daytona.test/api"; @@ -70,6 +71,47 @@ describe("DaytonaClient", () => { } }); + it("redacts the request's secrets that a failed response or fetch error echoes", async () => { + // Characters a query string encodes, so an echoed request URI carries the token encoded. + const token = "secret/ssh+token value"; + const echo = (request: RecordedRequest) => + `${request.method} ${request.url.toString()} ${request.headers.Authorization} ${token}`; + const responses = [ + (request: RecordedRequest) => jsonResponse(400, { message: `Invalid: ${echo(request)}` }), + (request: RecordedRequest) => new Response(`

${echo(request)}

`, { status: 502 }), + (request: RecordedRequest) => jsonResponse(500, { message: [echo(request), "again"] }), + ]; + const secrets = [token, encodeURIComponent(token), "secret%2Fssh%2Btoken+value", API_KEY]; + for (const respond of responses) { + const { fetch } = fakeFetch(respond); + const client = new DaytonaClient({ apiUrl: API_URL, apiKey: API_KEY, fetch }); + const error = await captureError(client.revokeSshAccess("sb-1", token)); + for (const secret of secrets) { + expect(error.message).not.toContain(secret); + expect(error.serverMessage ?? "").not.toContain(secret); + } + expect(error.message).toContain("DELETE /sandbox/sb-1/ssh-access"); + } + + // A fetch failure that names the request URL. + const failingFetch = ((input: URL) => + Promise.reject(new TypeError(`fetch failed for ${input.href}`))) as typeof fetch; + const failing = new DaytonaClient({ apiUrl: API_URL, apiKey: API_KEY, fetch: failingFetch }); + const transport = await captureError(failing.revokeSshAccess("sb-1", token)); + expect(transport.kind).toBe("unreachable"); + for (const secret of secrets) expect(transport.message).not.toContain(secret); + + // Any endpoint: a token minted earlier that another call's error echoes. + rememberDaytonaToken("minted-earlier-token-q7z"); + const other = new DaytonaClient({ + apiUrl: API_URL, + apiKey: API_KEY, + fetch: fakeFetch(() => jsonResponse(500, { message: "user minted-earlier-token-q7z" })).fetch, + }); + const otherError = await captureError(other.getSandbox("sb-1")); + expect(otherError.message).not.toContain("minted-earlier-token-q7z"); + }); + it("reduces an HTML error page or a long message to a short plain-text excerpt", async () => { const page = `

502 Bad Gateway

diff --git a/src/node/services/daytona/daytonaClient.ts b/src/node/services/daytona/daytonaClient.ts index 1ae08e1fcd2..4a393d1e42f 100644 --- a/src/node/services/daytona/daytonaClient.ts +++ b/src/node/services/daytona/daytonaClient.ts @@ -1,6 +1,10 @@ import { z } from "zod"; import type { DaytonaErrorKind } from "@/common/orpc/schemas/daytona"; import { getErrorMessage } from "@/common/utils/errors"; +import { + DAYTONA_TOKEN_PLACEHOLDER, + redactDaytonaTokens, +} from "@/node/runtime/daytonaTokenRedaction"; import { linkAbortSignal } from "@/node/utils/abort"; /** @@ -9,7 +13,9 @@ import { linkAbortSignal } from "@/node/utils/abort"; * pulls in aws-sdk, OpenTelemetry, axios and socket.io for the dozen calls Xum needs. * * Security: the API key and SSH tokens never appear in errors or logs. Error messages name the - * method and path only, never the query string (revoke passes the token there). + * method and path only, never the query string (revoke passes the token there). A control plane + * or proxy may echo the request (its URI, its Authorization header) in an error body or a fetch + * error: every diagnostic kept from a failed request goes through that request's redactor. */ const DAYTONA_REQUEST_TIMEOUT_MS = 15_000; @@ -149,6 +155,38 @@ interface RequestOptions { body?: unknown; auth?: boolean; signal?: AbortSignal; + /** Values this request sends that are secrets (in the query or the body), besides the key. */ + secrets?: string[]; +} + +const DAYTONA_API_KEY_PLACEHOLDER = ""; + +type Redact = (text: string) => string; + +/** A secret as an echo may carry it: raw, or encoded as a path or query component. */ +function encodedForms(secret: string): string[] { + const queryForm = new URLSearchParams({ s: secret }).toString().slice("s=".length); + // Encoded forms first: raw text left in one would otherwise survive as a partial match. + return [...new Set([queryForm, encodeURIComponent(secret), secret])]; +} + +function createRedactor(apiKey: string | undefined, secrets: string[]): Redact { + const replacements: Array<[string, string]> = []; + for (const secret of secrets) { + if (!secret) continue; + for (const form of encodedForms(secret)) replacements.push([form, DAYTONA_TOKEN_PLACEHOLDER]); + } + if (apiKey) { + for (const form of encodedForms(apiKey)) replacements.push([form, DAYTONA_API_KEY_PLACEHOLDER]); + } + return (text) => { + let redacted = text; + for (const [secret, placeholder] of replacements) { + redacted = redacted.replaceAll(secret, placeholder); + } + // Any token minted earlier, which an error of another request may still carry. + return redactDaytonaTokens(redacted); + }; } // Pagination guard: a control plane that keeps returning a cursor must not loop forever. @@ -161,8 +199,9 @@ const MAX_SERVER_MESSAGE_CHARS = 200; * Error text ends up in toasts and turn errors: a proxy or a non-Daytona URL can answer with a * whole HTML page, so drop tags, collapse whitespace and keep a short excerpt. */ -function toPlainExcerpt(text: string): string | undefined { - const plain = text +function toPlainExcerpt(text: string, redact: Redact): string | undefined { + // Redacted before the cut, so a truncated secret cannot survive as a prefix. + const plain = redact(text) .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, " ") .replace(/<[^>]*>/g, " ") .replace(/\s+/g, " ") @@ -173,20 +212,20 @@ function toPlainExcerpt(text: string): string | undefined { : plain; } -function extractServerMessage(bodyText: string): string | undefined { +function extractServerMessage(bodyText: string, redact: Redact): string | undefined { if (!bodyText) return undefined; let parsed: unknown; try { parsed = JSON.parse(bodyText); } catch { // Not JSON (proxy error page etc.): keep a short plain-text excerpt instead. - return toPlainExcerpt(bodyText); + return toPlainExcerpt(bodyText, redact); } if (parsed && typeof parsed === "object" && "message" in parsed) { const message = parsed.message; - if (typeof message === "string") return toPlainExcerpt(message); + if (typeof message === "string") return toPlainExcerpt(message, redact); if (Array.isArray(message)) { - return toPlainExcerpt(message.filter((m) => typeof m === "string").join("; ")); + return toPlainExcerpt(message.filter((m) => typeof m === "string").join("; "), redact); } } return undefined; @@ -220,9 +259,14 @@ function buildUrl(apiUrl: string, pathName: string, query: RequestOptions["query return url; } -function httpError(status: number, bodyText: string, label: string): DaytonaApiError { +function httpError( + status: number, + bodyText: string, + label: string, + redact: Redact +): DaytonaApiError { const kind = kindForStatus(status); - const serverMessage = extractServerMessage(bodyText); + const serverMessage = extractServerMessage(bodyText, redact); const base = `${describeStatus(kind, status)} for ${label}`; return new DaytonaApiError( kind, @@ -240,6 +284,7 @@ function transportError( timeoutMs: number; timedOut: boolean; cancelled: boolean; + redact: Redact; } ): DaytonaApiError { if (context.timedOut) { @@ -253,7 +298,7 @@ function transportError( } return new DaytonaApiError( "unreachable", - `Could not reach Daytona at ${context.origin} (${getErrorMessage(error)})` + `Could not reach Daytona at ${context.origin} (${context.redact(getErrorMessage(error))})` ); } @@ -277,13 +322,14 @@ export class DaytonaClient { ): Promise { const label = `${method} ${pathName}`; const url = buildUrl(this.apiUrl, pathName, options.query); - const { response, bodyText } = await this.send(url, label, { + const redact = createRedactor(this.apiKey, options.secrets ?? []); + const { response, bodyText } = await this.send(url, label, redact, { method, headers: this.buildHeaders(options), body: options.body === undefined ? undefined : JSON.stringify(options.body), callerSignal: options.signal, }); - if (!response.ok) throw httpError(response.status, bodyText, label); + if (!response.ok) throw httpError(response.status, bodyText, label, redact); if (!bodyText) return undefined; try { return JSON.parse(bodyText) as unknown; @@ -308,6 +354,7 @@ export class DaytonaClient { private async send( url: URL, label: string, + redact: Redact, init: { method: string; headers: Record; @@ -341,6 +388,7 @@ export class DaytonaClient { timeoutMs: this.timeoutMs, timedOut, cancelled: callerSignal?.aborted === true, + redact, }); } finally { clearTimeout(timer); @@ -458,6 +506,7 @@ export class DaytonaClient { async revokeSshAccess(id: string, token: string, signal?: AbortSignal): Promise { await this.request("DELETE", `/sandbox/${encodeURIComponent(id)}/ssh-access`, { query: { token }, + secrets: [token], signal, }); } diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index bd37163474a..1ef0c37d49a 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -3,6 +3,7 @@ import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; +import { log } from "@/node/services/log"; import { DaytonaApiError } from "./daytonaClient"; import { DAYTONA_BUSY_MESSAGE, @@ -17,6 +18,8 @@ class FakeControlPlane { states: string[] = []; exists = true; errorReason?: string; + /** A proxy that echoes the request URI and its Authorization header in a failed revoke. */ + revokeEchoesRequest = false; readonly calls: string[] = []; private tokenCounter = 0; readonly revoked: string[] = []; @@ -63,6 +66,10 @@ class FakeControlPlane { }); } if (route === "DELETE /sandbox/sb-1/ssh-access") { + if (this.revokeEchoesRequest) { + const auth = (init?.headers as Record | undefined)?.Authorization ?? ""; + return json(500, { message: `Revoke failed for ${url.toString()} (${auth})` }); + } this.revoked.push(url.searchParams.get("token") ?? ""); return json(200, {}); } @@ -189,6 +196,21 @@ describe("DaytonaService", () => { expect((await service.getSshToken("sb-1")).token).not.toBe(held.token); }); + it("logs a failed revoke without the token or the API key the response echoes", async () => { + const held = await service.getSshToken("sb-1"); + plane.revokeEchoesRequest = true; + const warn = spyOn(log, "warn"); + try { + await service.setConfig({ target: "eu" }); + const logged = JSON.stringify(warn.mock.calls); + expect(logged).toContain("Failed to revoke a Daytona SSH token"); + expect(logged).not.toContain(held.token); + expect(logged).not.toContain("Bearer k"); + } finally { + warn.mockRestore(); + } + }); + it("mints under credentials another Xum process saved, and not after its own change", async () => { const mintKeys: string[] = []; service = new DaytonaService({ From 91625e4d4ec757c70a78aa46e054ba4dda13d84e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:44:57 +0000 Subject: [PATCH 130/231] fix(daytona): run no inherited ProxyCommand, ProxyJump or LocalCommand for Daytona aliases --- src/node/runtime/SSH2ConnectionPool.test.ts | 63 +++++++++++++++++++++ src/node/runtime/SSH2ConnectionPool.ts | 11 +++- src/node/runtime/daytonaSshConfig.test.ts | 30 ++++++++++ src/node/runtime/sshConnectionPool.ts | 14 ++++- 4 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/node/runtime/SSH2ConnectionPool.test.ts b/src/node/runtime/SSH2ConnectionPool.test.ts index f325ea60890..c69a0e38ec9 100644 --- a/src/node/runtime/SSH2ConnectionPool.test.ts +++ b/src/node/runtime/SSH2ConnectionPool.test.ts @@ -234,6 +234,69 @@ describe.skipIf(process.platform === "win32")( } }); + // The command would get the token as %r and could route the connection anywhere. + it("runs no inherited ProxyCommand for a Daytona alias, only for other hosts", async () => { + const hostKey = newPrivateKey(); + const { config } = await startServer((conn) => { + conn.on("authentication", (ctx) => ctx.accept()); + conn.on("error", () => undefined); + }, hostKey); + const home = await fs.mkdtemp(path.join(os.tmpdir(), "xum-ssh2-daytona-proxy-")); + const originalUserProfile = process.env.USERPROFILE; + process.env.USERPROFILE = home; + try { + const knownHostsPath = path.join(home, "daytona_known_hosts"); + const marker = path.join(home, "proxy-ran"); + const parsed = utils.parseKey(hostKey); + if (parsed instanceof Error) throw parsed; + await fs.writeFile( + knownHostsPath, + `gateway.test ${parsed.type} ${parsed.getPublicSSH().toString("base64")}\n` + ); + await fs.mkdir(path.join(home, ".ssh")); + await fs.writeFile( + path.join(home, ".ssh", "config"), + [ + "Host *", + ` ProxyCommand sh -c 'echo %r >> ${marker}; exit 1'`, + "Host sb-1.xum--daytona", + " HostName 127.0.0.1", + ` Port ${String(config.port)}`, + " User tokenA", + " HostKeyAlias gateway.test", + ` UserKnownHostsFile ${knownHostsPath}`, + "Host plain-host", + " HostName 127.0.0.1", + ` Port ${String(config.port)}`, + " User plainuser", + ].join("\n") + ); + + const entry = await new SSH2ConnectionPool().acquireConnection( + { host: "sb-1.xum--daytona", identityFile: config.identityFile }, + { maxWaitMs: 0 } + ); + entry.client.end(); + expect(await Bun.file(marker).exists()).toBe(false); + + const plain = await new SSH2ConnectionPool() + .acquireConnection( + { host: "plain-host", identityFile: config.identityFile }, + { maxWaitMs: 0 } + ) + .then( + () => new Error("acquisition unexpectedly succeeded"), + (error: unknown) => error + ); + expect(plain).toBeInstanceOf(Error); + expect(await fs.readFile(marker, "utf8")).toContain("plainuser"); + } finally { + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + await fs.rm(home, { recursive: true, force: true }); + } + }); + it("a retired connection carries no new work and closes once its channels do", async () => { const { config } = await startServer((conn) => { conn.on("authentication", (ctx) => ctx.accept()); diff --git a/src/node/runtime/SSH2ConnectionPool.ts b/src/node/runtime/SSH2ConnectionPool.ts index fd6274e0f92..64e2aa97cfb 100644 --- a/src/node/runtime/SSH2ConnectionPool.ts +++ b/src/node/runtime/SSH2ConnectionPool.ts @@ -85,6 +85,15 @@ function createHostVerifier( : Promise.resolve(() => true); } +/** + * A Daytona alias connects straight to its pinned gateway: an inherited ProxyCommand (a user + * `Host *` entry) would get the token as `%r` and could route the connection anywhere. The + * parser reads no ProxyJump, so ssh2 never follows one. + */ +function withoutInheritedProxy(host: string, resolved: ResolvedSSHConfig): ResolvedSSHConfig { + return isXumDaytonaHost(host) ? { ...resolved, proxyCommand: undefined } : resolved; +} + let sshPromptService: SshPromptService | undefined; export function setSshPromptService(svc: SshPromptService): void { @@ -696,7 +705,7 @@ export class SSH2ConnectionPool { const key = makeConnectionKey(config); const retirement = this.retirementOf(key); try { - const resolved = await resolveSSHConfig(config.host); + const resolved = withoutInheritedProxy(config.host, await resolveSSHConfig(config.host)); const resolvedConfig: ResolvedSSHConfig = { ...resolved, port: config.port ?? resolved.port, diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index a5db0774e13..1dae99e8093 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -315,6 +315,36 @@ describe("DaytonaSshConfigWriter", () => { await writer.verifyResolvedConfig("sb-1", "tokenA"); }); + it.skipIf(!hasOpenSsh)( + "runs no inherited ProxyCommand, ProxyJump or LocalCommand for a Daytona alias", + async () => { + await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); + const resolve = (host: string) => { + const args = ["-G", "-F", sshConfigPath]; + appendOpenSSHHostKeyPolicyArgs(args, host); + return spawnSync("ssh", [...args, host]).stdout.toString(); + }; + for (const [routing, resolvedLine] of [ + ["ProxyCommand nc %r %h %p", "\nproxycommand nc %r %h %p\n"], + ["ProxyJump jump.example.com", "\nproxyjump jump.example.com\n"], + ]) { + await fs.writeFile( + sshConfigPath, + `Host *\n ${routing}\n PermitLocalCommand yes\n LocalCommand echo %r\n`, + "utf8" + ); + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + + const daytona = resolve(toXumDaytonaHost("sb-1")); + expect(daytona).not.toContain(resolvedLine); + expect(daytona).not.toMatch(/\nproxy(command|jump) /); + expect(daytona).toContain("\npermitlocalcommand no\n"); + // Other hosts keep the user's settings. + expect(resolve("other.example.com")).toContain(resolvedLine); + } + } + ); + describe("a user Host * entry that weakens host key checking", () => { const WEAKENING_CONFIG = "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n"; diff --git a/src/node/runtime/sshConnectionPool.ts b/src/node/runtime/sshConnectionPool.ts index 9fd76ca1c40..d6092ef2d74 100644 --- a/src/node/runtime/sshConnectionPool.ts +++ b/src/node/runtime/sshConnectionPool.ts @@ -61,6 +61,12 @@ export function isInteractiveHostKeyApprovalAvailable(): boolean { * The sandbox is not trusted either: a user `Host *` entry's agent, X11 or port forwards would * let its processes use the local agent, display or ports, so those are forced off too. The * bash AI proxy's reverse forward adds its `-R` through a control client that skips this. + * + * Nor may an inherited entry run a local command (which would get the token as `%r`) or route + * the connection past the pinned gateway: ProxyCommand, ProxyJump and LocalCommand are off. + * ProxyCommand goes first: OpenSSH applies only the first of the two it reads, so + * `ProxyJump=none` is inert on clients that predate it. KnownHostsCommand is left alone: + * OpenSSH before 8.5 refuses the option name, which would fail every connection. */ export function appendDaytonaOpenSSHArgs(args: string[], host: string): void { if (isXumDaytonaHost(host)) { @@ -72,7 +78,13 @@ export function appendDaytonaOpenSSHArgs(args: string[], host: string): void { "-o", "ForwardX11=no", "-o", - "ClearAllForwardings=yes" + "ClearAllForwardings=yes", + "-o", + "ProxyCommand=none", + "-o", + "ProxyJump=none", + "-o", + "PermitLocalCommand=no" ); } } From fdf994e82aa6bc616cc951e9fc5bf62c51cf4a00 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:06:38 +0000 Subject: [PATCH 131/231] fix(daytona): let a cached readiness check restart a sandbox that stopped --- src/node/runtime/DaytonaSSHRuntime.test.ts | 27 ++++++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 9 +++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 9d5a3129a52..2d932b81a99 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -1420,6 +1420,33 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { expect(rig.plane.countCalls("POST /sandbox/sb-nap/start")).toBe(1); }); + it("starts a sandbox that stopped within the ready cache for a new runtime's ensureReady", async () => { + execSpy.mockRestore(); + const sandbox = rig.plane.addSandbox({ id: "sb-nap3" }); + const local = localShellTransport(rig.rootDir); + const transport: SSHTransport = { + ...local, + spawnRemoteProcess: (command, options) => + sandbox.state === "started" + ? local.spawnRemoteProcess(command, options) + : Promise.reject(new RuntimeError("Connection closed by 127.0.0.1 port 22", "network")), + }; + const runtimeFor = () => + makeRuntime({ sandboxId: "sb-nap3" }, rig, { + transport, + projectPath: "/home/me/project", + workspaceName: "ws-a", + }); + const checkout = runtimeFor().getWorkspacePath("/home/me/project", "ws-a"); + execFileSync("git", ["init", "-q", checkout.replace(/^~/, rig.rootDir)]); + expect(await runtimeFor().ensureReady()).toEqual({ ready: true }); + sandbox.state = "stopped"; + + // The next turn or terminal builds a fresh runtime, still within the ready cache. + expect(await runtimeFor().ensureReady()).toEqual({ ready: true }); + expect(rig.plane.countCalls("POST /sandbox/sb-nap3/start")).toBe(1); + }); + /** The gateway answers a stopped or deleted sandbox with the same refusal as a bad token. */ function refusingTransportWhile(sandboxId: string, refuses: () => boolean): SSHTransport { const local = localShellTransport(rig.rootDir); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 9292f9a3e47..2c515e7267e 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -311,8 +311,9 @@ export class DaytonaSSHRuntime extends SSHRuntime { private readonly workspaceId?: string; private readonly agentHomeSeedSources?: AgentHomeSeedSources; /** - * Set once ensureReady passes on this instance. Only such a runtime (a turn's) restarts a - * sandbox that stopped under it; UI and background runtimes never start a billed sandbox. + * Set once ensureReady runs its checks on this instance. Only such a runtime (a turn's or a + * terminal's) restarts a sandbox that stopped under it; UI and background runtimes never + * start a billed sandbox. */ private restartsStoppedSandbox = false; /** SSHRuntime keeps its own reference private; a replaced token needs its backoff reset. */ @@ -429,9 +430,11 @@ export class DaytonaSSHRuntime extends SSHRuntime { options: EnsureReadyOptions | undefined, emitStatus: StatusEmitter ): Promise { + // Set before the probe: ensureReady is an explicit action, so a sandbox that stopped + // within the cache is started again (recoverForRetry) instead of failing until it expires. + this.restartsStoppedSandbox = true; const repoFailure = await this.checkRepo(options, emitStatus); if (repoFailure) return repoFailure; - this.restartsStoppedSandbox = true; return { ready: true }; } From 62308a2b830f444fe1e2ed73f050e16e02e85cef Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:08:01 +0000 Subject: [PATCH 132/231] fix(daytona): read the saved account before an idle operation pins it --- .../services/daytona/daytonaService.test.ts | 27 +++++++++++++++++++ src/node/services/daytona/daytonaService.ts | 27 ++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 1ef0c37d49a..76d777183bb 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -276,6 +276,33 @@ describe("DaytonaService", () => { expect(await generation()).toBe(before + 1); }); + it("takes the account another process saved while idle from a lease's first call", async () => { + const lookupKeys: string[] = []; + service = new DaytonaService({ + credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), + rootDir, + fetch: ((input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : input.toString(); + if ((init?.method ?? "GET") === "GET" && url.endsWith("/sandbox/sb-1")) { + lookupKeys.push((init?.headers as Record).Authorization); + } + return plane.fetch(input, init); + }) as typeof fetch, + now: () => clock, + }); + plane.states = ["started"]; + await service.getSandboxState("sb-1"); + const before = (await service.getAccount()).generation; + + // Another backend sharing this root saves another key while no lease runs here. + await new DaytonaCredentialsStore(rootDir, {}).update({ apiKey: "other" }); + await service.withAccountLease(() => service.getSandboxState("sb-1")); + + expect(lookupKeys).toEqual(["Bearer k", "Bearer other"]); + await service.getSandboxState("sb-1"); + expect((await service.getAccount()).generation).toBe(before + 1); + }); + it("leaves a resolve during its own settings save to that save", async () => { const revokeKeys: string[] = []; const store = new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index e7820e1754f..1fff0d8a1b4 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -342,6 +342,8 @@ export class DaytonaService { private credentialsSnapshot?: { credentials: DaytonaResolvedCredentials | null }; /** Operations running under withAccountLease; setConfig refuses while any runs. */ private accountLeases = 0; + /** The settings read a lease claimed while idle runs first. */ + private idleRefresh?: Promise; /** The settings change being saved; new leases wait for it. */ private settingsChange?: Promise; /** Per sandbox: bumped by every release of its SSH access, so a mint that spans one can tell. */ @@ -409,7 +411,19 @@ export class DaytonaService { * settings change; one that starts while a change is saved waits for it. Leases nest. */ async withAccountLease(fn: () => Promise): Promise { - while (this.settingsChange) await settled(this.settingsChange); + for (;;) { + while (this.settingsChange) await settled(this.settingsChange); + // A held lease pins the account; the outer one must not pin a stale snapshot, so it + // first takes what another Xum process may have saved since this one last read. + if (this.accountLeases > 0) break; + // Shared, so concurrent claimers resume (and start their operations) in call order. + this.idleRefresh ??= this.refreshIdleCredentials().finally(() => { + this.idleRefresh = undefined; + }); + await this.idleRefresh; + // No await between this check and the claim (as in setConfig). + if (!this.settingsChange) break; + } this.accountLeases++; try { return await fn(); @@ -661,6 +675,17 @@ export class DaytonaService { return { available: true }; } + /** The idle read before a lease; unusable settings fail the lease's own first call instead. */ + private async refreshIdleCredentials(): Promise { + try { + await this.resolveCredentials(); + } catch (error) { + log.debug("Could not read the Daytona settings before an operation", { + error: getErrorMessage(error), + }); + } + } + /** Unusable settings (an http DAYTONA_API_URL) throw a `not_configured` DaytonaApiError. */ private async resolveCredentials(): Promise { const pinned = () => From 8bed052618d355533d6d7f04e364fb74eeb9db76 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:10:55 +0000 Subject: [PATCH 133/231] fix(daytona): run no Daytona ssh before the earlier process's tokens are loaded --- src/node/runtime/DaytonaSSHRuntime.test.ts | 35 +++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 4 +++ .../services/daytona/daytonaService.test.ts | 32 +++++++++++++++++ src/node/services/daytona/daytonaService.ts | 26 ++++++++++++-- 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 2d932b81a99..20fc8535390 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -1389,6 +1389,41 @@ describe("DaytonaSSHRuntime ssh token redaction", () => { expect(String(error)).not.toContain(stale); expect(String(error)).toContain(`${DAYTONA_TOKEN_PLACEHOLDER}@127.0.0.1: Permission denied`); }); + + it("runs no ssh command before the include file's tokens are loaded, retrying a failed load", async () => { + const stale = "earlier-process-token-unloaded-9c1d"; + rig.plane.addSandbox({ id: "sb-old" }); + rig.writer.tokens.set("sb-old", stale); + const readTokens = spyOn(rig.writer, "readTokens").mockRejectedValueOnce( + new Error("EIO: i/o error, read") + ); + // Startup goes on without them. + let startupLoad: unknown; + await rig.service.loadPersistedTokens().catch((error: unknown) => (startupLoad = error)); + expect(startupLoad).toBeInstanceOf(Error); + + let readsWhenSpawned: number | undefined; + // A UI runtime: its commands never run ensureReady first. + const runtime = makeRuntime({ sandboxId: "sb-old" }, rig, { + transport: { + ...localShellTransport(rig.rootDir), + spawnRemoteProcess: () => { + readsWhenSpawned = readTokens.mock.calls.length; + return Promise.reject( + new RuntimeError(`${stale}@127.0.0.1: Permission denied (publickey).`, "network") + ); + }, + }, + }); + + const error = await runtime.exec("true", { cwd: "/tmp", timeout: 10 }).then( + () => new Error("exec unexpectedly succeeded"), + (rejection: unknown) => rejection + ); + expect(readsWhenSpawned).toBe(2); + expect(String(error)).not.toContain(stale); + expect(String(error)).toContain(`${DAYTONA_TOKEN_PLACEHOLDER}@127.0.0.1: Permission denied`); + }); }); describe("DaytonaSSHRuntime reconnect after a stop", () => { diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 2c515e7267e..5d50ba66aed 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -86,6 +86,7 @@ export type DaytonaRuntimeService = Pick< | "invalidateSandboxReady" | "replaceRejectedSshToken" | "sshTokenGeneration" + | "loadPersistedTokens" | "findSnapshot" | "getGatewayConfig" | "sandboxReferencesElsewhere" @@ -450,6 +451,9 @@ export class DaytonaSSHRuntime extends SSHRuntime { ): Promise { let result: SpawnResult; const sandboxId = this.daytonaConfig.sandboxId; + // ssh may send a token an earlier process left in the include file: the redactor must + // know it before ssh can quote it. Not every command runs after ensureReady. + if (sandboxId) await this.daytonaService.loadPersistedTokens(); // Before the attempt: a refusal replaces only the token this command used. const tokenGeneration = sandboxId ? this.daytonaService.sshTokenGeneration(sandboxId) : 0; try { diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 76d777183bb..dce2c2dc657 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -665,6 +665,38 @@ describe("DaytonaService", () => { expect(plane.revoked).toEqual(["earlier-process-token", minted ?? ""]); }); + it("loads the include file's tokens before ssh access when the startup load failed", async () => { + let failedReads = 1; + const writer = { + writeSandbox: () => Promise.resolve(), + updateToken: () => Promise.resolve(), + removeSandbox: () => Promise.resolve(), + readTokens: () => + failedReads-- > 0 + ? Promise.reject(new Error("EIO: i/o error, read")) + : Promise.resolve([{ sandboxId: "sb-1", token: "earlier-process-token" }]), + verifyResolvedConfig: () => Promise.resolve(), + repointGateway: () => Promise.resolve(), + }; + service = new DaytonaService({ + credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), + rootDir, + fetch: plane.fetch, + now: () => clock, + sshConfigWriter: writer, + }); + expect( + await service.loadPersistedTokens().then( + () => "loaded", + () => "failed" + ) + ).toBe("failed"); + + // The retry knows the earlier token, so the new one replaces and revokes it. + await service.ensureSshAccess("sb-1"); + expect(plane.revoked).toEqual(["earlier-process-token"]); + }); + it("reports test-connection failures as typed results", async () => { expect(await service.testConnection()).toEqual({ ok: true }); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 1fff0d8a1b4..32c95905c85 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -353,6 +353,8 @@ export class DaytonaService { /** Sandboxes whose include block and `~/.ssh/config` block this process has written. */ private readonly sshConfigured = new Set(); private readonly readyAt = new Map(); + private persistedTokensLoad?: Promise; + private persistedTokensLoaded = false; private readonly readyRequests = new Map(); /** Why the setup of a sandbox Xum created (and then deleted) failed, for later turns. */ private readonly setupFailures = new Map(); @@ -389,9 +391,28 @@ export class DaytonaService { * Tokens an earlier Xum process wrote to the include file. ssh keeps sending them until Xum * replaces them, so the redactor must know them; holding them also lets a replacement, a * settings change or an archive revoke them. The file has no expiry, so they are due for - * refresh at once. Startup calls this before any ssh runs. + * refresh at once. Startup starts this load but goes on when it fails or is slow, so every + * ssh to a sandbox (ensureSshAccess, the runtime's commands) waits for it first. Loads once; + * a failed read is logged and retried by the next caller. A missing file loads nothing. */ - async loadPersistedTokens(): Promise { + loadPersistedTokens(): Promise { + if (this.persistedTokensLoaded) return Promise.resolve(); + this.persistedTokensLoad ??= this.readPersistedTokens().then( + () => { + this.persistedTokensLoaded = true; + }, + (error: unknown) => { + this.persistedTokensLoad = undefined; + log.warn("Could not read the Daytona ssh tokens an earlier Xum process left", { + error: getErrorMessage(error), + }); + throw error; + } + ); + return this.persistedTokensLoad; + } + + private async readPersistedTokens(): Promise { for (const { sandboxId, token } of await this.sshConfigWriter.readTokens()) { rememberDaytonaToken(token); if (!this.tokens.has(sandboxId)) { @@ -1146,6 +1167,7 @@ export class DaytonaService { */ ensureSshAccess(sandboxId: string): Promise { return this.withAccountLease(async () => { + await this.loadPersistedTokens(); // Fails before a mint when the control plane cannot say where the gateway is. await this.getGatewayConfig(); // A cached token was written by the mint that produced it, so only a mint writes files. From 374724a283ccbb8929f9fa4c38b0d6c65fa22e03 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:12:12 +0000 Subject: [PATCH 134/231] refactor(daytona): keep spawnRemoteProcess under the CRAP complexity bound --- src/node/runtime/DaytonaSSHRuntime.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 5d50ba66aed..cacfa9d0b37 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -451,11 +451,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { ): Promise { let result: SpawnResult; const sandboxId = this.daytonaConfig.sandboxId; - // ssh may send a token an earlier process left in the include file: the redactor must - // know it before ssh can quote it. Not every command runs after ensureReady. - if (sandboxId) await this.daytonaService.loadPersistedTokens(); - // Before the attempt: a refusal replaces only the token this command used. - const tokenGeneration = sandboxId ? this.daytonaService.sshTokenGeneration(sandboxId) : 0; + const tokenGeneration = await this.prepareSshAttempt(sandboxId); try { result = await super.spawnRemoteProcess(fullCommand, options); } catch (error) { @@ -482,6 +478,16 @@ export class DaytonaSSHRuntime extends SSHRuntime { }; } + /** The token generation a refusal of this command must match. */ + private async prepareSshAttempt(sandboxId: string | undefined): Promise { + if (!sandboxId) return 0; + // ssh may send a token an earlier process left in the include file: the redactor must + // know it before ssh can quote it. Not every command runs after ensureReady. + await this.daytonaService.loadPersistedTokens(); + // Before the attempt: a refusal replaces only the token this command used. + return this.daytonaService.sshTokenGeneration(sandboxId); + } + /** * True when the failure was fixed (a new token, or a restarted sandbox) and a retry may pass. * The sandbox state decides, not the error text: a stopped or deleted sandbox also answers From 95f28cad70a6729cfc3590c8a4bd3f885260c81e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:34:15 +0000 Subject: [PATCH 135/231] fix(daytona): delete only sandboxes that carry this install's label --- src/node/runtime/DaytonaSSHRuntime.test.ts | 36 +++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 25 +++++++++---- .../services/daytona/daytona.testHarness.ts | 11 ++++-- .../daytona/daytonaLifecycleHooks.test.ts | 16 +++++++++ .../services/daytona/daytonaLifecycleHooks.ts | 7 ++-- src/node/services/daytona/daytonaService.ts | 35 ++++++++++++++++-- 6 files changed, 117 insertions(+), 13 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 20fc8535390..ef0ea87733a 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -712,6 +712,42 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { expect(rig.plane.find("xum-theirs")).toBe(other); }); + it("never deletes a sandbox without this install's label when the failed workspace is removed", async () => { + const sshDelete = spyOn(SSHRuntime.prototype, "deleteWorkspace").mockResolvedValue({ + success: true, + deletedPath: "/path", + }); + try { + // The create failed ambiguously while another install's sandbox held the name. + rig.plane.failingRoutes.add("POST /sandbox"); + const other = rig.plane.addSandbox({ + id: "sb-other", + name: "xum-theirs", + labels: { "xum.instance": "another-install", "xum.workspaceId": "ws-1" }, + }); + const daytona = { sandboxId: "xum-theirs", shared: false, ...SMALL }; + await makeRuntime(daytona) + .postCreateSetup(initParams()) + .catch(() => undefined); + rig.plane.failingRoutes.clear(); + + // The persisted workspace still says Xum created it; neither removal deletes it. + for (const force of [false, true]) { + const removal = await makeRuntime(daytona).deleteWorkspace( + "/home/me/project", + "feature", + force + ); + expect(removal.success).toBe(true); + if (removal.success) expect(removal.notice).toContain("xum.instance"); + expect(rig.plane.find("xum-theirs")).toBe(other); + } + expect(rig.plane.countCalls("DELETE /sandbox/xum-theirs")).toBe(0); + } finally { + sshDelete.mockRestore(); + } + }); + it("seeds the agent home of a created sandbox but never of an attached one", async () => { const hostXum = path.join(rig.rootDir, "host-xum"); await fs.mkdir(hostXum, { recursive: true }); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index cacfa9d0b37..932732744e1 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -49,6 +49,7 @@ import { DAYTONA_LABEL_INSTANCE, DAYTONA_LABEL_WORKSPACE_ID, DAYTONA_START_TIMEOUT_MS, + notOwnedSandboxNotice, unsupportedDaytonaSandboxKind, type UnsupportedDaytonaSandboxKind, type DaytonaAccount, @@ -942,7 +943,10 @@ export class DaytonaSSHRuntime extends SSHRuntime { return; } initLogger.logStep(`Deleting the sandbox "${sandboxId}" this setup created...`); - await this.daytonaService.deleteSandbox(sandboxId, create.account); + if ((await this.daytonaService.deleteSandbox(sandboxId, create.account)) === "notOwned") { + log.warn("Not deleting a Daytona sandbox without this install's label", { sandboxId }); + initLogger.logStderr(notOwnedSandboxNotice(sandboxId)); + } } catch (error) { // The persisted workspace still names the sandbox, so the reconciler leaves it alone; // removing the workspace deletes it. @@ -1179,7 +1183,11 @@ export class DaytonaSSHRuntime extends SSHRuntime { ): Promise { return this.daytonaService.withSandboxRemovalLock(async () => { if ((await this.sandboxFateOnDelete(sandboxId)) !== "delete") return undefined; - const result = await this.deleteOwnedSandbox(sandboxId, deletedPath, undefined); + const result = await this.deleteOwnedSandbox( + sandboxId, + { success: true, deletedPath }, + undefined + ); // Forced: the workspace goes whether or not the sandbox delete worked. this.recordRemoval(); return result; @@ -1300,7 +1308,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { removal: SandboxRemoval ): Promise { if (fate === "delete") { - return this.deleteOwnedSandbox(sandboxId, result.deletedPath, removal.errorPrefix); + return this.deleteOwnedSandbox(sandboxId, result, removal.errorPrefix); } if (fate === "handOff") return this.handOffSandbox(sandboxId, result, removal); await this.releaseAccessOnDelete(sandboxId, fate); @@ -1401,14 +1409,19 @@ export class DaytonaSSHRuntime extends SSHRuntime { } } + /** A sandbox without this install's label stays: only Xum's access to it goes. */ private async deleteOwnedSandbox( sandboxId: string, - deletedPath: string, + result: RemovedCheckout, errorPrefix: string | undefined ): Promise { try { - await this.daytonaService.deleteSandbox(sandboxId); - return { success: true, deletedPath }; + if ((await this.daytonaService.deleteSandbox(sandboxId)) === "deleted") { + return { success: true, deletedPath: result.deletedPath }; + } + await this.releaseAccessOnDelete(sandboxId, "release"); + const notice = [result.notice, notOwnedSandboxNotice(sandboxId)].filter(Boolean).join(" "); + return { ...result, notice }; } catch (error) { const message = getErrorMessage(error); log.error("Failed to delete Daytona sandbox", { sandboxId, error: message }); diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 360471f3092..2ea5cda0451 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -6,7 +6,7 @@ import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; import type { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; import type { SSHTransport } from "@/node/runtime/transports"; import type { DaytonaWorkspaceRef } from "./daytonaReconciler"; -import { DaytonaService } from "./daytonaService"; +import { DAYTONA_LABEL_INSTANCE, DaytonaService } from "./daytonaService"; /** * In-memory Daytona control plane for unit tests: enough of the REST API (by id or name, as @@ -58,6 +58,8 @@ export class FakeDaytonaControlPlane { omitCompute = false; /** Resources a sandbox gets when created without any. */ snapshotResources = { cpu: 1, memory: 1, disk: 3 }; + /** Labels of a sandbox added without any; the rig makes it this install's. */ + defaultLabels: Record = {}; /** Routes (`METHOD /path`) that never answer, for timeout tests. */ readonly hangingRoutes = new Set(); /** Routes (`METHOD /path`) that answer 500. */ @@ -91,7 +93,7 @@ export class FakeDaytonaControlPlane { cpu: 2, memory: 4, disk: 10, - labels: {}, + labels: { ...this.defaultLabels }, pendingPolls: 0, ...partial, }; @@ -356,11 +358,16 @@ export async function createDaytonaTestRig( }, listWorkspaces: () => workspaces, }); + // Sandboxes a test adds count as ones Xum created, as most tests need; a foreign one sets + // its own labels. + const ownLabels = { [DAYTONA_LABEL_INSTANCE]: await service.getInstanceId() }; + plane.defaultLabels = ownLabels; return { rootDir, plane, addAccount: (apiKey) => { const account = new FakeDaytonaControlPlane(() => clock.now); + account.defaultLabels = ownLabels; accounts.set(apiKey, account); return account; }, diff --git a/src/node/services/daytona/daytonaLifecycleHooks.test.ts b/src/node/services/daytona/daytonaLifecycleHooks.test.ts index 2c0e28daeac..446329b815d 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.test.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.test.ts @@ -98,6 +98,22 @@ describe("createDaytonaArchiveHook", () => { expect(rig.writer.tokens.has("sb-waking")).toBe(false); }); + it("keeps a sandbox without this install's label on archive delete, and says why", async () => { + rig.plane.addSandbox({ id: "sb-foreign", labels: { "xum.instance": "another-install" } }); + const warnings: string[] = []; + + const result = await archive( + { sandboxId: "sb-foreign", shared: false }, + "delete", + cleanCheck, + (w) => warnings.push(w) + ); + + expect(result.success).toBe(true); + expect(rig.plane.find("sb-foreign")?.state).toBe("started"); + expect(warnings.join(" ")).toContain("xum.instance"); + }); + it("never stops or deletes an attached or shared sandbox", async () => { rig.plane.addSandbox({ id: "sb-users" }); diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index b5e04e2e6e2..3fd3066b893 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -5,7 +5,7 @@ import type { WorkspaceMetadata } from "@/common/types/workspace"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; import type { BeforeArchiveHook } from "@/node/services/workspaceLifecycleHooks"; -import type { DaytonaService } from "./daytonaService"; +import { notOwnedSandboxNotice, type DaytonaService } from "./daytonaService"; const DAYTONA_STOP_TIMEOUT_MS = 60_000; @@ -154,7 +154,10 @@ async function deleteForArchive( ? await options.checkSandboxDeletable(args.workspaceMetadata) : Err("its worktree could not be checked for uncommitted work"); if (check.success) { - await options.daytonaService.deleteSandbox(sandboxId); + // Not Xum's after all (see deleteSandbox): it is neither deleted nor stopped. + if ((await options.daytonaService.deleteSandbox(sandboxId)) === "notOwned") { + args.reportWarning?.(notOwnedSandboxNotice(sandboxId)); + } return Ok(undefined); } log.warn("Daytona sandbox kept on archive: the delete checks failed", { diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 32c95905c85..25c71bdfc7c 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -64,6 +64,14 @@ export function unsupportedDaytonaSandboxKind(item: { } export const DAYTONA_LABEL_INSTANCE = "xum.instance"; + +/** Why a delete of a sandbox the workspace says Xum created kept it (see deleteSandbox). */ +export function notOwnedSandboxNotice(sandboxId: string): string { + return ( + `Daytona sandbox "${sandboxId}" was kept: it does not carry this Xum install's ` + + `"${DAYTONA_LABEL_INSTANCE}" label, so Xum did not create it.` + ); +} export const DAYTONA_LABEL_WORKSPACE_ID = "xum.workspaceId"; export const DAYTONA_LABEL_PROJECT = "xum.project"; @@ -1087,12 +1095,20 @@ export class DaytonaService { } /** - * Delete the sandbox and drop Xum's SSH access to it (token and include block). A sandbox - * that is already gone counts as deleted. `account` deletes it in the account it was + * Delete a sandbox Xum created and drop Xum's SSH access to it (token and include block). A + * sandbox that is already gone counts as deleted. `account` deletes it in the account it was * created in, even after the settings changed to another one. + * + * A workspace's `shared: false` is not proof: a create of unknown outcome may have left it + * naming a sandbox someone else holds. So only a sandbox with this install's label is + * deleted; "notOwned" leaves it, and its access, to the caller. */ - async deleteSandbox(sandboxId: string, account?: DaytonaAccount): Promise { + async deleteSandbox( + sandboxId: string, + account?: DaytonaAccount + ): Promise<"deleted" | "notOwned"> { const client = await this.clientOf(account); + if (!(await this.carriesInstanceLabel(client, sandboxId))) return "notOwned"; // Revoke first: Daytona refuses to revoke a token of a sandbox that no longer exists. await this.releaseSshAccess(sandboxId); try { @@ -1100,6 +1116,19 @@ export class DaytonaService { } catch (error) { if (!isDaytonaNotFound(error)) throw error; } + return "deleted"; + } + + /** True for a sandbox that is gone: there is nothing left to protect. */ + private async carriesInstanceLabel(client: DaytonaClient, sandboxId: string): Promise { + let sandbox: DaytonaSandbox; + try { + sandbox = await client.getSandbox(sandboxId); + } catch (error) { + if (isDaytonaNotFound(error)) return true; + throw error; + } + return sandbox.labels?.[DAYTONA_LABEL_INSTANCE] === (await this.getInstanceId()); } async refreshActivity(sandboxId: string): Promise { From 0596fdde5f9e07b622e968f0c07154eaf8c83a39 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:37:26 +0000 Subject: [PATCH 136/231] fix(daytona): follow the shared include file another backend edits --- src/node/runtime/DaytonaSSHRuntime.test.ts | 41 +++++++++- .../services/daytona/daytonaService.test.ts | 13 ++- src/node/services/daytona/daytonaService.ts | 82 +++++++++++++------ 3 files changed, 107 insertions(+), 29 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index ef0ea87733a..a0df8095c2a 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -1444,7 +1444,7 @@ describe("DaytonaSSHRuntime ssh token redaction", () => { transport: { ...localShellTransport(rig.rootDir), spawnRemoteProcess: () => { - readsWhenSpawned = readTokens.mock.calls.length; + readsWhenSpawned ??= readTokens.mock.calls.length; return Promise.reject( new RuntimeError(`${stale}@127.0.0.1: Permission denied (publickey).`, "network") ); @@ -1460,6 +1460,45 @@ describe("DaytonaSSHRuntime ssh token redaction", () => { expect(String(error)).not.toContain(stale); expect(String(error)).toContain(`${DAYTONA_TOKEN_PLACEHOLDER}@127.0.0.1: Permission denied`); }); + + it("follows the include file another backend sharing the root edits", async () => { + rig.plane.addSandbox({ id: "sb-both" }); + await rig.service.ensureSshAccess("sb-both"); + const ours = rig.writer.tokens.get("sb-both"); + + // Another backend archives a workspace on the sandbox and removes its alias. + rig.writer.tokens.delete("sb-both"); + await rig.service.ensureSshAccess("sb-both"); + expect(rig.writer.fullWrites).toEqual(["sb-both", "sb-both"]); + const rewritten = rig.writer.tokens.get("sb-both"); + expect(rewritten).toBeDefined(); + expect(rewritten).not.toBe(ours); + + // Another backend mints and writes its own token; ssh now sends that one. + const theirs = "other-backend-token-5e2b"; + rig.writer.tokens.set("sb-both", theirs); + const runtime = makeRuntime({ sandboxId: "sb-both" }, rig, { + transport: { + ...localShellTransport(rig.rootDir), + spawnRemoteProcess: () => + Promise.reject( + new RuntimeError(`${theirs}@127.0.0.1: Permission denied (publickey).`, "network") + ), + }, + }); + const error = await runtime.exec("true", { cwd: "/tmp", timeout: 10 }).then( + () => new Error("exec unexpectedly succeeded"), + (rejection: unknown) => rejection + ); + expect(String(error)).not.toContain(theirs); + expect(String(error)).toContain(DAYTONA_TOKEN_PLACEHOLDER); + + // Held as the sandbox's token: a release revokes it with the block. + rig.writer.tokens.set("sb-both", theirs); + await rig.service.ensureSshAccess("sb-both"); + await rig.service.releaseSshAccess("sb-both"); + expect(rig.plane.revokedTokens).toContain(theirs); + }); }); describe("DaytonaSSHRuntime reconnect after a stop", () => { diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index dce2c2dc657..675b7284c4e 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -446,7 +446,14 @@ describe("DaytonaService", () => { const order: string[] = []; /** Per retirement: whether the sandbox's next ssh access would set it up again. */ const staleAtRetire: boolean[] = []; - const record = (input: { token: string; gateway: { host: string; hostKeys: string[] } }) => { + /** The include file: what ensureSshAccess reads back before it trusts its cache. */ + const includeFile = new Map(); + const record = (input: { + sandboxId: string; + token: string; + gateway: { host: string; hostKeys: string[] }; + }) => { + includeFile.set(input.sandboxId, input.token); writes.push(`${input.token}@${input.gateway.host} ${input.gateway.hostKeys.join(",")}`); return Promise.resolve(); }; @@ -459,6 +466,7 @@ describe("DaytonaService", () => { writeSandbox: record, updateToken: record, removeSandbox: (sandboxId) => { + includeFile.delete(sandboxId); order.push(`remove ${sandboxId}`); return Promise.resolve(); }, @@ -466,7 +474,8 @@ describe("DaytonaService", () => { order.push(`repoint ${gateway.host} ${gateway.hostKeys.join(",")}`); return Promise.resolve(); }, - readTokens: () => Promise.resolve([]), + readTokens: () => + Promise.resolve([...includeFile].map(([sandboxId, token]) => ({ sandboxId, token }))), verifyResolvedConfig: () => Promise.resolve(), }, retireSshConnections: (sandboxId) => { diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 25c71bdfc7c..fc5e4cebb4f 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -361,7 +361,7 @@ export class DaytonaService { /** Sandboxes whose include block and `~/.ssh/config` block this process has written. */ private readonly sshConfigured = new Set(); private readonly readyAt = new Map(); - private persistedTokensLoad?: Promise; + /** Whether the include file was read once, seeding the tokens an earlier process left. */ private persistedTokensLoaded = false; private readonly readyRequests = new Map(); /** Why the setup of a sandbox Xum created (and then deleted) failed, for later turns. */ @@ -396,37 +396,67 @@ export class DaytonaService { } /** - * Tokens an earlier Xum process wrote to the include file. ssh keeps sending them until Xum - * replaces them, so the redactor must know them; holding them also lets a replacement, a - * settings change or an archive revoke them. The file has no expiry, so they are due for - * refresh at once. Startup starts this load but goes on when it fails or is slow, so every - * ssh to a sandbox (ensureSshAccess, the runtime's commands) waits for it first. Loads once; - * a failed read is logged and retried by the next caller. A missing file loads nothing. + * Register every token the include file holds now with the redactor. The file is shared by + * every Xum backend on this root, and ssh sends whatever token it holds: one an earlier + * process left (ssh keeps sending it until Xum replaces it) or one another backend wrote + * since. So every ssh to a sandbox (ensureSshAccess, the runtime's commands) reads it first, + * and a failed read fails that ssh. Startup reads it too but goes on when the read fails. + * A missing file holds nothing. */ - loadPersistedTokens(): Promise { - if (this.persistedTokensLoaded) return Promise.resolve(); - this.persistedTokensLoad ??= this.readPersistedTokens().then( - () => { - this.persistedTokensLoaded = true; - }, - (error: unknown) => { - this.persistedTokensLoad = undefined; - log.warn("Could not read the Daytona ssh tokens an earlier Xum process left", { - error: getErrorMessage(error), - }); - throw error; - } - ); - return this.persistedTokensLoad; + async loadPersistedTokens(): Promise { + await this.readIncludeFileTokens(); } - private async readPersistedTokens(): Promise { - for (const { sandboxId, token } of await this.sshConfigWriter.readTokens()) { + private async readIncludeFileTokens(): Promise> { + let entries: Array<{ sandboxId: string; token: string }>; + try { + entries = await this.sshConfigWriter.readTokens(); + } catch (error) { + log.warn("Could not read the Daytona ssh tokens in the include file", { + error: getErrorMessage(error), + }); + throw error; + } + const tokens = new Map(); + for (const { sandboxId, token } of entries) { rememberDaytonaToken(token); - if (!this.tokens.has(sandboxId)) { + tokens.set(sandboxId, token); + // An earlier process's tokens: holding them lets a replacement, a settings change or an + // archive revoke them. The file has no expiry, so they are due for refresh at once. + if (!this.persistedTokensLoaded && !this.tokens.has(sandboxId)) { this.cacheNewToken(sandboxId, { token, expiresAt: new Date(0).toISOString() }); } } + this.persistedTokensLoaded = true; + return tokens; + } + + /** + * This process's view of the include file goes stale when another backend edits it: one + * that released the sandbox's access removed the alias (write it again), one that minted + * wrote its own token (hold that one, which ssh now sends). Skipped when this process + * changed the sandbox's access during the read, which then may predate its own write. + */ + private async revalidateSshAccess(sandboxId: string): Promise { + const generation = this.sshTokenGeneration(sandboxId); + const releases = this.accessReleases.get(sandboxId); + const fileToken = (await this.readIncludeFileTokens()).get(sandboxId); + const unchanged = + generation === this.sshTokenGeneration(sandboxId) && + releases === this.accessReleases.get(sandboxId); + if (!unchanged || !this.sshConfigured.has(sandboxId)) return; + const cached = this.tokens.get(sandboxId); + if (fileToken === undefined) { + this.sshConfigured.delete(sandboxId); + this.readyAt.delete(sandboxId); + // Due at once: the mint writes the alias again and revokes this token. + if (cached) this.tokens.set(sandboxId, { ...cached, expiresAt: new Date(0).toISOString() }); + return; + } + // Its expiry is unknown; it was minted after this process's, so that expiry is a bound. + if (cached && cached.token !== fileToken) { + this.cacheNewToken(sandboxId, { token: fileToken, expiresAt: cached.expiresAt }); + } } getPublicConfig(): Promise { @@ -1196,7 +1226,7 @@ export class DaytonaService { */ ensureSshAccess(sandboxId: string): Promise { return this.withAccountLease(async () => { - await this.loadPersistedTokens(); + await this.revalidateSshAccess(sandboxId); // Fails before a mint when the control plane cannot say where the gateway is. await this.getGatewayConfig(); // A cached token was written by the mint that produced it, so only a mint writes files. From 52c984c5ed53c40e88adc9f56c8b385948b253dc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:41:51 +0000 Subject: [PATCH 137/231] fix(daytona): resolve Daytona aliases from Xum's include file alone in every ssh call --- src/node/runtime/SSH2ConnectionPool.test.ts | 167 +++++++++--------- src/node/runtime/SSH2ConnectionPool.ts | 13 +- src/node/runtime/SSHRuntime.ts | 2 +- src/node/runtime/daytonaSshConfig.test.ts | 79 ++++++--- src/node/runtime/daytonaSshConfig.ts | 15 ++ src/node/runtime/sshConfigParser.ts | 12 +- src/node/runtime/sshConnectionPool.test.ts | 44 +---- src/node/runtime/sshConnectionPool.ts | 51 ++---- .../transports/OpenSSHTransport.test.ts | 13 +- 9 files changed, 189 insertions(+), 207 deletions(-) diff --git a/src/node/runtime/SSH2ConnectionPool.test.ts b/src/node/runtime/SSH2ConnectionPool.test.ts index c69a0e38ec9..9accfd5a479 100644 --- a/src/node/runtime/SSH2ConnectionPool.test.ts +++ b/src/node/runtime/SSH2ConnectionPool.test.ts @@ -14,6 +14,7 @@ import { } from "./SSH2ConnectionPool"; import type { SSHConnectionConfig } from "./sshConnectionPool"; import { SSH2Transport } from "./transports/SSH2Transport"; +import { DaytonaSshConfigWriter } from "./daytonaSshConfig"; const PROXY_TOKENS = { host: "example.test", port: 2222, user: "alice" }; @@ -178,6 +179,38 @@ describe.skipIf(process.platform === "win32")( expect(waits.length).toBeGreaterThan(0); }); + const publicKeyLine = (privateKey: string) => { + const parsed = utils.parseKey(privateKey); + if (parsed instanceof Error) throw parsed; + return `${parsed.type} ${parsed.getPublicSSH().toString("base64")}`; + }; + + /** A home whose ssh config is `userConfig` plus Xum's Daytona block for sb-1 at `port`. */ + async function withDaytonaHome( + userConfig: string, + fn: (writer: DaytonaSshConfigWriter, home: string) => Promise + ): Promise { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "xum-ssh2-daytona-home-")); + // The ssh config parser reads USERPROFILE first (sshConfigParser.test.ts does the same). + const originalUserProfile = process.env.USERPROFILE; + process.env.USERPROFILE = home; + try { + const sshConfigPath = path.join(home, ".ssh", "config"); + await fs.mkdir(path.dirname(sshConfigPath)); + await fs.writeFile(sshConfigPath, userConfig); + // A space, as in "/Users/John Smith": daytonaSshConfig quotes the paths. + const writer = new DaytonaSshConfigWriter({ + xumHome: path.join(home, "John Smith", ".xum"), + sshConfigPath, + }); + await fn(writer, home); + } finally { + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + await fs.rm(home, { recursive: true, force: true }); + } + } + // ssh2 reads no known_hosts: without this check a Daytona alias accepted any gateway key. it("connects to a Daytona alias only when the gateway key matches the pinned one", async () => { const hostKey = newPrivateKey(); @@ -185,37 +218,18 @@ describe.skipIf(process.platform === "win32")( conn.on("authentication", (ctx) => ctx.accept()); conn.on("error", () => undefined); }, hostKey); - const home = await fs.mkdtemp(path.join(os.tmpdir(), "xum-ssh2-daytona-home-")); - // The ssh config parser reads USERPROFILE first (sshConfigParser.test.ts does the same). - const originalUserProfile = process.env.USERPROFILE; - process.env.USERPROFILE = home; - try { - // A space, as in "/Users/John Smith": daytonaSshConfig quotes the path. - const knownHostsPath = path.join(home, "John Smith", "daytona_known_hosts"); - await fs.mkdir(path.dirname(knownHostsPath)); - await fs.mkdir(path.join(home, ".ssh")); - await fs.writeFile( - path.join(home, ".ssh", "config"), - [ - "Host sb-1.xum--daytona", - " HostName 127.0.0.1", - ` Port ${String(config.port)}`, - " HostKeyAlias gateway.test", - ` UserKnownHostsFile "${knownHostsPath}"`, - " StrictHostKeyChecking yes", - ].join("\n") - ); - const pinLine = (privateKey: string) => { - const parsed = utils.parseKey(privateKey); - if (parsed instanceof Error) throw parsed; - return `gateway.test ${parsed.type} ${parsed.getPublicSSH().toString("base64")}\n`; - }; + await withDaytonaHome("", async (writer) => { + const gateway = { host: "127.0.0.1", port: config.port ?? 22 }; const daytona: SSHConnectionConfig = { host: "sb-1.xum--daytona", identityFile: config.identityFile, }; - await fs.writeFile(knownHostsPath, pinLine(newPrivateKey())); + await writer.writeSandbox({ + sandboxId: "sb-1", + token: "tokenA", + gateway: { ...gateway, hostKeys: [publicKeyLine(newPrivateKey())] }, + }); const refused = await new SSH2ConnectionPool() .acquireConnection(daytona, { maxWaitMs: 0 }) .then( @@ -224,76 +238,61 @@ describe.skipIf(process.platform === "win32")( ); expect(String(refused)).toContain("Host denied"); - await fs.writeFile(knownHostsPath, pinLine(hostKey)); + await writer.repointGateway({ ...gateway, hostKeys: [publicKeyLine(hostKey)] }); const entry = await new SSH2ConnectionPool().acquireConnection(daytona, { maxWaitMs: 0 }); entry.client.end(); - } finally { - if (originalUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = originalUserProfile; - await fs.rm(home, { recursive: true, force: true }); - } + }); }); - // The command would get the token as %r and could route the connection anywhere. - it("runs no inherited ProxyCommand for a Daytona alias, only for other hosts", async () => { + // A user directive with %r would get the token, and ProxyCommand could route anywhere. + it("runs no user ssh config directive for a Daytona alias, only for other hosts", async () => { const hostKey = newPrivateKey(); const { config } = await startServer((conn) => { conn.on("authentication", (ctx) => ctx.accept()); conn.on("error", () => undefined); }, hostKey); - const home = await fs.mkdtemp(path.join(os.tmpdir(), "xum-ssh2-daytona-proxy-")); - const originalUserProfile = process.env.USERPROFILE; - process.env.USERPROFILE = home; - try { - const knownHostsPath = path.join(home, "daytona_known_hosts"); - const marker = path.join(home, "proxy-ran"); - const parsed = utils.parseKey(hostKey); - if (parsed instanceof Error) throw parsed; - await fs.writeFile( - knownHostsPath, - `gateway.test ${parsed.type} ${parsed.getPublicSSH().toString("base64")}\n` - ); - await fs.mkdir(path.join(home, ".ssh")); - await fs.writeFile( - path.join(home, ".ssh", "config"), - [ - "Host *", - ` ProxyCommand sh -c 'echo %r >> ${marker}; exit 1'`, - "Host sb-1.xum--daytona", - " HostName 127.0.0.1", - ` Port ${String(config.port)}`, - " User tokenA", - " HostKeyAlias gateway.test", - ` UserKnownHostsFile ${knownHostsPath}`, - "Host plain-host", - " HostName 127.0.0.1", - ` Port ${String(config.port)}`, - " User plainuser", - ].join("\n") - ); - - const entry = await new SSH2ConnectionPool().acquireConnection( - { host: "sb-1.xum--daytona", identityFile: config.identityFile }, - { maxWaitMs: 0 } - ); - entry.client.end(); - expect(await Bun.file(marker).exists()).toBe(false); + const marker = path.join(os.tmpdir(), `xum-ssh2-ran-${String(process.pid)}-${Date.now()}`); + const plainHost = [ + "Host plain-host", + " HostName 127.0.0.1", + ` Port ${String(config.port)}`, + " User plainuser", + "", + ].join("\n"); + for (const userConfig of [ + `Host *\n ProxyCommand sh -c 'echo %r >> ${marker}; exit 1'\n${plainHost}`, + `Match host 127.0.0.1 !exec "echo %r >> ${marker}"\n${plainHost}`, + ]) { + await withDaytonaHome(userConfig, async (writer) => { + await writer.writeSandbox({ + sandboxId: "sb-1", + token: "tokenA", + gateway: { + host: "127.0.0.1", + port: config.port ?? 22, + hostKeys: [publicKeyLine(hostKey)], + }, + }); - const plain = await new SSH2ConnectionPool() - .acquireConnection( - { host: "plain-host", identityFile: config.identityFile }, + const entry = await new SSH2ConnectionPool().acquireConnection( + { host: "sb-1.xum--daytona", identityFile: config.identityFile }, { maxWaitMs: 0 } - ) - .then( - () => new Error("acquisition unexpectedly succeeded"), - (error: unknown) => error ); - expect(plain).toBeInstanceOf(Error); - expect(await fs.readFile(marker, "utf8")).toContain("plainuser"); - } finally { - if (originalUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = originalUserProfile; - await fs.rm(home, { recursive: true, force: true }); + entry.client.end(); + expect(await Bun.file(marker).exists()).toBe(false); + + await new SSH2ConnectionPool() + .acquireConnection( + { host: "plain-host", identityFile: config.identityFile }, + { maxWaitMs: 0 } + ) + .then( + (plain) => plain.client.end(), + () => undefined + ); + expect(await fs.readFile(marker, "utf8")).toContain("plainuser"); + await fs.rm(marker); + }); } }); diff --git a/src/node/runtime/SSH2ConnectionPool.ts b/src/node/runtime/SSH2ConnectionPool.ts index 64e2aa97cfb..9188b7bbb55 100644 --- a/src/node/runtime/SSH2ConnectionPool.ts +++ b/src/node/runtime/SSH2ConnectionPool.ts @@ -29,6 +29,7 @@ import { } from "./sshBackoff"; import { isPermanentSSHFailure } from "./Runtime"; import { isXumDaytonaHost } from "@/common/constants/daytona"; +import { getDaytonaSshConfigPath } from "./daytonaSshConfig"; /** * Daytona aliases pin the gateway key in Xum's own known_hosts (UserKnownHostsFile in the @@ -85,13 +86,9 @@ function createHostVerifier( : Promise.resolve(() => true); } -/** - * A Daytona alias connects straight to its pinned gateway: an inherited ProxyCommand (a user - * `Host *` entry) would get the token as `%r` and could route the connection anywhere. The - * parser reads no ProxyJump, so ssh2 never follows one. - */ -function withoutInheritedProxy(host: string, resolved: ResolvedSSHConfig): ResolvedSSHConfig { - return isXumDaytonaHost(host) ? { ...resolved, proxyCommand: undefined } : resolved; +/** As OpenSSH's `-F` (appendDaytonaOpenSSHArgs): a Daytona alias reads Xum's include file alone. */ +function sshConfigFileFor(host: string): string | undefined { + return isXumDaytonaHost(host) ? getDaytonaSshConfigPath() : undefined; } let sshPromptService: SshPromptService | undefined; @@ -705,7 +702,7 @@ export class SSH2ConnectionPool { const key = makeConnectionKey(config); const retirement = this.retirementOf(key); try { - const resolved = withoutInheritedProxy(config.host, await resolveSSHConfig(config.host)); + const resolved = await resolveSSHConfig(config.host, sshConfigFileFor(config.host)); const resolvedConfig: ResolvedSSHConfig = { ...resolved, port: config.port ?? resolved.port, diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index 69c5db77181..50f89d8dbc1 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -2132,7 +2132,7 @@ export class SSHRuntime extends RemoteRuntime { args.push("-o", "ServerAliveCountMax=2"); // Match the runtime's host key policy (permissive in headless mode). - appendOpenSSHHostKeyPolicyArgs(args, config.host); + appendOpenSSHHostKeyPolicyArgs(args, config.host, q); return args.join(" "); } diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index 1dae99e8093..447e63820a7 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -315,35 +315,58 @@ describe("DaytonaSshConfigWriter", () => { await writer.verifyResolvedConfig("sb-1", "tokenA"); }); - it.skipIf(!hasOpenSsh)( - "runs no inherited ProxyCommand, ProxyJump or LocalCommand for a Daytona alias", - async () => { - await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); - const resolve = (host: string) => { - const args = ["-G", "-F", sshConfigPath]; - appendOpenSSHHostKeyPolicyArgs(args, host); - return spawnSync("ssh", [...args, host]).stdout.toString(); - }; - for (const [routing, resolvedLine] of [ - ["ProxyCommand nc %r %h %p", "\nproxycommand nc %r %h %p\n"], - ["ProxyJump jump.example.com", "\nproxyjump jump.example.com\n"], - ]) { - await fs.writeFile( - sshConfigPath, - `Host *\n ${routing}\n PermitLocalCommand yes\n LocalCommand echo %r\n`, - "utf8" - ); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - - const daytona = resolve(toXumDaytonaHost("sb-1")); - expect(daytona).not.toContain(resolvedLine); - expect(daytona).not.toMatch(/\nproxy(command|jump) /); - expect(daytona).toContain("\npermitlocalcommand no\n"); - // Other hosts keep the user's settings. - expect(resolve("other.example.com")).toContain(resolvedLine); - } + // Any user directive with %r would get the token (the ssh user name), and `-o` cannot + // override a Match exec, so ssh reads only Xum's include file for a Daytona alias. + it.skipIf(!hasOpenSsh)("runs no user ssh config directive for a Daytona alias", async () => { + await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); + const markers = path.join(tempDir, "markers"); + await fs.mkdir(markers); + const resolve = (host: string) => { + const args = ["-G", "-F", sshConfigPath]; + appendOpenSSHHostKeyPolicyArgs(args, host); + return spawnSync("ssh", [...args, host]).stdout.toString(); + }; + for (const [routing, resolvedLine] of [ + ["ProxyCommand nc %r %h %p", "\nproxycommand nc %r %h %p\n"], + ["ProxyJump jump.example.com", "\nproxyjump jump.example.com\n"], + ]) { + await fs.writeFile( + sshConfigPath, + [ + `Match exec "touch ${markers}/%r"`, + "Host *", + " KnownHostsCommand /bin/echo %r", + ` ${routing}`, + " PermitLocalCommand yes", + " LocalCommand echo %r", + // Forwards would hand the untrusted sandbox the local agent, display or ports. + " ForwardAgent yes", + " ForwardX11 yes", + " RemoteForward 6000 localhost:6000", + "", + ].join("\n"), + "utf8" + ); + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + + const daytona = resolve(toXumDaytonaHost("sb-1")); + expect(daytona).toMatch(/^user tokenA$/m); + expect(daytona).not.toContain("knownhostscommand"); + expect(daytona).not.toMatch(/\nproxy(command|jump) /); + expect(daytona).toContain("\npermitlocalcommand no\n"); + expect(daytona).toContain("\nforwardagent no\n"); + expect(daytona).toContain("\nforwardx11 no\n"); + expect(daytona).not.toContain("remoteforward"); + expect(await fs.readdir(markers)).toEqual([]); + // Other hosts keep the user's settings. + const other = resolve("other.example.com"); + expect(other).toContain(resolvedLine); + expect(other).toContain("\nknownhostscommand /bin/echo %r\n"); + expect((await fs.readdir(markers)).length).toBe(1); + await fs.rm(markers, { recursive: true }); + await fs.mkdir(markers); } - ); + }); describe("a user Host * entry that weakens host key checking", () => { const WEAKENING_CONFIG = "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n"; diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 6c1df9e4872..3b7b8925915 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -9,6 +9,7 @@ import { XUM_DAYTONA_SSH_BLOCK_START, toXumDaytonaHost, } from "@/common/constants/daytona"; +import { getXumHome } from "@/common/constants/paths"; import { getErrorMessage } from "@/common/utils/errors"; import { ensureManagedSSHConfigBlock } from "@/node/runtime/muxSshConfigWriter"; import { log } from "@/node/services/log"; @@ -51,6 +52,19 @@ export function getDaytonaSshPaths(xumHome: string): DaytonaSshPaths { }; } +// The include file of the writer this backend built last (its DaytonaService's, which may use +// a root other than getXumHome()). +let activeIncludePath: string | undefined; + +/** + * The only ssh config a Daytona alias is resolved from, by OpenSSH (`-F`) and ssh2 alike. A user + * entry matching the alias (a `Host *` KnownHostsCommand, `Match exec`, ProxyCommand) would run + * a local command that gets the token as `%r`, and `-o` cannot override a Match exec. + */ +export function getDaytonaSshConfigPath(): string { + return activeIncludePath ?? getDaytonaSshPaths(getXumHome()).includePath; +} + export class DaytonaSshConfigError extends Error { constructor(message: string) { super(message); @@ -226,6 +240,7 @@ export class DaytonaSshConfigWriter { constructor(options: DaytonaSshConfigWriterOptions) { this.paths = getDaytonaSshPaths(options.xumHome); + activeIncludePath = this.paths.includePath; this.sshConfigPath = options.sshConfigPath; this.sshBinary = options.sshBinary ?? "ssh"; } diff --git a/src/node/runtime/sshConfigParser.ts b/src/node/runtime/sshConfigParser.ts index 31196052d60..90df28c14d5 100644 --- a/src/node/runtime/sshConfigParser.ts +++ b/src/node/runtime/sshConfigParser.ts @@ -599,9 +599,9 @@ async function expandIncludes( } /** The user's ssh config with its Includes expanded for `host`. */ -async function loadSSHConfig(host: string): Promise { +async function loadSSHConfig(host: string, configFile?: string): Promise { const homeDir = getHomeDir(); - const configPath = path.join(homeDir, ".ssh", "config"); + const configPath = configFile ?? path.join(homeDir, ".ssh", "config"); try { const content = await fs.readFile(configPath, "utf8"); @@ -618,11 +618,15 @@ async function loadSSHConfig(host: string): Promise { } } -export async function resolveSSHConfig(host: string): Promise { +/** `configFile` replaces ~/.ssh/config, as OpenSSH's `-F` does. */ +export async function resolveSSHConfig( + host: string, + configFile?: string +): Promise { const { host: hostAlias, user: userOverride } = parseHostAndUser(host); const homeDir = getHomeDir(); - const config = await loadSSHConfig(hostAlias); + const config = await loadSSHConfig(hostAlias, configFile); const computed = config ? userOverride ? config.compute({ Host: hostAlias, User: userOverride }) diff --git a/src/node/runtime/sshConnectionPool.test.ts b/src/node/runtime/sshConnectionPool.test.ts index 1f8581d81ef..6fc18464525 100644 --- a/src/node/runtime/sshConnectionPool.test.ts +++ b/src/node/runtime/sshConnectionPool.test.ts @@ -1,4 +1,3 @@ -import { spawnSync } from "child_process"; import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; @@ -10,6 +9,7 @@ import { SSHConnectionPool, type SSHRuntimeConfig, } from "./sshConnectionPool"; +import { getDaytonaSshConfigPath } from "./daytonaSshConfig"; import { isPermanentSSHFailure } from "./Runtime"; // bun-types (^1.2.23) lags the pinned runtime (bun@1.4.3), which implements this. @@ -162,52 +162,14 @@ describe("appendOpenSSHHostKeyPolicyArgs", () => { }); test.each(["headless-fallback", "strict"] as const)( - "forces strict checking of the pinned gateway key of a Daytona alias (%s mode)", + "reads only Xum's include file for a Daytona alias, without the fallback (%s mode)", (mode) => { const args: string[] = ["-T"]; setOpenSSHHostKeyPolicyMode(mode); appendOpenSSHHostKeyPolicyArgs(args, "xum-feature-a1b2c3.xum--daytona"); - expect(args).toContain("StrictHostKeyChecking=yes"); - expect(args).not.toContain("UserKnownHostsFile=/dev/null"); - } - ); - - // The sandbox is not trusted: a user's `Host *` forwards would hand it the local agent, the - // X display or local ports. Command-line options beat every config file. - test.skipIf(spawnSync("ssh", ["-V"]).status !== 0)( - "keeps a user's agent, X11 and port forwards off a Daytona alias", - async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-daytona-fwd-")); - try { - const configPath = path.join(dir, "config"); - await fs.writeFile( - configPath, - [ - "Host *", - " ForwardAgent yes", - " ForwardX11 yes", - " LocalForward 5000 localhost:5000", - " RemoteForward 6000 localhost:6000", - " DynamicForward 7000", - "", - ].join("\n") - ); - const host = "xum-feature-a1b2c3.xum--daytona"; - const args: string[] = []; - appendOpenSSHHostKeyPolicyArgs(args, host); - - const resolved = spawnSync("ssh", ["-G", "-F", configPath, ...args, host], { - encoding: "utf8", - }).stdout.split("\n"); - - expect(resolved).toContain("forwardagent no"); - expect(resolved).toContain("forwardx11 no"); - expect(resolved.filter((line) => /^(local|remote|dynamic)forward /.test(line))).toEqual([]); - } finally { - await fs.rm(dir, { recursive: true, force: true }); - } + expect(args).toEqual(["-T", "-F", getDaytonaSshConfigPath()]); } ); diff --git a/src/node/runtime/sshConnectionPool.ts b/src/node/runtime/sshConnectionPool.ts index d6092ef2d74..c99ceb6cbbb 100644 --- a/src/node/runtime/sshConnectionPool.ts +++ b/src/node/runtime/sshConnectionPool.ts @@ -32,6 +32,7 @@ import { } from "./sshBackoff"; import { isPermanentSSHFailure } from "./Runtime"; import { isXumDaytonaHost } from "@/common/constants/daytona"; +import { getDaytonaSshConfigPath } from "./daytonaSshConfig"; import { redactDaytonaHostStderr } from "./daytonaTokenRedaction"; export type OpenSSHHostKeyPolicyMode = "strict" | "headless-fallback"; @@ -52,47 +53,29 @@ export function isInteractiveHostKeyApprovalAvailable(): boolean { } /** - * A Daytona alias's include block pins the gateway key, but OpenSSH keeps the first value it - * reads, so a user `Host *` entry above Xum's block with `StrictHostKeyChecking no` would win and - * hand the token (the ssh user name) to any server. Command-line options beat every config file, - * so force strict checking: if an earlier entry also points UserKnownHostsFile elsewhere, the - * gateway key is unknown and ssh refuses before it sends the user name. - * - * The sandbox is not trusted either: a user `Host *` entry's agent, X11 or port forwards would - * let its processes use the local agent, display or ports, so those are forced off too. The - * bash AI proxy's reverse forward adds its `-R` through a control client that skips this. - * - * Nor may an inherited entry run a local command (which would get the token as `%r`) or route - * the connection past the pinned gateway: ProxyCommand, ProxyJump and LocalCommand are off. - * ProxyCommand goes first: OpenSSH applies only the first of the two it reads, so - * `ProxyJump=none` is inert on clients that predate it. KnownHostsCommand is left alone: - * OpenSSH before 8.5 refuses the option name, which would fail every connection. + * A Daytona alias is resolved from Xum's include file alone (getDaytonaSshConfigPath), never + * from ~/.ssh/config or /etc/ssh/ssh_config: no user entry can shadow the pinned gateway, run a + * local command with the token or forward anything into the untrusted sandbox. `quote` renders + * the path for a shell command string (GIT_SSH_COMMAND). */ -export function appendDaytonaOpenSSHArgs(args: string[], host: string): void { +export function appendDaytonaOpenSSHArgs( + args: string[], + host: string, + quote: (value: string) => string = (value) => value +): void { if (isXumDaytonaHost(host)) { - args.push( - "-o", - "StrictHostKeyChecking=yes", - "-o", - "ForwardAgent=no", - "-o", - "ForwardX11=no", - "-o", - "ClearAllForwardings=yes", - "-o", - "ProxyCommand=none", - "-o", - "ProxyJump=none", - "-o", - "PermitLocalCommand=no" - ); + args.push("-F", quote(getDaytonaSshConfigPath())); } } /** Daytona aliases never get the headless fallback, which would accept any gateway key. */ -export function appendOpenSSHHostKeyPolicyArgs(args: string[], host: string): void { +export function appendOpenSSHHostKeyPolicyArgs( + args: string[], + host: string, + quote?: (value: string) => string +): void { if (isXumDaytonaHost(host)) { - appendDaytonaOpenSSHArgs(args, host); + appendDaytonaOpenSSHArgs(args, host, quote); return; } if (hostKeyPolicyMode === "strict") { diff --git a/src/node/runtime/transports/OpenSSHTransport.test.ts b/src/node/runtime/transports/OpenSSHTransport.test.ts index 3585f21f159..956c9ca3073 100644 --- a/src/node/runtime/transports/OpenSSHTransport.test.ts +++ b/src/node/runtime/transports/OpenSSHTransport.test.ts @@ -7,6 +7,7 @@ import { toXumDaytonaHost } from "@/common/constants/daytona"; import * as ptySpawn from "../ptySpawn"; import { RuntimeError, isRuntimeTransportError } from "../Runtime"; import { sshConnectionPool } from "../sshConnectionPool"; +import { getDaytonaSshConfigPath } from "../daytonaSshConfig"; import { OpenSSHTransport, retireOpenSSHMasters } from "./OpenSSHTransport"; const BACKOFF_MESSAGE = @@ -54,7 +55,7 @@ describe("OpenSSHTransport connection acquisition failures", () => { }); describe("OpenSSHTransport terminals on a Daytona alias", () => { - it("force strict host key checking, as every other ssh call to the alias does", async () => { + it("read only Xum's include file, as every other ssh call to the alias does", async () => { spyOn(sshConnectionPool, "acquireConnection").mockResolvedValue(undefined); const spawnPty = spyOn(ptySpawn, "spawnPtyProcess").mockReturnValue({} as IPty); const host = toXumDaytonaHost("sb-1"); @@ -66,12 +67,10 @@ describe("OpenSSHTransport terminals on a Daytona alias", () => { }); const args = spawnPty.mock.calls[0][0].args; - // Command-line options beat a user `Host *` entry with `StrictHostKeyChecking no`. - const strict = args.findIndex( - (arg, i) => args[i - 1] === "-o" && arg === "StrictHostKeyChecking=yes" - ); - expect(strict).toBeGreaterThan(-1); - expect(strict).toBeLessThan(args.indexOf(host)); + const configFile = args.indexOf("-F"); + expect(args[configFile + 1]).toBe(getDaytonaSshConfigPath()); + expect(configFile).toBeGreaterThan(-1); + expect(configFile).toBeLessThan(args.indexOf(host)); }); }); From ca57219c15b1c2b7728167d76996c091a33c0342 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:43:36 +0000 Subject: [PATCH 138/231] refactor(daytona): drop the ssh -G shadowing check now that nothing can shadow Xum's block --- src/node/runtime/DaytonaSSHRuntime.test.ts | 10 +-- src/node/runtime/daytonaSshConfig.test.ts | 49 +--------- src/node/runtime/daytonaSshConfig.ts | 90 ------------------- .../services/daytona/daytona.testHarness.ts | 15 +--- .../services/daytona/daytonaService.test.ts | 33 ------- src/node/services/daytona/daytonaService.ts | 16 +--- 6 files changed, 16 insertions(+), 197 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index a0df8095c2a..0c6e594bdd3 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -561,7 +561,7 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { }); it("deletes the sandbox it created when setup fails afterwards", async () => { - rig.writer.verifyError = new Error("Your ssh config sets User"); + rig.writer.writeError = new Error("No space left on device"); const params = initParams(); const failure = await makeRuntime({ sandboxId: "xum-doomed", shared: false, ...SMALL }) @@ -572,7 +572,7 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { ); expect(failure).toBeInstanceOf(Error); - expect(String(failure)).toContain("Your ssh config sets User"); + expect(String(failure)).toContain("No space left on device"); expect(rig.plane.countCalls("POST /sandbox")).toBe(1); expect(rig.plane.find("xum-doomed")).toBeUndefined(); @@ -586,7 +586,7 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { expect(ready.ready).toBe(false); if (!ready.ready) { expect(ready.error).toContain("setup failed"); - expect(ready.error).toContain("Your ssh config sets User"); + expect(ready.error).toContain("No space left on device"); } }); @@ -601,7 +601,7 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { ["xum-transient", false], ["xum-persistent", true], ] as const) { - rig.writer.verifyError = new Error("Your ssh config sets User"); + rig.writer.writeError = new Error("No space left on device"); rig.plane.failingRoutes.add(`DELETE /sandbox/${sandboxId}`); const daytona = { sandboxId, shared: false, ...SMALL }; @@ -613,7 +613,7 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { // The persisted workspace still names the sandbox; removing it deletes the sandbox. rig.plane.failingRoutes.clear(); - if (!force) rig.writer.verifyError = undefined; + if (!force) rig.writer.writeError = undefined; rig.workspaces.splice(0, rig.workspaces.length, { workspaceId: "ws-1", runtimeConfig: sshDaytonaConfig(daytona), diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index 447e63820a7..cecb5d6ee53 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -270,51 +270,19 @@ describe("DaytonaSshConfigWriter", () => { ); }); - it.skipIf(!hasOpenSsh)("resolves the token as the ssh user through OpenSSH", async () => { + // What an editor's Remote-SSH resolves for the workspace's host, through ~/.ssh/config. + it.skipIf(!hasOpenSsh)("resolves the alias through the managed ssh config block", async () => { await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); await fs.writeFile(sshConfigPath, "Host other\n User someone\n", "utf8"); await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - await writer.verifyResolvedConfig("sb-1", "tokenA"); - + const alias = spawnSync("ssh", ["-G", "-F", sshConfigPath, toXumDaytonaHost("sb-1")]); + expect(alias.stdout.toString()).toMatch(/^user tokenA$/m); // The included Host block must not leak to unrelated hosts. const other = spawnSync("ssh", ["-G", "-F", sshConfigPath, "unrelated.example.com"]); expect(other.stdout.toString()).not.toContain("user tokenA"); }); - it.skipIf(!hasOpenSsh)("fails clearly when a user Host * User setting wins", async () => { - await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); - await fs.writeFile(sshConfigPath, "Host *\n User me\n", "utf8"); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - - const error = await writer.verifyResolvedConfig("sb-1", "tokenA").catch((e: unknown) => e); - expect((error as Error).message).toContain('"Host *"'); - expect((error as Error).message).not.toContain("tokenA"); - }); - - it.skipIf(!hasOpenSsh)("fails clearly when a user Host * routing setting wins", async () => { - await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); - for (const [setting, line] of [ - ["Port", "Port 2222"], - ["HostName", "HostName elsewhere.example.com"], - ["HostKeyAlias", "HostKeyAlias elsewhere.example.com"], - ]) { - await fs.writeFile(sshConfigPath, `Host *\n ${line}\n`, "utf8"); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - - const error = await writer.verifyResolvedConfig("sb-1", "tokenA").catch((e: unknown) => e); - expect(String(error)).toContain(`sets ${setting} for`); - } - // The block's own values (a mixed-case gateway host included) pass as ssh -G prints them. - await fs.writeFile(sshConfigPath, "Host other\n Port 2222\n", "utf8"); - await writer.writeSandbox({ - sandboxId: "sb-1", - token: "tokenA", - gateway: { ...GATEWAY, host: "SSH.Daytona.test", port: 2200 }, - }); - await writer.verifyResolvedConfig("sb-1", "tokenA"); - }); - // Any user directive with %r would get the token (the ssh user name), and `-o` cannot // override a Match exec, so ssh reads only Xum's include file for a Daytona alias. it.skipIf(!hasOpenSsh)("runs no user ssh config directive for a Daytona alias", async () => { @@ -382,14 +350,5 @@ describe("DaytonaSshConfigWriter", () => { expect(resolved).toContain("\nstricthostkeychecking true\n"); }); - - it.skipIf(!hasOpenSsh)("fails the ssh config check, naming the setting", async () => { - await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); - await fs.writeFile(sshConfigPath, WEAKENING_CONFIG, "utf8"); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - - const error = await writer.verifyResolvedConfig("sb-1", "tokenA").catch((e: unknown) => e); - expect((error as Error).message).toContain("sets UserKnownHostsFile"); - }); }); }); diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 3b7b8925915..db1cd746a46 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -1,8 +1,6 @@ -import { execFile } from "node:child_process"; import * as fs from "node:fs/promises"; import * as net from "node:net"; import * as path from "node:path"; -import { promisify } from "node:util"; import { XUM_DAYTONA_HOST_SUFFIX, XUM_DAYTONA_SSH_BLOCK_END, @@ -10,7 +8,6 @@ import { toXumDaytonaHost, } from "@/common/constants/daytona"; import { getXumHome } from "@/common/constants/paths"; -import { getErrorMessage } from "@/common/utils/errors"; import { ensureManagedSSHConfigBlock } from "@/node/runtime/muxSshConfigWriter"; import { log } from "@/node/services/log"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; @@ -18,8 +15,6 @@ import { ensurePrivateDir, isErrnoWithCode } from "@/node/utils/fs"; import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import writeFileAtomic from "@/node/utils/writeFileAtomic"; -const execFileAsync = promisify(execFile); - /** * SSH plumbing for Daytona sandboxes, reached through Daytona's SSH gateway where the access * token is the ssh username. @@ -230,19 +225,16 @@ export interface DaytonaSshConfigWriterOptions { xumHome: string; /** Defaults to `~/.ssh/config`; tests point it at a temp file. */ sshConfigPath?: string; - sshBinary?: string; } export class DaytonaSshConfigWriter { readonly paths: DaytonaSshPaths; private readonly sshConfigPath?: string; - private readonly sshBinary: string; constructor(options: DaytonaSshConfigWriterOptions) { this.paths = getDaytonaSshPaths(options.xumHome); activeIncludePath = this.paths.includePath; this.sshConfigPath = options.sshConfigPath; - this.sshBinary = options.sshBinary ?? "ssh"; } /** First write for a sandbox: known_hosts, its include block, and the managed ssh block. */ @@ -390,86 +382,4 @@ export class DaytonaSshConfigWriter { mode: 0o600, }); } - - /** - * OpenSSH keeps the first value it sees, so a user `Host *` entry above Xum's managed block - * silently replaces what the alias's block sets: the token (`User`), the gateway (`HostName`, - * `Port`, `HostKeyAlias`) or the pinned known_hosts file. Resolve the alias with `ssh -G`, - * compare every directive the include file holds for it, and fail with a clear message instead - * of an opaque auth, routing or host key failure. The token is never printed. Skipped when no - * OpenSSH client is installed (the ssh2 path parses the config itself). - */ - async verifyResolvedConfig(sandboxId: string, token: string): Promise { - const alias = toXumDaytonaHost(sandboxId); - const args = ["-G", ...(this.sshConfigPath ? ["-F", this.sshConfigPath] : []), alias]; - let stdout: string; - try { - ({ stdout } = await execFileAsync(this.sshBinary, args, { timeout: 10_000 })); - } catch (error) { - if (isErrnoWithCode(error, "ENOENT")) { - log.debug("Skipping Daytona ssh user check: no OpenSSH client found"); - return; - } - throw new DaytonaSshConfigError( - `Could not check the ssh config for ${alias} with ssh -G (${getErrorMessage(error).split("\n")[0]})` - ); - } - const block = (await this.readBlocks()).get(alias); - if (!block) { - throw new DaytonaSshConfigError( - `Xum's ssh settings for ${alias} are missing from ${this.paths.includePath}` - ); - } - const shadowed = shadowedDirectives(block, token, stdout); - if (shadowed.length > 0) { - const one = shadowed.length === 1; - throw new DaytonaSshConfigError( - `Your ssh config sets ${shadowed.join(", ")} for ${alias} before Xum's Daytona block ` + - `(often in a "Host *" entry), and OpenSSH keeps the first value. Move ` + - `${one ? "that line" : "those lines"} below the "${XUM_DAYTONA_SSH_BLOCK_START}" ` + - `block in ~/.ssh/config or scope ${one ? "it" : "them"} to specific hosts.` - ); - } - } -} - -// Every OpenSSH call forces it on the command line (appendDaytonaOpenSSHArgs) and the ssh2 -// transport always checks the pinned key, so a user value for it changes nothing. -const UNVERIFIED_DIRECTIVES = new Set(["stricthostkeychecking"]); - -/** The directives of a rendered host block that `ssh -G` (its `stdout`) resolves differently. */ -function shadowedDirectives(block: string, token: string, stdout: string): string[] { - const resolved = new Map(); - for (const line of stdout.split("\n")) { - const space = line.indexOf(" "); - if (space > 0) resolved.set(line.slice(0, space), line.slice(space + 1).trim()); - } - return hostBlockDirectives(block) - .filter(([name]) => !UNVERIFIED_DIRECTIVES.has(name.toLowerCase())) - .filter(([name, value]) => { - const key = name.toLowerCase(); - return !resolvesTo(key, key === "user" ? token : value, resolved.get(key)); - }) - .map(([name]) => name); -} - -/** The `Key value` lines of a rendered host block, in order, with quoted values unquoted. */ -function hostBlockDirectives(block: string): Array<[string, string]> { - return block - .split("\n") - .slice(1) - .flatMap((line): Array<[string, string]> => { - const match = /^\s+(\S+)\s+(.+?)\s*$/.exec(line); - return match ? [[match[1], match[2].replace(/^"(.*)"$/, "$1")]] : []; - }); -} - -/** Whether `ssh -G` printed `expected` for `key`, given how it normalizes the value. */ -function resolvesTo(key: string, expected: string, resolved: string | undefined): boolean { - if (key === "hostname" || key === "hostkeyalias") { - return resolved?.toLowerCase() === expected.toLowerCase(); - } - // ssh -G prints every known_hosts file on one line, separated by spaces. - if (key === "userknownhostsfile") return ` ${resolved ?? ""} `.includes(` ${expected} `); - return resolved === expected; } diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 2ea5cda0451..a4a74cf0c2b 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -269,20 +269,17 @@ export class FakeDaytonaControlPlane { /** Records ssh config writes instead of touching `~/.ssh/config`. */ export class FakeDaytonaSshConfigWriter implements Pick< DaytonaSshConfigWriter, - | "writeSandbox" - | "updateToken" - | "repointGateway" - | "removeSandbox" - | "verifyResolvedConfig" - | "readTokens" + "writeSandbox" | "updateToken" | "repointGateway" | "removeSandbox" | "readTokens" > { /** Current token per sandbox alias, as the include file would hold it. */ readonly tokens = new Map(); readonly fullWrites: string[] = []; readonly removed: string[] = []; - verifyError?: Error; + /** Fails every first write of a sandbox (a setup failure after the sandbox exists). */ + writeError?: Error; writeSandbox(input: { sandboxId: string; token: string }): Promise { + if (this.writeError) return Promise.reject(this.writeError); this.fullWrites.push(input.sandboxId); this.tokens.set(input.sandboxId, input.token); return Promise.resolve(); @@ -306,10 +303,6 @@ export class FakeDaytonaSshConfigWriter implements Pick< readTokens(): Promise> { return Promise.resolve([...this.tokens].map(([sandboxId, token]) => ({ sandboxId, token }))); } - - verifyResolvedConfig(): Promise { - return this.verifyError ? Promise.reject(this.verifyError) : Promise.resolve(); - } } export interface DaytonaTestRig { diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 675b7284c4e..a5ced14d8c7 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -346,35 +346,6 @@ describe("DaytonaService", () => { expect((await service.getAccount()).generation).toBe(before + 1); }); - it("reuses one token while the user fixes an ssh config that shadows it", async () => { - let shadowed = true; - const writer = { - writeSandbox: () => Promise.resolve(), - updateToken: () => Promise.resolve(), - removeSandbox: () => Promise.resolve(), - readTokens: () => Promise.resolve([]), - verifyResolvedConfig: () => - shadowed ? Promise.reject(new Error("ssh config shadows User")) : Promise.resolve(), - repointGateway: () => Promise.resolve(), - }; - const withWriter = new DaytonaService({ - credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), - rootDir, - fetch: plane.fetch, - now: () => clock, - sshConfigWriter: writer, - }); - for (let attempt = 0; attempt < 2; attempt++) { - expect(await withWriter.ensureSshAccess("sb-1").catch((e: unknown) => e)).toBeInstanceOf( - Error - ); - } - shadowed = false; - await withWriter.ensureSshAccess("sb-1"); - expect(plane.calls.filter((call) => call === "POST /sandbox/sb-1/ssh-access")).toHaveLength(1); - expect(plane.revoked).toEqual([]); - }); - /** A service whose first token mint waits until `releaseMint`; `includeFile` is its writer. */ function serviceWithHeldMint() { let mintStarted!: () => void; @@ -412,7 +383,6 @@ describe("DaytonaService", () => { return Promise.resolve(); }, readTokens: () => Promise.resolve([]), - verifyResolvedConfig: () => Promise.resolve(), }, }); return { service: heldService, started, releaseMint, written, includeFile }; @@ -476,7 +446,6 @@ describe("DaytonaService", () => { }, readTokens: () => Promise.resolve([...includeFile].map(([sandboxId, token]) => ({ sandboxId, token }))), - verifyResolvedConfig: () => Promise.resolve(), }, retireSshConnections: (sandboxId) => { retired.push(sandboxId); @@ -648,7 +617,6 @@ describe("DaytonaService", () => { removeSandbox: () => Promise.resolve(), readTokens: () => Promise.resolve([...includeFile].map(([sandboxId, token]) => ({ sandboxId, token }))), - verifyResolvedConfig: () => Promise.resolve(), repointGateway: () => Promise.resolve(), }; const startProcess = async () => { @@ -684,7 +652,6 @@ describe("DaytonaService", () => { failedReads-- > 0 ? Promise.reject(new Error("EIO: i/o error, read")) : Promise.resolve([{ sandboxId: "sb-1", token: "earlier-process-token" }]), - verifyResolvedConfig: () => Promise.resolve(), repointGateway: () => Promise.resolve(), }; service = new DaytonaService({ diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index fc5e4cebb4f..3c0e9f22ad8 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -214,12 +214,7 @@ export interface DaytonaServiceOptions { /** Defaults to a writer for the real `~/.ssh/config`; tests pass one aimed at a temp dir. */ sshConfigWriter?: Pick< DaytonaSshConfigWriter, - | "writeSandbox" - | "updateToken" - | "repointGateway" - | "removeSandbox" - | "verifyResolvedConfig" - | "readTokens" + "writeSandbox" | "updateToken" | "repointGateway" | "removeSandbox" | "readTokens" >; fetch?: typeof fetch; now?: () => number; @@ -1221,8 +1216,7 @@ export class DaytonaService { /** * A valid token in the include file for this sandbox. The first write in this process also - * writes known_hosts and the `~/.ssh/config` block and checks that ssh resolves the token as - * the user; a refresh rewrites only the include file. + * writes known_hosts and the `~/.ssh/config` block; a refresh rewrites only the include file. */ ensureSshAccess(sandboxId: string): Promise { return this.withAccountLease(async () => { @@ -1230,7 +1224,7 @@ export class DaytonaService { // Fails before a mint when the control plane cannot say where the gateway is. await this.getGatewayConfig(); // A cached token was written by the mint that produced it, so only a mint writes files. - const access = await this.getSshToken(sandboxId, { + await this.getSshToken(sandboxId, { onNewToken: async ({ token }) => { // Looked up after the mint, so a settings change before it cannot pair this token with // the old deployment's gateway; one after it makes the mint start over. @@ -1242,10 +1236,6 @@ export class DaytonaService { await this.sshConfigWriter.writeSandbox({ sandboxId, token, gateway }); }, }); - if (this.sshConfigured.has(sandboxId)) return; - // Outside the mint: when the user's ssh config shadows the token, a retry after they fix it - // reuses this token (already written) instead of minting another one. - await this.sshConfigWriter.verifyResolvedConfig(sandboxId, access.token); this.sshConfigured.add(sandboxId); }); } From ffb6e7bc48b5ed8e9825247e603605442721b95d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:10:57 +0000 Subject: [PATCH 139/231] fix(daytona): refetch a gateway that moved within the config cache and retry once --- src/node/runtime/DaytonaSSHRuntime.test.ts | 61 ++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 69 +++++++++++++------ .../services/daytona/daytona.testHarness.ts | 36 ++++++++-- src/node/services/daytona/daytonaService.ts | 60 ++++++++++++---- 4 files changed, 187 insertions(+), 39 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 0c6e594bdd3..0adf9a1644f 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -1557,6 +1557,67 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { expect(rig.plane.countCalls("POST /sandbox/sb-nap3/start")).toBe(1); }); + it("refetches a gateway that moved or rotated its keys within the config cache, then retries once", async () => { + rig.plane.addSandbox({ id: "sb-moved" }); + const local = localShellTransport(rig.rootDir); + let attempts = 0; + let healthResets = 0; + // ssh reaches the sandbox only through the gateway its include file and known_hosts name. + const transport: SSHTransport = { + ...local, + resetConnectionHealth: () => healthResets++, + spawnRemoteProcess: (command, options) => { + attempts++; + return JSON.stringify(rig.writer.gateway) === JSON.stringify(rig.plane.gateway) + ? local.spawnRemoteProcess(command, options) + : Promise.reject(new RuntimeError("Host key verification failed.", "network")); + }, + }; + const turn = () => makeRuntime({ sandboxId: "sb-moved" }, rig, { transport }); + expect(await turn().ensureReady()).toEqual({ ready: true }); + const configFetches = () => rig.plane.countCalls("GET /config"); + + const moves = [ + { ...rig.plane.gateway, host: "ssh2.daytona.test", port: 2222 }, + { ...rig.plane.gateway, hostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIRotated"] }, + ]; + for (const gateway of moves) { + // Within the five-minute cache: nothing refetches /config on its own. + rig.plane.gateway = gateway; + const [fetches, tries, resets] = [configFetches(), attempts, healthResets]; + const runtime = turn(); + expect(await runtime.ensureReady()).toEqual({ ready: true }); + // Two commands fail together; they share one refetch. + const streams = await Promise.all( + [1, 2].map(() => runtime.exec("echo ok", { cwd: "/tmp", timeout: 10 })) + ); + for (const stream of streams) { + expect(await new Response(stream.stdout).text()).toBe("ok\n"); + } + expect(attempts - tries).toBe(4); + expect(configFetches()).toBe(fetches + 1); + expect(rig.writer.repoints.at(-1)).toEqual(gateway); + expect(healthResets).toBeGreaterThan(resets); + } + + // The gateway did not change: the failure is something else, so no retry. + const before = { + fetches: configFetches(), + tries: attempts, + repoints: rig.writer.repoints.length, + }; + const unchanged = turn(); + expect(await unchanged.ensureReady()).toEqual({ ready: true }); + rig.writer.gateway = { ...rig.plane.gateway, host: "unreachable.test" }; + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(unchanged.exec("echo ok", { cwd: "/tmp", timeout: 10 })).rejects.toThrow( + "Host key verification failed" + ); + expect(attempts - before.tries).toBe(1); + expect(configFetches()).toBe(before.fetches + 1); + expect(rig.writer.repoints).toHaveLength(before.repoints); + }); + /** The gateway answers a stopped or deleted sandbox with the same refusal as a bad token. */ function refusingTransportWhile(sandboxId: string, refuses: () => boolean): SSHTransport { const local = localShellTransport(rig.rootDir); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 932732744e1..a355cd82f20 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -87,6 +87,8 @@ export type DaytonaRuntimeService = Pick< | "invalidateSandboxReady" | "replaceRejectedSshToken" | "sshTokenGeneration" + | "gatewayGeneration" + | "refreshGatewayAfterSshFailure" | "loadPersistedTokens" | "findSnapshot" | "getGatewayConfig" @@ -183,6 +185,16 @@ interface CreateAttempt { account?: DaytonaAccount; } +/** + * Captured before an ssh command: a recovery acts only on what changed since. A refusal + * replaces only the token this command used; a gateway another failure already followed is + * not refetched again. + */ +interface SshAttempt { + tokenGeneration: number; + gatewayGeneration: number; +} + type DeleteResult = | { success: true; deletedPath: string; notice?: string } | { success: false; error: string; leftoverPaths?: string[] }; @@ -443,8 +455,9 @@ export class DaytonaSSHRuntime extends SSHRuntime { /** * Every ssh command to the sandbox goes through here: its stderr and errors are redacted * (the token is the ssh user name). A refused token is replaced, and a connection failure on - * a turn's runtime whose sandbox stopped within the ready cache starts the sandbox again; - * either way the command is retried once. + * a turn's runtime whose sandbox stopped within the ready cache starts the sandbox again (or, + * when it still runs, follows a gateway Daytona moved); either way the command is retried + * once. */ protected override async spawnRemoteProcess( fullCommand: string, @@ -452,11 +465,11 @@ export class DaytonaSSHRuntime extends SSHRuntime { ): Promise { let result: SpawnResult; const sandboxId = this.daytonaConfig.sandboxId; - const tokenGeneration = await this.prepareSshAttempt(sandboxId); + const attempt = await this.prepareSshAttempt(sandboxId); try { result = await super.spawnRemoteProcess(fullCommand, options); } catch (error) { - if (!(await this.recoverForRetry(error, tokenGeneration, options.abortSignal))) { + if (!(await this.recoverForRetry(error, attempt, options.abortSignal))) { throw redactDaytonaError(error); } try { @@ -479,14 +492,16 @@ export class DaytonaSSHRuntime extends SSHRuntime { }; } - /** The token generation a refusal of this command must match. */ - private async prepareSshAttempt(sandboxId: string | undefined): Promise { - if (!sandboxId) return 0; + /** What a recovery from this command's failure must compare against (see SshAttempt). */ + private async prepareSshAttempt(sandboxId: string | undefined): Promise { + if (!sandboxId) return { tokenGeneration: 0, gatewayGeneration: 0 }; // ssh may send a token an earlier process left in the include file: the redactor must // know it before ssh can quote it. Not every command runs after ensureReady. await this.daytonaService.loadPersistedTokens(); - // Before the attempt: a refusal replaces only the token this command used. - return this.daytonaService.sshTokenGeneration(sandboxId); + return { + tokenGeneration: this.daytonaService.sshTokenGeneration(sandboxId), + gatewayGeneration: this.daytonaService.gatewayGeneration(), + }; } /** @@ -497,7 +512,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { */ private recoverForRetry( error: unknown, - tokenGeneration: number, + attempt: SshAttempt, abortSignal: AbortSignal | undefined ): Promise { return this.daytonaService.withAccountLease(async () => { @@ -508,22 +523,17 @@ export class DaytonaSSHRuntime extends SSHRuntime { if (!tokenRejected && !this.restartsStoppedSandbox) return false; this.daytonaService.invalidateSandboxReady(sandboxId); const state = await this.readStateAfterFailure(sandboxId); - return this.recoverInState( - sandboxId, - state, - { error, tokenRejected, tokenGeneration }, - abortSignal - ); + return this.recoverInState(sandboxId, state, { error, tokenRejected, attempt }, abortSignal); }); } private recoverInState( sandboxId: string, state: DaytonaSandboxState | undefined, - failure: { error: unknown; tokenRejected: boolean; tokenGeneration: number }, + failure: { error: unknown; tokenRejected: boolean; attempt: SshAttempt }, abortSignal: AbortSignal | undefined ): Promise { - const { error, tokenRejected, tokenGeneration } = failure; + const { error, tokenRejected, attempt } = failure; if (state === "missing") { throw new RuntimeError( sandboxMissingMessage(sandboxId), @@ -532,9 +542,10 @@ export class DaytonaSSHRuntime extends SSHRuntime { ); } if (state === "started") { + // Only a runtime that may restart the sandbox gets here without a refused token. return tokenRejected - ? this.replaceRejectedToken(sandboxId, tokenGeneration) - : Promise.resolve(false); + ? this.replaceRejectedToken(sandboxId, attempt.tokenGeneration) + : this.followMovedGateway(sandboxId, attempt.gatewayGeneration); } if (state === undefined || !this.restartsStoppedSandbox) return Promise.resolve(false); return this.restartSandbox(sandboxId, abortSignal); @@ -557,6 +568,24 @@ export class DaytonaSSHRuntime extends SSHRuntime { return true; } + private async followMovedGateway(sandboxId: string, gatewayGeneration: number): Promise { + let moved: boolean; + try { + moved = await this.daytonaService.refreshGatewayAfterSshFailure(sandboxId, gatewayGeneration); + } catch (refreshError) { + log.warn("Could not refetch the Daytona gateway after an ssh failure", { + sandboxId, + error: getErrorMessage(redactDaytonaError(refreshError)), + }); + return false; + } + if (!moved) return false; + log.info("The Daytona gateway moved; retrying the command once", { sandboxId }); + // A host key failure against the old gateway may have marked the alias as failing for good. + this.sshTransport.resetConnectionHealth?.(); + return true; + } + private async restartSandbox( sandboxId: string, abortSignal: AbortSignal | undefined diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index a4a74cf0c2b..3c324d15653 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -3,7 +3,7 @@ import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; -import type { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; +import type { DaytonaSshConfigWriter, DaytonaSshGateway } from "@/node/runtime/daytonaSshConfig"; import type { SSHTransport } from "@/node/runtime/transports"; import type { DaytonaWorkspaceRef } from "./daytonaReconciler"; import { DAYTONA_LABEL_INSTANCE, DaytonaService } from "./daytonaService"; @@ -48,6 +48,12 @@ export class FakeDaytonaControlPlane { listedOnly?: boolean; state?: string; }> = []; + /** The ssh gateway GET /config advertises; a test moves it or rotates its host keys. */ + gateway: DaytonaSshGateway = { + host: "ssh.daytona.test", + port: 22, + hostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey"], + }; /** The snapshot GET /config advertises as the deployment default; unset advertises none. */ defaultSnapshot?: string; /** Answer POST /sandbox with 400 when it carries cpu/memory/disk (fixed-resource snapshot). */ @@ -119,9 +125,9 @@ export class FakeDaytonaControlPlane { if (route === "GET /config") { return json(200, { - sshGatewayHost: "ssh.daytona.test", - sshGatewayPort: 22, - sshGatewayHostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey"], + sshGatewayHost: this.gateway.host, + sshGatewayPort: this.gateway.port, + sshGatewayHostKeys: this.gateway.hostKeys, ...(this.defaultSnapshot ? { defaultSnapshot: this.defaultSnapshot } : {}), }); } @@ -278,19 +284,35 @@ export class FakeDaytonaSshConfigWriter implements Pick< /** Fails every first write of a sandbox (a setup failure after the sandbox exists). */ writeError?: Error; - writeSandbox(input: { sandboxId: string; token: string }): Promise { + /** The gateway the include file and known_hosts point at, as last written or repointed. */ + gateway?: DaytonaSshGateway; + readonly repoints: DaytonaSshGateway[] = []; + + writeSandbox(input: { + sandboxId: string; + token: string; + gateway: DaytonaSshGateway; + }): Promise { if (this.writeError) return Promise.reject(this.writeError); this.fullWrites.push(input.sandboxId); + this.gateway = input.gateway; this.tokens.set(input.sandboxId, input.token); return Promise.resolve(); } - updateToken(input: { sandboxId: string; token: string }): Promise { + updateToken(input: { + sandboxId: string; + token: string; + gateway: DaytonaSshGateway; + }): Promise { + this.gateway = input.gateway; this.tokens.set(input.sandboxId, input.token); return Promise.resolve(); } - repointGateway(): Promise { + repointGateway(gateway: DaytonaSshGateway): Promise { + this.repoints.push(gateway); + this.gateway = gateway; return Promise.resolve(); } diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 3c0e9f22ad8..00f2d54acad 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -313,6 +313,8 @@ export class DaytonaService { private readonly fetchImpl?: typeof fetch; private readonly now: () => number; private readonly sleep: (ms: number) => Promise; + /** See gatewayGeneration. */ + private gatewayMoves = 0; private gatewayCache?: { apiUrl: string; generation: number; @@ -797,25 +799,35 @@ export class DaytonaService { return account?.client ?? (await this.getClient()).client; } - async getGatewayConfig(): Promise { + /** `force` refetches a cached `/config` that is still fresh (an in-flight fetch is shared). */ + async getGatewayConfig(options: { force?: boolean } = {}): Promise { const generation = this.credentialsGeneration; const { client, credentials } = await this.getClient(); const cached = this.cachedGateway(credentials.apiUrl, generation); - if (cached && this.now() - cached.fetchedAt < DAYTONA_GATEWAY_CONFIG_TTL_MS) { + if (cached && !options.force && this.now() - cached.fetchedAt < DAYTONA_GATEWAY_CONFIG_TTL_MS) { return cached.config; } - // Concurrent lookups share one refresh: a reply that started earlier and arrived last would - // otherwise publish the old gateway over the new one. + return this.sharedGatewayRefresh(client, credentials.apiUrl, generation, cached); + } + + /** + * Concurrent lookups share one refresh: a reply that started earlier and arrived last would + * otherwise publish the old gateway over the new one. + */ + private sharedGatewayRefresh( + client: DaytonaClient, + apiUrl: string, + generation: number, + cached: ReturnType + ): Promise { const inFlight = this.gatewayRequest; - if (inFlight?.apiUrl === credentials.apiUrl && inFlight.generation === generation) { + if (inFlight?.apiUrl === apiUrl && inFlight.generation === generation) { return inFlight.promise; } - const promise = this.refreshGateway(client, credentials.apiUrl, generation, cached).finally( - () => { - if (this.gatewayRequest?.promise === promise) this.gatewayRequest = undefined; - } - ); - this.gatewayRequest = { apiUrl: credentials.apiUrl, generation, promise }; + const promise = this.refreshGateway(client, apiUrl, generation, cached).finally(() => { + if (this.gatewayRequest?.promise === promise) this.gatewayRequest = undefined; + }); + this.gatewayRequest = { apiUrl, generation, promise }; return promise; } @@ -830,10 +842,34 @@ export class DaytonaService { if (this.credentialsGeneration !== generation) return config; const moved = cached && gatewayIdentity(cached.config) !== gatewayIdentity(config); this.gatewayCache = { apiUrl, generation, fetchedAt: this.now(), config }; - if (moved) await this.retireSshSetup(config); + if (moved) { + await this.retireSshSetup(config); + this.gatewayMoves++; + } return config; } + /** Bumped once a moved gateway's ssh setup was rewritten; capture it before an ssh command. */ + gatewayGeneration(): number { + return this.gatewayMoves; + } + + /** + * An ssh command to a started sandbox failed with neither a refused token nor a stop: Daytona + * may have moved its gateway or rotated its host keys within the `/config` cache. True when + * the gateway changed since `attemptGeneration` (refetching `/config` unless a concurrent + * failure already found the move) and the sandbox's ssh setup now points at the new one. + */ + async refreshGatewayAfterSshFailure( + sandboxId: string, + attemptGeneration: number + ): Promise { + if (this.gatewayMoves === attemptGeneration) await this.getGatewayConfig({ force: true }); + if (this.gatewayMoves === attemptGeneration) return false; + await this.ensureSshAccess(sandboxId); + return true; + } + /** The cached `/config` of these settings, fresh or not. */ private cachedGateway(apiUrl: string, generation: number) { const cached = this.gatewayCache; From 83ce12d0c01eef5ecf2935940847a05ef50388a2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:07:34 +0000 Subject: [PATCH 140/231] fix(terminal): shell-quote every argument of native-terminal ssh command strings --- src/node/services/terminalService.test.ts | 83 ++++++++++++++++++++++- src/node/services/terminalService.ts | 20 +++--- 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/src/node/services/terminalService.test.ts b/src/node/services/terminalService.test.ts index eead23eb211..53d45b7a369 100644 --- a/src/node/services/terminalService.test.ts +++ b/src/node/services/terminalService.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, mock, beforeEach, afterEach, spyOn, vi, type Mock } from "bun:test"; import { TerminalService } from "./terminalService"; +import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; import { WorkspaceMutationInProgressError, WorkspaceUseLeases, @@ -1496,8 +1497,8 @@ describe("TerminalService.openNative", () => { const script = call[1]?.[1]; expect(script).toContain('tell application "Terminal"'); expect(script).toContain("ssh"); - expect(script).toContain("-p 2222"); // port - expect(script).toContain("-i ~/.ssh/id_rsa"); // identity file + expect(script).toContain("'-p' '2222'"); // port + expect(script).toContain("'-i' '~/.ssh/id_rsa'"); // identity file expect(script).toContain("remote.example.com"); // host }); }); @@ -1881,6 +1882,84 @@ describe("TerminalService.openNative", () => { }); }); + // A native terminal gets ssh as one command string (Ghostty, Terminal.app, xfce4-terminal), so + // each argument must survive the shell's word splitting: a home like "/Users/John Smith" puts + // spaces in the Daytona include path and the identity file. + describe("SSH command strings", () => { + const writer = new DaytonaSshConfigWriter({ xumHome: "/tmp/John Smith/.xum" }); + const identityFile = "/tmp/John Smith/id key"; + const host = "sb-1.xum--daytona"; + const remotePath = "/home/user/it's here"; + const expectedArgv = [ + "ssh", + "-i", + identityFile, + "-t", + "-F", + writer.paths.includePath, + host, + `cd '${remotePath.replace(/'/g, "'\\''")}' && exec $SHELL`, + ]; + const configWithDaytonaWorkspace = { + ...configWithSSHWorkspace, + getAllWorkspaceMetadata: mock(() => + Promise.resolve([ + { + id: "ws-daytona", + projectPath: "/home/user/project", + name: "feature", + namedWorkspacePath: remotePath, + runtimeConfig: { type: "ssh", host, srcBaseDir: "~/src", identityFile }, + }, + ]) + ), + } as unknown as Config; + + /** The argv a POSIX shell splits `command` into. */ + function shellArgv(command: string): string[] { + const printed = Bun.spawnSync(["sh", "-c", `printf '%s\\0' ${command}`]).stdout.toString(); + return printed.split("\0").slice(0, -1); + } + + async function spawnedArgs(): Promise { + service = new TerminalService(configWithDaytonaWorkspace, mockPTYService, mockSecretsStore); + await service.openNative("ws-daytona"); + expect(spawnSpy).toHaveBeenCalledTimes(1); + return (spawnSpy.mock.calls[0] as [string, string[]])[1]; + } + + it("Ghostty", async () => { + setPlatform("darwin"); + fsStatSpy.mockImplementation((statPath: string) => + statPath === "/Applications/Ghostty.app/Contents/MacOS/ghostty" + ? Promise.resolve({ isFile: () => true, mode: 0o755 }) + : Promise.reject(new Error("ENOENT")) + ); + const args = await spawnedArgs(); + const command = args.find((arg) => arg.startsWith("--command=")); + expect(shellArgv(command?.slice("--command=".length) ?? "")).toEqual(expectedArgv); + }); + + it("Terminal.app", async () => { + setPlatform("darwin"); + spawnSyncSpy.mockImplementation((cmd: string, args: string[]) => + cmd === "which" && args?.[0] === "ghostty" ? { status: 1 } : { status: 0 } + ); + const script = (await spawnedArgs())[1]; + const appleScriptString = /do script "((?:[^"\\]|\\.)*)"/.exec(script)?.[1] ?? ""; + expect(shellArgv(appleScriptString.replace(/\\(.)/g, "$1"))).toEqual(expectedArgv); + }); + + it("xfce4-terminal", async () => { + setPlatform("linux"); + spawnSyncSpy.mockImplementation((cmd: string, args: string[]) => + cmd === "which" && args?.[0] === "xfce4-terminal" ? { status: 0 } : { status: 1 } + ); + const args = await spawnedArgs(); + expect(shellArgv(args[1])).toEqual(expectedArgv); + }); + }); + describe("Linux", () => { beforeEach(() => { setPlatform("linux"); diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index 6e4148bdedf..50e3e6aa4b7 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -41,6 +41,11 @@ import { NO_OSC_IDLE_FALLBACK_MS } from "@/constants/terminalActivity"; import { getErrorMessage } from "@/common/utils/errors"; import { shellQuote } from "@/common/utils/shell"; +/** One shell command string for terminals that take ssh as a string, not an argv. */ +function toShellCommand(argv: string[]): string { + return argv.map(shellQuote).join(" "); +} + function quoteForNativeTerminalCommandArg(value: string): string { if (process.platform === "win32") { // cmd.exe expands %VAR% even in double quotes, so escape literal % as %%. @@ -1044,7 +1049,7 @@ export class TerminalService { if (isSSH && sshArgs) { // Ghostty: Use --command flag to run SSH // Build the full SSH command as a single string - const sshCommand = ["ssh", ...sshArgs].join(" "); + const sshCommand = toShellCommand(["ssh", ...sshArgs]); args = ["-n", "-a", "Ghostty", "--args", `--command=${sshCommand}`]; } else if (command) { // Ghostty: Run command in workspace directory @@ -1069,16 +1074,7 @@ export class TerminalService { let args: string[]; if (isSSH && sshArgs) { // Terminal.app: Use osascript with proper AppleScript structure - // Properly escape single quotes in args before wrapping in quotes - const sshCommand = `ssh ${sshArgs - .map((arg) => { - if (arg.includes(" ") || arg.includes("'")) { - // Escape single quotes by ending quote, adding escaped quote, starting quote again - return `'${arg.replace(/'/g, "'\\''")}'`; - } - return arg; - }) - .join(" ")}`; + const sshCommand = toShellCommand(["ssh", ...sshArgs]); // Escape double quotes for AppleScript string const escapedCommand = sshCommand.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const script = `tell application "Terminal"\nactivate\ndo script "${escapedCommand}"\nend tell`; @@ -1155,7 +1151,7 @@ export class TerminalService { { cmd: "wezterm", args: ["start", "--", "ssh", ...sshArgs] }, { cmd: "gnome-terminal", args: ["--", "ssh", ...sshArgs] }, { cmd: "konsole", args: ["-e", "ssh", ...sshArgs] }, - { cmd: "xfce4-terminal", args: ["-e", `ssh ${sshArgs.join(" ")}`] }, + { cmd: "xfce4-terminal", args: ["-e", toShellCommand(["ssh", ...sshArgs])] }, { cmd: "xterm", args: ["-e", "ssh", ...sshArgs] }, ]; } else if (command) { From 7281c19195620bde541e10943500233756c3e403 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:23:06 +0000 Subject: [PATCH 141/231] docs(daytona): describe the -F isolation and the label check before deletes The kept-sandbox notice no longer claims Xum did not create a sandbox whose label was removed elsewhere. --- docs/runtime/daytona.mdx | 6 +++--- .../services/agentSkills/builtInSkillContent.generated.ts | 6 +++--- src/node/services/daytona/daytonaService.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index a2ce0af1dd4..02bf0fee8a9 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -63,7 +63,7 @@ Select **Existing** and pick a sandbox from your organization. Xum treats an att | Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs | | Sandbox deleted outside Xum | The workspace reports that the sandbox no longer exists; Xum does not recreate it | -A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses. When you delete the last workspaces on a sandbox at the same time, Xum deletes the sandbox once, after the last of them passes its check for uncommitted work. If you attach another workspace to a sandbox Xum created and delete the workspaces that Xum created it for first, the sandbox becomes yours like any attached one: Xum keeps it, says so when it removes the last of those workspaces, and never deletes it later. +A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses. When you delete the last workspaces on a sandbox at the same time, Xum deletes the sandbox once, after the last of them passes its check for uncommitted work. If you attach another workspace to a sandbox Xum created and delete the workspaces that Xum created it for first, the sandbox becomes yours like any attached one: Xum keeps it, says so when it removes the last of those workspaces, and never deletes it later. Before any delete, Xum checks that the sandbox still carries this install's `xum.instance` label; a sandbox without it (for example one that took the name while Xum's create failed, or one whose label was removed) is kept, and Xum says so. If the sandbox is still being destroyed (for example after a failed setup), delete waits up to a minute for Daytona to finish. A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard. @@ -73,11 +73,11 @@ Once a minute, Xum also stops running sandboxes that carry this install's label ## SSH setup -Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. At startup, Xum reads the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered. +Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, so it also filters tokens that another Xum process on the same Xum root wrote there. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered. Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts. -OpenSSH uses the first value it finds for each setting. If your `~/.ssh/config` has a `Host *` entry with a `User` or `UserKnownHostsFile` line above Xum's block, connections to sandboxes fail. Xum checks for this and reports it; move the line into a narrower `Host` entry, or below Xum's block. A `StrictHostKeyChecking` line there has no effect: Xum always runs `ssh` with strict host key checking for sandboxes. +Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, so a `Host *` entry above Xum's block with a `User` or `UserKnownHostsFile` line breaks those connections; move the line into a narrower `Host` entry, or below Xum's block. ## Notes diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index d58de1dae52..a1ab92a5b38 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10100,7 +10100,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "| Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs |", "| Sandbox deleted outside Xum | The workspace reports that the sandbox no longer exists; Xum does not recreate it |", "", - "A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses. When you delete the last workspaces on a sandbox at the same time, Xum deletes the sandbox once, after the last of them passes its check for uncommitted work. If you attach another workspace to a sandbox Xum created and delete the workspaces that Xum created it for first, the sandbox becomes yours like any attached one: Xum keeps it, says so when it removes the last of those workspaces, and never deletes it later.", + "A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses. When you delete the last workspaces on a sandbox at the same time, Xum deletes the sandbox once, after the last of them passes its check for uncommitted work. If you attach another workspace to a sandbox Xum created and delete the workspaces that Xum created it for first, the sandbox becomes yours like any attached one: Xum keeps it, says so when it removes the last of those workspaces, and never deletes it later. Before any delete, Xum checks that the sandbox still carries this install's `xum.instance` label; a sandbox without it (for example one that took the name while Xum's create failed, or one whose label was removed) is kept, and Xum says so.", "", "If the sandbox is still being destroyed (for example after a failed setup), delete waits up to a minute for Daytona to finish. A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard.", "", @@ -10110,11 +10110,11 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "## SSH setup", "", - "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. At startup, Xum reads the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered.", + "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, so it also filters tokens that another Xum process on the same Xum root wrote there. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered.", "", "Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts.", "", - "OpenSSH uses the first value it finds for each setting. If your `~/.ssh/config` has a `Host *` entry with a `User` or `UserKnownHostsFile` line above Xum's block, connections to sandboxes fail. Xum checks for this and reports it; move the line into a narrower `Host` entry, or below Xum's block. A `StrictHostKeyChecking` line there has no effect: Xum always runs `ssh` with strict host key checking for sandboxes.", + "Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, so a `Host *` entry above Xum's block with a `User` or `UserKnownHostsFile` line breaks those connections; move the line into a narrower `Host` entry, or below Xum's block.", "", "## Notes", "", diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 00f2d54acad..6fe74ae56e5 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -69,7 +69,7 @@ export const DAYTONA_LABEL_INSTANCE = "xum.instance"; export function notOwnedSandboxNotice(sandboxId: string): string { return ( `Daytona sandbox "${sandboxId}" was kept: it does not carry this Xum install's ` + - `"${DAYTONA_LABEL_INSTANCE}" label, so Xum did not create it.` + `"${DAYTONA_LABEL_INSTANCE}" label, so Xum does not treat it as its own.` ); } export const DAYTONA_LABEL_WORKSPACE_ID = "xum.workspaceId"; From 4873b20c17961f78df8db4b9b3a5e6f3b79bea28 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:30:13 +0000 Subject: [PATCH 142/231] fix(daytona): prove ownership before a removal or archive changes the sandbox --- src/node/runtime/DaytonaSSHRuntime.test.ts | 29 +++++++++----- src/node/runtime/DaytonaSSHRuntime.ts | 40 +++++++++++++++++++ .../daytona/daytonaLifecycleHooks.test.ts | 35 ++++++++++------ .../services/daytona/daytonaLifecycleHooks.ts | 6 +++ src/node/services/daytona/daytonaService.ts | 22 +++++++--- 5 files changed, 104 insertions(+), 28 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 0adf9a1644f..3da0bfaa2d5 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -712,7 +712,7 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { expect(rig.plane.find("xum-theirs")).toBe(other); }); - it("never deletes a sandbox without this install's label when the failed workspace is removed", async () => { + it("never touches a sandbox without this install's label when the failed workspace is removed", async () => { const sshDelete = spyOn(SSHRuntime.prototype, "deleteWorkspace").mockResolvedValue({ success: true, deletedPath: "/path", @@ -731,17 +731,24 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { .catch(() => undefined); rig.plane.failingRoutes.clear(); - // The persisted workspace still says Xum created it; neither removal deletes it. - for (const force of [false, true]) { - const removal = await makeRuntime(daytona).deleteWorkspace( - "/home/me/project", - "feature", - force - ); - expect(removal.success).toBe(true); - if (removal.success) expect(removal.notice).toContain("xum.instance"); - expect(rig.plane.find("xum-theirs")).toBe(other); + // The persisted workspace still says Xum created it. Their sandbox may hold a checkout at + // this workspace's path: no removal starts it, checks it, deletes that checkout or it. + for (const state of ["started", "stopped"]) { + for (const force of [false, true]) { + other.state = state; + const removal = await makeRuntime(daytona).deleteWorkspace( + "/home/me/project", + "feature", + force + ); + expect(removal.success).toBe(true); + if (removal.success) expect(removal.notice).toContain("xum.instance"); + expect(rig.plane.find("xum-theirs")).toBe(other); + expect(other.state).toBe(state); + } } + expect(sshDelete).not.toHaveBeenCalled(); + expect(rig.plane.countCalls("POST /sandbox/xum-theirs/start")).toBe(0); expect(rig.plane.countCalls("DELETE /sandbox/xum-theirs")).toBe(0); } finally { sshDelete.mockRestore(); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index a355cd82f20..bd963fc2dfd 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -95,6 +95,7 @@ export type DaytonaRuntimeService = Pick< | "sandboxReferencesElsewhere" | "isAliasStoredElsewhere" | "withSandboxRemovalLock" + | "sandboxOwnership" | "waitUntilDestroyed" | "recordWorkspaceRemoval" | "releaseSshAccess" @@ -1144,6 +1145,8 @@ export class DaytonaSSHRuntime extends SSHRuntime { log.warn("Daytona sandbox id not set, falling back to SSH-only deletion"); return sshDelete(); } + const unowned = await this.removalOfUnownedSandbox(sandboxId, deletedPath, force); + if (unowned) return unowned; const forced = force ? await this.forcedRemovalWithoutSsh(sandboxId, deletedPath) : undefined; if (forced) return forced; @@ -1161,6 +1164,43 @@ export class DaytonaSSHRuntime extends SSHRuntime { return this.settleAfterSshDelete(sandboxId, deletedPath, await sshDelete(), force); } + /** + * A sandbox this workspace says Xum created, but without this install's label: a create of + * unknown outcome left the workspace naming someone else's sandbox, which may hold a + * checkout at this workspace's path. So the removal changes nothing on it (no start, no + * checks, no checkout or sandbox delete) and only drops Xum's access. Undefined for a sandbox + * that is Xum's, attached, or gone: the removal goes on as usual. + */ + private async removalOfUnownedSandbox( + sandboxId: string, + deletedPath: string, + force: boolean + ): Promise { + if (this.daytonaConfig.shared === true) return undefined; + let ownership: Awaited>; + try { + ownership = await this.daytonaService.sandboxOwnership(sandboxId); + } catch (error) { + const message = getErrorMessage(error); + log.error("Could not read a Daytona sandbox's labels before removal", { + sandboxId, + error: message, + }); + return { + success: false, + error: `Could not check that Daytona sandbox "${sandboxId}" is Xum's: ${message}`, + // A forced removal goes on without it: remove shows this as a warning to act on. + ...(force ? { leftoverPaths: [`Daytona sandbox "${sandboxId}"`] } : {}), + }; + } + if (ownership !== "notOwned") return undefined; + return this.daytonaService.withSandboxRemovalLock(async () => { + await this.releaseAccessOnDelete(sandboxId, "keep"); + this.recordRemoval(); + return { success: true, deletedPath, notice: notOwnedSandboxNotice(sandboxId) }; + }); + } + /** A failed check (dirty or unpushed work) keeps the workspace and its sandbox. */ private settleAfterSshDelete( sandboxId: string, diff --git a/src/node/services/daytona/daytonaLifecycleHooks.test.ts b/src/node/services/daytona/daytonaLifecycleHooks.test.ts index 446329b815d..6f9fc983505 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.test.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.test.ts @@ -98,20 +98,31 @@ describe("createDaytonaArchiveHook", () => { expect(rig.writer.tokens.has("sb-waking")).toBe(false); }); - it("keeps a sandbox without this install's label on archive delete, and says why", async () => { + it("never stops or deletes a sandbox without this install's label on archive, and says why", async () => { rig.plane.addSandbox({ id: "sb-foreign", labels: { "xum.instance": "another-install" } }); - const warnings: string[] = []; - - const result = await archive( - { sandboxId: "sb-foreign", shared: false }, - "delete", - cleanCheck, - (w) => warnings.push(w) - ); + for (const behavior of ["stop", "delete"] as const) { + const warnings: string[] = []; + let checked = false; + const check = () => { + // The delete checks start a stopped sandbox to look at its checkout. + checked = true; + return cleanCheck(); + }; + + const result = await archive( + { sandboxId: "sb-foreign", shared: false }, + behavior, + check, + (w) => warnings.push(w) + ); - expect(result.success).toBe(true); - expect(rig.plane.find("sb-foreign")?.state).toBe("started"); - expect(warnings.join(" ")).toContain("xum.instance"); + expect(result.success).toBe(true); + expect(checked).toBe(false); + expect(rig.plane.find("sb-foreign")?.state).toBe("started"); + expect(warnings.join(" ")).toContain("xum.instance"); + } + expect(rig.plane.countCalls("POST /sandbox/sb-foreign/stop")).toBe(0); + expect(rig.plane.countCalls("DELETE /sandbox/sb-foreign")).toBe(0); }); it("never stops or deletes an attached or shared sandbox", async () => { diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index 3fd3066b893..088dc64bb44 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -22,6 +22,7 @@ export interface DaytonaArchiveHookOptions { | "getSandboxState" | "stopAndWait" | "deleteSandbox" + | "sandboxOwnership" | "sandboxUseElsewhere" | "isAliasStoredElsewhere" | "releaseSshAccess" @@ -86,6 +87,11 @@ async function applyArchiveBehavior( const behavior = args.coderWorkspaceArchiveBehavior ?? options.getArchiveBehavior(); if (behavior === "keep") return Ok(undefined); try { + // Neither policy, nor the delete checks' start, touches a sandbox that is not Xum's. + if ((await options.daytonaService.sandboxOwnership(sandboxId)) === "notOwned") { + args.reportWarning?.(notOwnedSandboxNotice(sandboxId)); + return Ok(undefined); + } if (behavior === "delete") { return archivedOthers ? await stopInsteadOfDelete(options, args, sandboxId) diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 6fe74ae56e5..a1c284b07bc 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -1169,7 +1169,7 @@ export class DaytonaService { account?: DaytonaAccount ): Promise<"deleted" | "notOwned"> { const client = await this.clientOf(account); - if (!(await this.carriesInstanceLabel(client, sandboxId))) return "notOwned"; + if ((await this.ownershipWith(client, sandboxId)) === "notOwned") return "notOwned"; // Revoke first: Daytona refuses to revoke a token of a sandbox that no longer exists. await this.releaseSshAccess(sandboxId); try { @@ -1180,16 +1180,28 @@ export class DaytonaService { return "deleted"; } - /** True for a sandbox that is gone: there is nothing left to protect. */ - private async carriesInstanceLabel(client: DaytonaClient, sandboxId: string): Promise { + /** + * Whether a sandbox a workspace says Xum created carries this install's label. Asked before + * any step that changes it (a start, a checkout delete, a stop or a delete): a create of + * unknown outcome may have left the workspace naming someone else's sandbox. + */ + async sandboxOwnership(sandboxId: string): Promise<"owned" | "notOwned" | "missing"> { + return this.ownershipWith((await this.getClient()).client, sandboxId); + } + + private async ownershipWith( + client: DaytonaClient, + sandboxId: string + ): Promise<"owned" | "notOwned" | "missing"> { let sandbox: DaytonaSandbox; try { sandbox = await client.getSandbox(sandboxId); } catch (error) { - if (isDaytonaNotFound(error)) return true; + if (isDaytonaNotFound(error)) return "missing"; throw error; } - return sandbox.labels?.[DAYTONA_LABEL_INSTANCE] === (await this.getInstanceId()); + const owned = sandbox.labels?.[DAYTONA_LABEL_INSTANCE] === (await this.getInstanceId()); + return owned ? "owned" : "notOwned"; } async refreshActivity(sandboxId: string): Promise { From 3a991bd601590053ca388c52421229958138eeb1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:34:34 +0000 Subject: [PATCH 143/231] fix(daytona): never bring up a sandbox without this install's label for a turn --- src/node/runtime/DaytonaSSHRuntime.test.ts | 52 +++++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 33 +++++++++++++ src/node/services/daytona/daytonaService.ts | 36 +++++++++++++- 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 3da0bfaa2d5..771726d817a 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -195,6 +195,58 @@ describe("DaytonaSSHRuntime.ensureReady", () => { expect(rig.plane.countCalls("POST /sandbox/sb-archived/ssh-access")).toBe(1); }); + it("never starts, mints for or runs ssh in a sandbox without this install's label", async () => { + // After a restart: no setup failure is remembered for the workspace whose create lost the + // name to another install's sandbox. + rig.plane.addSandbox({ + id: "xum-theirs", + state: "stopped", + labels: { "xum.instance": "another-install" }, + }); + let spawned = 0; + const local = localShellTransport(rig.rootDir); + const transport: SSHTransport = { + ...local, + spawnRemoteProcess: (command, options) => { + spawned++; + return local.spawnRemoteProcess(command, options); + }, + }; + const phases: string[] = []; + + const result = await makeRuntime({ sandboxId: "xum-theirs", shared: false }, rig, { + transport, + projectPath: "/home/me/project", + workspaceName: "feature", + }).ensureReady({ statusSink: (event) => phases.push(event.phase) }); + + expect(result.ready).toBe(false); + if (!result.ready) { + expect(result.errorType).toBe("runtime_not_ready"); + expect(result.error).toContain("xum.instance"); + } + expect(phases.at(-1)).toBe("error"); + expect(rig.plane.countCalls("POST /sandbox/xum-theirs/start")).toBe(0); + expect(rig.plane.countCalls("POST /sandbox/xum-theirs/ssh-access")).toBe(0); + expect(spawned).toBe(0); + }); + + it("proves a sandbox is this install's once per process", async () => { + rig.plane.addSandbox({ id: "sb-own" }); + const lookups = () => rig.plane.countCalls("GET /sandbox/sb-own"); + const perCheck: number[] = []; + for (let check = 0; check < 2; check++) { + rig.service.invalidateSandboxReady("sb-own"); + const before = lookups(); + expect(await makeRuntime({ sandboxId: "sb-own", shared: false }).ensureReady()).toEqual({ + ready: true, + }); + perCheck.push(lookups() - before); + } + // The start's own state read, plus the label read on the first check only. + expect(perCheck).toEqual([2, 1]); + }); + it("reports a sandbox removed outside Xum as runtime_not_ready and never recreates it", async () => { const result = await makeRuntime({ sandboxId: "sb-gone" }).ensureReady(); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index bd963fc2dfd..d70ad191a8c 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -645,6 +645,8 @@ export class DaytonaSSHRuntime extends SSHRuntime { emitStatus: StatusEmitter, signal: AbortSignal | undefined ): Promise { + const refusal = await this.refuseUnownedSandbox(sandboxId, emitStatus, signal); + if (refusal) return refusal; try { await this.daytonaService.ensureSandboxReady(sandboxId, { onStarting: () => emitStatus("starting", "Starting sandbox..."), @@ -657,6 +659,37 @@ export class DaytonaSSHRuntime extends SSHRuntime { } } + /** + * A sandbox this workspace says Xum created must carry this install's label before Xum starts + * it, mints for it or runs anything in it: a create of unknown outcome may have left the + * workspace naming someone else's sandbox (after a restart, no setup failure says so). A + * missing sandbox goes on, so the start reports it. + */ + private async refuseUnownedSandbox( + sandboxId: string, + emitStatus: StatusEmitter, + signal: AbortSignal | undefined + ): Promise { + if (this.daytonaConfig.shared === true) return undefined; + let ownership: Awaited>; + try { + ownership = await this.daytonaService.sandboxOwnership(sandboxId, { + reuseProof: true, + signal, + }); + } catch (error) { + emitStatus("error"); + return this.ensureReadyFailure(sandboxId, error, signal); + } + if (ownership !== "notOwned") return undefined; + const error = + `Daytona sandbox "${sandboxId}" does not carry this Xum install's ` + + `"${DAYTONA_LABEL_INSTANCE}" label, so Xum does not use it for this workspace. Delete ` + + "this workspace (the sandbox is kept)."; + emitStatus("error", error); + return { ready: false, error, errorType: "runtime_not_ready" }; + } + private async markReadyIfRepoPresent( sandboxId: string, options: EnsureReadyOptions | undefined, diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index a1c284b07bc..9bf4aa4c99a 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -361,6 +361,13 @@ export class DaytonaService { /** Whether the include file was read once, seeding the tokens an earlier process left. */ private persistedTokensLoaded = false; private readonly readyRequests = new Map(); + /** + * Sandboxes this process proved carry this install's label (never ones that do not), so a + * turn pays one label read per sandbox per process. Dropped with a delete or a handoff, and + * with the account. + */ + private readonly provenOwned = new Set(); + private readonly ownershipRequests = new Map>(); /** Why the setup of a sandbox Xum created (and then deleted) failed, for later turns. */ private readonly setupFailures = new Map(); private readonly listWorkspaces?: () => DaytonaWorkspaceRef[]; @@ -528,6 +535,7 @@ export class DaytonaService { */ private async retireAccountCaches(): Promise> { this.credentialsGeneration++; + this.provenOwned.clear(); const tokens = [...this.tokens]; const connected = this.sandboxesWithSsh(); this.gatewayCache = undefined; @@ -1103,6 +1111,7 @@ export class DaytonaService { */ async disownSandbox(sandboxId: string): Promise { const { client } = await this.getClient(); + this.provenOwned.delete(sandboxId); const labels = (await client.getSandbox(sandboxId)).labels ?? {}; if (!(DAYTONA_LABEL_INSTANCE in labels)) return; const kept = Object.entries(labels).filter(([key]) => key !== DAYTONA_LABEL_INSTANCE); @@ -1170,6 +1179,7 @@ export class DaytonaService { ): Promise<"deleted" | "notOwned"> { const client = await this.clientOf(account); if ((await this.ownershipWith(client, sandboxId)) === "notOwned") return "notOwned"; + this.provenOwned.delete(sandboxId); // Revoke first: Daytona refuses to revoke a token of a sandbox that no longer exists. await this.releaseSshAccess(sandboxId); try { @@ -1185,8 +1195,30 @@ export class DaytonaService { * any step that changes it (a start, a checkout delete, a stop or a delete): a create of * unknown outcome may have left the workspace naming someone else's sandbox. */ - async sandboxOwnership(sandboxId: string): Promise<"owned" | "notOwned" | "missing"> { - return this.ownershipWith((await this.getClient()).client, sandboxId); + async sandboxOwnership( + sandboxId: string, + options: { reuseProof?: boolean; signal?: AbortSignal } = {} + ): Promise<"owned" | "notOwned" | "missing"> { + // Only a turn or terminal reuses a proof: removals and archives read the label again. + if (!options.reuseProof) return this.ownershipWith((await this.getClient()).client, sandboxId); + if (this.provenOwned.has(sandboxId)) return "owned"; + // Concurrent checks share one read, so they reach the shared start together. + const request = this.ownershipRequests.get(sandboxId) ?? this.proveOwnership(sandboxId); + return raceAbort(request, options.signal); + } + + private proveOwnership(sandboxId: string): Promise<"owned" | "notOwned" | "missing"> { + const generation = this.credentialsGeneration; + const request = (async () => { + const ownership = await this.ownershipWith((await this.getClient()).client, sandboxId); + // A proof from before a settings change belongs to the old account. + if (ownership === "owned" && generation === this.credentialsGeneration) { + this.provenOwned.add(sandboxId); + } + return ownership; + })().finally(() => this.ownershipRequests.delete(sandboxId)); + this.ownershipRequests.set(sandboxId, request); + return request; } private async ownershipWith( From b805476db862a581d443d32b9c4437150d1ed7ea Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:49:16 +0000 Subject: [PATCH 144/231] fix(daytona): check ownership before the ready cache and expire the proof with it --- src/node/runtime/DaytonaSSHRuntime.test.ts | 46 ++++++++++++++++++++- src/node/runtime/DaytonaSSHRuntime.ts | 36 ++++++++++------ src/node/services/daytona/daytonaService.ts | 18 ++++---- 3 files changed, 79 insertions(+), 21 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 771726d817a..8d78d4ca824 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -231,7 +231,51 @@ describe("DaytonaSSHRuntime.ensureReady", () => { expect(spawned).toBe(0); }); - it("proves a sandbox is this install's once per process", async () => { + it("refuses a sandbox without this install's label even when an attached workspace made it ready", async () => { + // After a restart: the dedicated workspace's create lost the name to another install's + // sandbox, which a workspace attached on purpose and just used. + rig.plane.addSandbox({ id: "xum-theirs", labels: { "xum.instance": "another-install" } }); + expect(await makeRuntime({ sandboxId: "xum-theirs", shared: true }).ensureReady()).toEqual({ + ready: true, + }); + expect(rig.service.isSandboxReady("xum-theirs")).toBe(true); + let spawned = 0; + const local = localShellTransport(rig.rootDir); + const transport: SSHTransport = { + ...local, + spawnRemoteProcess: (command, options) => { + spawned++; + return local.spawnRemoteProcess(command, options); + }, + }; + + const result = await makeRuntime({ sandboxId: "xum-theirs", shared: false }, rig, { + transport, + projectPath: "/home/me/project", + workspaceName: "feature", + }).ensureReady(); + + expect(result.ready).toBe(false); + if (!result.ready) expect(result.error).toContain("xum.instance"); + expect(spawned).toBe(0); + }); + + it("sees a label removed after a turn once the ready cache window has passed", async () => { + const sandbox = rig.plane.addSandbox({ id: "sb-handed-back" }); + const turn = () => makeRuntime({ sandboxId: "sb-handed-back", shared: false }).ensureReady(); + expect(await turn()).toEqual({ ready: true }); + + // The user (or another backend) hands the sandbox back by dropping the label. + sandbox.labels = {}; + rig.clock.now += DAYTONA_READY_CACHE_MS / 2; + expect(await turn()).toEqual({ ready: true }); + rig.clock.now += DAYTONA_READY_CACHE_MS; + const refused = await turn(); + expect(refused.ready).toBe(false); + if (!refused.ready) expect(refused.error).toContain("xum.instance"); + }); + + it("proves a sandbox is this install's once per ready cache window", async () => { rig.plane.addSandbox({ id: "sb-own" }); const lookups = () => rig.plane.countCalls("GET /sandbox/sb-own"); const perCheck: number[] = []; diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index d70ad191a8c..51de5189338 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -412,22 +412,33 @@ export class DaytonaSSHRuntime extends SSHRuntime { errorType: "runtime_not_ready", }; } - const setupFailure = this.setupFailureResult(sandboxId); - if (setupFailure) return setupFailure; const emitStatus = makeStatusEmitter(options?.statusSink); + // Before the ready cache too: another workspace (one that attached the sandbox) may have + // made it ready. + const refusal = + this.setupFailureResult(sandboxId) ?? + (await this.refuseUnownedSandbox(sandboxId, emitStatus, options?.signal)); + if (refusal) return refusal; if (this.daytonaService.isSandboxReady(sandboxId)) { return this.ensureReadyFromCache(options, emitStatus); } - - emitStatus("checking"); - const failure = await this.bringSandboxUp(sandboxId, emitStatus, options?.signal); - if (failure) return failure; - const result = await this.markReadyIfRepoPresent(sandboxId, options, emitStatus); - if (result.ready) this.restartsStoppedSandbox = true; - return result; + return this.ensureReadyUncached(sandboxId, options, emitStatus); }); } + private async ensureReadyUncached( + sandboxId: string, + options: EnsureReadyOptions | undefined, + emitStatus: StatusEmitter + ): Promise { + emitStatus("checking"); + const failure = await this.bringSandboxUp(sandboxId, emitStatus, options?.signal); + if (failure) return failure; + const result = await this.markReadyIfRepoPresent(sandboxId, options, emitStatus); + if (result.ready) this.restartsStoppedSandbox = true; + return result; + } + private setupFailureResult(sandboxId: string): EnsureReadyResult | undefined { const setupFailure = this.daytonaService.getSetupFailure(sandboxId); if (!setupFailure) return undefined; @@ -594,7 +605,10 @@ export class DaytonaSSHRuntime extends SSHRuntime { log.info("Daytona sandbox is not running; starting it and retrying the command once", { sandboxId, }); - const failure = await this.bringSandboxUp(sandboxId, () => undefined, abortSignal); + // The proof ensureReady took may have expired since; a lapsed one is read again. + const failure = + (await this.refuseUnownedSandbox(sandboxId, () => undefined, abortSignal)) ?? + (await this.bringSandboxUp(sandboxId, () => undefined, abortSignal)); if (failure && !failure.ready) { log.warn("Could not restart the Daytona sandbox", { sandboxId, error: failure.error }); return false; @@ -645,8 +659,6 @@ export class DaytonaSSHRuntime extends SSHRuntime { emitStatus: StatusEmitter, signal: AbortSignal | undefined ): Promise { - const refusal = await this.refuseUnownedSandbox(sandboxId, emitStatus, signal); - if (refusal) return refusal; try { await this.daytonaService.ensureSandboxReady(sandboxId, { onStarting: () => emitStatus("starting", "Starting sandbox..."), diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 9bf4aa4c99a..9f3446595a1 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -362,11 +362,12 @@ export class DaytonaService { private persistedTokensLoaded = false; private readonly readyRequests = new Map(); /** - * Sandboxes this process proved carry this install's label (never ones that do not), so a - * turn pays one label read per sandbox per process. Dropped with a delete or a handoff, and - * with the account. + * When this process last proved a sandbox carries this install's label (never one that does + * not). A turn reuses a proof within DAYTONA_READY_CACHE_MS, so a label someone else removed + * is seen within that window and a healthy sandbox pays one label read per window. Dropped + * with a delete or a handoff, and with the account. */ - private readonly provenOwned = new Set(); + private readonly provenOwned = new Map(); private readonly ownershipRequests = new Map>(); /** Why the setup of a sandbox Xum created (and then deleted) failed, for later turns. */ private readonly setupFailures = new Map(); @@ -1201,7 +1202,8 @@ export class DaytonaService { ): Promise<"owned" | "notOwned" | "missing"> { // Only a turn or terminal reuses a proof: removals and archives read the label again. if (!options.reuseProof) return this.ownershipWith((await this.getClient()).client, sandboxId); - if (this.provenOwned.has(sandboxId)) return "owned"; + const provenAt = this.provenOwned.get(sandboxId); + if (provenAt !== undefined && this.now() - provenAt < DAYTONA_READY_CACHE_MS) return "owned"; // Concurrent checks share one read, so they reach the shared start together. const request = this.ownershipRequests.get(sandboxId) ?? this.proveOwnership(sandboxId); return raceAbort(request, options.signal); @@ -1212,9 +1214,9 @@ export class DaytonaService { const request = (async () => { const ownership = await this.ownershipWith((await this.getClient()).client, sandboxId); // A proof from before a settings change belongs to the old account. - if (ownership === "owned" && generation === this.credentialsGeneration) { - this.provenOwned.add(sandboxId); - } + if (ownership !== "owned") this.provenOwned.delete(sandboxId); + else if (generation === this.credentialsGeneration) + this.provenOwned.set(sandboxId, this.now()); return ownership; })().finally(() => this.ownershipRequests.delete(sandboxId)); this.ownershipRequests.set(sandboxId, request); From 575f0fb8e6656bcfba68ac6914ced906707ae8d9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:03:53 +0000 Subject: [PATCH 145/231] fix(daytona): apply an archived owner's deferred policy when the last attached workspace is removed --- src/node/runtime/DaytonaSSHRuntime.test.ts | 37 +++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 34 ++++++++++++++++- .../services/daytona/daytona.testHarness.ts | 8 +++- .../services/daytona/daytonaLifecycleHooks.ts | 29 ++++++++++++--- src/node/services/daytona/daytonaService.ts | 8 ++++ src/node/services/serviceContainer.ts | 18 +++++++-- 6 files changed, 123 insertions(+), 11 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 8d78d4ca824..392f0c19280 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -1415,6 +1415,43 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { expect(rig.plane.find("sb-7")).toBeDefined(); }); + it("applies the archived owner's deferred policy when the last attached workspace is removed", async () => { + const sshDelete = spyOn(SSHRuntime.prototype, "deleteWorkspace").mockResolvedValue({ + success: true, + deletedPath: "/path", + }); + try { + const sandbox = rig.plane.addSandbox({ id: "xum-owner" }); + // The owner was archived while the attached workspace worked there: it deferred its policy. + rig.workspaces.splice(0, rig.workspaces.length, { + workspaceId: "ws-owner", + runtimeConfig: sshDaytonaConfig({ sandboxId: "xum-owner", shared: false }), + archived: true, + }); + const attached = { sandboxId: "xum-owner", shared: true }; + for (const [behavior, expected] of [ + ["stop", "stopped"], + ["delete", "stopped"], + ["keep", "started"], + ] as const) { + sandbox.state = "started"; + rig.archiveBehavior = behavior; + const removal = await makeRuntime(attached, rig, { + workspaceId: "ws-attached", + }).deleteWorkspace("/home/me/project", "feature", false); + expect(removal.success).toBe(true); + expect(sandbox.state).toBe(expected); + if (behavior === "delete" && removal.success) { + expect(removal.notice).toContain("stopped, not deleted"); + } + } + // The archived owner's checkout is still on it: never deleted. + expect(rig.plane.find("xum-owner")).toBe(sandbox); + } finally { + sshDelete.mockRestore(); + } + }); + it("never deletes an attached sandbox, even with its last workspace", async () => { rig.plane.addSandbox({ id: "sb-users" }); rig.workspaces.push({ diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 51de5189338..9d471b69eec 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -55,6 +55,7 @@ import { type DaytonaAccount, type DaytonaService, } from "@/node/services/daytona/daytonaService"; +import { applyDeferredArchivePolicy } from "@/node/services/daytona/daytonaLifecycleHooks"; import { log } from "@/node/services/log"; import { execBuffered } from "@/node/utils/runtime/helpers"; import { seedAgentHome, type AgentHomeSeedSources } from "./daytonaAgentHomeSeed"; @@ -95,6 +96,8 @@ export type DaytonaRuntimeService = Pick< | "sandboxReferencesElsewhere" | "isAliasStoredElsewhere" | "withSandboxRemovalLock" + | "sandboxUseElsewhere" + | "getArchiveBehavior" | "sandboxOwnership" | "waitUntilDestroyed" | "recordWorkspaceRemoval" @@ -1426,7 +1429,36 @@ export class DaytonaSSHRuntime extends SSHRuntime { } if (fate === "handOff") return this.handOffSandbox(sandboxId, result, removal); await this.releaseAccessOnDelete(sandboxId, fate); - return result; + return fate === "keep" ? this.settleDeferredArchivePolicy(sandboxId, result) : result; + } + + /** + * A kept sandbox whose other workspaces are all archived, one of them its Xum owner: that + * owner's archive deferred its policy to the last use of the sandbox, which this removal + * ended. Applied as the archive hook does then; a failure is a warning, the removal stands. + */ + private async settleDeferredArchivePolicy( + sandboxId: string, + result: RemovedCheckout + ): Promise { + const behavior = this.daytonaService.getArchiveBehavior(); + if (behavior === "keep") return result; + let notice: string | undefined; + try { + const uses = await this.daytonaService.sandboxUseElsewhere(sandboxId, this.workspaceId); + if (uses !== "archivedOwner") return result; + if ((await this.daytonaService.sandboxOwnership(sandboxId)) !== "owned") return result; + notice = (await applyDeferredArchivePolicy(this.daytonaService, behavior, sandboxId))?.notice; + } catch (error) { + const message = getErrorMessage(error); + log.warn("Failed to apply the deferred archive policy to a Daytona sandbox", { + sandboxId, + error: message, + }); + notice = `Could not ${behavior} Daytona sandbox "${sandboxId}" as its archived workspace's policy asks: ${message}`; + } + if (!notice) return result; + return { ...result, notice: [result.notice, notice].filter(Boolean).join(" ") }; } /** diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 3c324d15653..a604d334e2d 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -2,6 +2,7 @@ import { spawn } from "child_process"; import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; +import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; import type { DaytonaSshConfigWriter, DaytonaSshGateway } from "@/node/runtime/daytonaSshConfig"; import type { SSHTransport } from "@/node/runtime/transports"; @@ -338,6 +339,8 @@ export interface DaytonaTestRig { clock: { now: number }; /** The workspaces the service sees (it deletes a sandbox only with its last one). */ workspaces: DaytonaWorkspaceRef[]; + /** The archive behavior in Settings the service reports; "keep" unless a test sets it. */ + archiveBehavior: CoderWorkspaceArchiveBehavior; cleanup: () => Promise; } @@ -372,12 +375,13 @@ export async function createDaytonaTestRig( return Promise.resolve(); }, listWorkspaces: () => workspaces, + getArchiveBehavior: () => rig.archiveBehavior, }); // Sandboxes a test adds count as ones Xum created, as most tests need; a foreign one sets // its own labels. const ownLabels = { [DAYTONA_LABEL_INSTANCE]: await service.getInstanceId() }; plane.defaultLabels = ownLabels; - return { + const rig: DaytonaTestRig = { rootDir, plane, addAccount: (apiKey) => { @@ -390,8 +394,10 @@ export async function createDaytonaTestRig( service, clock, workspaces, + archiveBehavior: "keep", cleanup: () => fs.rm(rootDir, { recursive: true, force: true }), }; + return rig; } /** Runs each "remote" command in a local bash whose HOME is `home`: the sandbox end of ssh. */ diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index 088dc64bb44..fff67b2cd41 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -113,14 +113,33 @@ async function stopInsteadOfDelete( sandboxId: string ): Promise> { const stopped = await stopForArchive(options, args, sandboxId); - if (stopped.success) { - args.reportWarning?.( - `Daytona sandbox "${sandboxId}" was stopped, not deleted: an archived workspace still has its checkout on it.` - ); - } + if (stopped.success) args.reportWarning?.(stoppedNotDeletedNotice(sandboxId)); return stopped; } +export function stoppedNotDeletedNotice(sandboxId: string): string { + return `Daytona sandbox "${sandboxId}" was stopped, not deleted: an archived workspace still has its checkout on it.`; +} + +/** + * The policy an archived owner deferred (see archiveSandbox), applied by whatever ends the + * last other use of the sandbox: with archived workspaces left on it, "delete" stops too. + * The caller proved the sandbox is Xum's. Undefined when nothing was done, else the notice. + */ +export async function applyDeferredArchivePolicy( + daytonaService: Pick, + behavior: CoderWorkspaceArchiveBehavior, + sandboxId: string, + now: () => number = Date.now +): Promise<{ notice?: string } | undefined> { + if (behavior === "keep") return undefined; + const { state } = await daytonaService.getSandboxState(sandboxId); + if (!isRunning(state)) return undefined; + log.debug("Stopping a Daytona sandbox whose archived owner deferred its policy", { sandboxId }); + await daytonaService.stopAndWait(sandboxId, now() + DAYTONA_STOP_TIMEOUT_MS); + return behavior === "delete" ? { notice: stoppedNotDeletedNotice(sandboxId) } : {}; +} + /** The sandbox as the archived workspace stores it (its name or its id). */ function storedSandboxId(workspaceMetadata: WorkspaceMetadata): string | undefined { // A shared-checkout sub-agent copies its parent's runtime config, sandbox ID included: diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 9f3446595a1..ca865cc53b0 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -9,6 +9,7 @@ import type { DaytonaTestConnectionResult, } from "@/common/orpc/schemas/daytona"; import { toXumDaytonaHost } from "@/common/constants/daytona"; +import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; import { getErrorMessage } from "@/common/utils/errors"; import { DaytonaConfigError, @@ -226,6 +227,11 @@ export interface DaytonaServiceOptions { listWorkspaces?: () => DaytonaWorkspaceRef[]; /** Defaults to retiring the pooled OpenSSH and ssh2 connections of the sandbox's alias. */ retireSshConnections?: (sandboxId: string) => Promise; + /** + * The archive behavior in Settings, for the removal that settles a policy an archived owner + * deferred (see the archive hook). Without it such a removal keeps the sandbox as it is. + */ + getArchiveBehavior?: () => CoderWorkspaceArchiveBehavior; } export interface DaytonaStartAndWaitOptions { @@ -372,6 +378,7 @@ export class DaytonaService { /** Why the setup of a sandbox Xum created (and then deleted) failed, for later turns. */ private readonly setupFailures = new Map(); private readonly listWorkspaces?: () => DaytonaWorkspaceRef[]; + readonly getArchiveBehavior: () => CoderWorkspaceArchiveBehavior; /** * Serializes "is this its last workspace" decisions. One key for every sandbox: workspaces * may name one sandbox by its name or by its id, so a per-string key would not serialize them. @@ -395,6 +402,7 @@ export class DaytonaService { this.now = options.now ?? Date.now; this.sleep = options.sleep ?? sleepWithAbort; this.listWorkspaces = options.listWorkspaces; + this.getArchiveBehavior = options.getArchiveBehavior ?? (() => "keep"); this.retireSshConnections = options.retireSshConnections ?? ((sandboxId) => retireSshConnections({ host: toXumDaytonaHost(sandboxId) })); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 58e511325c0..0745a0455da 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -66,7 +66,10 @@ import { listDaytonaWorkspaceRefs, } from "@/node/services/daytona/daytonaReconciler"; import { createDaytonaArchiveHook } from "@/node/services/daytona/daytonaLifecycleHooks"; -import { DEFAULT_CODER_ARCHIVE_BEHAVIOR } from "@/common/config/coderArchiveBehavior"; +import { + DEFAULT_CODER_ARCHIVE_BEHAVIOR, + type CoderWorkspaceArchiveBehavior, +} from "@/common/config/coderArchiveBehavior"; import { createRuntime, setGlobalDaytonaService } from "@/node/runtime/runtimeFactory"; import { createRuntimeForWorkspace } from "@/node/runtime/runtimeHelpers"; import { DaytonaSSHRuntime } from "@/node/runtime/DaytonaSSHRuntime"; @@ -353,6 +356,7 @@ export class ServiceContainer { credentialsStore: new DaytonaCredentialsStore(this.config.rootDir), rootDir: this.config.rootDir, listWorkspaces: () => listDaytonaWorkspaceRefs(this.config.loadConfigOrDefault()), + getArchiveBehavior: () => this.archiveBehavior(), }); this.perfCaptures = new PerfCaptureService({ dir: getXumPerfCapturesDir(this.config.rootDir), @@ -450,9 +454,7 @@ export class ServiceContainer { lifecycleHooks.registerBeforeArchive( createDaytonaArchiveHook({ daytonaService: this.daytonaService, - getArchiveBehavior: () => - this.config.loadConfigOrDefault().coderWorkspaceArchiveBehavior ?? - DEFAULT_CODER_ARCHIVE_BEHAVIOR, + getArchiveBehavior: () => this.archiveBehavior(), hasUnsettledRemoteBackgroundJobs: (workspaceMetadata) => this.backgroundProcessManager.hasUnsettledRemoteSpawnRecords( createRuntimeForWorkspace(workspaceMetadata), @@ -916,6 +918,14 @@ export class ServiceContainer { } /** Starts or stops the perf flight recorder to match the persisted experiment state. */ + /** Settings' archive behavior for Daytona sandboxes (shared with Coder workspaces). */ + private archiveBehavior(): CoderWorkspaceArchiveBehavior { + return ( + this.config.loadConfigOrDefault().coderWorkspaceArchiveBehavior ?? + DEFAULT_CODER_ARCHIVE_BEHAVIOR + ); + } + private syncPerfFlightRecorder(): void { const enabled = this.experimentsService.isExperimentEnabled( EXPERIMENT_IDS.PERF_FLIGHT_RECORDER From c8e9b1f87d7a5f05e7dedb6f30d0626ef8ca6aca Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:06:33 +0000 Subject: [PATCH 146/231] fix(daytona): revoke another backend's first token for a sandbox once replaced When another backend sharing the root wrote a sandbox's first include block after this process's startup load, ensureSshAccess minted a replacement with no previous token, so the other backend's token stayed live until it expired. It is now held as due at once for the sandbox this process is about to use, so the mint replaces and revokes it. The other backend's next command is refused and goes through the refused-token path, which mints its own. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 13 +++++++++++++ src/node/services/daytona/daytonaService.ts | 18 +++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 392f0c19280..01c18a94a15 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -1601,6 +1601,19 @@ describe("DaytonaSSHRuntime ssh token redaction", () => { expect(String(error)).toContain(`${DAYTONA_TOKEN_PLACEHOLDER}@127.0.0.1: Permission denied`); }); + it("revokes the token another backend wrote as a sandbox's first alias once it replaces it", async () => { + rig.plane.addSandbox({ id: "sb-theirs-first" }); + await rig.service.loadPersistedTokens(); + // After this backend's startup load, another one sets the sandbox up first. + rig.writer.tokens.set("sb-theirs-first", "other-backend-first-token"); + + await rig.service.ensureSshAccess("sb-theirs-first"); + + expect(rig.writer.tokens.get("sb-theirs-first")).not.toBe("other-backend-first-token"); + expect(rig.writer.fullWrites).toEqual(["sb-theirs-first"]); + expect(rig.plane.revokedTokens).toEqual(["other-backend-first-token"]); + }); + it("follows the include file another backend sharing the root edits", async () => { rig.plane.addSandbox({ id: "sb-both" }); await rig.service.ensureSshAccess("sb-both"); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index ca865cc53b0..642d5d831ff 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -457,7 +457,12 @@ export class DaytonaService { const unchanged = generation === this.sshTokenGeneration(sandboxId) && releases === this.accessReleases.get(sandboxId); - if (!unchanged || !this.sshConfigured.has(sandboxId)) return; + if (!unchanged) return; + if (this.sshConfigured.has(sandboxId)) this.followIncludeFile(sandboxId, fileToken); + else this.holdFirstSeenToken(sandboxId, fileToken); + } + + private followIncludeFile(sandboxId: string, fileToken: string | undefined): void { const cached = this.tokens.get(sandboxId); if (fileToken === undefined) { this.sshConfigured.delete(sandboxId); @@ -472,6 +477,17 @@ export class DaytonaService { } } + /** + * Another backend set the sandbox up first: held as due at once, the mint about to run + * replaces and revokes it (that backend's next command is refused and mints its own). Only + * for the sandbox this process is about to use: holding another backend's tokens for others + * would revoke them, live, on a settings change here. + */ + private holdFirstSeenToken(sandboxId: string, fileToken: string | undefined): void { + if (fileToken === undefined || this.tokens.has(sandboxId)) return; + this.cacheNewToken(sandboxId, { token: fileToken, expiresAt: new Date(0).toISOString() }); + } + getPublicConfig(): Promise { return this.credentialsStore.getPublicConfig(); } From 10d01df2bb411016f773ce1eb223d05a60cbf180 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:07:26 +0000 Subject: [PATCH 147/231] refactor(services): keep the perf recorder's comment on its method --- src/node/services/serviceContainer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 0745a0455da..e9a29094221 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -917,7 +917,6 @@ export class ServiceContainer { await this.telemetryService.shutdown(); } - /** Starts or stops the perf flight recorder to match the persisted experiment state. */ /** Settings' archive behavior for Daytona sandboxes (shared with Coder workspaces). */ private archiveBehavior(): CoderWorkspaceArchiveBehavior { return ( @@ -926,6 +925,7 @@ export class ServiceContainer { ); } + /** Starts or stops the perf flight recorder to match the persisted experiment state. */ private syncPerfFlightRecorder(): void { const enabled = this.experimentsService.isExperimentEnabled( EXPERIMENT_IDS.PERF_FLIGHT_RECORDER From 8524ac758efdbc3d71bfc9623b336f4fbee0ef0c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:28:57 +0000 Subject: [PATCH 148/231] refactor(daytona): name the removal's archive step for what it applies: the current setting The archive hook's attached-last path also reads the setting when the last use ends, so removal and archive stay consistent; the docs say so. --- docs/runtime/daytona.mdx | 2 +- src/node/runtime/DaytonaSSHRuntime.test.ts | 4 ++-- src/node/runtime/DaytonaSSHRuntime.ts | 18 ++++++++++-------- .../builtInSkillContent.generated.ts | 2 +- .../services/daytona/daytonaLifecycleHooks.ts | 12 ++++++------ src/node/services/daytona/daytonaService.ts | 6 +++--- 6 files changed, 23 insertions(+), 21 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index 02bf0fee8a9..a7f689642ab 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -63,7 +63,7 @@ Select **Existing** and pick a sandbox from your organization. Xum treats an att | Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs | | Sandbox deleted outside Xum | The workspace reports that the sandbox no longer exists; Xum does not recreate it | -A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses. When you delete the last workspaces on a sandbox at the same time, Xum deletes the sandbox once, after the last of them passes its check for uncommitted work. If you attach another workspace to a sandbox Xum created and delete the workspaces that Xum created it for first, the sandbox becomes yours like any attached one: Xum keeps it, says so when it removes the last of those workspaces, and never deletes it later. Before any delete, Xum checks that the sandbox still carries this install's `xum.instance` label; a sandbox without it (for example one that took the name while Xum's create failed, or one whose label was removed) is kept, and Xum says so. +A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses; once the last workspace using it is archived or removed, Xum applies the archive setting in force at that moment (with archived workspaces still on it, a delete setting stops the sandbox instead). When you delete the last workspaces on a sandbox at the same time, Xum deletes the sandbox once, after the last of them passes its check for uncommitted work. If you attach another workspace to a sandbox Xum created and delete the workspaces that Xum created it for first, the sandbox becomes yours like any attached one: Xum keeps it, says so when it removes the last of those workspaces, and never deletes it later. Before any delete, Xum checks that the sandbox still carries this install's `xum.instance` label; a sandbox without it (for example one that took the name while Xum's create failed, or one whose label was removed) is kept, and Xum says so. If the sandbox is still being destroyed (for example after a failed setup), delete waits up to a minute for Daytona to finish. A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard. diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 01c18a94a15..7d799a6b65e 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -1415,14 +1415,14 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { expect(rig.plane.find("sb-7")).toBeDefined(); }); - it("applies the archived owner's deferred policy when the last attached workspace is removed", async () => { + it("applies the archive setting when removing the last attached workspace leaves only an archived owner", async () => { const sshDelete = spyOn(SSHRuntime.prototype, "deleteWorkspace").mockResolvedValue({ success: true, deletedPath: "/path", }); try { const sandbox = rig.plane.addSandbox({ id: "xum-owner" }); - // The owner was archived while the attached workspace worked there: it deferred its policy. + // The owner was archived while the attached workspace worked there: it left the sandbox alone. rig.workspaces.splice(0, rig.workspaces.length, { workspaceId: "ws-owner", runtimeConfig: sshDaytonaConfig({ sandboxId: "xum-owner", shared: false }), diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 9d471b69eec..98ed4f3445c 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -55,7 +55,7 @@ import { type DaytonaAccount, type DaytonaService, } from "@/node/services/daytona/daytonaService"; -import { applyDeferredArchivePolicy } from "@/node/services/daytona/daytonaLifecycleHooks"; +import { applyArchiveSettingToIdleSandbox } from "@/node/services/daytona/daytonaLifecycleHooks"; import { log } from "@/node/services/log"; import { execBuffered } from "@/node/utils/runtime/helpers"; import { seedAgentHome, type AgentHomeSeedSources } from "./daytonaAgentHomeSeed"; @@ -1429,15 +1429,16 @@ export class DaytonaSSHRuntime extends SSHRuntime { } if (fate === "handOff") return this.handOffSandbox(sandboxId, result, removal); await this.releaseAccessOnDelete(sandboxId, fate); - return fate === "keep" ? this.settleDeferredArchivePolicy(sandboxId, result) : result; + return fate === "keep" ? this.applyArchiveSettingAfterLastUse(sandboxId, result) : result; } /** * A kept sandbox whose other workspaces are all archived, one of them its Xum owner: that - * owner's archive deferred its policy to the last use of the sandbox, which this removal - * ended. Applied as the archive hook does then; a failure is a warning, the removal stands. + * owner's archive left it alone while this workspace used it, and this removal ended that use. + * The archive setting in force now applies, as when the last user is archived (the archive + * hook reads it then too); a failure is a warning, the removal stands. */ - private async settleDeferredArchivePolicy( + private async applyArchiveSettingAfterLastUse( sandboxId: string, result: RemovedCheckout ): Promise { @@ -1448,14 +1449,15 @@ export class DaytonaSSHRuntime extends SSHRuntime { const uses = await this.daytonaService.sandboxUseElsewhere(sandboxId, this.workspaceId); if (uses !== "archivedOwner") return result; if ((await this.daytonaService.sandboxOwnership(sandboxId)) !== "owned") return result; - notice = (await applyDeferredArchivePolicy(this.daytonaService, behavior, sandboxId))?.notice; + notice = (await applyArchiveSettingToIdleSandbox(this.daytonaService, behavior, sandboxId)) + ?.notice; } catch (error) { const message = getErrorMessage(error); - log.warn("Failed to apply the deferred archive policy to a Daytona sandbox", { + log.warn("Failed to apply the archive setting to a Daytona sandbox", { sandboxId, error: message, }); - notice = `Could not ${behavior} Daytona sandbox "${sandboxId}" as its archived workspace's policy asks: ${message}`; + notice = `Could not stop Daytona sandbox "${sandboxId}" as the archive setting asks: ${message}`; } if (!notice) return result; return { ...result, notice: [result.notice, notice].filter(Boolean).join(" ") }; diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index a1ab92a5b38..800fe1e49c9 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10100,7 +10100,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "| Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs |", "| Sandbox deleted outside Xum | The workspace reports that the sandbox no longer exists; Xum does not recreate it |", "", - "A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses. When you delete the last workspaces on a sandbox at the same time, Xum deletes the sandbox once, after the last of them passes its check for uncommitted work. If you attach another workspace to a sandbox Xum created and delete the workspaces that Xum created it for first, the sandbox becomes yours like any attached one: Xum keeps it, says so when it removes the last of those workspaces, and never deletes it later. Before any delete, Xum checks that the sandbox still carries this install's `xum.instance` label; a sandbox without it (for example one that took the name while Xum's create failed, or one whose label was removed) is kept, and Xum says so.", + "A sandbox Xum created can serve several workspaces when you fork one. Xum deletes it only with the last of them, archived ones included; deleting any other one removes its worktree and keeps the sandbox. Archive does not stop or delete a sandbox that another workspace still uses; once the last workspace using it is archived or removed, Xum applies the archive setting in force at that moment (with archived workspaces still on it, a delete setting stops the sandbox instead). When you delete the last workspaces on a sandbox at the same time, Xum deletes the sandbox once, after the last of them passes its check for uncommitted work. If you attach another workspace to a sandbox Xum created and delete the workspaces that Xum created it for first, the sandbox becomes yours like any attached one: Xum keeps it, says so when it removes the last of those workspaces, and never deletes it later. Before any delete, Xum checks that the sandbox still carries this install's `xum.instance` label; a sandbox without it (for example one that took the name while Xum's create failed, or one whose label was removed) is kept, and Xum says so.", "", "If the sandbox is still being destroyed (for example after a failed setup), delete waits up to a minute for Daytona to finish. A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard.", "", diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index fff67b2cd41..782ad055dd0 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -66,8 +66,8 @@ async function archiveSandbox( // A start in flight would restart the sandbox after the stop below. await options.daytonaService.waitForSandboxReadiness(sandboxId); - // Only archived workspaces left on it: nobody works there now, so the policy applies, for - // this workspace's own sandbox or for one whose archived owner deferred it to this archive. + // Only archived workspaces left on it: nobody works there now, so the archive setting applies, + // for this workspace's own sandbox or for one whose archived owner left it to this archive. const owned = isDedicatedDaytonaSandbox(args.workspaceMetadata.runtimeConfig) || others === "archivedOwner"; const result = owned @@ -122,11 +122,11 @@ export function stoppedNotDeletedNotice(sandboxId: string): string { } /** - * The policy an archived owner deferred (see archiveSandbox), applied by whatever ends the - * last other use of the sandbox: with archived workspaces left on it, "delete" stops too. + * The archive setting for a sandbox whose last use just ended while archived workspaces stay on + * it (see archiveSandbox): "delete" stops too, since they still have their checkouts there. * The caller proved the sandbox is Xum's. Undefined when nothing was done, else the notice. */ -export async function applyDeferredArchivePolicy( +export async function applyArchiveSettingToIdleSandbox( daytonaService: Pick, behavior: CoderWorkspaceArchiveBehavior, sandboxId: string, @@ -135,7 +135,7 @@ export async function applyDeferredArchivePolicy( if (behavior === "keep") return undefined; const { state } = await daytonaService.getSandboxState(sandboxId); if (!isRunning(state)) return undefined; - log.debug("Stopping a Daytona sandbox whose archived owner deferred its policy", { sandboxId }); + log.debug("Stopping a Daytona sandbox that only archived workspaces use", { sandboxId }); await daytonaService.stopAndWait(sandboxId, now() + DAYTONA_STOP_TIMEOUT_MS); return behavior === "delete" ? { notice: stoppedNotDeletedNotice(sandboxId) } : {}; } diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 642d5d831ff..8bfb21f3341 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -228,8 +228,8 @@ export interface DaytonaServiceOptions { /** Defaults to retiring the pooled OpenSSH and ssh2 connections of the sandbox's alias. */ retireSshConnections?: (sandboxId: string) => Promise; /** - * The archive behavior in Settings, for the removal that settles a policy an archived owner - * deferred (see the archive hook). Without it such a removal keeps the sandbox as it is. + * The archive behavior in Settings, for a removal that ends the last use of a sandbox an + * archived owner left alone (see the archive hook). Without it such a removal keeps it as is. */ getArchiveBehavior?: () => CoderWorkspaceArchiveBehavior; } @@ -643,7 +643,7 @@ export class DaytonaService { /** * For archive: whether another workspace still works in the sandbox ("active"), only * archived ones reference it ("archived", or "archivedOwner" when one of them is a workspace - * whose sandbox Xum created, which deferred its archive policy), or none does. Matched and + * whose sandbox Xum created, whose archive left the sandbox alone), or none does. Matched and * failing closed (as "active") like sandboxReferencesElsewhere; removal still counts * archived workspaces. */ From 41b6253756458aa1282c76614cce5a7d73ddff9c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:50:32 +0000 Subject: [PATCH 149/231] fix(daytona): keep the saved settings when an update cannot read them --- .../config/daytonaCredentialsStore.test.ts | 24 ++++++++++++++ src/node/config/daytonaCredentialsStore.ts | 33 ++++++++++++++----- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/node/config/daytonaCredentialsStore.test.ts b/src/node/config/daytonaCredentialsStore.test.ts index fec5dd6cfb1..5ad3d88a729 100644 --- a/src/node/config/daytonaCredentialsStore.test.ts +++ b/src/node/config/daytonaCredentialsStore.test.ts @@ -110,6 +110,30 @@ describe("DaytonaCredentialsStore", () => { expect((await store.resolve())?.apiKey).toBe(KEY); }); + it("keeps the saved settings when the file cannot be read for an update", async () => { + const store = new DaytonaCredentialsStore(rootDir, {}); + await store.update({ apiKey: KEY, apiUrl: "https://byoc.example.com/api", target: "eu" }); + const file = path.join(rootDir, "daytona.json"); + + // A read that fails for another reason than absence (here: a permission problem). + await fs.chmod(file, 0o000); + let refusal: unknown; + try { + await store.update({ target: "us" }); + } catch (error) { + refusal = error; + } finally { + await fs.chmod(file, 0o600); + } + + expect(refusal).toBeInstanceOf(Error); + expect(await store.resolve()).toMatchObject({ + apiKey: KEY, + apiUrl: "https://byoc.example.com/api", + target: "eu", + }); + }); + it("refuses a stored API URL that does not validate instead of sending the key elsewhere", async () => { for (const apiUrl of ["not a url", "http://byoc.example.com/api"]) { await fs.writeFile( diff --git a/src/node/config/daytonaCredentialsStore.ts b/src/node/config/daytonaCredentialsStore.ts index 721dd554740..4cf07dad689 100644 --- a/src/node/config/daytonaCredentialsStore.ts +++ b/src/node/config/daytonaCredentialsStore.ts @@ -152,19 +152,34 @@ export class DaytonaCredentialsStore { this.env = env; } - /** A missing or malformed file reads as empty (self-healing): the user can re-enter the key. */ + /** + * A missing or malformed file reads as empty (self-healing): the user can re-enter the key. + * So does one that cannot be read, here only: this is for using and showing the settings. + */ async read(): Promise { + try { + return await this.readSaved(); + } catch (error) { + log.warn("Failed to read Daytona credentials file", { + filePath: this.filePath, + error: getErrorMessage(error), + }); + return {}; + } + } + + /** + * As read, but a read that fails for another reason than a missing file (an I/O error, a + * permission problem) throws: an update writes the whole file back, and taking such a read + * as empty would drop the saved key, URL and target. + */ + private async readSaved(): Promise { let raw: string; try { raw = await fs.readFile(this.filePath, "utf-8"); } catch (error) { - if (!isErrnoWithCode(error, "ENOENT")) { - log.warn("Failed to read Daytona credentials file", { - filePath: this.filePath, - error: getErrorMessage(error), - }); - } - return {}; + if (isErrnoWithCode(error, "ENOENT")) return {}; + throw error; } const stored = parseStoredCredentials(raw); if (!stored) { @@ -199,7 +214,7 @@ export class DaytonaCredentialsStore { timeoutMessage: "Another Xum process is saving Daytona settings.", }); try { - const next = applyPatch(await this.read(), patch); + const next = applyPatch(await this.readSaved(), patch); await writeFileAtomic(this.filePath, JSON.stringify(next, null, 2), { encoding: "utf-8", mode: 0o600, From 9a97eb41c02a52573ec70eded86078f0ba13953b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:51:51 +0000 Subject: [PATCH 150/231] fix(daytona): never pin the old account for a lease after unusable settings --- .../services/daytona/daytonaService.test.ts | 22 +++++++++++++++++++ src/node/services/daytona/daytonaService.ts | 5 ++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index a5ced14d8c7..0908906079e 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -303,6 +303,28 @@ describe("DaytonaService", () => { expect((await service.getAccount()).generation).toBe(before + 1); }); + it("fails a lease whose settings another process made unusable while idle", async () => { + plane.states = ["started"]; + await service.getSandboxState("sb-1"); + const requests = plane.calls.length; + + // A hand edit (or another backend) saves an API URL that must not get the key. + await fs.writeFile( + path.join(rootDir, "daytona.json"), + JSON.stringify({ apiKey: "k", apiUrl: "http://remote.example.com/api" }) + ); + const failure = await service + .withAccountLease(() => service.getSandboxState("sb-1")) + .then( + () => undefined, + (error: unknown) => error + ); + + expect(failure).toBeInstanceOf(DaytonaApiError); + expect((failure as DaytonaApiError).kind).toBe("not_configured"); + expect(plane.calls.length).toBe(requests); + }); + it("leaves a resolve during its own settings save to that save", async () => { const revokeKeys: string[] = []; const store = new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 8bfb21f3341..92d509c53ea 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -764,7 +764,7 @@ export class DaytonaService { return { available: true }; } - /** The idle read before a lease; unusable settings fail the lease's own first call instead. */ + /** The idle read before a lease; unusable settings fail the lease's own first call. */ private async refreshIdleCredentials(): Promise { try { await this.resolveCredentials(); @@ -772,6 +772,9 @@ export class DaytonaService { log.debug("Could not read the Daytona settings before an operation", { error: getErrorMessage(error), }); + // The lease must not pin the account this process used before: unusable settings must + // make Daytona unusable, so its first resolve reads them again and fails. + this.credentialsSnapshot = undefined; } } From d23785b2e6f67ba0840ec077fa81fa730a9a2c8e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:02:04 +0000 Subject: [PATCH 151/231] fix(ssh): end a reverse forward an earlier process left when its host is retired --- .../transports/OpenSSHTransport.test.ts | 69 +++++++++++++++++++ .../runtime/transports/OpenSSHTransport.ts | 43 +++++++++++- src/node/runtime/transports/index.ts | 9 ++- 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/src/node/runtime/transports/OpenSSHTransport.test.ts b/src/node/runtime/transports/OpenSSHTransport.test.ts index 956c9ca3073..917f9c3e209 100644 --- a/src/node/runtime/transports/OpenSSHTransport.test.ts +++ b/src/node/runtime/transports/OpenSSHTransport.test.ts @@ -9,6 +9,7 @@ import { RuntimeError, isRuntimeTransportError } from "../Runtime"; import { sshConnectionPool } from "../sshConnectionPool"; import { getDaytonaSshConfigPath } from "../daytonaSshConfig"; import { OpenSSHTransport, retireOpenSSHMasters } from "./OpenSSHTransport"; +import { retireSshConnections } from "./index"; const BACKOFF_MESSAGE = "SSH connection to example.test is in backoff for 2s. Last error: Connection refused"; @@ -142,3 +143,71 @@ describe("retireOpenSSHMasters", () => { } }); }); + +describe("OpenSSH reverse forwards", () => { + it("ends a forward an earlier process left when its host is retired", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-fake-ssh-")); + // A fake ssh: `-N` runs a master that holds its ControlPath until it exits; the control + // commands (`-O check|forward|exit|stop`) act on the master at `-S`. + await fs.writeFile( + path.join(dir, "ssh"), + `#!/bin/sh +socket=""; op=""; prev=""; master=no +for arg in "$@"; do + case "$prev" in -S) socket="$arg" ;; -O) op="$arg" ;; esac + case "$arg" in ControlPath=*) socket="\${arg#ControlPath=}" ;; -N) master=yes ;; esac + prev="$arg" +done +if [ "$master" = yes ]; then + echo $$ > "$socket.pid"; echo $$ > "${dir}/master.pid"; : > "$socket" + trap 'rm -f "$socket" "$socket.pid"; exit 0' TERM + while :; do sleep 0.05; done +fi +case "$op" in + check|forward) [ -e "$socket" ] ;; + exit) [ -e "$socket.pid" ] && kill "$(cat "$socket.pid")" ;; + *) exit 0 ;; +esac +`, + { mode: 0o755 } + ); + const originalPath = process.env.PATH; + process.env.PATH = `${dir}:${originalPath ?? ""}`; + spyOn(sshConnectionPool, "acquireConnection").mockResolvedValue(undefined); + const config = { host: toXumDaytonaHost("sb-forwarded") }; + let masterPid: number | undefined; + try { + const forward = await new OpenSSHTransport(config).openReverseForward(40123, 40124); + const pid = Number(await fs.readFile(path.join(dir, "master.pid"), "utf-8")); + masterPid = pid; + expect(isAlive(pid)).toBe(true); + // A restart: the new process holds no handle to the forward, which it adopts. + void forward; + + await retireSshConnections(config); + + await waitFor(() => !isAlive(pid)); + expect(isAlive(pid)).toBe(false); + } finally { + if (masterPid !== undefined && isAlive(masterPid)) process.kill(masterPid); + process.env.PATH = originalPath; + await fs.rm(dir, { recursive: true, force: true }); + } + }); +}); + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition() && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} diff --git a/src/node/runtime/transports/OpenSSHTransport.ts b/src/node/runtime/transports/OpenSSHTransport.ts index c10517eb3d1..3de80bb17f1 100644 --- a/src/node/runtime/transports/OpenSSHTransport.ts +++ b/src/node/runtime/transports/OpenSSHTransport.ts @@ -1,5 +1,5 @@ import { spawn } from "child_process"; -import { randomBytes } from "crypto"; +import { createHash } from "crypto"; import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; @@ -17,6 +17,7 @@ import { } from "../sshConnectionPool"; import type { SpawnResult } from "../RemoteRuntime"; import { RuntimeError } from "../Runtime"; +import { getXumHome } from "@/common/constants/paths"; import { assert } from "@/common/utils/assert"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; @@ -81,6 +82,42 @@ export async function retireOpenSSHMasters(config: SSHConnectionConfig): Promise ); } +/** + * The ControlPath of the host's reverse-forward master, per Xum root and host. Not random: a + * forward outlives a crash or a restart of the process that opened it (the next one adopts it), + * and only a path a later process can compute lets it end that forward. + */ +function reverseForwardControlPath(config: SSHConnectionConfig): string { + const key = [ + getXumHome(), + os.userInfo().username, + config.host, + config.port ?? 22, + config.identityFile ?? "", + ].join("\0"); + const hash = createHash("sha256").update(key).digest("hex").slice(0, 16); + return path.join(os.tmpdir(), `xum-fwd-${hash}`); +} + +/** + * End the host's reverse-forward master, whether this process opened it or adopted it from an + * earlier one: a retired host's forward would keep reaching the old machine, and its remote + * port would keep reaching the bash AI proxy. + */ +export async function closeOpenSSHReverseForward(config: SSHConnectionConfig): Promise { + const controlPath = reverseForwardControlPath(config); + if (!(await pathExists(controlPath))) return; + const result = await runSshClient( + ["-F", "/dev/null", "-S", controlPath, "-O", "exit", config.host], + config.host + ); + if (result.code !== 0) { + log.debug("[ssh] could not end a reverse forward", { controlPath, stderr: result.stderr }); + } + // A socket left without a master would keep the next master from listening there. + await fs.rm(controlPath, { force: true }); +} + async function pathExists(filePath: string): Promise { try { await fs.lstat(filePath); @@ -227,7 +264,9 @@ export class OpenSSHTransport implements SSHTransport { assert(Number.isInteger(localPort) && localPort > 0 && localPort < 65536, "bad localPort"); await this.acquireConnection({ maxWaitMs: 0 }); - const controlPath = path.join(os.tmpdir(), `xum-fwd-${randomBytes(8).toString("hex")}`); + // Only after adoption failed: whatever master is still there no longer works. + await closeOpenSSHReverseForward(this.config); + const controlPath = reverseForwardControlPath(this.config); const args: string[] = []; if (this.config.port) args.push("-p", this.config.port.toString()); if (this.config.identityFile) args.push("-i", this.config.identityFile); diff --git a/src/node/runtime/transports/index.ts b/src/node/runtime/transports/index.ts index c89a17473fd..2bb700849ac 100644 --- a/src/node/runtime/transports/index.ts +++ b/src/node/runtime/transports/index.ts @@ -1,6 +1,10 @@ import { ssh2ConnectionPool } from "../SSH2ConnectionPool"; import type { SSHConnectionConfig } from "../sshConnectionPool"; -import { OpenSSHTransport, retireOpenSSHMasters } from "./OpenSSHTransport"; +import { + OpenSSHTransport, + closeOpenSSHReverseForward, + retireOpenSSHMasters, +} from "./OpenSSHTransport"; import { SSH2Transport } from "./SSH2Transport"; import type { SSHTransport } from "./SSHTransport"; @@ -27,5 +31,6 @@ export function onSshHostRetired(listener: (host: string) => void): () => void { export async function retireSshConnections(config: SSHConnectionConfig): Promise { ssh2ConnectionPool.retireConnection(config); for (const listener of retiredHostListeners) listener(config.host); - await retireOpenSSHMasters(config); + // The listeners close forwards this process owns; one adopted after a restart has no handle. + await Promise.all([retireOpenSSHMasters(config), closeOpenSSHReverseForward(config)]); } From 81eaf9cdb5ae65b92cc92424d06e3349b989c656 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:50:03 +0000 Subject: [PATCH 152/231] fix(ssh): open the unescaped literal name of an Include segment without wildcards --- src/node/runtime/sshConfigParser.test.ts | 27 ++++++++++++++++++++++++ src/node/runtime/sshConfigParser.ts | 13 +++++++----- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/node/runtime/sshConfigParser.test.ts b/src/node/runtime/sshConfigParser.test.ts index b6eb3be8bc4..f78922a7570 100644 --- a/src/node/runtime/sshConfigParser.test.ts +++ b/src/node/runtime/sshConfigParser.test.ts @@ -290,6 +290,33 @@ describe("resolveSSHConfig", () => { }); }); + // OpenSSH's glob(3) unescapes every segment, with or without a real wildcard in it. + test.skipIf(process.platform === "win32")( + "reads an Include's escaped glob characters as the literal file names OpenSSH opens", + async () => { + await withTempHome(async (homeDir) => { + const confDir = path.join(homeDir, ".ssh", "conf.d"); + await fs.mkdir(confDir); + const files = ["foo*", "foo?", "foo[", "foo\\", "fooX", "foo[*"]; + for (const [i, name] of files.entries()) { + await fs.writeFile(path.join(confDir, name), `Host host-${i}\n User user-${i}\n`); + } + const configPath = path.join(homeDir, ".ssh", "config"); + // Four backslashes in the config text name one: the config parser and glob(3) each unescape. + const patterns = ["foo\\*", "foo\\?", "foo\\[", "foo\\\\\\\\", "fo\\o\\X", "foo[\\*"]; + await fs.writeFile( + configPath, + `${patterns.map((pattern) => `Include ${confDir}/${pattern}`).join("\n")}\nHost *\n User none\n` + ); + + for (const i of files.keys()) { + expect((await resolveSSHConfig(`host-${i}`)).user).toBe(`user-${i}`); + if (hasOpenSsh) expect(opensshOptions(configPath, `host-${i}`)("user")).toBe(`user-${i}`); + } + }); + } + ); + test("matches an Include wildcard as glob(3) does: ? is one character, * skips dot files", async () => { await withTempHome(async (homeDir) => { const confDir = path.join(homeDir, ".ssh", "conf.d"); diff --git a/src/node/runtime/sshConfigParser.ts b/src/node/runtime/sshConfigParser.ts index 90df28c14d5..8d3196e11fc 100644 --- a/src/node/runtime/sshConfigParser.ts +++ b/src/node/runtime/sshConfigParser.ts @@ -384,9 +384,9 @@ async function globPath(pattern: string): Promise { const { root } = path.parse(normalized); let matches = [root]; for (const segment of normalized.slice(root.length).split(path.sep).filter(Boolean)) { - const matcher = segmentMatcher(segment); + const { matcher, literal } = segmentPattern(segment); if (!matcher) { - matches = matches.map((dir) => path.join(dir, segment)); + matches = matches.map((dir) => path.join(dir, literal)); continue; } const next: string[][] = []; @@ -419,18 +419,21 @@ const escapeRegExp = (text: string) => text.replace(REGEXP_SPECIAL, "\\$&"); /** * One path segment of a glob(3) pattern as a RegExp: `*`, `?` (one character), bracket * expressions (`[ab]`, `[a-z]`, negated by a leading `!` or `^`; an unclosed `[` is literal) - * and `\` escapes. Undefined when the segment has no wildcard. + * and `\` escapes. Without a wildcard there is no matcher, only the unescaped `literal` name + * glob(3) opens. */ -function segmentMatcher(segment: string): RegExp | undefined { +function segmentPattern(segment: string): { matcher?: RegExp; literal: string } { let source = ""; + let literal = ""; let wildcard = false; for (let i = 0; i < segment.length; i++) { const token = segmentToken(segment, i); source += token.source; wildcard ||= token.wildcard; + if (!token.wildcard) literal += segment[token.end]; i = token.end; } - return wildcard ? new RegExp(`^${source}$`, "s") : undefined; + return wildcard ? { matcher: new RegExp(`^${source}$`, "s"), literal } : { literal }; } const WILDCARD_SOURCES = new Map([ From 28ded5dc7647cf5f4883971817ca35c88a0fb0bb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:53:14 +0000 Subject: [PATCH 153/231] test(ssh): skip the escaped Include glob test on Windows without test.skipIf --- src/node/runtime/sshConfigParser.test.ts | 47 ++++++++++++------------ 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/src/node/runtime/sshConfigParser.test.ts b/src/node/runtime/sshConfigParser.test.ts index f78922a7570..95328dffeba 100644 --- a/src/node/runtime/sshConfigParser.test.ts +++ b/src/node/runtime/sshConfigParser.test.ts @@ -291,31 +291,30 @@ describe("resolveSSHConfig", () => { }); // OpenSSH's glob(3) unescapes every segment, with or without a real wildcard in it. - test.skipIf(process.platform === "win32")( - "reads an Include's escaped glob characters as the literal file names OpenSSH opens", - async () => { - await withTempHome(async (homeDir) => { - const confDir = path.join(homeDir, ".ssh", "conf.d"); - await fs.mkdir(confDir); - const files = ["foo*", "foo?", "foo[", "foo\\", "fooX", "foo[*"]; - for (const [i, name] of files.entries()) { - await fs.writeFile(path.join(confDir, name), `Host host-${i}\n User user-${i}\n`); - } - const configPath = path.join(homeDir, ".ssh", "config"); - // Four backslashes in the config text name one: the config parser and glob(3) each unescape. - const patterns = ["foo\\*", "foo\\?", "foo\\[", "foo\\\\\\\\", "fo\\o\\X", "foo[\\*"]; - await fs.writeFile( - configPath, - `${patterns.map((pattern) => `Include ${confDir}/${pattern}`).join("\n")}\nHost *\n User none\n` - ); + test("reads an Include's escaped glob characters as the literal file names OpenSSH opens", async () => { + // Windows file names cannot hold "*" or "?". + if (process.platform === "win32") return; + await withTempHome(async (homeDir) => { + const confDir = path.join(homeDir, ".ssh", "conf.d"); + await fs.mkdir(confDir); + const files = ["foo*", "foo?", "foo[", "foo\\", "fooX", "foo[*"]; + for (const [i, name] of files.entries()) { + await fs.writeFile(path.join(confDir, name), `Host host-${i}\n User user-${i}\n`); + } + const configPath = path.join(homeDir, ".ssh", "config"); + // Four backslashes in the config text name one: the config parser and glob(3) each unescape. + const patterns = ["foo\\*", "foo\\?", "foo\\[", "foo\\\\\\\\", "fo\\o\\X", "foo[\\*"]; + await fs.writeFile( + configPath, + `${patterns.map((pattern) => `Include ${confDir}/${pattern}`).join("\n")}\nHost *\n User none\n` + ); - for (const i of files.keys()) { - expect((await resolveSSHConfig(`host-${i}`)).user).toBe(`user-${i}`); - if (hasOpenSsh) expect(opensshOptions(configPath, `host-${i}`)("user")).toBe(`user-${i}`); - } - }); - } - ); + for (const i of files.keys()) { + expect((await resolveSSHConfig(`host-${i}`)).user).toBe(`user-${i}`); + if (hasOpenSsh) expect(opensshOptions(configPath, `host-${i}`)("user")).toBe(`user-${i}`); + } + }); + }); test("matches an Include wildcard as glob(3) does: ? is one character, * skips dot files", async () => { await withTempHome(async (homeDir) => { From 51817380954bd35d97a506fb725f339835346abf Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:51:48 +0000 Subject: [PATCH 154/231] fix(daytona): show no previous account's resources while Daytona settings reload --- src/browser/hooks/useDaytonaResources.test.ts | 53 ++++++++++++++++++- src/browser/hooks/useDaytonaResources.ts | 30 +++++++++-- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/browser/hooks/useDaytonaResources.test.ts b/src/browser/hooks/useDaytonaResources.test.ts index 329003b8f8d..cb6cb705bb2 100644 --- a/src/browser/hooks/useDaytonaResources.test.ts +++ b/src/browser/hooks/useDaytonaResources.test.ts @@ -1,12 +1,13 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { restoreDomGlobals, saveDomGlobals } from "../../../tests/ui/domGlobals"; import { createElement, type ComponentProps, type ReactNode } from "react"; -import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { act, cleanup, render, renderHook, waitFor } from "@testing-library/react"; import { GlobalWindow } from "happy-dom"; import { APIProvider } from "@/browser/contexts/API"; import { createTestApiClient } from "@/browser/testUtils"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import type { DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; +import { DaytonaSandboxForm } from "@/browser/features/Runtime/DaytonaControls"; import { useDaytonaResources } from "./useDaytonaResources"; describe("useDaytonaResources", () => { @@ -57,4 +58,54 @@ describe("useDaytonaResources", () => { await waitFor(() => expect(result.current.snapshots).toEqual(["base"])); expect(result.current.config?.apiKeySet).toBe(true); }); + // Kept, the old account's config would keep the form ready with its snapshots and sandboxes. + test("drops what the previous load found when a reload cannot read the config", async () => { + let failing = false; + const client = createTestApiClient({ + daytona: { + getConfig: (): Promise => + failing + ? Promise.reject(new Error("backend unreachable")) + : Promise.resolve({ + apiKeySet: true, + apiKeySource: "settings", + apiUrl: "https://app.daytona.io/api", + target: null, + }), + listSnapshots: () => Promise.resolve({ ok: true as const, snapshots: [{ name: "base" }] }), + listSandboxes: () => + Promise.resolve({ + ok: true as const, + sandboxes: [{ id: "sb-old", name: "old", state: "started", labels: {} }], + }), + }, + }); + const wrapper = (props: { children: ReactNode }) => + createElement(APIProvider, { client } as ComponentProps, props.children); + const { result } = renderHook(() => useDaytonaResources({ loadSandboxes: true }), { + wrapper, + }); + await waitFor(() => expect(result.current.sandboxes).toHaveLength(1)); + expect(result.current.snapshots).toEqual(["base"]); + + failing = true; + act(() => { + window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.DAYTONA_CONFIG_CHANGED)); + }); + + await waitFor(() => expect(result.current.configError).toBe("backend unreachable")); + expect(result.current.config).toBeNull(); + expect(result.current.snapshots).toEqual([]); + expect(result.current.sandboxes).toEqual([]); + const form = render( + createElement(DaytonaSandboxForm, { + config: {}, + onConfigChange: () => undefined, + resources: result.current, + allowExisting: true, + }) + ); + expect(form.getByRole("alert").textContent).toContain("backend unreachable"); + expect(form.queryByText("base")).toBeNull(); + }); }); diff --git a/src/browser/hooks/useDaytonaResources.ts b/src/browser/hooks/useDaytonaResources.ts index eb620f89399..9890199cb03 100644 --- a/src/browser/hooks/useDaytonaResources.ts +++ b/src/browser/hooks/useDaytonaResources.ts @@ -55,18 +55,40 @@ export function useDaytonaConfigVersion(): number { return version; } +/** What a load depends on: another API connection or saved access starts from empty. */ +interface DaytonaLoadKey { + api: unknown; + configVersion: number; + loadSandboxes: boolean; +} + +interface LoadedDaytonaResources extends DaytonaLoadKey { + resources: DaytonaResources; +} + +/** `loaded`'s resources if they came from the load `key` names, else empty ones. */ +function resourcesFor(loaded: LoadedDaytonaResources | null, key: DaytonaLoadKey) { + return loaded?.api === key.api && + loaded.configVersion === key.configVersion && + loaded.loadSandboxes === key.loadSandboxes + ? loaded.resources + : EMPTY_DAYTONA_RESOURCES; +} + export function useDaytonaResources(options: UseDaytonaResourcesOptions): DaytonaResources { const { api } = useAPI(); const configVersion = useDaytonaConfigVersion(); // One state object so consumers get a stable identity until something actually loads. - const [resources, setResources] = useState(EMPTY_DAYTONA_RESOURCES); + const [loaded, setLoaded] = useState(null); const { loadSandboxes } = options; useEffect(() => { if (!api) return; let active = true; const update = (patch: Partial) => { - if (active) setResources((prev) => ({ ...prev, ...patch })); + if (!active) return; + const key = { api, configVersion, loadSandboxes }; + setLoaded((prev) => ({ ...key, resources: { ...resourcesFor(prev, key), ...patch } })); }; const load = async () => { @@ -116,5 +138,7 @@ export function useDaytonaResources(options: UseDaytonaResourcesOptions): Dayton }; }, [api, loadSandboxes, configVersion]); - return resources; + // A reload never shows what the previous load found: a failed or keyless reload would + // otherwise leave the form ready with the old account's config, snapshots and sandboxes. + return resourcesFor(loaded, { api, configVersion, loadSandboxes }); } From ecebbfd6b5899ab7e7cf550ab6330144e64cdd26 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:53:15 +0000 Subject: [PATCH 155/231] fix(daytona): narrow the loaded Daytona resources before reading them --- src/browser/hooks/useDaytonaResources.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/browser/hooks/useDaytonaResources.ts b/src/browser/hooks/useDaytonaResources.ts index 9890199cb03..c35bc4a2db3 100644 --- a/src/browser/hooks/useDaytonaResources.ts +++ b/src/browser/hooks/useDaytonaResources.ts @@ -68,7 +68,8 @@ interface LoadedDaytonaResources extends DaytonaLoadKey { /** `loaded`'s resources if they came from the load `key` names, else empty ones. */ function resourcesFor(loaded: LoadedDaytonaResources | null, key: DaytonaLoadKey) { - return loaded?.api === key.api && + return loaded !== null && + loaded.api === key.api && loaded.configVersion === key.configVersion && loaded.loadSandboxes === key.loadSandboxes ? loaded.resources From bf53066412c6cffc5098603f79993f47b140d922 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:28:10 +0000 Subject: [PATCH 156/231] fix(daytona): prepare the sandbox before opening an external editor --- src/browser/utils/openInEditor.test.ts | 103 ++++++++++++++++++ src/browser/utils/openInEditor.ts | 20 +++- src/common/orpc/schemas/api.ts | 8 ++ src/node/orpc/router.ts | 6 + src/node/runtime/runtimeHelpers.ts | 17 +++ src/node/services/terminalService.ts | 13 +-- .../workspaceService.runtimeStatus.test.ts | 73 ++++++++++++- src/node/services/workspaceService.ts | 18 +++ 8 files changed, 248 insertions(+), 10 deletions(-) diff --git a/src/browser/utils/openInEditor.test.ts b/src/browser/utils/openInEditor.test.ts index d16ce5d66cb..693590b994a 100644 --- a/src/browser/utils/openInEditor.test.ts +++ b/src/browser/utils/openInEditor.test.ts @@ -119,6 +119,109 @@ describe("openInEditor", () => { expect(url.endsWith(`${filePath}:1:1`)).toBe(true); }); + describe("Daytona SSH workspaces", () => { + const daytonaConfig: RuntimeConfig = { + type: "ssh", + host: "xum-daytona-sb-1", + srcBaseDir: "/home/daytona/xum", + daytona: { sandboxId: "sb-1" }, + }; + + test("prepares the sandbox before recording and launching the editor", async () => { + const events: string[] = []; + const ensureDaytonaSandboxReady = mock(() => { + events.push("prepare"); + return Promise.resolve({ success: true as const, data: undefined }); + }); + const api = createTestApiClient({ + general: { + recordEditorOpen: () => { + events.push("record"); + return Promise.resolve({ success: true as const, data: undefined }); + }, + }, + workspace: { ensureDaytonaSandboxReady }, + }); + const windowValue = { + ...createMockWindow([]), + open: (url: string) => { + events.push(`open ${url}`); + return null; + }, + }; + + const result = await withWindow(windowValue, () => + openInEditor({ api, workspaceId, targetPath: parentDir, runtimeConfig: daytonaConfig }) + ); + + expect(result).toEqual({ success: true }); + expect(ensureDaytonaSandboxReady).toHaveBeenCalledWith({ workspaceId }); + expect(events.slice(0, 2)).toEqual(["prepare", "record"]); + expect(events[2]).toContain("ssh-remote+xum-daytona-sb-1"); + }); + + test("refused readiness shows its error and opens nothing", async () => { + const calls: OpenCall[] = []; + const recordEditorOpen = mock(() => + Promise.resolve({ success: true as const, data: undefined }) + ); + const api = createTestApiClient({ + general: { recordEditorOpen }, + workspace: { + ensureDaytonaSandboxReady: () => + Promise.resolve({ success: false as const, error: "Daytona sandbox was deleted" }), + }, + }); + + const result = await withWindow(createMockWindow(calls), () => + openInEditor({ api, workspaceId, targetPath: parentDir, runtimeConfig: daytonaConfig }) + ); + + expect(result).toEqual({ success: false, error: "Daytona sandbox was deleted" }); + expect(recordEditorOpen).not.toHaveBeenCalled(); + expect(calls).toEqual([]); + }); + + test("browser mode: a refused readiness closes the placeholder", async () => { + const calls: OpenCall[] = []; + const { windowValue, placeholder } = createBrowserModeWindow(calls); + const api = createTestApiClient({ + workspace: { + ensureDaytonaSandboxReady: () => + Promise.resolve({ success: false as const, error: "sandbox is archived" }), + }, + }); + + const result = await withWindow(windowValue, () => + openInEditor({ api, workspaceId, targetPath: parentDir, runtimeConfig: daytonaConfig }) + ); + + expect(result).toEqual({ success: false, error: "sandbox is archived" }); + expect(placeholder.navigations).toEqual([]); + expect(placeholder.closed).toBe(true); + }); + + test("other SSH workspaces launch without preparing", async () => { + const calls: OpenCall[] = []; + const ensureDaytonaSandboxReady = mock(() => + Promise.resolve({ success: true as const, data: undefined }) + ); + + const result = await withWindow(createMockWindow(calls), () => + openInEditor({ + api: createApiStub({ workspace: { ensureDaytonaSandboxReady } }), + workspaceId, + targetPath: parentDir, + runtimeConfig: { type: "ssh", host: "devbox", srcBaseDir: "~/xum" }, + }) + ); + + expect(result.success).toBe(true); + expect(calls.length).toBe(1); + expect(ensureDaytonaSandboxReady).not.toHaveBeenCalled(); + }); + }); + test("opens devcontainer deep links with mapped container path", async () => { const calls: OpenCall[] = []; diff --git a/src/browser/utils/openInEditor.ts b/src/browser/utils/openInEditor.ts index 637dc45703b..e6e34de1c0c 100644 --- a/src/browser/utils/openInEditor.ts +++ b/src/browser/utils/openInEditor.ts @@ -242,6 +242,24 @@ export async function openInEditor(args: OpenInEditorArgs): Promise Promise +): Promise { + if (!isSSHRuntime(args.runtimeConfig) || !args.runtimeConfig.daytona || !args.api) { + return launch(); + } + const ready = await args.api.workspace.ensureDaytonaSandboxReady({ + workspaceId: args.workspaceId, + }); + return ready.success ? launch() : { success: false, error: ready.error }; +} + async function openInEditorWithLaunch( args: OpenInEditorArgs, editorConfig: EditorConfig, @@ -385,7 +403,7 @@ async function openInEditorWithLaunch( }; } - return launch(deepLink); + return launchAfterDaytonaPrepare(args, () => launch(deepLink)); } // Custom editor: diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 7234e26d7d5..4f1f4421904 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -2121,6 +2121,14 @@ export const workspace = { input: z.object({ workspaceId: z.string() }), output: ResultSchema(z.void(), z.string()), }, + /** + * Start a Daytona workspace's sandbox and refresh its ssh token before an external editor + * connects to the sandbox's ssh alias. A no-op for every other runtime. + */ + ensureDaytonaSandboxReady: { + input: z.object({ workspaceId: z.string() }), + output: ResultSchema(z.void(), z.string()), + }, getRuntimeStatuses: { input: z.object({ workspaceIds: z.array(z.string()) }), output: z.record(z.string(), WorkspaceRuntimeStatusSchema), diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 4316cbe84e2..b95511e0f68 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -2255,6 +2255,12 @@ export const router = (authToken?: string) => { .handler(async ({ context, input }) => context.workspaceService.stopRuntime(input.workspaceId) ), + ensureDaytonaSandboxReady: t + .input(schemas.workspace.ensureDaytonaSandboxReady.input) + .output(schemas.workspace.ensureDaytonaSandboxReady.output) + .handler(async ({ context, input }) => + context.workspaceService.ensureDaytonaSandboxReady(input.workspaceId) + ), getRuntimeStatuses: t .input(schemas.workspace.getRuntimeStatuses.input) .output(schemas.workspace.getRuntimeStatuses.output) diff --git a/src/node/runtime/runtimeHelpers.ts b/src/node/runtime/runtimeHelpers.ts index cfe16292e6c..a3b6b1f28d7 100644 --- a/src/node/runtime/runtimeHelpers.ts +++ b/src/node/runtime/runtimeHelpers.ts @@ -6,6 +6,7 @@ import { isSSHRuntime, type RuntimeConfig, } from "@/common/types/runtime"; +import { Err, Ok, type Result } from "@/common/types/result"; import type { Runtime } from "./Runtime"; import { createRuntime } from "./runtimeFactory"; @@ -162,3 +163,19 @@ export function createRuntimeForWorkspace(metadata: WorkspaceMetadataForRuntime) workspaceId: metadata.id, }); } + +/** + * A Daytona sandbox may be stopped, or its token (the ssh user name) due for refresh: bring it + * up before something outside Xum (a native terminal, an external editor) connects to its ssh + * alias. Only explicit user actions call this, so starting a stopped sandbox is fine. A no-op + * for every other runtime. + */ +export async function ensureDaytonaSandboxReady( + metadata: WorkspaceMetadataForRuntime +): Promise> { + if (!isSSHRuntime(metadata.runtimeConfig) || !metadata.runtimeConfig.daytona) { + return Ok(undefined); + } + const ready = await createRuntimeForWorkspace(metadata).ensureReady(); + return ready.ready ? Ok(undefined) : Err(ready.error); +} diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index 50e3e6aa4b7..c5185f18a45 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -26,6 +26,7 @@ import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime, isDockerRuntime, isDevcontainerRuntime } from "@/common/types/runtime"; import { createRuntimeForWorkspace, + ensureDaytonaSandboxReady, resolveWorkspaceExecutionPath, } from "@/node/runtime/runtimeHelpers"; import { log } from "@/node/services/log"; @@ -77,16 +78,12 @@ function assertTerminalsEnabled(): void { } } -/** - * A Daytona sandbox may be stopped, or its token (the ssh user name) due for refresh: bring it - * up before a native emulator runs ssh. The user asked for this terminal, so starting it is fine. - */ +/** The user asked for this terminal: a Daytona sandbox must be up before a native emulator runs ssh. */ async function ensureDaytonaSandboxUp( - workspace: Parameters[0] + workspace: Parameters[0] ): Promise { - if (!isSSHRuntime(workspace.runtimeConfig) || !workspace.runtimeConfig.daytona) return; - const ready = await createRuntimeForWorkspace(workspace).ensureReady(); - if (!ready.ready) throw new Error(ready.error); + const ready = await ensureDaytonaSandboxReady(workspace); + if (!ready.success) throw new Error(ready.error); } export class TerminalService { diff --git a/src/node/services/workspaceService.runtimeStatus.test.ts b/src/node/services/workspaceService.runtimeStatus.test.ts index 6873035b3c0..350fcdba4c5 100644 --- a/src/node/services/workspaceService.runtimeStatus.test.ts +++ b/src/node/services/workspaceService.runtimeStatus.test.ts @@ -1,9 +1,11 @@ -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import * as fs from "node:fs/promises"; import path from "node:path"; import type { RuntimeConfig } from "@/common/types/runtime"; import { shellQuote } from "@/common/utils/shell"; import type { TerminalService } from "@/node/services/terminalService"; +import type { Runtime } from "@/node/runtime/Runtime"; +import * as runtimeHelpers from "@/node/runtime/runtimeHelpers"; import { createWorkspaceServiceHarness, type WorkspaceServiceHarness, @@ -221,3 +223,72 @@ describe.skipIf(process.platform === "win32")( }); } ); + +describe("WorkspaceService.ensureDaytonaSandboxReady", () => { + let harness: WorkspaceServiceHarness; + let projectPath: string; + let createRuntime: { mockRestore: () => void }; + const ensureReady = mock(() => + Promise.resolve({ + ready: false as const, + error: 'Daytona sandbox "sb-1" no longer exists', + errorType: "runtime_not_ready" as const, + }) + ); + + beforeEach(async () => { + harness = await createWorkspaceServiceHarness(); + projectPath = path.join(harness.rootDir, "project"); + await fs.mkdir(projectPath); + ensureReady.mockClear(); + createRuntime = spyOn(runtimeHelpers, "createRuntimeForWorkspace").mockReturnValue({ + ensureReady, + } as unknown as Runtime); + }); + + afterEach(async () => { + createRuntime.mockRestore(); + await harness.cleanup(); + }); + + async function addDaytonaWorkspace(archivedAt?: string): Promise { + await harness.config.editConfig((config) => { + config.projects.set(projectPath, { + trusted: true, + workspaces: [ + { + id: "dt", + name: "dt", + path: path.join(harness.rootDir, "checkouts", "dt"), + runtimeConfig: { + type: "ssh", + host: "sb-1.xum--daytona", + srcBaseDir: "/home/daytona/xum", + daytona: { sandboxId: "sb-1" }, + }, + createdAt: "2026-01-01T00:00:00.000Z", + archivedAt, + }, + ], + }); + return config; + }); + } + + test("brings the sandbox up and reports why it cannot", async () => { + await addDaytonaWorkspace(); + + expect(await harness.service.ensureDaytonaSandboxReady("dt")).toEqual({ + success: false, + error: 'Daytona sandbox "sb-1" no longer exists', + }); + expect(ensureReady).toHaveBeenCalledTimes(1); + }); + + test("refuses an archived workspace without starting its sandbox", async () => { + await addDaytonaWorkspace("2026-01-02T00:00:00.000Z"); + + expect((await harness.service.ensureDaytonaSandboxReady("dt")).success).toBe(false); + expect(ensureReady).not.toHaveBeenCalled(); + }); +}); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 718f967ceb3..53716078da8 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -125,6 +125,7 @@ import { DockerRuntime, getContainerName } from "@/node/runtime/DockerRuntime"; import { createRuntimeContextForWorkspace, createRuntimeForWorkspace, + ensureDaytonaSandboxReady, resolveWorkspaceExecutionPath, resolveWorkspaceRootPath, } from "@/node/runtime/runtimeHelpers"; @@ -13825,6 +13826,23 @@ export class WorkspaceService return Ok(undefined); } + /** + * Opening an external editor is an explicit action, so a stopped Daytona sandbox is started + * (and its ssh token refreshed) before the editor connects to its ssh alias. An archived + * workspace is refused like a native terminal: its sandbox must stay stopped. + */ + async ensureDaytonaSandboxReady(workspaceId: string): Promise> { + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const workspace = allMetadata.find((w) => w.id === workspaceId); + if (!workspace) { + return Err(this.workspaceNotFoundError()); + } + if (isWorkspaceArchived(workspace.archivedAt, workspace.unarchivedAt)) { + return Err(`Workspace is archived: ${workspaceId}. Unarchive it before opening an editor.`); + } + return ensureDaytonaSandboxReady(workspace); + } + async getRuntimeStatuses( workspaceIds: string[] ): Promise> { From 04935adcb4e927f7d6bbf041b941abadd707c701 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:22:50 +0000 Subject: [PATCH 157/231] fix(daytona): put the hardened Daytona ssh block first in ~/.ssh/config An editor's Remote-SSH reads ~/.ssh/config without Xum's -F, and OpenSSH keeps the first value it reads, so an earlier user `Host *` that turns off host key checking, forwards the agent or sets a proxy applied to sandbox aliases. The Daytona managed block now goes on the first line (an appended block from an earlier Xum moves there), ends with a `Host *` scope reset so the user's following top-level lines still apply to every host, and each alias sets ProxyCommand none, ForwardAgent no, ForwardX11 no and PermitLocalCommand no. KnownHostsCommand is left alone: it needs OpenSSH 8.5 or an IgnoreUnknown that would replace the user's own. --- docs/runtime/daytona.mdx | 4 +- src/node/runtime/daytonaSshConfig.test.ts | 97 ++++++++++++++++++++- src/node/runtime/daytonaSshConfig.ts | 21 ++++- src/node/runtime/muxSshConfigWriter.test.ts | 22 +++++ src/node/runtime/muxSshConfigWriter.ts | 67 ++++++++++---- src/node/runtime/sshConfigParser.test.ts | 60 +++++++++++++ 6 files changed, 246 insertions(+), 25 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index a7f689642ab..e0b5c3d4a98 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -73,11 +73,11 @@ Once a minute, Xum also stops running sandboxes that carry this install's label ## SSH setup -Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, so it also filters tokens that another Xum process on the same Xum root wrote there. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered. +Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, so it also filters tokens that another Xum process on the same Xum root wrote there. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered. Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts. -Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, so a `Host *` entry above Xum's block with a `User` or `UserKnownHostsFile` line breaks those connections; move the line into a narrower `Host` entry, or below Xum's block. +Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, and Xum keeps its block first in the file, so its settings win for sandboxes in those clients too: strict host key checking against the pinned keys, the token as the user name, no proxy or jump host, and no agent or X11 forwarding. Your other entries still apply to every other host. A `KnownHostsCommand` or `Match exec` line in your config still runs for sandboxes in your own clients, with the token as `%r`; scope such lines to the hosts that need them. ## Notes diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index cecb5d6ee53..33ef1bbe0b1 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -37,7 +37,7 @@ describe("DaytonaSshConfigWriter", () => { await fs.rm(tempDir, { recursive: true, force: true }); }); - it("appends a Host-scoped Include block after the user's config and writes 0600 files", async () => { + it("puts a Host-scoped Include block before the user's config and writes 0600 files", async () => { await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); await fs.writeFile(sshConfigPath, "Host work\n User me\n", "utf8"); @@ -45,12 +45,13 @@ describe("DaytonaSshConfigWriter", () => { expect(await fs.readFile(sshConfigPath, "utf8")).toBe( [ - "Host work", - " User me", XUM_DAYTONA_SSH_BLOCK_START, "Host *.xum--daytona", ` Include ${writer.paths.includePath}`, + "Host *", XUM_DAYTONA_SSH_BLOCK_END, + "Host work", + " User me", "", ].join("\n") ); @@ -58,6 +59,10 @@ describe("DaytonaSshConfigWriter", () => { expect(include).toContain( [ "Host sb-1.xum--daytona", + " ProxyCommand none", + " ForwardAgent no", + " ForwardX11 no", + " PermitLocalCommand no", " HostName ssh.daytona.test", " Port 22", " User tokenA", @@ -111,7 +116,6 @@ describe("DaytonaSshConfigWriter", () => { expect(include).not.toContain("ssh.daytona.test"); expect(include).toContain( [ - "Host sb-2.xum--daytona", " HostName ssh2.daytona.test", " Port 2222", " User tokenB", @@ -336,6 +340,91 @@ describe("DaytonaSshConfigWriter", () => { } }); + // Editors (VS Code Remote-SSH, Zed) run the user's own ssh against ~/.ssh/config, without the + // `-F` Xum passes, and OpenSSH keeps the first value it reads for each option. + describe("an editor's ssh client reading the user's ssh config", () => { + const resolveFromUserConfig = (host: string) => + spawnSync("ssh", ["-G", "-F", sshConfigPath, host]).stdout.toString(); + + it.skipIf(!hasOpenSsh)( + "applies Xum's hardening to the alias over an earlier user Host * entry", + async () => { + await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); + await fs.writeFile( + sshConfigPath, + [ + "Host *", + " StrictHostKeyChecking no", + " UserKnownHostsFile /dev/null", + " ForwardAgent yes", + " ForwardX11 yes", + " PermitLocalCommand yes", + " ProxyCommand nc -X connect -x gateway.example.com:3128 %h %p", + " User me", + "", + ].join("\n"), + "utf8" + ); + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + + const daytona = resolveFromUserConfig(toXumDaytonaHost("sb-1")); + expect(daytona).toMatch(/^user tokenA$/m); + expect(daytona).toContain("\nstricthostkeychecking true\n"); + expect(daytona).toContain(`\nuserknownhostsfile ${writer.paths.knownHostsPath}\n`); + expect(daytona).toContain("\nforwardagent no\n"); + expect(daytona).toContain("\nforwardx11 no\n"); + expect(daytona).toContain("\npermitlocalcommand no\n"); + expect(daytona).not.toMatch(/\nproxy(command|jump) /); + + const other = resolveFromUserConfig("other.example.com"); + expect(other).toMatch(/^user me$/m); + expect(other).toContain("\nstricthostkeychecking false\n"); + expect(other).toContain("\nuserknownhostsfile /dev/null\n"); + expect(other).toContain("\nforwardagent yes\n"); + expect(other).toContain("\nforwardx11 yes\n"); + expect(other).toContain("\nproxycommand nc -X connect -x gateway.example.com:3128 %h %p\n"); + } + ); + + it.skipIf(!hasOpenSsh)("ignores a ProxyJump the user sets for every host", async () => { + await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); + await fs.writeFile(sshConfigPath, "Host *\n ProxyJump jump.example.com\n", "utf8"); + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + + expect(resolveFromUserConfig(toXumDaytonaHost("sb-1"))).not.toMatch(/\nproxy(command|jump) /); + expect(resolveFromUserConfig("other.example.com")).toContain( + "\nproxyjump jump.example.com\n" + ); + }); + + it("moves a block an earlier Xum appended to the top of the file", async () => { + await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); + const userConfig = "Host *\n ForwardAgent yes\n"; + await fs.writeFile( + sshConfigPath, + [ + userConfig.trimEnd(), + XUM_DAYTONA_SSH_BLOCK_START, + "Host *.xum--daytona", + ` Include ${writer.paths.includePath}`, + XUM_DAYTONA_SSH_BLOCK_END, + "", + ].join("\n"), + "utf8" + ); + + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + const content = await fs.readFile(sshConfigPath, "utf8"); + expect(content.startsWith(`${XUM_DAYTONA_SSH_BLOCK_START}\n`)).toBe(true); + expect(content.endsWith(`${XUM_DAYTONA_SSH_BLOCK_END}\n${userConfig}`)).toBe(true); + expect(content.split(XUM_DAYTONA_SSH_BLOCK_START)).toHaveLength(2); + + // Already on top: rewriting it changes nothing. + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + expect(await fs.readFile(sshConfigPath, "utf8")).toBe(content); + }); + }); + describe("a user Host * entry that weakens host key checking", () => { const WEAKENING_CONFIG = "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n"; diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index db1cd746a46..7e06e7dfd13 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -115,15 +115,19 @@ function quoteConfigPath(filePath: string): string { } /** - * The block in `~/.ssh/config`. OpenSSH scopes an Include to the Host/Match block above it and - * the managed block sits at the end of the file, so a bare Include would land inside the user's - * last Host block. Scoping it to `Host *.xum--daytona` keeps it from leaking to other hosts. + * The block in `~/.ssh/config`, which an editor's own ssh reads (Xum's ssh reads the include + * file alone, via `-F`). It goes first in the file: OpenSSH keeps the first value it reads for an + * option, so the alias's hardening beats any user entry such as a `Host *` that turns off host + * key checking or forwards the agent. The Include is scoped to `Host *.xum--daytona` so it + * reaches no other host, and the closing `Host *` puts the user's top-level lines that follow + * back in scope for every host, as they were before the block. */ export function renderDaytonaManagedBlock(includePath: string): string { return [ XUM_DAYTONA_SSH_BLOCK_START, `Host *.${XUM_DAYTONA_HOST_SUFFIX}`, ` Include ${quoteConfigPath(includePath)}`, + "Host *", XUM_DAYTONA_SSH_BLOCK_END, ].join("\n"); } @@ -165,8 +169,18 @@ export function renderDaytonaHostBlock(input: { assertValid(input.sandboxId, SANDBOX_ID_PATTERN, "sandbox id"); assertValid(input.token, TOKEN_PATTERN, "SSH token"); validateGateway(input.gateway); + // The sandbox and the network path to the gateway are untrusted, and an editor's ssh reads + // this block before the user's entries, so it must not inherit their proxy (which would see + // the token), agent, display or local commands. `ProxyCommand none` comes first: OpenSSH then + // ignores a later ProxyJump too. KnownHostsCommand is left alone: OpenSSH before 8.5 rejects it + // unless an `IgnoreUnknown` covers it, and ours would replace the user's own `IgnoreUnknown` + // (first value wins). Forwards an editor asks for on its command line are left alone. return [ `Host ${toXumDaytonaHost(input.sandboxId)}`, + " ProxyCommand none", + " ForwardAgent no", + " ForwardX11 no", + " PermitLocalCommand no", ` HostName ${input.gateway.host}`, ` Port ${input.gateway.port}`, ` User ${input.token}`, @@ -254,6 +268,7 @@ export class DaytonaSshConfigWriter { }, block: renderDaytonaManagedBlock(this.paths.includePath), sshConfigPath: this.sshConfigPath, + placement: "top", }); } diff --git a/src/node/runtime/muxSshConfigWriter.test.ts b/src/node/runtime/muxSshConfigWriter.test.ts index 8c8594e9ffa..9cac1c63cd0 100644 --- a/src/node/runtime/muxSshConfigWriter.test.ts +++ b/src/node/runtime/muxSshConfigWriter.test.ts @@ -309,6 +309,28 @@ describe("ensureMuxCoderSSHConfigFile", () => { expect(content).toBe(expected); }); + it("moves a top-placed block from anywhere in the file to its first line", async () => { + const sshConfigPath = await makeSSHConfigPath(); + const markers = { start: "# >>> top >>>", end: "# <<< top <<<", label: "Top" }; + const render = (host: string) => [markers.start, `Host ${host}`, markers.end].join("\n"); + const writeTop = (host: string) => + ensureManagedSSHConfigBlock({ + markers, + block: render(host), + sshConfigPath, + placement: "top", + }); + + await writeTop("first"); + expect(await readSSHConfig(sshConfigPath)).toBe(`${render("first")}\n`); + + const userBefore = "Host github.com\n User git\n"; + const userAfter = "Host internal\n User dev"; + await writeSSHConfig(sshConfigPath, `${userBefore}${render("old")}\n${userAfter}`); + await writeTop("new"); + expect(await readSSHConfig(sshConfigPath)).toBe(`${render("new")}\n${userBefore}${userAfter}`); + }); + it("replaces mux block when it is at the very start of the file", async () => { const sshConfigPath = await makeSSHConfigPath(); const originalBinaryPath = "/opt/coder-old"; diff --git a/src/node/runtime/muxSshConfigWriter.ts b/src/node/runtime/muxSshConfigWriter.ts index 3c79efcf8dc..9f63c6851ff 100644 --- a/src/node/runtime/muxSshConfigWriter.ts +++ b/src/node/runtime/muxSshConfigWriter.ts @@ -75,11 +75,11 @@ export interface ManagedBlockMarkers { label: string; } -function replaceMuxBlock( +/** The marked block's full lines: from its start marker's line through its end marker's newline. */ +function findBlockLines( existingContent: string, - nextMuxBlock: string, markers: ManagedBlockMarkers -): string { +): { start: number; end: number; hadTrailingNewline: boolean } { const startMarkerIndex = existingContent.indexOf(markers.start); const endMarkerIndex = existingContent.indexOf(markers.end, startMarkerIndex); @@ -88,23 +88,46 @@ function replaceMuxBlock( } const lineStartIndex = existingContent.lastIndexOf("\n", startMarkerIndex); - const replaceStart = lineStartIndex === -1 ? 0 : lineStartIndex + 1; - const lineEndIndex = existingContent.indexOf("\n", endMarkerIndex); - const replaceEnd = lineEndIndex === -1 ? existingContent.length : lineEndIndex + 1; + return { + start: lineStartIndex === -1 ? 0 : lineStartIndex + 1, + end: lineEndIndex === -1 ? existingContent.length : lineEndIndex + 1, + hadTrailingNewline: lineEndIndex !== -1, + }; +} - const beforeBlock = existingContent.slice(0, replaceStart); - const afterBlock = existingContent.slice(replaceEnd); +function replaceMuxBlock( + existingContent: string, + nextMuxBlock: string, + markers: ManagedBlockMarkers +): string { + const lines = findBlockLines(existingContent, markers); + const beforeBlock = existingContent.slice(0, lines.start); + const afterBlock = existingContent.slice(lines.end); if (afterBlock.length === 0) { - const blockHadTrailingNewline = lineEndIndex !== -1; - const suffix = blockHadTrailingNewline ? "\n" : ""; + const suffix = lines.hadTrailingNewline ? "\n" : ""; return `${beforeBlock}${nextMuxBlock}${suffix}`; } return `${beforeBlock}${nextMuxBlock}\n${afterBlock}`; } +/** Puts the block on the first line, moving it there if it sits anywhere else. */ +function prependMuxBlock( + existingContent: string, + nextMuxBlock: string, + markers: ManagedBlockMarkers, + hasBlock: boolean +): string { + let rest = existingContent; + if (hasBlock) { + const lines = findBlockLines(existingContent, markers); + rest = existingContent.slice(0, lines.start) + existingContent.slice(lines.end); + } + return `${nextMuxBlock}\n${rest}`; +} + function appendMuxBlock(existingContent: string, nextMuxBlock: string): string { if (existingContent.length === 0) { return `${nextMuxBlock}\n`; @@ -236,14 +259,20 @@ async function writeConfigAtomically( } /** - * Write (or replace in place) one Xum-managed, marker-delimited block in the user's ssh config, - * leaving every other line untouched. Shared by the Coder and Daytona providers. + * Write one Xum-managed, marker-delimited block in the user's ssh config, leaving every other + * line untouched. Shared by the Coder and Daytona providers. */ export async function ensureManagedSSHConfigBlock(opts: { markers: ManagedBlockMarkers; /** Full block text, markers included. */ block: string; sshConfigPath?: string; + /** + * "end" (default) appends a new block and replaces an existing one in place. "top" keeps the + * block on the first line, where OpenSSH's first-value-wins rule lets its options beat every + * user entry; a block found elsewhere moves there. + */ + placement?: "top" | "end"; }): Promise { const { markers } = opts; const configuredSSHConfigPath = opts.sshConfigPath ?? path.join(os.homedir(), ".ssh", "config"); @@ -265,7 +294,7 @@ export async function ensureManagedSSHConfigBlock(opts: { timeoutMessage: "Another Xum process is updating the ssh config.", }); try { - await writeManagedBlock(sshConfigPath, markers, opts.block); + await writeManagedBlock(sshConfigPath, markers, opts.block, opts.placement ?? "end"); } finally { await release(); } @@ -275,7 +304,8 @@ export async function ensureManagedSSHConfigBlock(opts: { async function writeManagedBlock( sshConfigPath: string, markers: ManagedBlockMarkers, - block: string + block: string, + placement: "top" | "end" ): Promise { const { content: existingContent, mode: existingMode } = await loadSSHConfigContent(sshConfigPath); @@ -291,10 +321,15 @@ async function writeManagedBlock( throw new Error(`Corrupted SSH config: mismatched Xum ${markers.label} SSH markers detected.`); } - const nextContent = - startMarkerCount === 1 + const hasBlock = startMarkerCount === 1; + let nextContent: string; + if (placement === "top") { + nextContent = prependMuxBlock(existingContent, block, markers, hasBlock); + } else { + nextContent = hasBlock ? replaceMuxBlock(existingContent, block, markers) : appendMuxBlock(existingContent, block); + } await writeConfigAtomically(sshConfigPath, nextContent, existingMode); } diff --git a/src/node/runtime/sshConfigParser.test.ts b/src/node/runtime/sshConfigParser.test.ts index 95328dffeba..27825360c4c 100644 --- a/src/node/runtime/sshConfigParser.test.ts +++ b/src/node/runtime/sshConfigParser.test.ts @@ -2,6 +2,7 @@ import { spawnSync } from "child_process"; import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; +import { XUM_DAYTONA_SSH_BLOCK_START } from "@/common/constants/daytona"; import { DaytonaSshConfigWriter } from "./daytonaSshConfig"; import { resolveSSHConfig } from "./sshConfigParser"; @@ -142,6 +143,65 @@ describe("resolveSSHConfig", () => { }); }); + // The Daytona block goes first in the file; the user's global options, top-level Include and + // Host entries after it must resolve as they did before for every other host. + test("resolves other hosts the same after Xum puts its Daytona block on top", async () => { + await withTempHome(async (homeDir) => { + const configPath = path.join(homeDir, ".ssh", "config"); + await fs.mkdir(path.join(homeDir, ".ssh", "conf.d")); + await fs.writeFile( + path.join(homeDir, ".ssh", "conf.d", "team.conf"), + "Host team-*\n User teamuser\n Port 2200\n", + "utf8" + ); + await fs.writeFile( + configPath, + [ + "User globaluser", + "ServerAliveInterval 30", + `Include ${path.join(homeDir, ".ssh", "conf.d", "*")}`, + "Host box", + " HostName 10.0.0.1", + " ProxyCommand nc %h %p", + "Host *", + " Port 2222", + " ForwardAgent yes", + "", + ].join("\n"), + "utf8" + ); + const hosts = ["box", "team-a", "plain.example.com"]; + const snapshot = async () => + Promise.all( + hosts.map(async (host) => ({ + parsed: await resolveSSHConfig(host), + openssh: hasOpenSsh + ? spawnSync("ssh", ["-G", "-F", configPath, host], { encoding: "utf8" }).stdout + : "", + })) + ); + const before = await snapshot(); + + await new DaytonaSshConfigWriter({ + xumHome: path.join(homeDir, ".xum"), + sshConfigPath: configPath, + }).writeSandbox({ + sandboxId: "sb-1", + token: "tokenA", + gateway: { host: "ssh.daytona.test", port: 22, hostKeys: ["ssh-ed25519 AAAA"] }, + }); + + expect((await fs.readFile(configPath, "utf8")).startsWith(XUM_DAYTONA_SSH_BLOCK_START)).toBe( + true + ); + expect(await snapshot()).toEqual(before); + expect(before[0].parsed.user).toBe("globaluser"); + expect(before[1].parsed.port).toBe(2200); + expect(before[2].parsed.port).toBe(2222); + expect((await resolveSSHConfig("sb-1.xum--daytona")).user).toBe("tokenA"); + }); + }); + test("keeps a space in a quoted UserKnownHostsFile and splits unquoted files", async () => { await withTempHome(async (homeDir) => { const configPath = path.join(homeDir, ".ssh", "config"); From 4040f70beabf8ec601c91fe8ff1666fdadead9ce Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:22:51 +0000 Subject: [PATCH 158/231] chore(docs): regenerate built-in skill content for the Daytona SSH setup docs --- .../services/agentSkills/builtInSkillContent.generated.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 800fe1e49c9..af3b36744f1 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10110,11 +10110,11 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "## SSH setup", "", - "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the end of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, so it also filters tokens that another Xum process on the same Xum root wrote there. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered.", + "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, so it also filters tokens that another Xum process on the same Xum root wrote there. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered.", "", "Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts.", "", - "Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, so a `Host *` entry above Xum's block with a `User` or `UserKnownHostsFile` line breaks those connections; move the line into a narrower `Host` entry, or below Xum's block.", + "Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, and Xum keeps its block first in the file, so its settings win for sandboxes in those clients too: strict host key checking against the pinned keys, the token as the user name, no proxy or jump host, and no agent or X11 forwarding. Your other entries still apply to every other host. A `KnownHostsCommand` or `Match exec` line in your config still runs for sandboxes in your own clients, with the token as `%r`; scope such lines to the hosts that need them.", "", "## Notes", "", From 86d4af183ed8f0ac89a3fd536f83a07c3a43c17b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:45:51 +0000 Subject: [PATCH 159/231] fix(daytona): refuse opening a sandbox in an editor from a remote browser --- src/browser/utils/openInEditor.test.ts | 31 ++++++++++++++++++++++++-- src/browser/utils/openInEditor.ts | 16 ++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/browser/utils/openInEditor.test.ts b/src/browser/utils/openInEditor.test.ts index 693590b994a..a9a34c72f99 100644 --- a/src/browser/utils/openInEditor.test.ts +++ b/src/browser/utils/openInEditor.test.ts @@ -51,7 +51,7 @@ describe("openInEditor", () => { // navigations and close() calls, mirroring a real popup. function createBrowserModeWindow( calls: OpenCall[], - opts?: { popupBlocked?: boolean; denyEditorPlaceholders?: boolean } + opts?: { popupBlocked?: boolean; denyEditorPlaceholders?: boolean; hostname?: string } ) { const placeholder = { closed: false, @@ -68,7 +68,7 @@ describe("openInEditor", () => { }); const windowValue = { localStorage: { getItem: () => null }, - location: { hostname: "localhost" }, + location: { hostname: opts?.hostname ?? "localhost" }, open: (url: string, target?: string) => { calls.push([url, target]); const wrapperBlocked = @@ -201,6 +201,33 @@ describe("openInEditor", () => { expect(placeholder.closed).toBe(true); }); + test("remote browser: refuses before preparing, recording or launching", async () => { + const calls: OpenCall[] = []; + const { windowValue, placeholder } = createBrowserModeWindow(calls, { + hostname: "xum.example.com", + }); + const ensureDaytonaSandboxReady = mock(() => + Promise.resolve({ success: true as const, data: undefined }) + ); + const recordEditorOpen = mock(() => + Promise.resolve({ success: true as const, data: undefined }) + ); + const api = createTestApiClient({ + general: { recordEditorOpen }, + workspace: { ensureDaytonaSandboxReady }, + }); + + const result = await withWindow(windowValue, () => + openInEditor({ api, workspaceId, targetPath: parentDir, runtimeConfig: daytonaConfig }) + ); + + expect(result.success).toBe(false); + expect(ensureDaytonaSandboxReady).not.toHaveBeenCalled(); + expect(recordEditorOpen).not.toHaveBeenCalled(); + expect(placeholder.navigations).toEqual([]); + expect(placeholder.closed).toBe(true); + }); + test("other SSH workspaces launch without preparing", async () => { const calls: OpenCall[] = []; const ensureDaytonaSandboxReady = mock(() => diff --git a/src/browser/utils/openInEditor.ts b/src/browser/utils/openInEditor.ts index e6e34de1c0c..abdd872362a 100644 --- a/src/browser/utils/openInEditor.ts +++ b/src/browser/utils/openInEditor.ts @@ -246,12 +246,26 @@ export async function openInEditor(args: OpenInEditorArgs): Promise Promise ): Promise { - if (!isSSHRuntime(args.runtimeConfig) || !args.runtimeConfig.daytona || !args.api) { + if (!isSSHRuntime(args.runtimeConfig) || !args.runtimeConfig.daytona) { + return launch(); + } + if (isBrowserModeNow() && !isLocalhost(window.location.hostname)) { + return { + success: false, + error: + "Daytona sandboxes open in an editor only from the Xum desktop app or a browser on the Xum server's machine.", + }; + } + if (!args.api) { return launch(); } const ready = await args.api.workspace.ensureDaytonaSandboxReady({ From 34197178dc6754d011294ba90cb3a33a484c49e5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:50:24 +0000 Subject: [PATCH 160/231] fix(daytona): put back the ~/.ssh/config block on ssh access while the token is fresh --- src/node/runtime/daytonaSshConfig.test.ts | 14 ++++++++++ src/node/runtime/daytonaSshConfig.ts | 9 ++++++ src/node/runtime/muxSshConfigWriter.test.ts | 3 +- src/node/runtime/muxSshConfigWriter.ts | 2 ++ .../services/daytona/daytona.testHarness.ts | 11 +++++++- .../services/daytona/daytonaService.test.ts | 28 +++++++++++++++++++ src/node/services/daytona/daytonaService.ts | 10 ++++++- 7 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index 33ef1bbe0b1..37135eec5df 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -100,6 +100,20 @@ describe("DaytonaSshConfigWriter", () => { expect(afterRemove).toContain("Host sb-2.xum--daytona"); }); + it("puts back a removed managed block, and leaves an unchanged config untouched", async () => { + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + const written = await fs.readFile(sshConfigPath, "utf8"); + const { ino } = await fs.stat(sshConfigPath); + + await writer.ensureManagedBlock(); + // The atomic write renames a new file into place, so an unchanged inode means no write. + expect((await fs.stat(sshConfigPath)).ino).toBe(ino); + + await fs.writeFile(sshConfigPath, "Host work\n User me\n", "utf8"); + await writer.ensureManagedBlock(); + expect(await fs.readFile(sshConfigPath, "utf8")).toBe(`${written}Host work\n User me\n`); + }); + it("repoints every sandbox at a new gateway in the write that replaces its host keys", async () => { await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); await writer.writeSandbox({ sandboxId: "sb-2", token: "tokenB", gateway: GATEWAY }); diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 7e06e7dfd13..24fad8543a7 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -260,6 +260,15 @@ export class DaytonaSshConfigWriter { await this.installGateway(input.gateway, (blocks) => blocks.set(toXumDaytonaHost(input.sandboxId), this.renderBlock(input)) ); + await this.ensureManagedBlock(); + } + + /** + * The `~/.ssh/config` block that includes the sandboxes' file for the user's own ssh clients. + * Another tool may remove it while the include file and its tokens stay; no write when it is + * already in place. + */ + async ensureManagedBlock(): Promise { await ensureManagedSSHConfigBlock({ markers: { start: XUM_DAYTONA_SSH_BLOCK_START, diff --git a/src/node/runtime/muxSshConfigWriter.test.ts b/src/node/runtime/muxSshConfigWriter.test.ts index 9cac1c63cd0..bfa8acf30f4 100644 --- a/src/node/runtime/muxSshConfigWriter.test.ts +++ b/src/node/runtime/muxSshConfigWriter.test.ts @@ -526,9 +526,10 @@ describe("ensureMuxCoderSSHConfigFile", () => { const writeFileSpy = spyOn(fs, "writeFile"); try { + // Different binaries, so the second call changes the block: an unchanged one is not written. await Promise.all([ ensureMuxCoderSSHConfigFile({ coderBinaryPath, sshConfigPath }), - ensureMuxCoderSSHConfigFile({ coderBinaryPath, sshConfigPath }), + ensureMuxCoderSSHConfigFile({ coderBinaryPath: "/opt/coder/bin/coder", sshConfigPath }), ]); const tempPaths = writeFileSpy.mock.calls diff --git a/src/node/runtime/muxSshConfigWriter.ts b/src/node/runtime/muxSshConfigWriter.ts index 9f63c6851ff..44431d33877 100644 --- a/src/node/runtime/muxSshConfigWriter.ts +++ b/src/node/runtime/muxSshConfigWriter.ts @@ -331,6 +331,8 @@ async function writeManagedBlock( : appendMuxBlock(existingContent, block); } + // Daytona checks its block on every ssh access: leave an unchanged file alone. + if (nextContent === existingContent) return; await writeConfigAtomically(sshConfigPath, nextContent, existingMode); } diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index a604d334e2d..4e3d55e3b6b 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -276,7 +276,12 @@ export class FakeDaytonaControlPlane { /** Records ssh config writes instead of touching `~/.ssh/config`. */ export class FakeDaytonaSshConfigWriter implements Pick< DaytonaSshConfigWriter, - "writeSandbox" | "updateToken" | "repointGateway" | "removeSandbox" | "readTokens" + | "writeSandbox" + | "ensureManagedBlock" + | "updateToken" + | "repointGateway" + | "removeSandbox" + | "readTokens" > { /** Current token per sandbox alias, as the include file would hold it. */ readonly tokens = new Map(); @@ -301,6 +306,10 @@ export class FakeDaytonaSshConfigWriter implements Pick< return Promise.resolve(); } + ensureManagedBlock(): Promise { + return Promise.resolve(); + } + updateToken(input: { sandboxId: string; token: string; diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 0908906079e..43f61c5c323 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -3,6 +3,8 @@ import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; +import { XUM_DAYTONA_SSH_BLOCK_START } from "@/common/constants/daytona"; +import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; import { log } from "@/node/services/log"; import { DaytonaApiError } from "./daytonaClient"; import { @@ -398,6 +400,7 @@ describe("DaytonaService", () => { now: () => clock, sshConfigWriter: { writeSandbox: store, + ensureManagedBlock: () => Promise.resolve(), updateToken: store, repointGateway: () => Promise.resolve(), removeSandbox: (sandboxId: string) => { @@ -456,6 +459,7 @@ describe("DaytonaService", () => { now: () => clock, sshConfigWriter: { writeSandbox: record, + ensureManagedBlock: () => Promise.resolve(), updateToken: record, removeSandbox: (sandboxId) => { includeFile.delete(sandboxId); @@ -635,6 +639,7 @@ describe("DaytonaService", () => { }; const writer = { writeSandbox: store, + ensureManagedBlock: () => Promise.resolve(), updateToken: store, removeSandbox: () => Promise.resolve(), readTokens: () => @@ -668,6 +673,7 @@ describe("DaytonaService", () => { let failedReads = 1; const writer = { writeSandbox: () => Promise.resolve(), + ensureManagedBlock: () => Promise.resolve(), updateToken: () => Promise.resolve(), removeSandbox: () => Promise.resolve(), readTokens: () => @@ -695,6 +701,28 @@ describe("DaytonaService", () => { expect(plane.revoked).toEqual(["earlier-process-token"]); }); + it("puts back a removed ~/.ssh/config block on ssh access while the cached token is fresh", async () => { + const sshConfigPath = path.join(rootDir, "home", ".ssh", "config"); + service = new DaytonaService({ + credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), + rootDir, + fetch: plane.fetch, + now: () => clock, + sshConfigWriter: new DaytonaSshConfigWriter({ xumHome: rootDir, sshConfigPath }), + }); + await service.ensureSshAccess("sb-1"); + expect(await fs.readFile(sshConfigPath, "utf8")).toContain(XUM_DAYTONA_SSH_BLOCK_START); + // A dotfiles tool replaces the file: the include file and the cached token stay. + await fs.writeFile(sshConfigPath, "Host work\n User me\n", "utf8"); + + await service.ensureSshAccess("sb-1"); + + const restored = await fs.readFile(sshConfigPath, "utf8"); + expect(restored.startsWith(XUM_DAYTONA_SSH_BLOCK_START)).toBe(true); + expect(restored).toContain("Host work\n User me\n"); + expect(plane.calls.filter((call) => call === "POST /sandbox/sb-1/ssh-access")).toHaveLength(1); + }); + it("reports test-connection failures as typed results", async () => { expect(await service.testConnection()).toEqual({ ok: true }); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 92d509c53ea..9e0ce7ca747 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -215,7 +215,12 @@ export interface DaytonaServiceOptions { /** Defaults to a writer for the real `~/.ssh/config`; tests pass one aimed at a temp dir. */ sshConfigWriter?: Pick< DaytonaSshConfigWriter, - "writeSandbox" | "updateToken" | "repointGateway" | "removeSandbox" | "readTokens" + | "writeSandbox" + | "ensureManagedBlock" + | "updateToken" + | "repointGateway" + | "removeSandbox" + | "readTokens" >; fetch?: typeof fetch; now?: () => number; @@ -1326,6 +1331,8 @@ export class DaytonaService { /** * A valid token in the include file for this sandbox. The first write in this process also * writes known_hosts and the `~/.ssh/config` block; a refresh rewrites only the include file. + * The `~/.ssh/config` block is checked every time: another tool may have removed it while a + * cached token stays fresh, and an external editor needs it to resolve the alias. */ ensureSshAccess(sandboxId: string): Promise { return this.withAccountLease(async () => { @@ -1345,6 +1352,7 @@ export class DaytonaService { await this.sshConfigWriter.writeSandbox({ sandboxId, token, gateway }); }, }); + await this.sshConfigWriter.ensureManagedBlock(); this.sshConfigured.add(sandboxId); }); } From 69eb98671dc8eff3403c121223b4c8ac8ec5dec9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:04:23 +0000 Subject: [PATCH 161/231] fix(daytona): load Daytona settings and resources again when the connection is live While Xum reconnects, useAPI() can keep the last client, whose calls fail at once with ConnectionLostError, and the same object may come back. The creation controls' resources and the Settings access form loaded once per client, so a load that failed in the gap stayed failed. Both now load on useLiveAPI(): nothing runs while reconnecting, and the load runs again when the connection is live. The access form also clears its load error after a load that succeeds. --- .../contexts/connection.testHarness.tsx | 27 ++++++++++++++ .../Sections/DaytonaSettingsForms.test.tsx | 23 ++++++++++++ .../Sections/DaytonaSettingsForms.tsx | 21 +++++++---- src/browser/hooks/useDaytonaResources.test.ts | 37 +++++++++++++++++++ src/browser/hooks/useDaytonaResources.ts | 16 +++++--- 5 files changed, 110 insertions(+), 14 deletions(-) diff --git a/src/browser/contexts/connection.testHarness.tsx b/src/browser/contexts/connection.testHarness.tsx index 655115f6558..c5a5997bd4a 100644 --- a/src/browser/contexts/connection.testHarness.tsx +++ b/src/browser/contexts/connection.testHarness.tsx @@ -118,3 +118,30 @@ export function createConnectionHarness( } export type ConnectionHarness = ReturnType; + +/** + * A connection that starts reconnecting and comes back with the same client object, as the + * provider may keep it across a reconnect. The client's calls are the test's to fail meanwhile. + */ +export function createKeptClientConnection(client: APIClient) { + const state = (status: "connected" | "reconnecting"): UseAPIResult => + status === "connected" + ? connected(client) + : { status, api: client, error: null, attempt: 1, authenticate: noop, retry: noop }; + let latest = state("reconnecting"); + let publish: ((value: UseAPIResult) => void) | null = null; + + function Wrapper(props: { children: ReactNode }) { + const [value, setValue] = useState(() => latest); + publish = setValue; + return {props.children}; + } + + return { + Wrapper, + setStatus(status: "connected" | "reconnecting"): void { + latest = state(status); + if (publish != null) act(() => publish?.(latest)); + }, + }; +} diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.test.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.test.tsx index 5dd19efb70e..f1e8652203f 100644 --- a/src/browser/features/Settings/Sections/DaytonaSettingsForms.test.tsx +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.test.tsx @@ -4,6 +4,8 @@ import { cleanup, render, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { installDom } from "../../../../../tests/ui/dom"; import { APIProvider } from "@/browser/contexts/API"; +import { createKeptClientConnection } from "@/browser/contexts/connection.testHarness"; +import { ConnectionLostError } from "@/browser/contexts/connectionLink"; import { createTestApiClient } from "@/browser/testUtils"; import type { DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; import { DaytonaAccessForm } from "./DaytonaSettingsForms"; @@ -65,6 +67,27 @@ describe("DaytonaAccessForm", () => { expect(sent).toEqual({ apiKey: "new-key" }); }); + test("loads the saved settings once the connection is live after a reconnect", async () => { + let live = false; + const client = createTestApiClient({ + daytona: { + getConfig: () => + live ? Promise.resolve(ENV_CONFIG) : Promise.reject(new ConnectionLostError()), + }, + }); + const connection = createKeptClientConnection(client); + const view = render(, { wrapper: connection.Wrapper }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + live = true; + connection.setStatus("connected"); + + await waitFor(() => + expect((view.getByLabelText("API URL") as HTMLInputElement).value).toBe(ENV_CONFIG.apiUrl) + ); + expect(view.queryByText(/Could not load Daytona settings/)).toBeNull(); + }); + test("saves a URL the user edited", async () => { const sent = await saveAfter(async (fields, user) => { await user.clear(fields.url); diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx index cc4c8a88c2d..7efb7347513 100644 --- a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { CheckCircle2, Loader2, XCircle } from "lucide-react"; import { Button } from "@/browser/components/Button/Button"; import { Input } from "@/browser/components/Input/Input"; -import { useAPI } from "@/browser/contexts/API"; +import { useAPI, useLiveAPI } from "@/browser/contexts/API"; import { DAYTONA_DEFAULT_API_URL } from "@/common/constants/daytona"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import type { DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; @@ -36,17 +36,22 @@ function describeKey(config: DaytonaPublicConfig | null): string { type DaytonaApi = NonNullable["api"]>; -/** The saved public config (never the key itself), loaded once per API connection. */ -function useDaytonaPublicConfig(api: DaytonaApi | null) { +/** + * The saved public config (never the key itself), loaded each time the connection becomes live: + * while Xum reconnects the last client may stay (and come back), and its calls fail at once. + */ +function useDaytonaPublicConfig(liveApi: DaytonaApi | null) { const [config, setConfig] = useState(null); const [loadError, setLoadError] = useState(null); useEffect(() => { - if (!api) return; + if (!liveApi) return; let active = true; - api.daytona + liveApi.daytona .getConfig() .then((next) => { - if (active) setConfig(next); + if (!active) return; + setConfig(next); + setLoadError(null); }) .catch((error: unknown) => { if (active) setLoadError(`Could not load Daytona settings: ${getErrorMessage(error)}`); @@ -54,7 +59,7 @@ function useDaytonaPublicConfig(api: DaytonaApi | null) { return () => { active = false; }; - }, [api]); + }, [liveApi]); return { config, setConfig, loadError }; } @@ -76,7 +81,7 @@ function useDaytonaConnectionTest(api: DaytonaApi | null) { /** The saved config plus the save and test-connection calls. */ function useDaytonaAccess() { const { api } = useAPI(); - const { config, setConfig, loadError } = useDaytonaPublicConfig(api); + const { config, setConfig, loadError } = useDaytonaPublicConfig(useLiveAPI()); const { test, runTest, resetTest } = useDaytonaConnectionTest(api); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); diff --git a/src/browser/hooks/useDaytonaResources.test.ts b/src/browser/hooks/useDaytonaResources.test.ts index cb6cb705bb2..681f9b40807 100644 --- a/src/browser/hooks/useDaytonaResources.test.ts +++ b/src/browser/hooks/useDaytonaResources.test.ts @@ -4,6 +4,8 @@ import { createElement, type ComponentProps, type ReactNode } from "react"; import { act, cleanup, render, renderHook, waitFor } from "@testing-library/react"; import { GlobalWindow } from "happy-dom"; import { APIProvider } from "@/browser/contexts/API"; +import { createKeptClientConnection } from "@/browser/contexts/connection.testHarness"; +import { ConnectionLostError } from "@/browser/contexts/connectionLink"; import { createTestApiClient } from "@/browser/testUtils"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import type { DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; @@ -58,6 +60,41 @@ describe("useDaytonaResources", () => { await waitFor(() => expect(result.current.snapshots).toEqual(["base"])); expect(result.current.config?.apiKeySet).toBe(true); }); + test("loads once the connection is live again after a load that failed while reconnecting", async () => { + let live = false; + const whenLive = (value: T): Promise => + live ? Promise.resolve(value) : Promise.reject(new ConnectionLostError()); + const client = createTestApiClient({ + daytona: { + getConfig: () => + whenLive({ + apiKeySet: true, + apiKeySource: "settings", + apiUrl: "https://app.daytona.io/api", + target: null, + }), + listSnapshots: () => whenLive({ ok: true as const, snapshots: [{ name: "base" }] }), + listSandboxes: () => whenLive({ ok: true as const, sandboxes: [] }), + }, + }); + const connection = createKeptClientConnection(client); + const { result } = renderHook(() => useDaytonaResources({ loadSandboxes: true }), { + wrapper: connection.Wrapper, + }); + // A reconnect gap is not a Daytona error: the form waits instead of reporting one. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + expect(result.current.configError).toBeNull(); + + live = true; + connection.setStatus("connected"); + + await waitFor(() => expect(result.current.snapshots).toEqual(["base"])); + expect(result.current.config?.apiKeySet).toBe(true); + expect(result.current.configError).toBeNull(); + }); + // Kept, the old account's config would keep the form ready with its snapshots and sandboxes. test("drops what the previous load found when a reload cannot read the config", async () => { let failing = false; diff --git a/src/browser/hooks/useDaytonaResources.ts b/src/browser/hooks/useDaytonaResources.ts index c35bc4a2db3..593c147feb9 100644 --- a/src/browser/hooks/useDaytonaResources.ts +++ b/src/browser/hooks/useDaytonaResources.ts @@ -4,7 +4,7 @@ * The runtime config itself is owned by the caller (selectedRuntime.daytona or project defaults). */ import { useEffect, useState } from "react"; -import { useAPI } from "@/browser/contexts/API"; +import { useAPI, useLiveAPI } from "@/browser/contexts/API"; import type { DaytonaPublicConfig, DaytonaSandboxSummary } from "@/common/orpc/schemas/daytona"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { getErrorMessage } from "@/common/utils/errors"; @@ -78,13 +78,17 @@ function resourcesFor(loaded: LoadedDaytonaResources | null, key: DaytonaLoadKey export function useDaytonaResources(options: UseDaytonaResourcesOptions): DaytonaResources { const { api } = useAPI(); + // Loads run on the live client only. While Xum reconnects `api` can stay the last client, + // whose calls fail at once, and the same object may come back: a load keyed on it alone would + // keep that failure until something else changed. + const liveApi = useLiveAPI(); const configVersion = useDaytonaConfigVersion(); // One state object so consumers get a stable identity until something actually loads. const [loaded, setLoaded] = useState(null); const { loadSandboxes } = options; useEffect(() => { - if (!api) return; + if (!liveApi) return; let active = true; const update = (patch: Partial) => { if (!active) return; @@ -93,12 +97,12 @@ export function useDaytonaResources(options: UseDaytonaResourcesOptions): Dayton }; const load = async () => { - const config = await api.daytona.getConfig(); + const config = await liveApi.daytona.getConfig(); update({ config, configError: null }); if (!config.apiKeySet) return; update({ loadingSnapshots: true, loadingSandboxes: loadSandboxes }); await Promise.all([ - api.daytona.listSnapshots().then((result) => + liveApi.daytona.listSnapshots().then((result) => update( result.ok ? { @@ -110,7 +114,7 @@ export function useDaytonaResources(options: UseDaytonaResourcesOptions): Dayton ) ), loadSandboxes - ? api.daytona + ? liveApi.daytona .listSandboxes() .then((result) => update( @@ -137,7 +141,7 @@ export function useDaytonaResources(options: UseDaytonaResourcesOptions): Dayton return () => { active = false; }; - }, [api, loadSandboxes, configVersion]); + }, [api, liveApi, loadSandboxes, configVersion]); // A reload never shows what the previous load found: a failed or keyless reload would // otherwise leave the form ready with the old account's config, snapshots and sandboxes. From 99790974362c3809335c10109a9e3d3cededc035 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:27:53 +0000 Subject: [PATCH 162/231] fix(daytona): start the sandbox for an editor only once the editor use is admitted --- src/browser/utils/openInEditor.test.ts | 102 ++---------------- src/browser/utils/openInEditor.ts | 34 +++--- src/common/orpc/schemas/api.ts | 11 +- src/node/orpc/router.ts | 6 -- .../workspaceService.runtimeStatus.test.ts | 43 +++++--- src/node/services/workspaceService.ts | 61 ++++++----- 6 files changed, 86 insertions(+), 171 deletions(-) diff --git a/src/browser/utils/openInEditor.test.ts b/src/browser/utils/openInEditor.test.ts index a9a34c72f99..61c762bacd2 100644 --- a/src/browser/utils/openInEditor.test.ts +++ b/src/browser/utils/openInEditor.test.ts @@ -127,125 +127,41 @@ describe("openInEditor", () => { daytona: { sandboxId: "sb-1" }, }; - test("prepares the sandbox before recording and launching the editor", async () => { - const events: string[] = []; - const ensureDaytonaSandboxReady = mock(() => { - events.push("prepare"); - return Promise.resolve({ success: true as const, data: undefined }); - }); - const api = createTestApiClient({ - general: { - recordEditorOpen: () => { - events.push("record"); - return Promise.resolve({ success: true as const, data: undefined }); - }, - }, - workspace: { ensureDaytonaSandboxReady }, - }); - const windowValue = { - ...createMockWindow([]), - open: (url: string) => { - events.push(`open ${url}`); - return null; - }, - }; - - const result = await withWindow(windowValue, () => - openInEditor({ api, workspaceId, targetPath: parentDir, runtimeConfig: daytonaConfig }) - ); - - expect(result).toEqual({ success: true }); - expect(ensureDaytonaSandboxReady).toHaveBeenCalledWith({ workspaceId }); - expect(events.slice(0, 2)).toEqual(["prepare", "record"]); - expect(events[2]).toContain("ssh-remote+xum-daytona-sb-1"); - }); - - test("refused readiness shows its error and opens nothing", async () => { - const calls: OpenCall[] = []; - const recordEditorOpen = mock(() => - Promise.resolve({ success: true as const, data: undefined }) - ); - const api = createTestApiClient({ - general: { recordEditorOpen }, - workspace: { - ensureDaytonaSandboxReady: () => - Promise.resolve({ success: false as const, error: "Daytona sandbox was deleted" }), - }, - }); - - const result = await withWindow(createMockWindow(calls), () => - openInEditor({ api, workspaceId, targetPath: parentDir, runtimeConfig: daytonaConfig }) - ); - - expect(result).toEqual({ success: false, error: "Daytona sandbox was deleted" }); - expect(recordEditorOpen).not.toHaveBeenCalled(); - expect(calls).toEqual([]); - }); - - test("browser mode: a refused readiness closes the placeholder", async () => { - const calls: OpenCall[] = []; - const { windowValue, placeholder } = createBrowserModeWindow(calls); - const api = createTestApiClient({ - workspace: { - ensureDaytonaSandboxReady: () => - Promise.resolve({ success: false as const, error: "sandbox is archived" }), - }, - }); - - const result = await withWindow(windowValue, () => - openInEditor({ api, workspaceId, targetPath: parentDir, runtimeConfig: daytonaConfig }) - ); - - expect(result).toEqual({ success: false, error: "sandbox is archived" }); - expect(placeholder.navigations).toEqual([]); - expect(placeholder.closed).toBe(true); - }); - - test("remote browser: refuses before preparing, recording or launching", async () => { + test("remote browser: refuses before recording or launching", async () => { const calls: OpenCall[] = []; const { windowValue, placeholder } = createBrowserModeWindow(calls, { hostname: "xum.example.com", }); - const ensureDaytonaSandboxReady = mock(() => - Promise.resolve({ success: true as const, data: undefined }) - ); const recordEditorOpen = mock(() => Promise.resolve({ success: true as const, data: undefined }) ); - const api = createTestApiClient({ - general: { recordEditorOpen }, - workspace: { ensureDaytonaSandboxReady }, - }); + const api = createTestApiClient({ general: { recordEditorOpen } }); const result = await withWindow(windowValue, () => openInEditor({ api, workspaceId, targetPath: parentDir, runtimeConfig: daytonaConfig }) ); expect(result.success).toBe(false); - expect(ensureDaytonaSandboxReady).not.toHaveBeenCalled(); expect(recordEditorOpen).not.toHaveBeenCalled(); expect(placeholder.navigations).toEqual([]); expect(placeholder.closed).toBe(true); }); - test("other SSH workspaces launch without preparing", async () => { + test("a browser on the server's machine opens the sandbox", async () => { const calls: OpenCall[] = []; - const ensureDaytonaSandboxReady = mock(() => - Promise.resolve({ success: true as const, data: undefined }) - ); + const { windowValue, placeholder } = createBrowserModeWindow(calls); - const result = await withWindow(createMockWindow(calls), () => + const result = await withWindow(windowValue, () => openInEditor({ - api: createApiStub({ workspace: { ensureDaytonaSandboxReady } }), + api: createApiStub(), workspaceId, targetPath: parentDir, - runtimeConfig: { type: "ssh", host: "devbox", srcBaseDir: "~/xum" }, + runtimeConfig: daytonaConfig, }) ); - expect(result.success).toBe(true); - expect(calls.length).toBe(1); - expect(ensureDaytonaSandboxReady).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + expect(placeholder.navigations[0]).toContain("ssh-remote+xum-daytona-sb-1"); }); }); diff --git a/src/browser/utils/openInEditor.ts b/src/browser/utils/openInEditor.ts index abdd872362a..1b60b0d8f20 100644 --- a/src/browser/utils/openInEditor.ts +++ b/src/browser/utils/openInEditor.ts @@ -243,35 +243,25 @@ export async function openInEditor(args: OpenInEditorArgs): Promise Promise ): Promise { - if (!isSSHRuntime(args.runtimeConfig) || !args.runtimeConfig.daytona) { - return launch(); - } - if (isBrowserModeNow() && !isLocalhost(window.location.hostname)) { - return { + const isDaytona = isSSHRuntime(args.runtimeConfig) && args.runtimeConfig.daytona != null; + if (isDaytona && isBrowserModeNow() && !isLocalhost(window.location.hostname)) { + return Promise.resolve({ success: false, error: "Daytona sandboxes open in an editor only from the Xum desktop app or a browser on the Xum server's machine.", - }; - } - if (!args.api) { - return launch(); + }); } - const ready = await args.api.workspace.ensureDaytonaSandboxReady({ - workspaceId: args.workspaceId, - }); - return ready.success ? launch() : { success: false, error: ready.error }; + return launch(); } async function openInEditorWithLaunch( @@ -417,7 +407,7 @@ async function openInEditorWithLaunch( }; } - return launchAfterDaytonaPrepare(args, () => launch(deepLink)); + return launchDaytonaFromThisMachine(args, () => launch(deepLink)); } // Custom editor: diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 4f1f4421904..c78fa83afad 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -2121,14 +2121,6 @@ export const workspace = { input: z.object({ workspaceId: z.string() }), output: ResultSchema(z.void(), z.string()), }, - /** - * Start a Daytona workspace's sandbox and refresh its ssh token before an external editor - * connects to the sandbox's ssh alias. A no-op for every other runtime. - */ - ensureDaytonaSandboxReady: { - input: z.object({ workspaceId: z.string() }), - output: ResultSchema(z.void(), z.string()), - }, getRuntimeStatuses: { input: z.object({ workspaceIds: z.array(z.string()) }), output: z.record(z.string(), WorkspaceRuntimeStatusSchema), @@ -4059,7 +4051,8 @@ export const general = { * or custom command). External editors are untrackable once open, so model-driven snapshot * archives consult this durable record; refuses while the workspace is being archived. * Called by the renderer before launching editor deep links (custom-editor opens record on - * the backend openInEditor route itself). + * the backend openInEditor route itself). For a Daytona workspace it also starts the sandbox + * and refreshes its ssh token, so the editor can connect to the sandbox's ssh alias. */ recordEditorOpen: { // launchToken is generated by the CLIENT before admission so it survives response loss: diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index b95511e0f68..4316cbe84e2 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -2255,12 +2255,6 @@ export const router = (authToken?: string) => { .handler(async ({ context, input }) => context.workspaceService.stopRuntime(input.workspaceId) ), - ensureDaytonaSandboxReady: t - .input(schemas.workspace.ensureDaytonaSandboxReady.input) - .output(schemas.workspace.ensureDaytonaSandboxReady.output) - .handler(async ({ context, input }) => - context.workspaceService.ensureDaytonaSandboxReady(input.workspaceId) - ), getRuntimeStatuses: t .input(schemas.workspace.getRuntimeStatuses.input) .output(schemas.workspace.getRuntimeStatuses.output) diff --git a/src/node/services/workspaceService.runtimeStatus.test.ts b/src/node/services/workspaceService.runtimeStatus.test.ts index 350fcdba4c5..dc08e1aa470 100644 --- a/src/node/services/workspaceService.runtimeStatus.test.ts +++ b/src/node/services/workspaceService.runtimeStatus.test.ts @@ -6,6 +6,7 @@ import { shellQuote } from "@/common/utils/shell"; import type { TerminalService } from "@/node/services/terminalService"; import type { Runtime } from "@/node/runtime/Runtime"; import * as runtimeHelpers from "@/node/runtime/runtimeHelpers"; +import { workspaceUseLeasesFor } from "@/node/services/workspaceUseLeases"; import { createWorkspaceServiceHarness, type WorkspaceServiceHarness, @@ -224,23 +225,27 @@ describe.skipIf(process.platform === "win32")( } ); -describe("WorkspaceService.ensureDaytonaSandboxReady", () => { +describe("WorkspaceService.recordExternalEditorOpen for a Daytona workspace", () => { let harness: WorkspaceServiceHarness; let projectPath: string; let createRuntime: { mockRestore: () => void }; - const ensureReady = mock(() => - Promise.resolve({ - ready: false as const, - error: 'Daytona sandbox "sb-1" no longer exists', - errorType: "runtime_not_ready" as const, - }) - ); + /** The editor use leases held on the workspace while each readiness check ran. */ + let leasesDuringReadiness: number[]; + let readiness: { ready: true } | { ready: false; error: string }; + const ensureReady = mock(() => { + leasesDuringReadiness.push(workspaceUseLeasesFor(harness.config).heldCount("dt", "editor")); + return Promise.resolve( + readiness.ready ? readiness : { ...readiness, errorType: "runtime_not_ready" as const } + ); + }); beforeEach(async () => { harness = await createWorkspaceServiceHarness(); projectPath = path.join(harness.rootDir, "project"); await fs.mkdir(projectPath); ensureReady.mockClear(); + leasesDuringReadiness = []; + readiness = { ready: true }; createRuntime = spyOn(runtimeHelpers, "createRuntimeForWorkspace").mockReturnValue({ ensureReady, } as unknown as Runtime); @@ -275,20 +280,34 @@ describe("WorkspaceService.ensureDaytonaSandboxReady", () => { }); } - test("brings the sandbox up and reports why it cannot", async () => { + // An archive cannot run while the editor use is held, so a sandbox started here cannot be + // left running by an archive that saw it stopped. + test("brings the sandbox up only once the editor use is admitted", async () => { + await addDaytonaWorkspace(); + + expect(await harness.service.recordExternalEditorOpen("dt", "tok-1")).toEqual({ + success: true, + data: undefined, + }); + expect(leasesDuringReadiness).toEqual([1]); + }); + + test("refuses the open and releases the use when the sandbox cannot come up", async () => { await addDaytonaWorkspace(); + readiness = { ready: false, error: 'Daytona sandbox "sb-1" no longer exists' }; - expect(await harness.service.ensureDaytonaSandboxReady("dt")).toEqual({ + expect(await harness.service.recordExternalEditorOpen("dt", "tok-1")).toEqual({ success: false, error: 'Daytona sandbox "sb-1" no longer exists', }); - expect(ensureReady).toHaveBeenCalledTimes(1); + expect(leasesDuringReadiness).toEqual([1]); + expect(workspaceUseLeasesFor(harness.config).heldCount("dt", "editor")).toBe(0); }); test("refuses an archived workspace without starting its sandbox", async () => { await addDaytonaWorkspace("2026-01-02T00:00:00.000Z"); - expect((await harness.service.ensureDaytonaSandboxReady("dt")).success).toBe(false); + expect((await harness.service.recordExternalEditorOpen("dt", "tok-1")).success).toBe(false); expect(ensureReady).not.toHaveBeenCalled(); }); }); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 53716078da8..45666397563 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -12047,6 +12047,35 @@ export class WorkspaceService } } + /** + * Refusals once the editor use lease is held, all before the durable marker write so a + * refused open cannot permanently gate future snapshot archives of this workspace. + */ + private async refuseAdmittedEditorOpen( + workspaceId: string, + entry: { projectPath: string; workspace: Workspace } + ): Promise { + const { workspace } = entry; + // Persisted archived state (not just an in-progress archive): a stale renderer can request + // an editor for an already-archived workspace whose checkout may already be snapshot and + // removed (mirrors TerminalService.openNative and the send/PTY/desktop admissions). + if (isWorkspaceArchived(workspace.archivedAt, workspace.unarchivedAt)) { + return `Workspace is archived: ${workspaceId}. Unarchive it before opening an editor.`; + } + if (workspace.runtimeConfig == null) return undefined; + // The editor connects to a Daytona sandbox's ssh alias on its own, so start a stopped + // sandbox and refresh its token first. Only under the lease, as for a native terminal: an + // archive cannot run meanwhile, so it cannot see the sandbox stopped and leave it running. + const ready = await ensureDaytonaSandboxReady({ + id: workspaceId, + runtimeConfig: workspace.runtimeConfig, + projectPath: entry.projectPath, + name: workspace.name ?? workspaceId, + namedWorkspacePath: workspace.path, + }); + return ready.success ? undefined : ready.error; + } + /** Body of recordExternalEditorOpenForLaunch after pending-recording admission. */ private async recordExternalEditorOpenAdmitted( workspaceId: string @@ -12078,19 +12107,10 @@ export class WorkspaceService await lease.release(); return Err(`Workspace not found: ${workspaceId}`); } - // Persisted archived state (not just an in-progress archive): a stale renderer can request - // an editor for an already-archived workspace whose checkout may already be snapshot and - // removed (mirrors TerminalService.openNative and the send/PTY/desktop admissions). - // Checked before the durable marker write so a refused open cannot permanently gate - // future snapshot archives of this workspace. - if ( - isWorkspaceArchived( - workspaceEntry.workspace.archivedAt, - workspaceEntry.workspace.unarchivedAt - ) - ) { + const refusal = await this.refuseAdmittedEditorOpen(workspaceId, workspaceEntry); + if (refusal != null) { await lease.release(); - return Err(`Workspace is archived: ${workspaceId}. Unarchive it before opening an editor.`); + return Err(refusal); } // Before any durable effect: one share per workspace is kept, after a failed launch too // (fail closed). @@ -13826,23 +13846,6 @@ export class WorkspaceService return Ok(undefined); } - /** - * Opening an external editor is an explicit action, so a stopped Daytona sandbox is started - * (and its ssh token refreshed) before the editor connects to its ssh alias. An archived - * workspace is refused like a native terminal: its sandbox must stay stopped. - */ - async ensureDaytonaSandboxReady(workspaceId: string): Promise> { - const allMetadata = await this.config.getAllWorkspaceMetadata(); - const workspace = allMetadata.find((w) => w.id === workspaceId); - if (!workspace) { - return Err(this.workspaceNotFoundError()); - } - if (isWorkspaceArchived(workspace.archivedAt, workspace.unarchivedAt)) { - return Err(`Workspace is archived: ${workspaceId}. Unarchive it before opening an editor.`); - } - return ensureDaytonaSandboxReady(workspace); - } - async getRuntimeStatuses( workspaceIds: string[] ): Promise> { From af312d595908769ab2a518c0b428d0a703679923 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:59:34 +0000 Subject: [PATCH 163/231] fix(daytona): settle the sandbox when the last borrower of a removed owner goes --- .../services/workspaceService.remove.test.ts | 77 +++++++++++++++++++ src/node/services/workspaceService.ts | 24 +++++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/node/services/workspaceService.remove.test.ts b/src/node/services/workspaceService.remove.test.ts index b29fbaab3aa..73b142ada0b 100644 --- a/src/node/services/workspaceService.remove.test.ts +++ b/src/node/services/workspaceService.remove.test.ts @@ -742,6 +742,83 @@ describe("WorkspaceService remove shared-workspace guard", () => { } }); + describe("on a Daytona sandbox", () => { + const daytonaRuntime = { + type: "ssh" as const, + host: "sb-1.xum--daytona", + srcBaseDir: "/home/daytona/xum", + daytona: { sandboxId: "sb-1" }, + }; + + async function createDaytonaHarness(borrowers: string[]): Promise { + const harness = await createWorkspaceServiceHarness(); + await saveWorkspaces(harness.config, projectPath, [ + projectWorkspace(projectPath, "parent-ws", "parent-ws-id", { + runtimeConfig: daytonaRuntime, + }), + ...borrowers.map((id) => + projectWorkspace(projectPath, "parent-ws", id, { + name: `agent_explore_${id}`, + runtimeConfig: daytonaRuntime, + parentWorkspaceId: "parent-ws-id", + taskIsolation: "none", + taskStatus: "running", + }) + ), + ]); + return harness; + } + + // The owner's removal skipped the sandbox for the borrower, so the borrower's removal + // settles it as the owner's would have: nothing else could delete it later. + test("the last borrower of a removed owner settles the sandbox", async () => { + const { deleteWorkspace, createRuntimeSpy } = mockDeleteWorkspace(); + try { + await using harness = await createDaytonaHarness([workspaceId]); + + expect((await harness.service.remove("parent-ws-id", true)).success).toBe(true); + expect(deleteWorkspace).not.toHaveBeenCalled(); + + expect((await harness.service.remove(workspaceId, true)).success).toBe(true); + expect(deleteWorkspace).toHaveBeenCalledTimes(1); + expect(createRuntimeSpy.mock.calls.at(-1)?.[0]).toEqual(daytonaRuntime); + } finally { + createRuntimeSpy.mockRestore(); + } + }); + + test("a borrower leaves the sandbox alone while another workspace still uses it", async () => { + const { deleteWorkspace, createRuntimeSpy } = mockDeleteWorkspace(); + try { + await using harness = await createDaytonaHarness([workspaceId, "sibling-shared"]); + + // The owner remains. + expect((await harness.service.remove("sibling-shared", true)).success).toBe(true); + expect(deleteWorkspace).not.toHaveBeenCalled(); + + // The owner is gone, but a sibling borrower still works in the sandbox. + await harness.config.editConfig((config) => { + const project = config.projects.get(projectPath); + if (project) { + project.workspaces = project.workspaces.filter((ws) => ws.id !== "parent-ws-id"); + project.workspaces.push( + projectWorkspace(projectPath, "parent-ws", "sibling-shared", { + runtimeConfig: daytonaRuntime, + taskIsolation: "none", + taskStatus: "running", + }) + ); + } + return config; + }); + expect((await harness.service.remove(workspaceId, true)).success).toBe(true); + expect(deleteWorkspace).not.toHaveBeenCalled(); + } finally { + createRuntimeSpy.mockRestore(); + } + }); + }); + test("deletes a parent checkout when its shared child already reported", async () => { const { deleteWorkspace, createRuntimeSpy } = mockDeleteWorkspace(); try { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 45666397563..019dcb7b561 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -358,6 +358,7 @@ import { getSrcBaseDir, isSSHRuntime, isDedicatedDaytonaSandbox, + getDaytonaSandboxId, isDockerRuntime, isDevcontainerRuntime, } from "@/common/types/runtime"; @@ -1151,6 +1152,25 @@ const ARCHIVE_OWN_ACTIVITY_POLICY = { backgroundProcesses: "allow", } as const; +/** + * An isolation: "none" task borrows another workspace's checkout, so its removal leaves the + * runtime alone. Except the last workspace on a Daytona sandbox: its owner was removed while + * this task borrowed the checkout, so that removal skipped the sandbox, and nothing else would + * ever delete it. This removal then runs the runtime deletion the owner's would have run (on + * the persisted path, the owner's checkout), deleting or releasing the sandbox. + */ +function borrowsLiveCheckout(config: ProjectsConfig, workspaceId: string): boolean { + const entry = findWorkspaceEntry(config, workspaceId); + if (entry?.workspace.taskIsolation !== "none") return false; + const sandboxId = getDaytonaSandboxId(entry.workspace.runtimeConfig); + if (sandboxId === undefined) return true; + return Array.from(config.projects.values()).some((project) => + project.workspaces.some( + (ws) => ws.id !== workspaceId && getDaytonaSandboxId(ws.runtimeConfig) === sandboxId + ) + ); +} + /** What archive refusals call the dedicated remote box, so Daytona users are not told "Coder". */ function dedicatedRemoteBoxLabel(metadata: WorkspaceMetadata | undefined): string { const runtimeConfig = metadata?.runtimeConfig; @@ -8289,8 +8309,8 @@ export class WorkspaceService // parent's working tree, so skip runtime deletion and only remove config/session state. // Runtime deletion is keyed on the task's unique name today (a safe no-op), but guard // explicitly so this stays correct if runtime deletion ever resolves the persisted path. - const taskSharesParentCheckout = - findWorkspaceEntry(configSnapshot, workspaceId)?.workspace.taskIsolation === "none"; + // borrowsLiveCheckout makes one exception, for a Daytona sandbox with no owner left. + const taskSharesParentCheckout = borrowsLiveCheckout(configSnapshot, workspaceId); // Inverse direction: this workspace's checkout may be shared by live isolation: "none" // descendants (their persisted path points at it). Deleting it would yank the cwd out From eeb56334edf107741c45c37184282e96978d879f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:27:11 +0000 Subject: [PATCH 164/231] fix(daytona): fail a credentials resolve when the settings file cannot be read When daytona.json selects one account but a read fails with a permission or I/O error, resolve() took the file as empty and fell back to DAYTONA_API_KEY/DAYTONA_API_URL, so the next lease could pin and run create, start or delete on another account. Every read of the file is now strict: a missing file still reads as empty and a malformed one is still ignored, but any other read failure throws, failing resolve(), the operation and getPublicConfig (Settings shows the error rather than the environment's key). The service already turns that into a failed operation, and the reconciler logs it and retries next sweep. --- .../config/daytonaCredentialsStore.test.ts | 47 +++++++++++++++++++ src/node/config/daytonaCredentialsStore.ts | 28 ++++------- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/src/node/config/daytonaCredentialsStore.test.ts b/src/node/config/daytonaCredentialsStore.test.ts index 5ad3d88a729..c1f1b7fda08 100644 --- a/src/node/config/daytonaCredentialsStore.test.ts +++ b/src/node/config/daytonaCredentialsStore.test.ts @@ -3,6 +3,7 @@ import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; import { DAYTONA_DEFAULT_API_URL } from "@/common/constants/daytona"; +import { createDaytonaTestRig } from "@/node/services/daytona/daytona.testHarness"; import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import { DaytonaCredentialsStore } from "./daytonaCredentialsStore"; @@ -134,6 +135,52 @@ describe("DaytonaCredentialsStore", () => { }); }); + // The file selects account A; DAYTONA_API_KEY/DAYTONA_API_URL name account B. Taking an + // unreadable file as empty would silently run create, start and delete on B. + it("refuses to fall back to the environment when the saved settings cannot be read", async () => { + const env = { DAYTONA_API_KEY: "env-key", DAYTONA_API_URL: "https://env.example.com/api" }; + const store = new DaytonaCredentialsStore(rootDir, env); + await store.update({ apiKey: KEY, apiUrl: "https://byoc.example.com/api" }); + const file = path.join(rootDir, "daytona.json"); + + await fs.chmod(file, 0o000); + try { + // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime + await expect(store.resolve()).rejects.toThrow(/Could not read the Daytona settings/); + // Settings shows the read error instead of claiming the environment's key is in use. + // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime + await expect(store.getPublicConfig()).rejects.toThrow(/Could not read the Daytona settings/); + } finally { + await fs.chmod(file, 0o600); + } + expect(await store.resolve()).toMatchObject({ apiKey: KEY, apiKeySource: "settings" }); + }); + + it("runs no Daytona operation on the environment's account after a failed settings read", async () => { + const rig = await createDaytonaTestRig({ apiKey: "env-key" }); + try { + const saved = rig.addAccount(KEY); + await rig.service.setConfig({ apiKey: KEY }); + const file = path.join(rig.rootDir, "daytona.json"); + await fs.chmod(file, 0o000); + try { + // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime + await expect( + rig.service.withAccountLease(() => rig.service.listInstanceSandboxes()) + ).rejects.toThrow(/Could not read the Daytona settings/); + expect((await rig.service.listSnapshots()).ok).toBe(false); + } finally { + await fs.chmod(file, 0o600); + } + expect(rig.plane.calls).toEqual([]); + await rig.service.withAccountLease(() => rig.service.listInstanceSandboxes()); + expect(saved.calls.length).toBeGreaterThan(0); + expect(rig.plane.calls).toEqual([]); + } finally { + await rig.cleanup(); + } + }); + it("refuses a stored API URL that does not validate instead of sending the key elsewhere", async () => { for (const apiUrl of ["not a url", "http://byoc.example.com/api"]) { await fs.writeFile( diff --git a/src/node/config/daytonaCredentialsStore.ts b/src/node/config/daytonaCredentialsStore.ts index 4cf07dad689..699775c1c4b 100644 --- a/src/node/config/daytonaCredentialsStore.ts +++ b/src/node/config/daytonaCredentialsStore.ts @@ -154,32 +154,20 @@ export class DaytonaCredentialsStore { /** * A missing or malformed file reads as empty (self-healing): the user can re-enter the key. - * So does one that cannot be read, here only: this is for using and showing the settings. + * A read that fails for another reason (an I/O error, a permission problem) throws, for every + * caller: an update writes the whole file back, and a resolve that took it as empty would + * fall back to DAYTONA_API_KEY, another account than the one the saved settings select. */ async read(): Promise { - try { - return await this.readSaved(); - } catch (error) { - log.warn("Failed to read Daytona credentials file", { - filePath: this.filePath, - error: getErrorMessage(error), - }); - return {}; - } - } - - /** - * As read, but a read that fails for another reason than a missing file (an I/O error, a - * permission problem) throws: an update writes the whole file back, and taking such a read - * as empty would drop the saved key, URL and target. - */ - private async readSaved(): Promise { let raw: string; try { raw = await fs.readFile(this.filePath, "utf-8"); } catch (error) { if (isErrnoWithCode(error, "ENOENT")) return {}; - throw error; + throw new Error( + `Could not read the Daytona settings in ${this.filePath}: ${getErrorMessage(error)}`, + { cause: error } + ); } const stored = parseStoredCredentials(raw); if (!stored) { @@ -214,7 +202,7 @@ export class DaytonaCredentialsStore { timeoutMessage: "Another Xum process is saving Daytona settings.", }); try { - const next = applyPatch(await this.readSaved(), patch); + const next = applyPatch(await this.read(), patch); await writeFileAtomic(this.filePath, JSON.stringify(next, null, 2), { encoding: "utf-8", mode: 0o600, From ed4ead58dbe3701554762157383fe90ec907d8d8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 05:42:24 +0000 Subject: [PATCH 165/231] fix(daytona): refuse an editor open while a user RemoteForward reaches the sandbox An external editor's ssh reads ~/.ssh/config for a Daytona alias, and OpenSSH adds up RemoteForward lines instead of keeping the first, so the managed block cannot turn off a user's `Host *` RemoteForward: the untrusted sandbox would get a tunnel to a local service. ClearAllForwardings would also drop the editor's own command-line tunnel. After the sandbox is ready, the editor open now resolves the alias with `ssh -G` (user and system config) and refuses when any remoteforward applies, failing closed when ssh cannot resolve it; the output, which holds the token, is never logged or returned. Each alias also gets `Tunnel no`. The native terminal reads only daytona.conf and is unchanged. Docs and built-in skill content updated. --- docs/runtime/daytona.mdx | 2 +- src/node/runtime/daytonaSshConfig.test.ts | 54 ++++++++++++++++++- src/node/runtime/daytonaSshConfig.ts | 43 ++++++++++++++- src/node/runtime/runtimeHelpers.ts | 27 ++++++++++ .../builtInSkillContent.generated.ts | 2 +- .../workspaceService.runtimeStatus.test.ts | 26 +++++++++ src/node/services/workspaceService.ts | 6 ++- 7 files changed, 155 insertions(+), 5 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index e0b5c3d4a98..8475d1f1871 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -77,7 +77,7 @@ Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and a Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts. -Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, and Xum keeps its block first in the file, so its settings win for sandboxes in those clients too: strict host key checking against the pinned keys, the token as the user name, no proxy or jump host, and no agent or X11 forwarding. Your other entries still apply to every other host. A `KnownHostsCommand` or `Match exec` line in your config still runs for sandboxes in your own clients, with the token as `%r`; scope such lines to the hosts that need them. +Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, and Xum keeps its block first in the file, so its settings win for sandboxes in those clients too: strict host key checking against the pinned keys, the token as the user name, no proxy or jump host, and no agent or X11 forwarding. Your other entries still apply to every other host. A `KnownHostsCommand` or `Match exec` line in your config still runs for sandboxes in your own clients, with the token as `%r`; scope such lines to the hosts that need them. OpenSSH adds up `RemoteForward` lines instead of keeping the first, so Xum refuses to open an editor on a sandbox while a `RemoteForward` in your SSH config applies to it; scope it to the hosts that need it. ## Notes diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index 37135eec5df..fb902788d8d 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -9,7 +9,11 @@ import { toXumDaytonaHost, } from "@/common/constants/daytona"; import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; -import { DaytonaSshConfigWriter, renderDaytonaKnownHosts } from "./daytonaSshConfig"; +import { + DaytonaSshConfigWriter, + hasInheritedRemoteForward, + renderDaytonaKnownHosts, +} from "./daytonaSshConfig"; import { appendOpenSSHHostKeyPolicyArgs } from "./sshConnectionPool"; const GATEWAY = { @@ -63,6 +67,7 @@ describe("DaytonaSshConfigWriter", () => { " ForwardAgent no", " ForwardX11 no", " PermitLocalCommand no", + " Tunnel no", " HostName ssh.daytona.test", " Port 22", " User tokenA", @@ -411,6 +416,53 @@ describe("DaytonaSshConfigWriter", () => { ); }); + // OpenSSH adds up forwards instead of keeping the first one, so the block cannot turn a + // user's RemoteForward off; and ClearAllForwardings would also drop the editor's own tunnel. + it.skipIf(!hasOpenSsh)("reports a user RemoteForward that reaches the alias", async () => { + await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); + const alias = toXumDaytonaHost("sb-1"); + const check = () => hasInheritedRemoteForward(alias, { sshConfigPath }); + for (const forward of [ + "RemoteForward 6000 127.0.0.1:3000", + `RemoteForward ${path.join(tempDir, "remote.sock")} ${path.join(tempDir, "local.sock")}`, + ]) { + await fs.writeFile(sshConfigPath, `Host *\n ${forward}\n`, "utf8"); + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + expect(await check()).toBe(true); + } + + await fs.writeFile( + sshConfigPath, + "Host work\n RemoteForward 6000 127.0.0.1:3000\nHost *\n User me\n", + "utf8" + ); + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + expect(await check()).toBe(false); + }); + + it("fails closed, without the resolved config, when ssh cannot resolve the alias", async () => { + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + // A config ssh refuses to parse. + await fs.writeFile(sshConfigPath, "Host *\n NoSuchOption yes\n", "utf8"); + let refusal: unknown; + try { + await hasInheritedRemoteForward(toXumDaytonaHost("sb-1"), { sshConfigPath }); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(Error); + expect(String(refusal)).not.toContain("tokenA"); + }); + + it.skipIf(!hasOpenSsh)("gives the sandbox no tun device a user Tunnel asks for", async () => { + await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); + await fs.writeFile(sshConfigPath, "Host *\n Tunnel yes\n", "utf8"); + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + + expect(resolveFromUserConfig(toXumDaytonaHost("sb-1"))).toContain("\ntunnel false\n"); + expect(resolveFromUserConfig("other.example.com")).toContain("\ntunnel point-to-point\n"); + }); + it("moves a block an earlier Xum appended to the top of the file", async () => { await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); const userConfig = "Host *\n ForwardAgent yes\n"; diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 24fad8543a7..4751e7548c5 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -1,3 +1,4 @@ +import { execFile } from "node:child_process"; import * as fs from "node:fs/promises"; import * as net from "node:net"; import * as path from "node:path"; @@ -174,13 +175,16 @@ export function renderDaytonaHostBlock(input: { // the token), agent, display or local commands. `ProxyCommand none` comes first: OpenSSH then // ignores a later ProxyJump too. KnownHostsCommand is left alone: OpenSSH before 8.5 rejects it // unless an `IgnoreUnknown` covers it, and ours would replace the user's own `IgnoreUnknown` - // (first value wins). Forwards an editor asks for on its command line are left alone. + // (first value wins). `Tunnel no` keeps a user's `Tunnel` from giving the sandbox a tun device. + // Forwards are not first-value-wins (see hasInheritedRemoteForward), and those an editor asks + // for on its command line are left alone. return [ `Host ${toXumDaytonaHost(input.sandboxId)}`, " ProxyCommand none", " ForwardAgent no", " ForwardX11 no", " PermitLocalCommand no", + " Tunnel no", ` HostName ${input.gateway.host}`, ` Port ${input.gateway.port}`, ` User ${input.token}`, @@ -190,6 +194,43 @@ export function renderDaytonaHostBlock(input: { ].join("\n"); } +const RESOLVE_ALIAS_TIMEOUT_MS = 10_000; + +/** + * Whether an editor's ssh would open a reverse forward into the sandbox for `alias`: OpenSSH + * adds up every RemoteForward that matches instead of keeping the first, so Xum's block cannot + * turn a user's `Host *` one off, and ClearAllForwardings would also drop the tunnel an editor + * opens on its command line. Resolved as the editor does (`ssh -G` with the user's and the + * system config). Throws when ssh cannot resolve the alias, so a caller fails closed. The output + * holds the token (as `user`): it is never logged or put in an error. + */ +export function hasInheritedRemoteForward( + alias: string, + /** Tests read a temp config instead of the user's (`-F` skips the system config too). */ + options: { sshConfigPath?: string } = {} +): Promise { + const args = options.sshConfigPath ? ["-G", "-F", options.sshConfigPath, alias] : ["-G", alias]; + return new Promise((resolve, reject) => { + execFile( + "ssh", + args, + { timeout: RESOLVE_ALIAS_TIMEOUT_MS, windowsHide: true, maxBuffer: 1024 * 1024 }, + (error, stdout) => { + if (error) { + const code = (error as NodeJS.ErrnoException).code ?? "unknown"; + reject( + new DaytonaSshConfigError( + `Could not check your SSH config for ${alias} (ssh -G failed: ${String(code)})` + ) + ); + return; + } + resolve(/^remoteforward\s/im.test(stdout)); + } + ); + }); +} + const INCLUDE_FILE_HEADER = "# Managed by Xum (Daytona sandboxes). Xum rewrites this file; do not edit it."; diff --git a/src/node/runtime/runtimeHelpers.ts b/src/node/runtime/runtimeHelpers.ts index a3b6b1f28d7..5d45bd6ccd7 100644 --- a/src/node/runtime/runtimeHelpers.ts +++ b/src/node/runtime/runtimeHelpers.ts @@ -7,6 +7,8 @@ import { type RuntimeConfig, } from "@/common/types/runtime"; import { Err, Ok, type Result } from "@/common/types/result"; +import { getErrorMessage } from "@/common/utils/errors"; +import { hasInheritedRemoteForward } from "./daytonaSshConfig"; import type { Runtime } from "./Runtime"; import { createRuntime } from "./runtimeFactory"; @@ -179,3 +181,28 @@ export async function ensureDaytonaSandboxReady( const ready = await createRuntimeForWorkspace(metadata).ensureReady(); return ready.ready ? Ok(undefined) : Err(ready.error); } + +/** + * An external editor reads the user's ssh config for a Daytona alias (Xum's own ssh does not), + * and a RemoteForward there would give the untrusted sandbox a tunnel to a local service: such + * an open is refused. Fails closed when ssh cannot resolve the alias. A no-op for every other + * runtime. + */ +export async function refuseDaytonaEditorRemoteForward( + runtimeConfig: WorkspaceMetadataForRuntime["runtimeConfig"] +): Promise> { + if (!isSSHRuntime(runtimeConfig) || !runtimeConfig.daytona) return Ok(undefined); + let forwarded: boolean; + try { + forwarded = await hasInheritedRemoteForward(runtimeConfig.host); + } catch (error) { + return Err(`${getErrorMessage(error)}; not opening an editor on the Daytona sandbox.`); + } + return forwarded + ? Err( + "A RemoteForward in your SSH config applies to Daytona sandboxes, so the editor would " + + "open a tunnel from the sandbox to this machine. Scope it to the hosts that need it " + + "(a narrower Host entry) and open the editor again." + ) + : Ok(undefined); +} diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index af3b36744f1..d071f76d3c3 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10114,7 +10114,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts.", "", - "Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, and Xum keeps its block first in the file, so its settings win for sandboxes in those clients too: strict host key checking against the pinned keys, the token as the user name, no proxy or jump host, and no agent or X11 forwarding. Your other entries still apply to every other host. A `KnownHostsCommand` or `Match exec` line in your config still runs for sandboxes in your own clients, with the token as `%r`; scope such lines to the hosts that need them.", + "Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, and Xum keeps its block first in the file, so its settings win for sandboxes in those clients too: strict host key checking against the pinned keys, the token as the user name, no proxy or jump host, and no agent or X11 forwarding. Your other entries still apply to every other host. A `KnownHostsCommand` or `Match exec` line in your config still runs for sandboxes in your own clients, with the token as `%r`; scope such lines to the hosts that need them. OpenSSH adds up `RemoteForward` lines instead of keeping the first, so Xum refuses to open an editor on a sandbox while a `RemoteForward` in your SSH config applies to it; scope it to the hosts that need it.", "", "## Notes", "", diff --git a/src/node/services/workspaceService.runtimeStatus.test.ts b/src/node/services/workspaceService.runtimeStatus.test.ts index dc08e1aa470..f4800c10b31 100644 --- a/src/node/services/workspaceService.runtimeStatus.test.ts +++ b/src/node/services/workspaceService.runtimeStatus.test.ts @@ -5,6 +5,7 @@ import type { RuntimeConfig } from "@/common/types/runtime"; import { shellQuote } from "@/common/utils/shell"; import type { TerminalService } from "@/node/services/terminalService"; import type { Runtime } from "@/node/runtime/Runtime"; +import * as daytonaSshConfig from "@/node/runtime/daytonaSshConfig"; import * as runtimeHelpers from "@/node/runtime/runtimeHelpers"; import { workspaceUseLeasesFor } from "@/node/services/workspaceUseLeases"; import { @@ -229,6 +230,7 @@ describe("WorkspaceService.recordExternalEditorOpen for a Daytona workspace", () let harness: WorkspaceServiceHarness; let projectPath: string; let createRuntime: { mockRestore: () => void }; + let forwardCheck: ReturnType>; /** The editor use leases held on the workspace while each readiness check ran. */ let leasesDuringReadiness: number[]; let readiness: { ready: true } | { ready: false; error: string }; @@ -249,10 +251,13 @@ describe("WorkspaceService.recordExternalEditorOpen for a Daytona workspace", () createRuntime = spyOn(runtimeHelpers, "createRuntimeForWorkspace").mockReturnValue({ ensureReady, } as unknown as Runtime); + // The user's real ssh config must not decide these tests. + forwardCheck = spyOn(daytonaSshConfig, "hasInheritedRemoteForward").mockResolvedValue(false); }); afterEach(async () => { createRuntime.mockRestore(); + forwardCheck.mockRestore(); await harness.cleanup(); }); @@ -304,6 +309,27 @@ describe("WorkspaceService.recordExternalEditorOpen for a Daytona workspace", () expect(workspaceUseLeasesFor(harness.config).heldCount("dt", "editor")).toBe(0); }); + // The editor reads the user's ssh config, where a matching RemoteForward would give the + // sandbox a tunnel to a local service. + test("refuses the open when the user's ssh config forwards into the sandbox", async () => { + await addDaytonaWorkspace(); + const markerPath = path.join(harness.config.sessionsDir, "dt", "external-editor-opened"); + for (const check of [ + () => Promise.resolve(true), + () => Promise.reject(new daytonaSshConfig.DaytonaSshConfigError("ssh -G failed")), + ]) { + forwardCheck.mockImplementationOnce(check); + + const result = await harness.service.recordExternalEditorOpen("dt", "tok-1"); + + expect(result.success).toBe(false); + expect(forwardCheck).toHaveBeenLastCalledWith("sb-1.xum--daytona"); + expect(workspaceUseLeasesFor(harness.config).heldCount("dt", "editor")).toBe(0); + expect(await Bun.file(markerPath).exists()).toBe(false); + } + expect(leasesDuringReadiness).toEqual([1, 1]); + }); + test("refuses an archived workspace without starting its sandbox", async () => { await addDaytonaWorkspace("2026-01-02T00:00:00.000Z"); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 019dcb7b561..27fafd272ce 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -126,6 +126,7 @@ import { createRuntimeContextForWorkspace, createRuntimeForWorkspace, ensureDaytonaSandboxReady, + refuseDaytonaEditorRemoteForward, resolveWorkspaceExecutionPath, resolveWorkspaceRootPath, } from "@/node/runtime/runtimeHelpers"; @@ -12093,7 +12094,10 @@ export class WorkspaceService name: workspace.name ?? workspaceId, namedWorkspacePath: workspace.path, }); - return ready.success ? undefined : ready.error; + if (!ready.success) return ready.error; + // After readiness, so the alias resolves with the managed block and a current token. + const forwarding = await refuseDaytonaEditorRemoteForward(workspace.runtimeConfig); + return forwarding.success ? undefined : forwarding.error; } /** Body of recordExternalEditorOpenForLaunch after pending-recording admission. */ From b30a6195a3a093766f035b8f98b3f6e8286742a5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 05:47:48 +0000 Subject: [PATCH 166/231] fix(daytona): restore the editor's ssh setup when sandbox readiness is cached --- src/node/runtime/DaytonaSSHRuntime.test.ts | 16 +++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 27 ++++++++++++++++++- src/node/runtime/Runtime.ts | 7 +++++ src/node/runtime/runtimeHelpers.ts | 2 +- .../services/daytona/daytona.testHarness.ts | 4 +++ .../workspaceService.runtimeStatus.test.ts | 6 +++-- 6 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 7d799a6b65e..8673211d094 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -113,6 +113,22 @@ describe("DaytonaSSHRuntime.ensureReady", () => { expect(rig.plane.countCalls("GET /sandbox/sb-ready")).toBeGreaterThan(getsAfterFirst); }); + it("puts back the ~/.ssh/config block for an external ssh client within the ready cache", async () => { + rig.plane.addSandbox({ id: "sb-ready" }); + expect(await makeRuntime({ sandboxId: "sb-ready" }).ensureReady()).toEqual({ ready: true }); + rig.writer.managedBlockPresent = false; + + // Xum's own commands read only daytona.conf: a cached check leaves the block alone. + expect(await makeRuntime({ sandboxId: "sb-ready" }).ensureReady()).toEqual({ ready: true }); + expect(rig.writer.managedBlockPresent).toBe(false); + + const forEditor = await makeRuntime({ sandboxId: "sb-ready" }).ensureReady({ + externalSshClient: true, + }); + expect(forEditor).toEqual({ ready: true }); + expect(rig.writer.managedBlockPresent).toBe(true); + }); + it("checks each workspace's checkout while their shared sandbox is cached as ready", async () => { execSpy.mockRestore(); rig.plane.addSandbox({ id: "sb-shared" }); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 98ed4f3445c..35171a12b4b 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -83,6 +83,7 @@ export type DaytonaRuntimeService = Pick< | "startAndWait" | "deleteSandbox" | "ensureSandboxReady" + | "ensureSshAccess" | "isSandboxReady" | "markSandboxReady" | "invalidateSandboxReady" @@ -423,7 +424,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { (await this.refuseUnownedSandbox(sandboxId, emitStatus, options?.signal)); if (refusal) return refusal; if (this.daytonaService.isSandboxReady(sandboxId)) { - return this.ensureReadyFromCache(options, emitStatus); + return this.ensureReadyFromCache(sandboxId, options, emitStatus); } return this.ensureReadyUncached(sandboxId, options, emitStatus); }); @@ -456,12 +457,17 @@ export class DaytonaSSHRuntime extends SSHRuntime { /** The cache covers the sandbox, not this workspace's checkout: a fork shares the sandbox. */ private async ensureReadyFromCache( + sandboxId: string, options: EnsureReadyOptions | undefined, emitStatus: StatusEmitter ): Promise { // Set before the probe: ensureReady is an explicit action, so a sandbox that stopped // within the cache is started again (recoverForRetry) instead of failing until it expires. this.restartsStoppedSandbox = true; + const accessFailure = options?.externalSshClient + ? await this.restoreExternalSshAccess(sandboxId, emitStatus, options.signal) + : undefined; + if (accessFailure) return accessFailure; const repoFailure = await this.checkRepo(options, emitStatus); if (repoFailure) return repoFailure; return { ready: true }; @@ -656,6 +662,25 @@ export class DaytonaSSHRuntime extends SSHRuntime { } } + /** + * The ready cache proves Xum's own ssh (`-F daytona.conf`) works, not the user's ssh config + * an external client reads: another tool may have removed the managed block since. Only + * external clients ask for this, so cached checks on the exec path never touch that file. + */ + private async restoreExternalSshAccess( + sandboxId: string, + emitStatus: StatusEmitter, + signal: AbortSignal | undefined + ): Promise { + try { + await this.daytonaService.ensureSshAccess(sandboxId); + return undefined; + } catch (error) { + emitStatus("error"); + return this.ensureReadyFailure(sandboxId, error, signal); + } + } + /** Start the sandbox if needed and refresh ssh access; a failure result, or undefined. */ private async bringSandboxUp( sandboxId: string, diff --git a/src/node/runtime/Runtime.ts b/src/node/runtime/Runtime.ts index 1a959873e90..25ea563dd0c 100644 --- a/src/node/runtime/Runtime.ts +++ b/src/node/runtime/Runtime.ts @@ -442,6 +442,13 @@ export interface EnsureReadyOptions { * Abort signal to cancel long-running operations. */ signal?: AbortSignal; + + /** + * An ssh client outside Xum (an external editor, a native terminal) connects next, so + * runtimes whose readiness cache skips the user's ssh setup redo it (Daytona: the + * `~/.ssh/config` block and the alias's token). + */ + externalSshClient?: boolean; } /** diff --git a/src/node/runtime/runtimeHelpers.ts b/src/node/runtime/runtimeHelpers.ts index 5d45bd6ccd7..3689814898b 100644 --- a/src/node/runtime/runtimeHelpers.ts +++ b/src/node/runtime/runtimeHelpers.ts @@ -178,7 +178,7 @@ export async function ensureDaytonaSandboxReady( if (!isSSHRuntime(metadata.runtimeConfig) || !metadata.runtimeConfig.daytona) { return Ok(undefined); } - const ready = await createRuntimeForWorkspace(metadata).ensureReady(); + const ready = await createRuntimeForWorkspace(metadata).ensureReady({ externalSshClient: true }); return ready.ready ? Ok(undefined) : Err(ready.error); } diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 4e3d55e3b6b..4df629a8be5 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -306,7 +306,11 @@ export class FakeDaytonaSshConfigWriter implements Pick< return Promise.resolve(); } + /** False once a test removes the `~/.ssh/config` block, as a dotfiles tool might. */ + managedBlockPresent = false; + ensureManagedBlock(): Promise { + this.managedBlockPresent = true; return Promise.resolve(); } diff --git a/src/node/services/workspaceService.runtimeStatus.test.ts b/src/node/services/workspaceService.runtimeStatus.test.ts index f4800c10b31..2c997767246 100644 --- a/src/node/services/workspaceService.runtimeStatus.test.ts +++ b/src/node/services/workspaceService.runtimeStatus.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import type { RuntimeConfig } from "@/common/types/runtime"; import { shellQuote } from "@/common/utils/shell"; import type { TerminalService } from "@/node/services/terminalService"; -import type { Runtime } from "@/node/runtime/Runtime"; +import type { EnsureReadyOptions, Runtime } from "@/node/runtime/Runtime"; import * as daytonaSshConfig from "@/node/runtime/daytonaSshConfig"; import * as runtimeHelpers from "@/node/runtime/runtimeHelpers"; import { workspaceUseLeasesFor } from "@/node/services/workspaceUseLeases"; @@ -234,7 +234,7 @@ describe("WorkspaceService.recordExternalEditorOpen for a Daytona workspace", () /** The editor use leases held on the workspace while each readiness check ran. */ let leasesDuringReadiness: number[]; let readiness: { ready: true } | { ready: false; error: string }; - const ensureReady = mock(() => { + const ensureReady = mock((_options?: EnsureReadyOptions) => { leasesDuringReadiness.push(workspaceUseLeasesFor(harness.config).heldCount("dt", "editor")); return Promise.resolve( readiness.ready ? readiness : { ...readiness, errorType: "runtime_not_ready" as const } @@ -295,6 +295,8 @@ describe("WorkspaceService.recordExternalEditorOpen for a Daytona workspace", () data: undefined, }); expect(leasesDuringReadiness).toEqual([1]); + // The editor reads the user's ssh config, which the ready cache does not cover. + expect(ensureReady).toHaveBeenCalledWith({ externalSshClient: true }); }); test("refuses the open and releases the use when the sandbox cannot come up", async () => { From 36c57f1935fb345578ce884252f7d64b5259b3b2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 05:47:24 +0000 Subject: [PATCH 167/231] fix(daytona): never follow a symlink when seeding the sandbox home A snapshot may already hold a seed destination as a link, such as `~/.mux/tool_env -> ~/.ssh/authorized_keys`; the seed script's `>` and chmod followed it and clobbered the target. The script now refuses a symlinked directory below the home (removing it could drop snapshot content), writes each file to a fresh mktemp file in its directory, chmods that, and renames it over the destination. A destination symlink is removed first so the rename replaces the link instead of moving the file into a directory it points at; a real directory there fails the seed. --- src/node/runtime/daytonaAgentHomeSeed.test.ts | 64 +++++++++++++++++++ src/node/runtime/daytonaAgentHomeSeed.ts | 45 +++++++++++-- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/src/node/runtime/daytonaAgentHomeSeed.test.ts b/src/node/runtime/daytonaAgentHomeSeed.test.ts index 0cee87baa69..9b691601e49 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.test.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.test.ts @@ -33,6 +33,16 @@ async function runSeedScript(script: string): Promise { await child; } +/** The script's failure, or null when it succeeded. */ +async function seedFailure(script: string): Promise<{ stderr?: string } | null> { + try { + await runSeedScript(script); + return null; + } catch (error) { + return error as { stderr?: string }; + } +} + describe("agent-home seeding", () => { it("recreates hooks and global skills where the remote lookups read them", async () => { await fs.writeFile(path.join(hostXum, "tool_env"), "export JOBS=4\n"); @@ -66,6 +76,60 @@ describe("agent-home seeding", () => { expect(envMode & 0o111).toBe(0); }); + // A snapshot may already hold a seed path as a link, e.g. `tool_env -> ~/.ssh/authorized_keys`. + describe("a snapshot that already holds a seed destination", () => { + const seed = (remotePath: string) => + buildAgentHomeSeedScript([ + { remotePath, content: Buffer.from("export JOBS=4\n"), executable: true }, + ]); + + it("replaces a destination symlink with a regular file, leaving its target alone", async () => { + const keys = path.join(remoteHome, ".ssh", "authorized_keys"); + await fs.mkdir(path.dirname(keys), { recursive: true }); + await fs.writeFile(keys, "ssh-ed25519 AAAA user\n", { mode: 0o600 }); + await fs.mkdir(path.join(remoteHome, ".mux")); + await fs.symlink(keys, path.join(remoteHome, ".mux", "tool_env")); + // A dangling link would otherwise create its target. + await fs.symlink(path.join(root, "created"), path.join(remoteHome, ".mux", "tool_pre")); + + await runSeedScript( + buildAgentHomeSeedScript([ + { remotePath: "~/.mux/tool_env", content: Buffer.from("export A=1\n"), executable: true }, + { remotePath: "~/.mux/tool_pre", content: Buffer.from("exit 0\n"), executable: true }, + ]) + ); + + expect(await fs.readFile(keys, "utf-8")).toBe("ssh-ed25519 AAAA user\n"); + expect((await fs.stat(keys)).mode & 0o777).toBe(0o600); + expect(await Bun.file(path.join(root, "created")).exists()).toBe(false); + const written = path.join(remoteHome, ".mux", "tool_env"); + expect((await fs.lstat(written)).isFile()).toBe(true); + expect(await fs.readFile(written, "utf-8")).toBe("export A=1\n"); + expect((await fs.stat(written)).mode & 0o777).toBe(0o755); + expect(await fs.readdir(path.join(remoteHome, ".mux"))).toEqual(["tool_env", "tool_pre"]); + }); + + it("refuses a symlinked directory on the way, writing nothing through it", async () => { + const elsewhere = path.join(root, "elsewhere"); + await fs.mkdir(elsewhere); + await fs.symlink(elsewhere, path.join(remoteHome, ".mux")); + + const failure = await seedFailure(seed("~/.mux/skills/deploy/SKILL.md")); + + expect(failure?.stderr).toContain("is a symlink"); + expect(await fs.readdir(elsewhere)).toEqual([]); + }); + + it("refuses a destination that is a directory instead of writing into it", async () => { + await fs.mkdir(path.join(remoteHome, ".mux", "tool_env"), { recursive: true }); + + const failure = await seedFailure(seed("~/.mux/tool_env")); + + expect(failure?.stderr).toContain("is a directory"); + expect(await fs.readdir(path.join(remoteHome, ".mux", "tool_env"))).toEqual([]); + }); + }); + it("collects nothing when the host has no agent files", async () => { expect(await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux")).toEqual( [] diff --git a/src/node/runtime/daytonaAgentHomeSeed.ts b/src/node/runtime/daytonaAgentHomeSeed.ts index 640400236bf..a6a05985204 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.ts @@ -261,18 +261,55 @@ export async function collectAgentHomeSeedFiles( return files; } +/** + * Shell helpers of the seed script. The snapshot may already hold a seed path, and a write or + * chmod that followed a link there (`tool_env -> ~/.ssh/authorized_keys`) would clobber the + * link's target. So nothing follows a link: + * - a directory below the home that is a symlink fails the seed: removing it could drop + * snapshot content, and writing through it would land anywhere; + * - each file is written to a fresh `mktemp` file in its own directory, chmodded there, and + * renamed over the destination. A destination symlink is removed first, so the rename + * replaces it (Xum owns the box and overwrites its seed files) instead of `mv` moving the + * file into a directory the link points at; a real directory there fails the seed. + */ +const SEED_SCRIPT_PRELUDE = [ + "set -e", + "xum_seed_tmp=", + `trap 'rm -f "$xum_seed_tmp"' EXIT`, + `xum_seed_refuse() { echo "Refusing to seed $1: $2" >&2; exit 1; }`, + `xum_seed_dir() { if [ -L "$1" ]; then xum_seed_refuse "$1" "it is a symlink"; fi; [ -d "$1" ] || mkdir "$1"; }`, + `xum_seed_open() { if [ -d "$1" ] && [ ! -L "$1" ]; then xum_seed_refuse "$1" "it is a directory"; fi; xum_seed_tmp=$(mktemp "$2/.xum-seed.XXXXXX"); }`, + `xum_seed_close() { chmod "$2" "$xum_seed_tmp"; if [ -L "$1" ]; then rm -f "$1"; fi; mv -f "$xum_seed_tmp" "$1"; xum_seed_tmp=; }`, +]; + +/** The `~/`-relative directories from the home down to `remoteDir`, each one level deeper. */ +function directoriesBelowHome(remoteDir: string): string[] { + if (!remoteDir.startsWith("~/")) { + throw new Error(`Agent seed paths must be under the sandbox home: ${remoteDir}`); + } + const parts = remoteDir.slice(2).split("/").filter(Boolean); + return parts.map((_, index) => `~/${parts.slice(0, index + 1).join("/")}`); +} + /** One POSIX sh script that writes every file (sent on stdin: no argv size limit applies). */ export function buildAgentHomeSeedScript(files: readonly AgentHomeSeedFile[]): string { - const lines = ["set -e"]; + const lines = [...SEED_SCRIPT_PRELUDE]; + const checkedDirs = new Set(); for (const file of files) { const target = expandTildeForSSH(file.remotePath); - lines.push(`mkdir -p ${expandTildeForSSH(path.posix.dirname(file.remotePath))}`); - lines.push(`base64 -d > ${target} <<'${HEREDOC_MARKER}'`); + const remoteDir = path.posix.dirname(file.remotePath); + for (const dir of directoriesBelowHome(remoteDir)) { + if (checkedDirs.has(dir)) continue; + checkedDirs.add(dir); + lines.push(`xum_seed_dir ${expandTildeForSSH(dir)}`); + } + lines.push(`xum_seed_open ${target} ${expandTildeForSSH(remoteDir)}`); + lines.push(`base64 -d > "$xum_seed_tmp" <<'${HEREDOC_MARKER}'`); // Line-wrapped base64 never contains the marker. A loop, not a spread: a spread passes // every line as an argument, and Node throws a RangeError past ~125k (a 10 MiB file). for (const line of file.content.toString("base64").match(/.{1,76}/g) ?? []) lines.push(line); lines.push(HEREDOC_MARKER); - lines.push(`chmod ${file.executable ? "755" : "644"} ${target}`); + lines.push(`xum_seed_close ${target} ${file.executable ? "755" : "644"}`); } return `${lines.join("\n")}\n`; } From 2785b213963a8932922edd14d240e1de9c64c068 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:14:37 +0000 Subject: [PATCH 168/231] fix(daytona): probe every workspace on a shared sandbox before an archive stops it --- .../daytona/daytonaLifecycleHooks.test.ts | 39 +++++++++++ .../services/daytona/daytonaLifecycleHooks.ts | 37 ++++++++--- src/node/services/daytona/daytonaService.ts | 64 ++++++++++++++++--- src/node/services/serviceContainer.ts | 7 +- 4 files changed, 127 insertions(+), 20 deletions(-) diff --git a/src/node/services/daytona/daytonaLifecycleHooks.test.ts b/src/node/services/daytona/daytonaLifecycleHooks.test.ts index 6f9fc983505..1f81913db66 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.test.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.test.ts @@ -333,6 +333,45 @@ describe("createDaytonaArchiveHook", () => { expect(rig.plane.find("sb-idle")).toBeUndefined(); }); + it("keeps a shared sandbox on a model-driven archive while another workspace's job may run", async () => { + rig.plane.addSandbox({ id: "sb-shared" }); + rig.workspaces.push({ + workspaceId: "owner", + runtimeConfig: metadata({ sandboxId: "sb-shared", shared: false }).runtimeConfig, + archived: true, + }); + const probed: string[] = []; + let ownerJobRuns = true; + const modelArchive = (behavior: CoderWorkspaceArchiveBehavior) => + createDaytonaArchiveHook({ + daytonaService: rig.service, + getArchiveBehavior: () => behavior, + checkSandboxDeletable: cleanCheck, + hasUnsettledRemoteBackgroundJobs: (ref) => { + probed.push(ref.workspaceId); + return Promise.resolve(Ok(ref.workspaceId === "owner" && ownerJobRuns)); + }, + })({ + workspaceId: "ws", + workspaceMetadata: metadata({ sandboxId: "sb-shared", forked: true, shared: false }), + refuseStopUnderUnverifiedRemoteJobs: true, + }); + + // The archived owner's job survived a backend exit: neither policy stops the sandbox. + for (const behavior of ["stop", "delete"] as const) { + const result = await modelArchive(behavior); + expect(result.success).toBe(false); + if (!result.success) expect(result.error).toContain("background process"); + expect(rig.plane.find("sb-shared")?.state).toBe("started"); + } + expect(probed).toContain("owner"); + + // Every workspace on it is clear: the policy applies as before. + ownerJobRuns = false; + expect((await modelArchive("stop")).success).toBe(true); + expect(rig.plane.find("sb-shared")?.state).toBe("stopped"); + }); + it("keeps a dirty sandbox on archive delete: starts it for the check, then stops it and warns", async () => { rig.plane.addSandbox({ id: "sb-dirty", state: "stopped" }); const sshCheck = spyOn( diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index 782ad055dd0..8a5df1d5276 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -5,7 +5,11 @@ import type { WorkspaceMetadata } from "@/common/types/workspace"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; import type { BeforeArchiveHook } from "@/node/services/workspaceLifecycleHooks"; -import { notOwnedSandboxNotice, type DaytonaService } from "./daytonaService"; +import { + notOwnedSandboxNotice, + type DaytonaService, + type DaytonaWorkspaceOnSandbox, +} from "./daytonaService"; const DAYTONA_STOP_TIMEOUT_MS = 60_000; @@ -24,15 +28,19 @@ export interface DaytonaArchiveHookOptions { | "deleteSandbox" | "sandboxOwnership" | "sandboxUseElsewhere" + | "otherWorkspacesOnSandbox" | "isAliasStoredElsewhere" | "releaseSshAccess" | "waitForSandboxReadiness" | "withAccountLease" >; getArchiveBehavior: () => CoderWorkspaceArchiveBehavior; - /** As for Coder: model-driven archives must not stop a box under a surviving detached job. */ + /** + * As for Coder: model-driven archives must not stop a box under a surviving detached job. + * Asked for every workspace on the sandbox, since a stop ends their jobs too. + */ hasUnsettledRemoteBackgroundJobs?: ( - workspaceMetadata: WorkspaceMetadata + workspace: DaytonaWorkspaceOnSandbox ) => Promise>; /** * The dirty and unpushed checks deleteWorkspace runs (starting a stopped sandbox for them). @@ -235,21 +243,32 @@ async function refuseUnderRemoteJobs( if (args.refuseStopUnderUnverifiedRemoteJobs !== true) return undefined; const state = knownState ?? (await options.daytonaService.getSandboxState(sandboxId)).state; if (!isRunning(state)) return undefined; - return probeRemoteJobs(options, args.workspaceMetadata, sandboxId); + const { id, runtimeConfig } = args.workspaceMetadata; + // An archived workspace on the sandbox may have a job that outlived a backend exit too. + const others = await options.daytonaService.otherWorkspacesOnSandbox(sandboxId, id); + if (!others) { + return `Cannot list the other workspaces on Daytona sandbox "${sandboxId}" to verify that none of them still runs a background process; stopping it could terminate a surviving job. ${MANUAL_ARCHIVE}`; + } + for (const workspace of [{ workspaceId: id, runtimeConfig }, ...others]) { + const refusal = await probeRemoteJobs(options, workspace, sandboxId); + if (refusal) return refusal; + } + return undefined; } +const MANUAL_ARCHIVE = "Ask the user to archive this workspace manually."; + async function probeRemoteJobs( options: DaytonaArchiveHookOptions, - workspaceMetadata: WorkspaceMetadata, + workspace: DaytonaWorkspaceOnSandbox, sandboxId: string ): Promise { - const manual = "Ask the user to archive this workspace manually."; if (options.hasUnsettledRemoteBackgroundJobs == null) { - return `Cannot verify that no background process is still running in Daytona sandbox "${sandboxId}"; stopping it could terminate a surviving job. ${manual}`; + return `Cannot verify that no background process is still running in Daytona sandbox "${sandboxId}"; stopping it could terminate a surviving job. ${MANUAL_ARCHIVE}`; } - const probe = await options.hasUnsettledRemoteBackgroundJobs(workspaceMetadata); + const probe = await options.hasUnsettledRemoteBackgroundJobs(workspace); if (!probe.success) { - return `Cannot verify that no background process is still running in Daytona sandbox "${sandboxId}" (${probe.error}); stopping it could terminate a surviving job. ${manual}`; + return `Cannot verify that no background process is still running in Daytona sandbox "${sandboxId}" (${probe.error}); stopping it could terminate a surviving job. ${MANUAL_ARCHIVE}`; } if (probe.data) { return `A background process from a previous session may still be running in Daytona sandbox "${sandboxId}"; stopping it would terminate that job. Terminate the process (or wait for it to finish) or ask the user to archive this workspace manually.`; diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 9e0ce7ca747..1ba13a6f0a7 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -21,7 +21,11 @@ import { loadOrCreateInstallationId } from "@/node/config/installationIdentity"; import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; import { retireSshConnections } from "@/node/runtime/transports"; import { rememberDaytonaToken } from "@/node/runtime/daytonaTokenRedaction"; -import { getDaytonaSandboxId, isDedicatedDaytonaSandbox } from "@/common/types/runtime"; +import { + getDaytonaSandboxId, + isDedicatedDaytonaSandbox, + type RuntimeConfig, +} from "@/common/types/runtime"; import { log } from "@/node/services/log"; import { raceAbort, sleepWithAbort } from "@/node/utils/abort"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; @@ -39,6 +43,22 @@ import { import type { DaytonaWorkspaceRef } from "./daytonaReconciler"; /** What another workspace stores as its sandbox, and whether it attached it. */ +/** A workspace whose runtime is on a given sandbox. */ +export interface DaytonaWorkspaceOnSandbox { + workspaceId: string; + runtimeConfig: RuntimeConfig; +} + +/** The ref with the sandbox string it stores, or none when it stores no sandbox. */ +function onSandboxRef( + ref: DaytonaWorkspaceRef +): Array { + const stored = getDaytonaSandboxId(ref.runtimeConfig); + return stored && ref.runtimeConfig + ? [{ stored, workspaceId: ref.workspaceId, runtimeConfig: ref.runtimeConfig }] + : []; +} + interface StoredSandboxRef { stored: string; attached: boolean; @@ -666,11 +686,32 @@ export class DaytonaService { return matched.some((ref) => !ref.attached) ? "archivedOwner" : "archived"; } + /** + * The other workspaces on the sandbox (archived ones and borrowed checkouts included), for + * checks that must cover every job in it. Matched as in sandboxReferencesElsewhere; undefined + * when the workspace list, the caller's id or the lookup is unavailable. + */ + async otherWorkspacesOnSandbox( + sandboxId: string, + workspaceId: string | undefined + ): Promise { + if (!this.listWorkspaces || !workspaceId) return undefined; + const others = this.listWorkspaces().flatMap((ref) => + ref.workspaceId === workspaceId || this.isRemovalInFlight(ref) ? [] : onSandboxRef(ref) + ); + if (others.length === 0) return []; + const matched = await this.refsMatchingSandbox(sandboxId, others); + return matched?.map((ref) => ({ + workspaceId: ref.workspaceId, + runtimeConfig: ref.runtimeConfig, + })); + } + /** The refs that store this sandbox by its name or its id; undefined when the lookup fails. */ - private async refsMatchingSandbox( + private async refsMatchingSandbox( sandboxId: string, - refs: StoredSandboxRef[] - ): Promise { + refs: T[] + ): Promise { const aliases = refs.every((ref) => ref.stored === sandboxId) ? new Set([sandboxId]) : await this.sandboxAliases(sandboxId); @@ -699,10 +740,10 @@ export class DaytonaService { ): StoredSandboxRef[] { return refs.flatMap((ref) => { const stored = getDaytonaSandboxId(ref.runtimeConfig); - const removed = - ref.pendingRemovalId !== undefined && - this.removedWorkspaces.get(ref.workspaceId) === ref.pendingRemovalId; - const counts = ref.workspaceId !== workspaceId && !removed && ref.borrowsCheckout !== true; + const counts = + ref.workspaceId !== workspaceId && + !this.isRemovalInFlight(ref) && + ref.borrowsCheckout !== true; return counts && stored ? [ { @@ -715,6 +756,13 @@ export class DaytonaService { }); } + private isRemovalInFlight(ref: DaytonaWorkspaceRef): boolean { + return ( + ref.pendingRemovalId !== undefined && + this.removedWorkspaces.get(ref.workspaceId) === ref.pendingRemovalId + ); + } + /** The sandbox's id and name (empty when it is gone); undefined when the lookup fails. */ private async sandboxAliases(sandboxId: string): Promise | undefined> { try { diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index e9a29094221..912672abbc4 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -455,10 +455,11 @@ export class ServiceContainer { createDaytonaArchiveHook({ daytonaService: this.daytonaService, getArchiveBehavior: () => this.archiveBehavior(), - hasUnsettledRemoteBackgroundJobs: (workspaceMetadata) => + // Spawn records sit under the runtime's temp dir by workspace id: no checkout needed. + hasUnsettledRemoteBackgroundJobs: (workspace) => this.backgroundProcessManager.hasUnsettledRemoteSpawnRecords( - createRuntimeForWorkspace(workspaceMetadata), - workspaceMetadata.id + createRuntime(workspace.runtimeConfig, { workspaceId: workspace.workspaceId }), + workspace.workspaceId ), checkSandboxDeletable: (workspaceMetadata) => { const runtime = createRuntimeForWorkspace(workspaceMetadata); From 9803d698d6321a46f70e25350d7699470840733f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:13:21 +0000 Subject: [PATCH 169/231] fix(ssh): match dot files with an escaped leading dot in an Include glob The hidden-entry filter looked at the raw segment, so `Include conf.d/\.*.conf` skipped every dot file while OpenSSH loads them: glob(3) matches a leading "." with a literal dot, escaped or not. The filter now uses the unescaped pattern the matcher is built from; a wildcard or a bracket expression still never matches a leading dot. --- src/node/runtime/sshConfigParser.test.ts | 39 ++++++++++++++++++++++++ src/node/runtime/sshConfigParser.ts | 34 +++++++++++++++------ 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/node/runtime/sshConfigParser.test.ts b/src/node/runtime/sshConfigParser.test.ts index 27825360c4c..386c280ac05 100644 --- a/src/node/runtime/sshConfigParser.test.ts +++ b/src/node/runtime/sshConfigParser.test.ts @@ -376,6 +376,45 @@ describe("resolveSSHConfig", () => { }); }); + // glob(3) matches a leading "." with a literal one, escaped or not, never with a wildcard + // or a bracket expression. + test("matches dot files with an escaped leading dot in an Include pattern", async () => { + // Windows file names and paths differ (and the escape is a separator there). + if (process.platform === "win32") return; + await withTempHome(async (homeDir) => { + // Absolute: OpenSSH resolves a relative Include against the real user's ~/.ssh. + const confDir = path.join(homeDir, ".ssh", "conf.d"); + await fs.mkdir(confDir); + for (const [name, host] of [ + [".escaped.conf", "escaped"], + [".plain.conf", "plain"], + [".bracket.conf", "bracket"], + ]) { + await fs.writeFile(path.join(confDir, name), `Host ${host}\n User user-${host}\n`, "utf8"); + } + const configPath = path.join(homeDir, ".ssh", "config"); + await fs.writeFile( + configPath, + [ + `Include ${confDir}/\\.e*.conf ${confDir}/.p*.conf ${confDir}/[.]b*.conf`, + "Host *", + " User none", + "", + ].join("\n"), + "utf8" + ); + + for (const [host, user] of [ + ["escaped", "user-escaped"], + ["plain", "user-plain"], + ["bracket", "none"], + ]) { + expect((await resolveSSHConfig(host)).user).toBe(user); + if (hasOpenSsh) expect(opensshOptions(configPath, host)("user")).toBe(user); + } + }); + }); + test("matches an Include wildcard as glob(3) does: ? is one character, * skips dot files", async () => { await withTempHome(async (homeDir) => { const confDir = path.join(homeDir, ".ssh", "conf.d"); diff --git a/src/node/runtime/sshConfigParser.ts b/src/node/runtime/sshConfigParser.ts index 8d3196e11fc..c1e69013cfe 100644 --- a/src/node/runtime/sshConfigParser.ts +++ b/src/node/runtime/sshConfigParser.ts @@ -384,25 +384,34 @@ async function globPath(pattern: string): Promise { const { root } = path.parse(normalized); let matches = [root]; for (const segment of normalized.slice(root.length).split(path.sep).filter(Boolean)) { - const { matcher, literal } = segmentPattern(segment); + const { matcher, literal, literalLeadingDot } = segmentPattern(segment); if (!matcher) { matches = matches.map((dir) => path.join(dir, literal)); continue; } const next: string[][] = []; - for (const dir of matches) next.push(await matchingEntries(dir, segment, matcher)); + for (const dir of matches) { + next.push(await matchingEntries(dir, segment, { matcher, literalLeadingDot })); + } matches = next.flat(); } return matches.sort(); } -async function matchingEntries(dir: string, segment: string, matcher: RegExp): Promise { +async function matchingEntries( + dir: string, + segment: string, + pattern: { matcher: RegExp; literalLeadingDot: boolean } +): Promise { try { const entries = await fs.readdir(dir); - // glob(3) matches a leading "." only with a literal one. - const dotFiles = segment.startsWith("."); + // glob(3) matches a leading "." only with a literal one (escaped or not), never with a + // wildcard or a bracket expression. return entries - .filter((entry) => (dotFiles || !entry.startsWith(".")) && matcher.test(entry)) + .filter( + (entry) => + (pattern.literalLeadingDot || !entry.startsWith(".")) && pattern.matcher.test(entry) + ) .map((entry) => path.join(dir, entry)); } catch (error) { log.debug("Failed to expand SSH config Include", { @@ -420,20 +429,27 @@ const escapeRegExp = (text: string) => text.replace(REGEXP_SPECIAL, "\\$&"); * One path segment of a glob(3) pattern as a RegExp: `*`, `?` (one character), bracket * expressions (`[ab]`, `[a-z]`, negated by a leading `!` or `^`; an unclosed `[` is literal) * and `\` escapes. Without a wildcard there is no matcher, only the unescaped `literal` name - * glob(3) opens. + * glob(3) opens. `literalLeadingDot`: the pattern starts with a literal "." once unescaped. */ -function segmentPattern(segment: string): { matcher?: RegExp; literal: string } { +function segmentPattern(segment: string): { + matcher?: RegExp; + literal: string; + literalLeadingDot: boolean; +} { let source = ""; let literal = ""; let wildcard = false; + let literalLeadingDot = false; for (let i = 0; i < segment.length; i++) { const token = segmentToken(segment, i); + if (i === 0) literalLeadingDot = !token.wildcard && segment[token.end] === "."; source += token.source; wildcard ||= token.wildcard; if (!token.wildcard) literal += segment[token.end]; i = token.end; } - return wildcard ? { matcher: new RegExp(`^${source}$`, "s"), literal } : { literal }; + const matcher = wildcard ? new RegExp(`^${source}$`, "s") : undefined; + return { matcher, literal, literalLeadingDot }; } const WILDCARD_SOURCES = new Map([ From 284ca63e02a019a2c944c92f07722b4a6c6608a1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:15:18 +0000 Subject: [PATCH 170/231] fix(daytona): read each sandbox's state once per runtime status refresh Forks and attachments share one Daytona sandbox, and getRuntimeStatuses read the sandbox state once per workspace, so a fork tree sent a burst of identical control-plane requests on every initial or focus refresh, which throttling could turn into unknown statuses. The requested workspaces are now grouped by sandbox, each sandbox is read once, and its status (or unknown after a failed read) goes to all of its workspaces. --- .../workspaceService.runtimeStatus.test.ts | 86 +++++++++++++++++++ src/node/services/workspaceService.ts | 22 +++-- 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/node/services/workspaceService.runtimeStatus.test.ts b/src/node/services/workspaceService.runtimeStatus.test.ts index 2c997767246..186e5d66d31 100644 --- a/src/node/services/workspaceService.runtimeStatus.test.ts +++ b/src/node/services/workspaceService.runtimeStatus.test.ts @@ -6,6 +6,8 @@ import { shellQuote } from "@/common/utils/shell"; import type { TerminalService } from "@/node/services/terminalService"; import type { EnsureReadyOptions, Runtime } from "@/node/runtime/Runtime"; import * as daytonaSshConfig from "@/node/runtime/daytonaSshConfig"; +import * as runtimeFactory from "@/node/runtime/runtimeFactory"; +import type { DaytonaRuntimeService } from "@/node/runtime/DaytonaSSHRuntime"; import * as runtimeHelpers from "@/node/runtime/runtimeHelpers"; import { workspaceUseLeasesFor } from "@/node/services/workspaceUseLeases"; import { @@ -226,6 +228,90 @@ describe.skipIf(process.platform === "win32")( } ); +describe("WorkspaceService.getRuntimeStatuses for Daytona workspaces", () => { + let harness: WorkspaceServiceHarness; + let projectPath: string; + let globalService: { mockRestore: () => void }; + const getSandboxState = mock((sandboxId: string) => + sandboxId === "sb-broken" + ? Promise.reject(new Error("throttled")) + : Promise.resolve({ + state: sandboxId === "sb-1" ? ("started" as const) : ("stopped" as const), + }) + ); + + beforeEach(async () => { + harness = await createWorkspaceServiceHarness(); + projectPath = path.join(harness.rootDir, "project"); + await fs.mkdir(projectPath); + getSandboxState.mockClear(); + globalService = spyOn(runtimeFactory, "getGlobalDaytonaService").mockReturnValue({ + getSandboxState, + } as unknown as DaytonaRuntimeService); + }); + + afterEach(async () => { + globalService.mockRestore(); + await harness.cleanup(); + }); + + async function addDaytonaWorkspaces(sandboxByWorkspace: Record): Promise { + await harness.config.editConfig((config) => { + config.projects.set(projectPath, { + trusted: true, + workspaces: Object.entries(sandboxByWorkspace).map(([id, sandboxId]) => ({ + id, + name: id, + path: path.join(harness.rootDir, "checkouts", id), + runtimeConfig: { + type: "ssh", + host: `${sandboxId}.xum--daytona`, + srcBaseDir: "/home/daytona/xum", + daytona: { sandboxId }, + }, + createdAt: "2026-01-01T00:00:00.000Z", + })), + }); + return config; + }); + } + + // Forks and attachments share one sandbox: a large fork tree must not burst the control plane. + test("reads each sandbox's state once and gives it to every workspace on it", async () => { + await addDaytonaWorkspaces({ + parent: "sb-1", + fork1: "sb-1", + fork2: "sb-1", + other: "sb-2", + broken1: "sb-broken", + broken2: "sb-broken", + }); + + expect( + await harness.service.getRuntimeStatuses([ + "parent", + "fork1", + "fork2", + "other", + "broken1", + "broken2", + ]) + ).toEqual({ + parent: "running", + fork1: "running", + fork2: "running", + other: "stopped", + broken1: "unknown", + broken2: "unknown", + }); + expect(getSandboxState.mock.calls.map(([sandboxId]) => sandboxId).sort()).toEqual([ + "sb-1", + "sb-2", + "sb-broken", + ]); + }); +}); + describe("WorkspaceService.recordExternalEditorOpen for a Daytona workspace", () => { let harness: WorkspaceServiceHarness; let projectPath: string; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 27fafd272ce..893726ee61b 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1096,17 +1096,29 @@ const DAYTONA_RUNTIME_STATUS: Record ): Promise> { + const workspacesBySandbox = new Map(); + for (const workspaceId of workspaceIds) { + const runtimeConfig = metadataById.get(workspaceId)?.runtimeConfig; + const sandboxId = isSSHRuntime(runtimeConfig) ? runtimeConfig.daytona?.sandboxId : undefined; + if (!sandboxId) continue; + const workspaces = workspacesBySandbox.get(sandboxId) ?? []; + workspaces.push(workspaceId); + workspacesBySandbox.set(sandboxId, workspaces); + } const statuses: Record = {}; await Promise.all( - workspaceIds.map(async (workspaceId) => { - const runtimeConfig = metadataById.get(workspaceId)?.runtimeConfig; - const sandboxId = isSSHRuntime(runtimeConfig) ? runtimeConfig.daytona?.sandboxId : undefined; - if (sandboxId) statuses[workspaceId] = await probeDaytonaRuntimeStatus(sandboxId); + [...workspacesBySandbox].map(async ([sandboxId, workspaces]) => { + const status = await probeDaytonaRuntimeStatus(sandboxId); + for (const workspaceId of workspaces) statuses[workspaceId] = status; }) ); return statuses; From ac6ed1cadc81b3b3def40bddfd05141f569b1daf Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:16:11 +0000 Subject: [PATCH 171/231] chore(daytona): keep StoredSandboxRef's doc comment on it --- src/node/services/daytona/daytonaService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 1ba13a6f0a7..6a0aa8c8591 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -42,7 +42,6 @@ import { } from "./daytonaClient"; import type { DaytonaWorkspaceRef } from "./daytonaReconciler"; -/** What another workspace stores as its sandbox, and whether it attached it. */ /** A workspace whose runtime is on a given sandbox. */ export interface DaytonaWorkspaceOnSandbox { workspaceId: string; @@ -59,6 +58,7 @@ function onSandboxRef( : []; } +/** What another workspace stores as its sandbox, and whether it attached it. */ interface StoredSandboxRef { stored: string; attached: boolean; From 5956abc5423f051801ab2f797a4d4765911e7c68 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:47:13 +0000 Subject: [PATCH 172/231] fix(ssh): resolve a checkout path under ~ on the host before an editor opens it --- src/browser/utils/openInEditor.test.ts | 28 ++++++++ src/browser/utils/openInEditor.ts | 70 +++++++++++++++---- src/common/orpc/schemas/api.ts | 4 +- src/node/orpc/router.ts | 6 +- .../workspaceService.runtimeStatus.test.ts | 27 +++++++ src/node/services/workspaceService.ts | 48 ++++++++++++- 6 files changed, 164 insertions(+), 19 deletions(-) diff --git a/src/browser/utils/openInEditor.test.ts b/src/browser/utils/openInEditor.test.ts index 61c762bacd2..ef381038cf4 100644 --- a/src/browser/utils/openInEditor.test.ts +++ b/src/browser/utils/openInEditor.test.ts @@ -163,6 +163,34 @@ describe("openInEditor", () => { expect(result).toEqual({ success: true }); expect(placeholder.navigations[0]).toContain("ssh-remote+xum-daytona-sb-1"); }); + + // The default srcBaseDir (~/mux) is persisted unresolved: the sandbox may not exist yet when + // the workspace is created, so only the backend can resolve it once the sandbox is up. + test("opens the path the backend resolved for a checkout under ~", async () => { + const calls: OpenCall[] = []; + const recordEditorOpen = mock((_input: { workspaceId: string; remotePath?: string | null }) => + Promise.resolve({ + success: true as const, + data: { remotePath: "/home/daytona/mux/project/feature" }, + }) + ); + const api = createTestApiClient({ general: { recordEditorOpen } }); + + const result = await withWindow(createMockWindow(calls), () => + openInEditor({ + api, + workspaceId, + targetPath: "~/mux/project/feature", + runtimeConfig: { ...daytonaConfig, srcBaseDir: "~/mux" }, + }) + ); + + expect(result).toEqual({ success: true }); + expect(recordEditorOpen.mock.calls[0]?.[0].remotePath).toBe("~/mux/project/feature"); + expect(calls.map(([url]) => url)).toEqual([ + "vscode://vscode-remote/ssh-remote+xum-daytona-sb-1/home/daytona/mux/project/feature", + ]); + }); }); test("opens devcontainer deep links with mapped container path", async () => { diff --git a/src/browser/utils/openInEditor.ts b/src/browser/utils/openInEditor.ts index 1b60b0d8f20..fe11374d526 100644 --- a/src/browser/utils/openInEditor.ts +++ b/src/browser/utils/openInEditor.ts @@ -154,7 +154,9 @@ export async function openInEditor(args: OpenInEditorArgs): Promise => { + const recordOpenBeforeLaunch = async ( + remotePath: string | undefined + ): Promise<{ launchToken: string; remotePath?: string } | { error: string }> => { if (!args.api) { return { error: @@ -169,11 +171,12 @@ export async function openInEditor(args: OpenInEditorArgs): Promise placeholder?.closed === true; - const recordThenLaunch = async (deepLink: string): Promise => { + const recordThenLaunch = async ( + deepLink: string, + remote?: RemoteEditorPath + ): Promise => { // The user can close the blank placeholder during any await that ran before this point // (devcontainer/SSH discovery); refuse before recording so no marker needs rolling back. if (isPlaceholderClosed()) { return { success: false, error: placeholderClosedError }; } - const admission = await recordOpenBeforeLaunch(); + const admission = await recordOpenBeforeLaunch(pathToResolve(remote)); if ("error" in admission) { return { success: false, error: admission.error }; } @@ -224,11 +230,12 @@ export async function openInEditor(args: OpenInEditorArgs): Promise string | null; +} + +function pathToResolve(remote: RemoteEditorPath | undefined): string | undefined { + return remote?.path; +} + +function resolvedRemotePath(data: { remotePath: string } | null | undefined): string | undefined { + return data?.remotePath; +} + +function remoteDeepLink( + deepLink: string, + remote: RemoteEditorPath | undefined, + resolvedPath: string | undefined +): string { + if (remote == null || resolvedPath == null) return deepLink; + return remote.deepLinkFor(resolvedPath) ?? deepLink; +} + /** * A browser on another machine cannot open a Daytona sandbox: the deep link launches the editor * there, but the alias, token and pinned host keys exist only in the Xum server's @@ -267,7 +301,7 @@ function launchDaytonaFromThisMachine( async function openInEditorWithLaunch( args: OpenInEditorArgs, editorConfig: EditorConfig, - launch: (deepLink: string) => Promise + launch: (deepLink: string, remote?: RemoteEditorPath) => Promise ): Promise { const isSSH = isSSHRuntime(args.runtimeConfig); const isDocker = isDockerRuntime(args.runtimeConfig); @@ -392,13 +426,16 @@ async function openInEditorWithLaunch( // else: localhost access to local workspace → no SSH needed // VS Code/Cursor SSH deep links treat the path as a folder unless a line/column is present. - const deepLink = getEditorDeepLink({ - editor: editorConfig.editor as DeepLinkEditor, - path: args.targetPath, - sshHost, - line: args.isFile && sshHost ? 1 : undefined, - column: args.isFile && sshHost ? 1 : undefined, - }); + const position = args.isFile && sshHost ? 1 : undefined; + const deepLinkFor = (path: string) => + getEditorDeepLink({ + editor: editorConfig.editor as DeepLinkEditor, + path, + sshHost, + line: position, + column: position, + }); + const deepLink = deepLinkFor(args.targetPath); if (!deepLink) { return { @@ -407,7 +444,10 @@ async function openInEditorWithLaunch( }; } - return launchDaytonaFromThisMachine(args, () => launch(deepLink)); + // The backend resolves only an SSH workspace's path under ~ (see RemoteEditorPath). + return launchDaytonaFromThisMachine(args, () => + launch(deepLink, { path: args.targetPath, deepLinkFor }) + ); } // Custom editor: diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index c78fa83afad..943da03784a 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -4062,8 +4062,10 @@ export const general = { input: z.object({ workspaceId: z.string(), launchToken: z.string().min(1).max(128), + // An SSH checkout path the editor opens; one under `~` comes back resolved on the host. + remotePath: z.string().nullish(), }), - output: ResultSchema(z.void(), z.string()), + output: ResultSchema(z.object({ remotePath: z.string() }).nullish(), z.string()), }, /** * Undo a recordEditorOpen whose deep-link launch provably never happened (the renderer's diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 4316cbe84e2..4b733a634bf 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1251,7 +1251,11 @@ export const router = (authToken?: string) => { .input(schemas.general.recordEditorOpen.input) .output(schemas.general.recordEditorOpen.output) .handler(async ({ context, input }) => - context.workspaceService.recordExternalEditorOpen(input.workspaceId, input.launchToken) + context.workspaceService.recordExternalEditorOpen( + input.workspaceId, + input.launchToken, + input.remotePath + ) ), rollbackEditorOpen: t .input(schemas.general.rollbackEditorOpen.input) diff --git a/src/node/services/workspaceService.runtimeStatus.test.ts b/src/node/services/workspaceService.runtimeStatus.test.ts index 186e5d66d31..8241d441778 100644 --- a/src/node/services/workspaceService.runtimeStatus.test.ts +++ b/src/node/services/workspaceService.runtimeStatus.test.ts @@ -397,6 +397,33 @@ describe("WorkspaceService.recordExternalEditorOpen for a Daytona workspace", () expect(workspaceUseLeasesFor(harness.config).heldCount("dt", "editor")).toBe(0); }); + // A workspace created with the default srcBaseDir keeps its ~ path (the sandbox did not exist + // yet); the editor needs it absolute, which only the running sandbox can tell. + test("resolves a checkout path under ~ on the sandbox once it is ready", async () => { + await addDaytonaWorkspace(); + const markerPath = path.join(harness.config.sessionsDir, "dt", "external-editor-opened"); + const resolvePath = mock((p: string) => { + // Only once the sandbox is ready under the editor use. + expect(leasesDuringReadiness.length).toBeGreaterThan(0); + return Promise.resolve(p.replace(/^~/, "/home/daytona")); + }); + createRuntime.mockRestore(); + createRuntime = spyOn(runtimeHelpers, "createRuntimeForWorkspace").mockReturnValue({ + ensureReady, + resolvePath, + } as unknown as Runtime); + + // A path that cannot be resolved opens nothing and leaves no marker behind. + resolvePath.mockImplementationOnce(() => Promise.reject(new Error("ssh: connection refused"))); + const refused = await harness.service.recordExternalEditorOpen("dt", "tok-1", "~/mux/x"); + expect(refused.success).toBe(false); + expect(await Bun.file(markerPath).exists()).toBe(false); + + expect( + await harness.service.recordExternalEditorOpen("dt", "tok-2", "~/mux/project/dt") + ).toEqual({ success: true, data: { remotePath: "/home/daytona/mux/project/dt" } }); + }); + // The editor reads the user's ssh config, where a matching RemoteForward would give the // sandbox a tunnel to a local service. test("refuses the open when the user's ssh config forwards into the sandbox", async () => { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 893726ee61b..83ea5db64ab 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1184,6 +1184,10 @@ function borrowsLiveCheckout(config: ProjectsConfig, workspaceId: string): boole ); } +function isHomeRelativePath(remotePath: string | null | undefined): remotePath is string { + return remotePath === "~" || remotePath?.startsWith("~/") === true; +} + /** What archive refusals call the dedicated remote box, so Daytona users are not told "Coder". */ function dedicatedRemoteBoxLabel(metadata: WorkspaceMetadata | undefined): string { const runtimeConfig = metadata?.runtimeConfig; @@ -11954,11 +11958,21 @@ export class WorkspaceService * first refuses the open while an open recorded first is observed by the sink's * untrackable-app check before snapshot capture. */ - async recordExternalEditorOpen(workspaceId: string, launchToken: string): Promise> { + async recordExternalEditorOpen( + workspaceId: string, + launchToken: string, + remotePath?: string | null + ): Promise> { const admitted = await this.recordExternalEditorOpenForLaunch(workspaceId); if (!admitted.success) { return admitted; } + // Before the tombstone check below: a rollback that arrives meanwhile is still consumed. + const resolved = await this.resolveEditorRemotePath(workspaceId, remotePath); + if (!resolved.success) { + await admitted.data.rollbackAfterFailedLaunch(); + return resolved; + } // A rollback for this token that raced ahead of the recording (the renderer saw the RPC // reject while this handler was still persisting the marker) is consumed here, in the // same synchronous block that would otherwise register the rollback entry: the renderer @@ -11980,7 +11994,37 @@ export class WorkspaceService workspaceId, rollback: admitted.data.rollbackAfterFailedLaunch, }); - return Ok(undefined); + return Ok(resolved.data); + } + + /** + * SSH workspaces whose runtime is provisioned after creation (Daytona, Coder) keep the + * unresolved default srcBaseDir (`~/mux`), so their checkout path starts with `~`. An + * editor's ssh deep link needs it absolute: resolve it on the host, which the admission + * above has made ready. Older records keep working, since nothing is rewritten. + */ + private async resolveEditorRemotePath( + workspaceId: string, + remotePath: string | null | undefined + ): Promise> { + if (!isHomeRelativePath(remotePath)) return Ok(undefined); + const entry = findWorkspaceEntry(this.config.loadConfigOrDefault(), workspaceId); + const runtimeConfig = entry?.workspace.runtimeConfig; + if (entry == null || !isSSHRuntime(runtimeConfig)) return Ok(undefined); + try { + const runtime = createRuntimeForWorkspace({ + id: workspaceId, + runtimeConfig, + projectPath: entry.projectPath, + name: entry.workspace.name ?? workspaceId, + namedWorkspacePath: entry.workspace.path, + }); + return Ok({ remotePath: await runtime.resolvePath(remotePath) }); + } catch (error) { + return Err( + `Could not resolve ${remotePath} on the workspace's host: ${getErrorMessage(error)}` + ); + } } /** From 5aea16da622335a62ba7d9bef0f880b88afa978b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:42:40 +0000 Subject: [PATCH 173/231] fix(daytona): accept an absolute DNS name for the SSH gateway A self-hosted deployment may advertise its gateway as `gateway.example.com.`, which the hostname check refused before writing any ssh config. A single trailing dot is now accepted and kept: HostName, HostKeyAlias and the pinned known_hosts lines carry the same string, which OpenSSH and the ssh2 pin check use as written. The check is now label-based, so empty labels (`a..b`, a leading or a second trailing dot) are refused. --- src/node/runtime/daytonaSshConfig.test.ts | 42 +++++++++++++++++++++++ src/node/runtime/daytonaSshConfig.ts | 8 +++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index fb902788d8d..dc7e0678e46 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -14,6 +14,7 @@ import { hasInheritedRemoteForward, renderDaytonaKnownHosts, } from "./daytonaSshConfig"; +import { resolveSSHConfig } from "./sshConfigParser"; import { appendOpenSSHHostKeyPolicyArgs } from "./sshConnectionPool"; const GATEWAY = { @@ -283,6 +284,47 @@ describe("DaytonaSshConfigWriter", () => { ).rejects.toThrow(/Refusing to write an invalid Daytona gateway host/); }); + // Self-hosted deployments may name the gateway absolutely. Kept as written: ssh uses the + // HostKeyAlias as is, both to look up the pinned key and (ssh2) to match known_hosts lines. + it("writes an absolute gateway name with its trailing dot, pinned under that name", async () => { + const gateway = { ...GATEWAY, host: "gateway.example.com.", port: 2222 }; + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway }); + + const knownHosts = await fs.readFile(writer.paths.knownHostsPath, "utf8"); + expect(knownHosts).toBe( + [ + "gateway.example.com. ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGatewayKey", + "[gateway.example.com.]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGatewayKey", + "", + ].join("\n") + ); + const parsed = await resolveSSHConfig(toXumDaytonaHost("sb-1"), writer.paths.includePath); + expect(parsed.hostName).toBe("gateway.example.com."); + expect(parsed.hostKeyAlias).toBe("gateway.example.com."); + if (hasOpenSsh) { + const resolved = spawnSync("ssh", ["-G", "-F", sshConfigPath, "sb-1.xum--daytona"], { + encoding: "utf8", + }).stdout.split("\n"); + expect(resolved).toContain("hostname gateway.example.com."); + expect(resolved).toContain("hostkeyalias gateway.example.com."); + // What ssh does with that alias: find the pinned key in Xum's known_hosts. + const lookup = spawnSync("ssh-keygen", [ + "-F", + "gateway.example.com.", + "-f", + writer.paths.knownHostsPath, + ]); + expect(lookup.status).toBe(0); + } + + for (const host of ["gateway.example.com..", "gateway..example.com", ".example.com", "."]) { + // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime + await expect( + writer.writeSandbox({ sandboxId: "sb-2", token: "t", gateway: { ...gateway, host } }) + ).rejects.toThrow(/Refusing to write an invalid Daytona gateway host/); + } + }); + it("port-qualifies known_hosts entries for a gateway off port 22", () => { expect(renderDaytonaKnownHosts({ ...GATEWAY, port: 2222 })).toBe( [ diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 4751e7548c5..dd787c0348f 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -72,7 +72,9 @@ export class DaytonaSshConfigError extends Error { // value would let it inject arbitrary ssh directives (ProxyCommand runs local commands). const SANDBOX_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; const TOKEN_PATTERN = /^[A-Za-z0-9._~+/=-]+$/; -const HOSTNAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/; +// Dot-separated labels (no empty one), optionally absolute: one trailing dot. +const HOSTNAME_LABEL = "[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?"; +const HOSTNAME_PATTERN = new RegExp(`^${HOSTNAME_LABEL}(?:\\.${HOSTNAME_LABEL})*\\.?$`); const HOST_KEY_PATTERN = /^([A-Za-z0-9@.-]+)\s+([A-Za-z0-9+/]+={0,3})(?:\s.*)?$/; function assertValid(value: string, pattern: RegExp, what: string): void { @@ -84,7 +86,9 @@ function assertValid(value: string, pattern: RegExp, what: string): void { /** * A DNS name or an IP literal: self-hosted deployments may advertise an IPv6 gateway, which * OpenSSH takes in HostName and HostKeyAlias. A zone id (`%eth0`) is refused: ssh config - * would expand its `%` as a token. + * would expand its `%` as a token. An absolute name keeps its trailing dot: HostName, + * HostKeyAlias and the pinned known_hosts lines all carry the same string, which OpenSSH and + * the ssh2 pin check use as written. */ function isGatewayHost(host: string): boolean { return HOSTNAME_PATTERN.test(host) || (net.isIPv6(host) && !host.includes("%")); From 717ea3c8a66c47e20dfdbdd44c478faa1f42fce3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:48:13 +0000 Subject: [PATCH 174/231] fix(daytona): remove the old account's aliases before retiring its connections When another backend on this Xum root switched Daytona accounts, noticeAccount dropped this process's caches and retired its pooled connections, but left the old account's blocks in the shared include file, so a fresh ssh from such an alias kept reaching the old sandbox until its token expired. Before retiring the connections it now removes, under the include file lock, each alias whose block still holds a token this process held for the old account (as releaseSshAccess removes a released one); a block the other backend rewrote with its own token stays. --- src/node/runtime/daytonaSshConfig.ts | 16 +++++ .../services/daytona/daytona.testHarness.ts | 12 +++- .../services/daytona/daytonaService.test.ts | 69 +++++++++++++++++++ src/node/services/daytona/daytonaService.ts | 26 ++++++- 4 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index dd787c0348f..a26bf50794d 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -373,6 +373,22 @@ export class DaytonaSshConfigWriter { }); } + /** + * Remove each alias whose block still holds the given token. Another backend that rewrote + * the alias (a new token, maybe of another account) keeps its block. + */ + async removeSandboxTokens(held: ReadonlyArray<{ sandboxId: string; token: string }>) { + if (held.length === 0) return; + const stale = new Map(held.map(({ sandboxId, token }) => [toXumDaytonaHost(sandboxId), token])); + await this.withIncludeFileLock(async () => { + const blocks = await this.readBlocks(); + const removed = tokensIn(blocks) + .map(({ sandboxId, token }) => ({ alias: toXumDaytonaHost(sandboxId), token })) + .filter(({ alias, token }) => stale.get(alias) === token && blocks.delete(alias)); + if (removed.length > 0) await this.writeBlocks(blocks); + }); + } + /** The sandbox ids and tokens the include file holds (written by this or an earlier process). */ async readTokens(): Promise> { return tokensIn(await this.readBlocks()); diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 4df629a8be5..3cbc5e59a1e 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -77,6 +77,8 @@ export class FakeDaytonaControlPlane { readonly onRequest = new Map Promise>(); private nextId = 1; private nextToken = 1; + /** Minted tokens are ``: real tokens never repeat across accounts. */ + tokenPrefix = "tok"; constructor(private readonly now: () => number) {} @@ -219,7 +221,7 @@ export class FakeDaytonaControlPlane { case "POST ssh-access": { const minutes = Number(url.searchParams.get("expiresInMinutes")); return json(200, { - token: `tok${this.nextToken++}`, + token: `${this.tokenPrefix}${this.nextToken++}`, expiresAt: new Date(this.now() + minutes * 60_000).toISOString(), }); } @@ -281,6 +283,7 @@ export class FakeDaytonaSshConfigWriter implements Pick< | "updateToken" | "repointGateway" | "removeSandbox" + | "removeSandboxTokens" | "readTokens" > { /** Current token per sandbox alias, as the include file would hold it. */ @@ -336,6 +339,13 @@ export class FakeDaytonaSshConfigWriter implements Pick< return Promise.resolve(); } + removeSandboxTokens(held: ReadonlyArray<{ sandboxId: string; token: string }>): Promise { + for (const { sandboxId, token } of held) { + if (this.tokens.get(sandboxId) === token) this.tokens.delete(sandboxId); + } + return Promise.resolve(); + } + readTokens(): Promise> { return Promise.resolve([...this.tokens].map(([sandboxId, token]) => ({ sandboxId, token }))); } diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 43f61c5c323..bf683f89e97 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -7,6 +7,7 @@ import { XUM_DAYTONA_SSH_BLOCK_START } from "@/common/constants/daytona"; import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; import { log } from "@/node/services/log"; import { DaytonaApiError } from "./daytonaClient"; +import { FakeDaytonaControlPlane } from "./daytona.testHarness"; import { DAYTONA_BUSY_MESSAGE, DAYTONA_LABEL_INSTANCE, @@ -407,6 +408,8 @@ describe("DaytonaService", () => { includeFile.delete(sandboxId); return Promise.resolve(); }, + removeSandboxTokens: () => Promise.resolve(), + readTokens: () => Promise.resolve([]), }, }); @@ -470,6 +473,8 @@ describe("DaytonaService", () => { order.push(`repoint ${gateway.host} ${gateway.hostKeys.join(",")}`); return Promise.resolve(); }, + removeSandboxTokens: () => Promise.resolve(), + readTokens: () => Promise.resolve([...includeFile].map(([sandboxId, token]) => ({ sandboxId, token }))), }, @@ -642,6 +647,8 @@ describe("DaytonaService", () => { ensureManagedBlock: () => Promise.resolve(), updateToken: store, removeSandbox: () => Promise.resolve(), + removeSandboxTokens: () => Promise.resolve(), + readTokens: () => Promise.resolve([...includeFile].map(([sandboxId, token]) => ({ sandboxId, token }))), repointGateway: () => Promise.resolve(), @@ -676,6 +683,8 @@ describe("DaytonaService", () => { ensureManagedBlock: () => Promise.resolve(), updateToken: () => Promise.resolve(), removeSandbox: () => Promise.resolve(), + removeSandboxTokens: () => Promise.resolve(), + readTokens: () => failedReads-- > 0 ? Promise.reject(new Error("EIO: i/o error, read")) @@ -764,4 +773,64 @@ describe("DaytonaService", () => { expect(labels[DAYTONA_LABEL_INSTANCE]).toBe(again[DAYTONA_LABEL_INSTANCE]); expect(labels[DAYTONA_LABEL_PROJECT]).toBe("my-project"); }); + + // Another backend on this root switched accounts: this one cannot revoke the old tokens, so + // their aliases must leave the shared include file before it retires the connections, or a + // fresh ssh from an alias would keep reaching the old account's sandbox. + it("drops the old account's aliases from the include file when another process switches", async () => { + const planes = new Map([ + ["Bearer key-a", new FakeDaytonaControlPlane(() => clock)], + ["Bearer key-b", new FakeDaytonaControlPlane(() => clock)], + ]); + for (const [auth, accountPlane] of planes) { + accountPlane.tokenPrefix = `${auth.slice(-1)}-tok`; + for (const id of ["sb-1", "sb-2", "sb-3"]) accountPlane.addSandbox({ id, state: "started" }); + } + const sshConfigPath = path.join(rootDir, "home", ".ssh", "config"); + const backend = () => + new DaytonaService({ + credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "key-a" }), + rootDir, + sshConfigWriter: new DaytonaSshConfigWriter({ xumHome: rootDir, sshConfigPath }), + fetch: ((input: string | URL | Request, init?: RequestInit) => { + const auth = (init?.headers as Record | undefined)?.Authorization ?? ""; + // GET /config is public: both accounts share one deployment. + return (planes.get(auth) ?? planes.get("Bearer key-a"))!.fetch(input, init); + }) as typeof fetch, + now: () => clock, + }); + const desktop = backend(); + const server = backend(); + const tokenOf = async (sandboxId: string): Promise => { + const tokens = await new DaytonaSshConfigWriter({ + xumHome: rootDir, + sshConfigPath, + }).readTokens(); + const token = tokens.find((entry) => entry.sandboxId === sandboxId)?.token; + if (token === undefined) throw new Error(`No include block for ${sandboxId}`); + return token; + }; + await desktop.ensureSshAccess("sb-1"); + await desktop.ensureSshAccess("sb-3"); + await tokenOf("sb-3"); + + await server.setConfig({ apiKey: "key-b" }); + await server.ensureSshAccess("sb-1"); + await server.ensureSshAccess("sb-2"); + const serverTokens: Record = { + "sb-1": await tokenOf("sb-1"), + "sb-2": await tokenOf("sb-2"), + }; + + // A status probe is enough for the desktop to notice. + await desktop.getSandboxState("sb-2"); + + const remaining = await new DaytonaSshConfigWriter({ + xumHome: rootDir, + sshConfigPath, + }).readTokens(); + expect(Object.fromEntries(remaining.map((entry) => [entry.sandboxId, entry.token]))).toEqual( + serverTokens + ); + }); }); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 6a0aa8c8591..54a74994a67 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -240,6 +240,7 @@ export interface DaytonaServiceOptions { | "updateToken" | "repointGateway" | "removeSandbox" + | "removeSandboxTokens" | "readTokens" >; fetch?: typeof fetch; @@ -583,7 +584,9 @@ export class DaytonaService { * finishes under the old settings: it sees the bump and starts over. Returns the tokens * dropped, for the caller to revoke. */ - private async retireAccountCaches(): Promise> { + private async retireAccountCaches( + beforeRetiringConnections?: (tokens: Array<[string, DaytonaSshAccess]>) => Promise + ): Promise> { this.credentialsGeneration++; this.provenOwned.clear(); const tokens = [...this.tokens]; @@ -592,6 +595,7 @@ export class DaytonaService { this.tokens.clear(); this.sshConfigured.clear(); this.readyAt.clear(); + await beforeRetiringConnections?.(tokens); await this.retirePooledSsh(connected); return tokens; } @@ -619,7 +623,25 @@ export class DaytonaService { this.accountFingerprint = fingerprint; if (previous === undefined || previous === fingerprint) return; log.info("Daytona settings changed in another Xum process; dropping cached SSH access"); - await this.retireAccountCaches(); + await this.retireAccountCaches((tokens) => this.removeOldAccountAliases(tokens)); + } + + /** + * The old account's tokens cannot be revoked here, so their aliases leave the shared include + * file before the connections are retired (as releaseSshAccess does): a fresh ssh from an + * alias would otherwise keep reaching the old sandbox until the token expires. Only blocks + * still holding this process's token go: the other backend may have rewritten an alias. + */ + private async removeOldAccountAliases(tokens: Array<[string, DaytonaSshAccess]>) { + try { + await this.sshConfigWriter.removeSandboxTokens( + tokens.map(([sandboxId, access]) => ({ sandboxId, token: access.token })) + ); + } catch (error) { + log.warn("Could not remove the old Daytona account's ssh aliases", { + error: getErrorMessage(error), + }); + } } private async getClientIfConfigured(): Promise { From 8dbb7c4b9353aa31b624f466f855b125e044ff1e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:28:49 +0000 Subject: [PATCH 175/231] fix(daytona): change only the include blocks this backend owns A backend may now change an include-file block other than the sandbox it is writing only while that block still holds a token it minted (or took over from an earlier run before any account change), matched exactly. - retireAccountCaches removes those blocks before it retires the connections, for every caller: a local settings change whose revoke fails no longer leaves the old account's aliases live. - writeSandbox and repointGateway rewrite only the caller's own blocks, and known_hosts keeps the pins of any gateway a remaining block still dials, so a late old-account gateway install cannot move another account's blocks to its gateway. - A mint discarded after a release removes its block only if it still holds that mint's token. --- src/node/runtime/SSH2ConnectionPool.test.ts | 4 +- src/node/runtime/daytonaSshConfig.test.ts | 50 +++++- src/node/runtime/daytonaSshConfig.ts | 78 +++++++-- .../services/daytona/daytonaService.test.ts | 151 ++++++++++++++---- src/node/services/daytona/daytonaService.ts | 104 ++++++++---- 5 files changed, 300 insertions(+), 87 deletions(-) diff --git a/src/node/runtime/SSH2ConnectionPool.test.ts b/src/node/runtime/SSH2ConnectionPool.test.ts index 9accfd5a479..16eb7840bff 100644 --- a/src/node/runtime/SSH2ConnectionPool.test.ts +++ b/src/node/runtime/SSH2ConnectionPool.test.ts @@ -238,7 +238,9 @@ describe.skipIf(process.platform === "win32")( ); expect(String(refused)).toContain("Host denied"); - await writer.repointGateway({ ...gateway, hostKeys: [publicKeyLine(hostKey)] }); + await writer.repointGateway({ ...gateway, hostKeys: [publicKeyLine(hostKey)] }, [ + { sandboxId: "sb-1", token: "tokenA" }, + ]); const entry = await new SSH2ConnectionPool().acquireConnection(daytona, { maxWaitMs: 0 }); entry.client.end(); }); diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index dc7e0678e46..820a13f930c 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -130,7 +130,8 @@ describe("DaytonaSshConfigWriter", () => { }; // sb-1 is refreshed first; sb-2 still has a turn on the shared known_hosts file. - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA2", gateway: moved }); + const owned = [{ sandboxId: "sb-2", token: "tokenB" }]; + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA2", gateway: moved, owned }); const include = await fs.readFile(writer.paths.includePath, "utf8"); expect(include).not.toContain("ssh.daytona.test"); @@ -148,18 +149,59 @@ describe("DaytonaSshConfigWriter", () => { ]); // Moved back without a token refresh: every block follows, tokens kept. - await writer.repointGateway(GATEWAY); + await writer.repointGateway(GATEWAY, [...owned, { sandboxId: "sb-1", token: "tokenA2" }]); expect(await fs.readFile(writer.paths.includePath, "utf8")).not.toContain("ssh2.daytona.test"); expect(await writer.readTokens()).toHaveLength(2); expect(await fs.readFile(writer.paths.knownHostsPath, "utf8")).toContain("GatewayKey"); }); + // Another backend on this root (maybe on another account and deployment) wrote sb-2: only + // blocks holding the caller's own tokens follow its gateway, and sb-2 keeps its pins. + it("leaves a block it does not own, and its pinned keys, alone when the gateway changes", async () => { + const other = { + host: "ssh.other.test", + port: 2200, + hostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOtherKey"], + }; + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxId: "sb-3", token: "tokenA3", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxId: "sb-2", token: "tokenB", gateway: other }); + const otherBlock = /Host sb-2\.xum--daytona\n(?: {2}.*\n?)*/.exec( + await fs.readFile(writer.paths.includePath, "utf8") + )?.[0]; + + // A stale holder of sb-2's old token cannot claim the block either. + const owned = [ + { sandboxId: "sb-3", token: "tokenA3" }, + { sandboxId: "sb-2", token: "tokenB-old" }, + ]; + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA2", gateway: GATEWAY, owned }); + await writer.repointGateway(GATEWAY, owned); + + const include = await fs.readFile(writer.paths.includePath, "utf8"); + expect(otherBlock).toContain("HostName ssh.other.test"); + expect(include).toContain(otherBlock ?? "missing"); + const knownHosts = await fs.readFile(writer.paths.knownHostsPath, "utf8"); + expect(knownHosts).toContain( + "ssh.daytona.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGatewayKey" + ); + expect(knownHosts).toContain("ssh.other.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOtherKey"); + expect(knownHosts).toContain( + "[ssh.other.test]:2200 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOtherKey" + ); + + // Once nothing points at the other gateway, its pins go. + await writer.removeSandbox("sb-2"); + await writer.repointGateway(GATEWAY, owned); + expect(await fs.readFile(writer.paths.knownHostsPath, "utf8")).not.toContain("OtherKey"); + }); + it("waits for another backend's include file update before reading the file", async () => { await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); const updates = [ () => writer.writeSandbox({ sandboxId: "sb-2", token: "tokenB", gateway: GATEWAY }), () => writer.updateToken({ sandboxId: "sb-1", token: "tokenA2", gateway: GATEWAY }), - () => writer.repointGateway(GATEWAY), + () => writer.repointGateway(GATEWAY, []), () => writer.removeSandbox("sb-1"), ]; for (const update of updates) { @@ -234,7 +276,7 @@ describe("DaytonaSshConfigWriter", () => { for (const update of [ () => tokenWriter.writeSandbox(input), () => tokenWriter.updateToken(input), - () => tokenWriter.repointGateway(GATEWAY), + () => tokenWriter.repointGateway(GATEWAY, []), ]) { // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime await expect(update()).rejects.toThrow( diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index a26bf50794d..6339abb41ff 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -264,9 +264,14 @@ function renderIncludeFile(blocks: Map): string { return `${[INCLUDE_FILE_HEADER, ...blocks.values()].join("\n\n")}\n`; } -function tokensIn(blocks: Map): Array<{ sandboxId: string; token: string }> { +export interface SandboxToken { + sandboxId: string; + token: string; +} + +function tokensIn(blocks: Map): SandboxToken[] { const suffix = `.${XUM_DAYTONA_HOST_SUFFIX}`; - const tokens: Array<{ sandboxId: string; token: string }> = []; + const tokens: SandboxToken[] = []; for (const [alias, block] of blocks) { const token = /^\s*User\s+(\S+)\s*$/m.exec(block)?.[1]; if (alias.endsWith(suffix) && token) { @@ -276,6 +281,21 @@ function tokensIn(blocks: Map): Array<{ sandboxId: string; token return tokens; } +function hostKeyAliasesIn(blocks: Map): string[] { + return [...blocks.values()].flatMap( + (block) => /^\s*HostKeyAlias\s+(\S+)\s*$/m.exec(block)?.[1] ?? [] + ); +} + +/** The hosts a known_hosts line (as renderDaytonaKnownHosts writes it) pins: `h` or `[h]:port`. */ +function knownHostsLineHosts(line: string): string[] { + const field = line.trim().split(/\s+/)[0] ?? ""; + return field + .split(",") + .filter((entry) => entry.length > 0) + .map((entry) => /^\[(.+)\]:\d+$/.exec(entry)?.[1] ?? entry); +} + // One lock per include file across writer instances in this process (withIncludeFileLock adds // the cross-process one): blocks of different sandboxes share it. const includeFileLocks = new MutexMap(); @@ -296,13 +316,17 @@ export class DaytonaSshConfigWriter { this.sshConfigPath = options.sshConfigPath; } - /** First write for a sandbox: known_hosts, its include block, and the managed ssh block. */ + /** + * First write for a sandbox: known_hosts, its include block, and the managed ssh block. The + * other blocks in `owned` (the caller's own tokens) follow the gateway; see installGateway. + */ async writeSandbox(input: { sandboxId: string; token: string; gateway: DaytonaSshGateway; + owned?: readonly SandboxToken[]; }): Promise { - await this.installGateway(input.gateway, (blocks) => + await this.installGateway(input.gateway, input.owned ?? [], (blocks) => blocks.set(toXumDaytonaHost(input.sandboxId), this.renderBlock(input)) ); await this.ensureManagedBlock(); @@ -326,31 +350,44 @@ export class DaytonaSshConfigWriter { }); } - /** Daytona moved its gateway: repoint every sandbox's block (keeping its token) and pins. */ - async repointGateway(gateway: DaytonaSshGateway): Promise { - await this.installGateway(gateway, () => undefined); + /** Daytona moved its gateway: repoint the caller's own blocks (keeping their tokens). */ + async repointGateway(gateway: DaytonaSshGateway, owned: readonly SandboxToken[]): Promise { + await this.installGateway(gateway, owned, () => undefined); } /** - * known_hosts is shared by every alias: a gateway change repoints them all (each keeps its - * token) in the same step, or another sandbox's turn would dial the old host with new pins. + * known_hosts is shared by every alias: a gateway change repoints the caller's other blocks + * (each keeps its token) in the same step, or another sandbox's turn would dial the old host + * with new pins. Only blocks still holding a token in `owned` move: another backend on this + * root may have written the rest for another account (and deployment) after a settings + * change, and its block would keep its token, so that backend would never repair it. Pins of + * a gateway some remaining block still dials stay for the same reason. */ private async installGateway( gateway: DaytonaSshGateway, + owned: readonly SandboxToken[], edit: (blocks: Map) => void ): Promise { - const knownHosts = renderDaytonaKnownHosts(gateway); + const gatewayLines = renderDaytonaKnownHosts(gateway); this.assertPathsReadLiterallyBySsh(); + const ownedTokens = new Map(owned.map(({ sandboxId, token }) => [sandboxId, token])); await this.withIncludeFileLock(async () => { - await writeFileAtomic(this.paths.knownHostsPath, knownHosts, { - encoding: "utf-8", - mode: 0o600, - }); const blocks = await this.readBlocks(); for (const other of tokensIn(blocks)) { + if (ownedTokens.get(other.sandboxId) !== other.token) continue; blocks.set(toXumDaytonaHost(other.sandboxId), this.renderBlock({ ...other, gateway })); } edit(blocks); + const keptHosts = new Set(hostKeyAliasesIn(blocks)); + keptHosts.delete(gateway.host); + const keptLines = (await this.readKnownHosts()) + .split("\n") + .filter((line) => knownHostsLineHosts(line).some((host) => keptHosts.has(host))); + await writeFileAtomic( + this.paths.knownHostsPath, + keptLines.length > 0 ? `${keptLines.join("\n")}\n${gatewayLines}` : gatewayLines, + { encoding: "utf-8", mode: 0o600 } + ); await this.writeBlocks(blocks); }); } @@ -377,7 +414,7 @@ export class DaytonaSshConfigWriter { * Remove each alias whose block still holds the given token. Another backend that rewrote * the alias (a new token, maybe of another account) keeps its block. */ - async removeSandboxTokens(held: ReadonlyArray<{ sandboxId: string; token: string }>) { + async removeSandboxTokens(held: readonly SandboxToken[]) { if (held.length === 0) return; const stale = new Map(held.map(({ sandboxId, token }) => [toXumDaytonaHost(sandboxId), token])); await this.withIncludeFileLock(async () => { @@ -389,8 +426,17 @@ export class DaytonaSshConfigWriter { }); } + private async readKnownHosts(): Promise { + try { + return await fs.readFile(this.paths.knownHostsPath, "utf-8"); + } catch (error) { + if (isErrnoWithCode(error, "ENOENT")) return ""; + throw error; + } + } + /** The sandbox ids and tokens the include file holds (written by this or an earlier process). */ - async readTokens(): Promise> { + async readTokens(): Promise { return tokensIn(await this.readBlocks()); } diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index bf683f89e97..ac0b5b393e8 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -774,63 +774,148 @@ describe("DaytonaService", () => { expect(labels[DAYTONA_LABEL_PROJECT]).toBe("my-project"); }); - // Another backend on this root switched accounts: this one cannot revoke the old tokens, so - // their aliases must leave the shared include file before it retires the connections, or a - // fresh ssh from an alias would keep reaching the old account's sandbox. - it("drops the old account's aliases from the include file when another process switches", async () => { + /** + * Backends on one Xum root (a desktop app next to `xum server`) with two accounts: key-a on + * Daytona's default deployment, key-b on its own, with its own ssh gateway. They share the + * saved settings and the real include file under a temp home. + */ + function accountsOnOneRoot() { const planes = new Map([ ["Bearer key-a", new FakeDaytonaControlPlane(() => clock)], ["Bearer key-b", new FakeDaytonaControlPlane(() => clock)], ]); for (const [auth, accountPlane] of planes) { accountPlane.tokenPrefix = `${auth.slice(-1)}-tok`; - for (const id of ["sb-1", "sb-2", "sb-3"]) accountPlane.addSandbox({ id, state: "started" }); + for (const id of ["sb-1", "sb-2", "sb-3", "sb-4"]) { + accountPlane.addSandbox({ id, state: "started" }); + } } + const planeA = planes.get("Bearer key-a")!; + const planeB = planes.get("Bearer key-b")!; + planeB.gateway = { + host: "ssh.b.daytona.test", + port: 22, + hostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAccountBKey"], + }; const sshConfigPath = path.join(rootDir, "home", ".ssh", "config"); - const backend = () => + const writer = () => new DaytonaSshConfigWriter({ xumHome: rootDir, sshConfigPath }); + const backend = ( + options: { retireSshConnections?: (sandboxId: string) => Promise } = {} + ) => new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "key-a" }), rootDir, - sshConfigWriter: new DaytonaSshConfigWriter({ xumHome: rootDir, sshConfigPath }), + sshConfigWriter: writer(), fetch: ((input: string | URL | Request, init?: RequestInit) => { - const auth = (init?.headers as Record | undefined)?.Authorization ?? ""; - // GET /config is public: both accounts share one deployment. - return (planes.get(auth) ?? planes.get("Bearer key-a"))!.fetch(input, init); + const url = new URL(input instanceof Request ? input.url : input.toString()); + const auth = (init?.headers as Record | undefined)?.Authorization; + // GET /config is public: the URL names the deployment. + const deployment = url.hostname === "b.daytona.test" ? planeB : planeA; + return (auth === undefined ? deployment : planes.get(auth)!).fetch(input, init); }) as typeof fetch, now: () => clock, + ...options, }); - const desktop = backend(); - const server = backend(); - const tokenOf = async (sandboxId: string): Promise => { - const tokens = await new DaytonaSshConfigWriter({ - xumHome: rootDir, - sshConfigPath, - }).readTokens(); - const token = tokens.find((entry) => entry.sandboxId === sandboxId)?.token; - if (token === undefined) throw new Error(`No include block for ${sandboxId}`); - return token; + const switchToB = { apiKey: "key-b", apiUrl: "https://b.daytona.test/api" }; + /** Token and gateway host per alias in the include file. */ + const blocks = async () => { + const include = await fs.readFile(writer().paths.includePath, "utf8").catch(() => ""); + return Object.fromEntries( + [...include.matchAll(/^Host (\S+)\.xum--daytona\n((?: {2}.*\n?)*)/gm)].map( + ([, sandboxId, body]) => [ + sandboxId, + `${/User (\S+)/.exec(body)?.[1] ?? "?"}@${/HostName (\S+)/.exec(body)?.[1] ?? "?"}`, + ] + ) + ); }; + return { planeA, planeB, backend, writer, switchToB, blocks }; + } + + // Another backend on this root switched accounts: this one cannot revoke the old tokens, so + // their aliases must leave the shared include file before it retires the connections, or a + // fresh ssh from an alias would keep reaching the old account's sandbox. + it("drops the old account's aliases from the include file when another process switches", async () => { + const rig = accountsOnOneRoot(); + const desktop = rig.backend(); + const server = rig.backend(); + await server.loadPersistedTokens(); await desktop.ensureSshAccess("sb-1"); await desktop.ensureSshAccess("sb-3"); - await tokenOf("sb-3"); + expect(Object.keys(await rig.blocks())).toEqual(["sb-1", "sb-3"]); - await server.setConfig({ apiKey: "key-b" }); + await server.setConfig(rig.switchToB); await server.ensureSshAccess("sb-1"); await server.ensureSshAccess("sb-2"); - const serverTokens: Record = { - "sb-1": await tokenOf("sb-1"), - "sb-2": await tokenOf("sb-2"), - }; + const serverBlocks = await rig.blocks(); + expect(Object.keys(serverBlocks).sort()).toEqual(["sb-1", "sb-2", "sb-3"]); // A status probe is enough for the desktop to notice. await desktop.getSandboxState("sb-2"); - const remaining = await new DaytonaSshConfigWriter({ - xumHome: rootDir, - sshConfigPath, - }).readTokens(); - expect(Object.fromEntries(remaining.map((entry) => [entry.sandboxId, entry.token]))).toEqual( - serverTokens - ); + const { "sb-3": _desktopOnly, ...kept } = serverBlocks; + expect(await rig.blocks()).toEqual(kept); + }); + + // The old key may already be invalid: its tokens stay live until they expire, so the aliases + // that carry them must not outlive the connections the switch retires. + it("drops the old account's aliases before retiring connections even when revoking fails", async () => { + const rig = accountsOnOneRoot(); + const aliasesAtRetire: string[][] = []; + const service = rig.backend({ + retireSshConnections: async () => { + aliasesAtRetire.push(Object.keys(await rig.blocks())); + }, + }); + await service.ensureSshAccess("sb-1"); + await service.ensureSshAccess("sb-2"); + rig.planeA.failingRoutes.add("DELETE /sandbox/sb-1/ssh-access"); + rig.planeA.failingRoutes.add("DELETE /sandbox/sb-2/ssh-access"); + + await service.setConfig(rig.switchToB); + + expect(rig.planeA.revokedTokens).toEqual([]); + expect(aliasesAtRetire).toEqual([[], []]); + expect(await rig.blocks()).toEqual({}); + }); + + // The desktop's mint started under account A; the server then switched to B and wrote its + // own blocks for B's gateway. The desktop's late gateway install must not move B's blocks + // (which keep B's token, so the server would never repair them) back to A's gateway. + it("leaves another account's blocks alone when an old-account gateway install lands late", async () => { + const rig = accountsOnOneRoot(); + const desktop = rig.backend(); + const server = rig.backend(); + await server.loadPersistedTokens(); + await desktop.ensureSshAccess("sb-1"); + await desktop.ensureSshAccess("sb-4"); + let mintStarted!: () => void; + const started = new Promise((resolve) => (mintStarted = resolve)); + let releaseMint!: () => void; + const released = new Promise((resolve) => (releaseMint = resolve)); + rig.planeA.onRequest.set("POST /sandbox/sb-3/ssh-access", () => { + mintStarted(); + return released; + }); + const lateInstall = desktop.ensureSshAccess("sb-3"); + await started; + + await server.setConfig(rig.switchToB); + await server.ensureSshAccess("sb-1"); + await server.ensureSshAccess("sb-2"); + const serverBlocks = await rig.blocks(); + releaseMint(); + await lateInstall; + + const after = await rig.blocks(); + expect(after["sb-1"]).toBe(serverBlocks["sb-1"]); + expect(after["sb-2"]).toBe(serverBlocks["sb-2"]); + expect(after["sb-1"]).toEndWith("@ssh.b.daytona.test"); + expect(after["sb-3"]).toMatch(/^a-tok\d+@ssh\.daytona\.test$/); + expect(after["sb-4"]).toMatch(/^a-tok\d+@ssh\.daytona\.test$/); + // Each gateway keeps its pins while a block dials it. + const knownHosts = await fs.readFile(rig.writer().paths.knownHostsPath, "utf8"); + expect(knownHosts).toContain("ssh.b.daytona.test ssh-ed25519"); + expect(knownHosts).toContain("ssh.daytona.test ssh-ed25519"); }); }); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 54a74994a67..213d7dd31cb 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -18,7 +18,7 @@ import { type DaytonaResolvedCredentials, } from "@/node/config/daytonaCredentialsStore"; import { loadOrCreateInstallationId } from "@/node/config/installationIdentity"; -import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; +import { DaytonaSshConfigWriter, type SandboxToken } from "@/node/runtime/daytonaSshConfig"; import { retireSshConnections } from "@/node/runtime/transports"; import { rememberDaytonaToken } from "@/node/runtime/daytonaTokenRedaction"; import { @@ -392,6 +392,13 @@ export class DaytonaService { private readonly readyAt = new Map(); /** Whether the include file was read once, seeding the tokens an earlier process left. */ private persistedTokensLoaded = false; + /** + * The cached tokens this process minted, or took over from an earlier run before any account + * change: the only include blocks it changes besides the one sandbox it writes (exact token + * match, as removeSandboxTokens does). A token it merely follows was written by another + * backend, which may be on another account by now. + */ + private readonly ownedTokens = new Set(); private readonly readyRequests = new Map(); /** * When this process last proved a sandbox carries this install's label (never one that does @@ -461,9 +468,15 @@ export class DaytonaService { rememberDaytonaToken(token); tokens.set(sandboxId, token); // An earlier process's tokens: holding them lets a replacement, a settings change or an - // archive revoke them. The file has no expiry, so they are due for refresh at once. + // archive revoke them. The file has no expiry, so they are due for refresh at once. They + // are this process's own only while it has seen no account change: they then belong to + // the account it runs for (the saved credentials are shared by every backend on the root). if (!this.persistedTokensLoaded && !this.tokens.has(sandboxId)) { - this.cacheNewToken(sandboxId, { token, expiresAt: new Date(0).toISOString() }); + this.cacheNewToken( + sandboxId, + { token, expiresAt: new Date(0).toISOString() }, + { owned: this.credentialsGeneration === 0 } + ); } } this.persistedTokensLoaded = true; @@ -499,7 +512,11 @@ export class DaytonaService { } // Its expiry is unknown; it was minted after this process's, so that expiry is a bound. if (cached && cached.token !== fileToken) { - this.cacheNewToken(sandboxId, { token: fileToken, expiresAt: cached.expiresAt }); + this.cacheNewToken( + sandboxId, + { token: fileToken, expiresAt: cached.expiresAt }, + { owned: false } + ); } } @@ -511,7 +528,11 @@ export class DaytonaService { */ private holdFirstSeenToken(sandboxId: string, fileToken: string | undefined): void { if (fileToken === undefined || this.tokens.has(sandboxId)) return; - this.cacheNewToken(sandboxId, { token: fileToken, expiresAt: new Date(0).toISOString() }); + this.cacheNewToken( + sandboxId, + { token: fileToken, expiresAt: new Date(0).toISOString() }, + { owned: false } + ); } getPublicConfig(): Promise { @@ -583,23 +604,42 @@ export class DaytonaService { * Tokens and gateway data belong to the old account or deployment. A mint in flight * finishes under the old settings: it sees the bump and starts over. Returns the tokens * dropped, for the caller to revoke. + * + * The old account's aliases leave the shared include file before the connections are retired + * (as releaseSshAccess does), whether or not the caller then manages to revoke the tokens: a + * fresh ssh from an alias would otherwise keep reaching the old sandbox until its token + * expires. Only blocks still holding a token this process owns go: another backend may have + * rewritten an alias, maybe for the new account. */ - private async retireAccountCaches( - beforeRetiringConnections?: (tokens: Array<[string, DaytonaSshAccess]>) => Promise - ): Promise> { + private async retireAccountCaches(): Promise> { this.credentialsGeneration++; this.provenOwned.clear(); const tokens = [...this.tokens]; + const owned = this.ownedSshTokens(); const connected = this.sandboxesWithSsh(); this.gatewayCache = undefined; this.tokens.clear(); + this.ownedTokens.clear(); this.sshConfigured.clear(); this.readyAt.clear(); - await beforeRetiringConnections?.(tokens); + try { + await this.sshConfigWriter.removeSandboxTokens(owned); + } catch (error) { + log.warn("Could not remove the old Daytona account's ssh aliases", { + error: getErrorMessage(error), + }); + } await this.retirePooledSsh(connected); return tokens; } + /** The include blocks this process may change besides the one it writes (see ownedTokens). */ + private ownedSshTokens(): SandboxToken[] { + return [...this.tokens] + .filter(([, access]) => this.ownedTokens.has(access.token)) + .map(([sandboxId, access]) => ({ sandboxId, token: access.token })); + } + private async readSavedCredentials(): Promise< { credentials: DaytonaResolvedCredentials | null } | undefined > { @@ -623,25 +663,7 @@ export class DaytonaService { this.accountFingerprint = fingerprint; if (previous === undefined || previous === fingerprint) return; log.info("Daytona settings changed in another Xum process; dropping cached SSH access"); - await this.retireAccountCaches((tokens) => this.removeOldAccountAliases(tokens)); - } - - /** - * The old account's tokens cannot be revoked here, so their aliases leave the shared include - * file before the connections are retired (as releaseSshAccess does): a fresh ssh from an - * alias would otherwise keep reaching the old sandbox until the token expires. Only blocks - * still holding this process's token go: the other backend may have rewritten an alias. - */ - private async removeOldAccountAliases(tokens: Array<[string, DaytonaSshAccess]>) { - try { - await this.sshConfigWriter.removeSandboxTokens( - tokens.map(([sandboxId, access]) => ({ sandboxId, token: access.token })) - ); - } catch (error) { - log.warn("Could not remove the old Daytona account's ssh aliases", { - error: getErrorMessage(error), - }); - } + await this.retireAccountCaches(); } private async getClientIfConfigured(): Promise { @@ -1004,7 +1026,7 @@ export class DaytonaService { this.tokens.set(sandboxId, { ...access, expiresAt: new Date(0).toISOString() }); } try { - await this.sshConfigWriter.repointGateway(toSshGateway(config)); + await this.sshConfigWriter.repointGateway(toSshGateway(config), this.ownedSshTokens()); } catch (error) { // Each sandbox's next ssh access rewrites its setup anyway. log.warn("Could not repoint the Daytona ssh setup at the moved gateway", { @@ -1419,7 +1441,12 @@ export class DaytonaService { await this.sshConfigWriter.updateToken({ sandboxId, token, gateway }); return; } - await this.sshConfigWriter.writeSandbox({ sandboxId, token, gateway }); + await this.sshConfigWriter.writeSandbox({ + sandboxId, + token, + gateway, + owned: this.ownedSshTokens(), + }); }, }); await this.sshConfigWriter.ensureManagedBlock(); @@ -1435,7 +1462,14 @@ export class DaytonaService { return this.tokenGenerations.get(sandboxId) ?? 0; } - private cacheNewToken(sandboxId: string, access: DaytonaSshAccess): void { + private cacheNewToken( + sandboxId: string, + access: DaytonaSshAccess, + options: { owned: boolean } + ): void { + const replaced = this.tokens.get(sandboxId)?.token; + if (replaced !== undefined) this.ownedTokens.delete(replaced); + if (options.owned) this.ownedTokens.add(access.token); this.tokens.set(sandboxId, access); this.tokenGenerations.set(sandboxId, ++this.lastTokenGeneration); } @@ -1549,7 +1583,7 @@ export class DaytonaService { } const outdatedAfterWrite = this.settleOutdatedMint(mint, true); if (outdatedAfterWrite) return outdatedAfterWrite; - this.cacheNewToken(sandboxId, access); + this.cacheNewToken(sandboxId, access, { owned: true }); if (previous && previous.token !== access.token) { await this.revokeQuietly(client, sandboxId, previous.token); } @@ -1594,7 +1628,10 @@ export class DaytonaService { wroteIncludeFile: boolean ): Promise { await this.revokeQuietly(client, sandboxId, access.token); - if (wroteIncludeFile) await this.sshConfigWriter.removeSandbox(sandboxId); + // Token-qualified: another backend may have written the alias since. + if (wroteIncludeFile) { + await this.sshConfigWriter.removeSandboxTokens([{ sandboxId, token: access.token }]); + } throw new Error( `Xum released its SSH access to Daytona sandbox "${sandboxId}" while a token was minted` ); @@ -1605,6 +1642,7 @@ export class DaytonaService { const cached = this.tokens.get(sandboxId); this.tokens.delete(sandboxId); if (!cached) return; + this.ownedTokens.delete(cached.token); const { client } = await this.getClient(); await this.revokeQuietly(client, sandboxId, cached.token); } From c6c2f32da1dff7b374ef2b34c13b3ddb754c36c1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:02:18 +0000 Subject: [PATCH 176/231] fix(daytona): refuse ssh to an old account's alias until it is removed When an account retirement cannot remove its owned include blocks (an unwritable daytona.conf), it no longer forgets them. They stay in memory as stale aliases: every Daytona ssh attempt (the runtime's prepared attempt and ensureSshAccess) retries the removal first and is refused for a sandbox whose stale alias remains. Later retirements add to the list. A sandbox the new account sets up again is unblocked once its block holds the new token, so a removal that keeps failing cannot block it for good. With nothing pending the check does no I/O. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 68 ++++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 2 + src/node/services/daytona/daytonaService.ts | 71 +++++++++++++++++---- 3 files changed, 130 insertions(+), 11 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 8673211d094..8e45263706c 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -1617,6 +1617,74 @@ describe("DaytonaSSHRuntime ssh token redaction", () => { expect(String(error)).toContain(`${DAYTONA_TOKEN_PLACEHOLDER}@127.0.0.1: Permission denied`); }); + // A settings change must not leave an alias behind that a UI runtime's next ssh (which never + // runs ensureReady) would use to reach the old account's sandbox with a still-live token. + it("refuses ssh to an old account's alias until the settings change removed it", async () => { + execSpy.mockRestore(); + rig.plane.addSandbox({ id: "sb-old" }); + rig.addAccount("new-key"); + await rig.service.ensureSshAccess("sb-old"); + const oldToken = rig.writer.tokens.get("sb-old"); + const unwritable = () => new Error("EACCES: permission denied, open 'daytona.conf'"); + const removeTokens = spyOn(rig.writer, "removeSandboxTokens") + .mockImplementationOnce(() => Promise.reject(unwritable())) + .mockImplementationOnce(() => Promise.reject(unwritable())); + await rig.service.setConfig({ apiKey: "new-key" }); + expect(rig.writer.tokens.get("sb-old")).toBe(oldToken); + let spawned = 0; + const local = localShellTransport(rig.rootDir); + const runtime = makeRuntime({ sandboxId: "sb-old" }, rig, { + transport: { + ...local, + spawnRemoteProcess: (command, options) => { + spawned++; + return local.spawnRemoteProcess(command, options); + }, + }, + }); + + const refused = await runtime.exec("true", { cwd: "/tmp", timeout: 10 }).then( + () => new Error("exec unexpectedly succeeded"), + (rejection: unknown) => rejection + ); + expect(String(refused)).toContain("old Daytona account"); + expect(String(refused)).not.toContain(oldToken ?? "missing"); + expect(spawned).toBe(0); + expect(removeTokens).toHaveBeenCalledTimes(2); + + // Writable again: the next attempt removes the alias first, then goes on. + const result = await runtimeHelpers.execBuffered(runtime, "true", { cwd: "/tmp", timeout: 10 }); + expect(result.exitCode).toBe(0); + expect(removeTokens).toHaveBeenCalledTimes(3); + expect(rig.writer.tokens.has("sb-old")).toBe(false); + expect(spawned).toBe(1); + }); + + // Writing the sandbox's block for the new account replaces the old token, which is all the + // removal was for: a removal that keeps failing does not block that sandbox for good. + it("lets the new account set a sandbox up again while the old alias cleanup fails", async () => { + execSpy.mockRestore(); + rig.plane.addSandbox({ id: "sb-both" }); + const next = rig.addAccount("new-key"); + next.tokenPrefix = "new-tok"; + next.addSandbox({ id: "sb-both" }); + await rig.service.ensureSshAccess("sb-both"); + const oldToken = rig.writer.tokens.get("sb-both"); + spyOn(rig.writer, "removeSandboxTokens").mockImplementation(() => + Promise.reject(new Error("EACCES: permission denied, open 'daytona.conf'")) + ); + await rig.service.setConfig({ apiKey: "new-key" }); + + await rig.service.ensureSshAccess("sb-both"); + + expect(rig.writer.tokens.get("sb-both")).not.toBe(oldToken); + const runtime = makeRuntime({ sandboxId: "sb-both" }, rig, { + transport: localShellTransport(rig.rootDir), + }); + const result = await runtimeHelpers.execBuffered(runtime, "true", { cwd: "/tmp", timeout: 10 }); + expect(result.exitCode).toBe(0); + }); + it("revokes the token another backend wrote as a sandbox's first alias once it replaces it", async () => { rig.plane.addSandbox({ id: "sb-theirs-first" }); await rig.service.loadPersistedTokens(); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 35171a12b4b..789307069d7 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -92,6 +92,7 @@ export type DaytonaRuntimeService = Pick< | "gatewayGeneration" | "refreshGatewayAfterSshFailure" | "loadPersistedTokens" + | "requireCurrentSshAlias" | "findSnapshot" | "getGatewayConfig" | "sandboxReferencesElsewhere" @@ -516,6 +517,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { /** What a recovery from this command's failure must compare against (see SshAttempt). */ private async prepareSshAttempt(sandboxId: string | undefined): Promise { if (!sandboxId) return { tokenGeneration: 0, gatewayGeneration: 0 }; + await this.daytonaService.requireCurrentSshAlias(sandboxId); // ssh may send a token an earlier process left in the include file: the redactor must // know it before ssh can quote it. Not every command runs after ensureReady. await this.daytonaService.loadPersistedTokens(); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 213d7dd31cb..bd2cd969cc6 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -18,7 +18,11 @@ import { type DaytonaResolvedCredentials, } from "@/node/config/daytonaCredentialsStore"; import { loadOrCreateInstallationId } from "@/node/config/installationIdentity"; -import { DaytonaSshConfigWriter, type SandboxToken } from "@/node/runtime/daytonaSshConfig"; +import { + DaytonaSshConfigWriter, + getDaytonaSshPaths, + type SandboxToken, +} from "@/node/runtime/daytonaSshConfig"; import { retireSshConnections } from "@/node/runtime/transports"; import { rememberDaytonaToken } from "@/node/runtime/daytonaTokenRedaction"; import { @@ -399,6 +403,13 @@ export class DaytonaService { * backend, which may be on another account by now. */ private readonly ownedTokens = new Set(); + /** + * Old-account blocks an account retirement could not remove (an unwritable include file). + * In memory only: until each is removed, or rewritten for the new account, ssh to its + * sandbox is refused (requireCurrentSshAlias), as it would reach the old account's sandbox + * with a token that stays live until it expires. + */ + private staleAliases: SandboxToken[] = []; private readonly readyRequests = new Map(); /** * When this process last proved a sandbox carries this install's label (never one that does @@ -622,15 +633,49 @@ export class DaytonaService { this.ownedTokens.clear(); this.sshConfigured.clear(); this.readyAt.clear(); + this.staleAliases.push(...owned); + await this.removeStaleAliases(); + await this.retirePooledSsh(connected); + return tokens; + } + + /** Remove the stale aliases (see staleAliases); a failure keeps them for the next attempt. */ + private async removeStaleAliases(): Promise { + const removing = this.staleAliases; + if (removing.length === 0) return; try { - await this.sshConfigWriter.removeSandboxTokens(owned); + await this.sshConfigWriter.removeSandboxTokens(removing); } catch (error) { log.warn("Could not remove the old Daytona account's ssh aliases", { error: getErrorMessage(error), }); + return; } - await this.retirePooledSsh(connected); - return tokens; + // A retirement during the removal may have added more. + this.staleAliases = this.staleAliases.filter((alias) => !removing.includes(alias)); + } + + /** + * Run before every ssh to the sandbox: refuses while its include block may still hold an old + * account's token (see staleAliases), retrying the removal first. Nothing to do, and no + * I/O, in the common case. + */ + async requireCurrentSshAlias(sandboxId: string): Promise { + if (this.staleAliases.length === 0) return; + await this.removeStaleAliases(); + if (this.staleAliases.some((alias) => alias.sandboxId === sandboxId)) { + throw new Error( + `Xum could not remove the old Daytona account's ssh alias for sandbox "${sandboxId}" ` + + `after a settings change, so it will not ssh to it until it can. ` + + `Check that ${getDaytonaSshPaths(this.rootDir).includePath} is writable.` + ); + } + } + + /** The sandbox's block now holds a token of the current account. */ + private replacedStaleAlias(sandboxId: string): void { + if (this.staleAliases.length === 0) return; + this.staleAliases = this.staleAliases.filter((alias) => alias.sandboxId !== sandboxId); } /** The include blocks this process may change besides the one it writes (see ownedTokens). */ @@ -1439,16 +1484,20 @@ export class DaytonaService { const gateway = toSshGateway(await this.getGatewayConfig()); if (this.sshConfigured.has(sandboxId)) { await this.sshConfigWriter.updateToken({ sandboxId, token, gateway }); - return; + } else { + await this.sshConfigWriter.writeSandbox({ + sandboxId, + token, + gateway, + owned: this.ownedSshTokens(), + }); } - await this.sshConfigWriter.writeSandbox({ - sandboxId, - token, - gateway, - owned: this.ownedSshTokens(), - }); + // The new account's block replaced the old token: this sandbox is no longer blocked + // on a removal that may keep failing. + this.replacedStaleAlias(sandboxId); }, }); + await this.requireCurrentSshAlias(sandboxId); await this.sshConfigWriter.ensureManagedBlock(); this.sshConfigured.add(sandboxId); }); From dd50e62730e1cdaf62ae122b66a213422252f2e3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:01:07 +0000 Subject: [PATCH 177/231] fix(daytona): report a failed sandbox setup only to the workspace whose setup failed --- src/node/runtime/DaytonaSSHRuntime.test.ts | 47 +++++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 32 ++++++++------ src/node/services/daytona/daytonaService.ts | 19 ++++++--- 3 files changed, 81 insertions(+), 17 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 8e45263706c..45ad20ed538 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -743,6 +743,53 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { } }); + it("reports a failed setup only to its own workspace, not to one that attached the sandbox", async () => { + const sshDelete = spyOn(SSHRuntime.prototype, "deleteWorkspace").mockResolvedValue({ + success: true, + deletedPath: "/path", + }); + try { + const dedicated = { sandboxId: "xum-left", shared: false, ...SMALL }; + rig.writer.writeError = new Error("No space left on device"); + rig.plane.failingRoutes.add("DELETE /sandbox/xum-left"); + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(makeRuntime(dedicated).postCreateSetup(initParams())).rejects.toThrow( + "setup failed" + ); + expect(rig.plane.find("xum-left")).toBeDefined(); + rig.plane.failingRoutes.clear(); + rig.writer.writeError = undefined; + + // The surviving sandbox is picked in the Existing picker for a new workspace. + const attached = { sandboxId: "xum-left", shared: true }; + const attach = () => makeRuntime(attached, rig, { workspaceId: "ws-2" }); + await attach().postCreateSetup(initParams()); + expect(await attach().ensureReady()).toEqual({ ready: true }); + + const failed = await makeRuntime(dedicated).ensureReady(); + expect(failed.ready).toBe(false); + if (!failed.ready) expect(failed.error).toContain("No space left on device"); + + // Removing the failed workspace forgets its failure. + rig.workspaces.splice( + 0, + rig.workspaces.length, + { workspaceId: "ws-1", runtimeConfig: sshDaytonaConfig(dedicated) }, + { workspaceId: "ws-2", runtimeConfig: sshDaytonaConfig(attached) } + ); + claimRemovals(); + const removal = await makeRuntime(dedicated).deleteWorkspace( + "/home/me/project", + "feature", + true + ); + expect(removal.success).toBe(true); + expect(rig.service.getSetupFailure("ws-1")).toBeUndefined(); + } finally { + sshDelete.mockRestore(); + } + }); + it("refuses a settings change while it creates a sandbox, and takes one afterwards", async () => { const newAccount = rig.addAccount("new-key"); // The user saves another account's key while the create request is in flight. diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 789307069d7..784c3fd0f23 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -107,6 +107,7 @@ export type DaytonaRuntimeService = Pick< | "disownSandbox" | "recordSetupFailure" | "getSetupFailure" + | "clearSetupFailure" >; export interface DaytonaSSHRuntimeConfig extends SSHRuntimeConfig { @@ -421,7 +422,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { // Before the ready cache too: another workspace (one that attached the sandbox) may have // made it ready. const refusal = - this.setupFailureResult(sandboxId) ?? + this.setupFailureResult() ?? (await this.refuseUnownedSandbox(sandboxId, emitStatus, options?.signal)); if (refusal) return refusal; if (this.daytonaService.isSandboxReady(sandboxId)) { @@ -444,8 +445,13 @@ export class DaytonaSSHRuntime extends SSHRuntime { return result; } - private setupFailureResult(sandboxId: string): EnsureReadyResult | undefined { - const setupFailure = this.daytonaService.getSetupFailure(sandboxId); + private recordSetupFailure(reason: string): void { + if (this.workspaceId) this.daytonaService.recordSetupFailure(this.workspaceId, reason); + } + + private setupFailureResult(): EnsureReadyResult | undefined { + if (!this.workspaceId) return undefined; + const setupFailure = this.daytonaService.getSetupFailure(this.workspaceId); if (!setupFailure) return undefined; return { ready: false, @@ -976,11 +982,9 @@ export class DaytonaSSHRuntime extends SSHRuntime { : redactDaytonaTokens(getErrorMessage(error)); log.error("Daytona sandbox setup failed", { sandboxId, error: errorMsg }); params.initLogger.logStderr(`Daytona sandbox setup failed: ${errorMsg}`); - if (creates) { - // Later turns report this reason instead of "deleted outside Xum", and never use a - // sandbox of that name that Xum did not create. - this.daytonaService.recordSetupFailure(sandboxId, errorMsg); - } + // This workspace's later turns report this reason instead of "deleted outside Xum", and + // never use a sandbox of that name that Xum did not create. + if (creates) this.recordSetupFailure(errorMsg); if (create.outcome !== "none") { await this.deleteCreatedSandbox(sandboxId, create, params.initLogger); } @@ -1191,16 +1195,20 @@ export class DaytonaSSHRuntime extends SSHRuntime { trusted?: boolean, options?: { keepBranch?: boolean } ): Promise { - return this.daytonaService.withAccountLease(() => - this.deleteWorkspaceAndSandbox( + return this.daytonaService.withAccountLease(async () => { + const result = await this.deleteWorkspaceAndSandbox( projectPath, workspaceName, force, abortSignal, trusted, options - ) - ); + ); + if (result.success && this.workspaceId) { + this.daytonaService.clearSetupFailure(this.workspaceId); + } + return result; + }); } private async deleteWorkspaceAndSandbox( diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index bd2cd969cc6..4ac499f287f 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -419,7 +419,11 @@ export class DaytonaService { */ private readonly provenOwned = new Map(); private readonly ownershipRequests = new Map>(); - /** Why the setup of a sandbox Xum created (and then deleted) failed, for later turns. */ + /** + * Why a workspace's setup of the sandbox Xum created for it failed, for its later turns. Per + * workspace, not per sandbox: a sandbox the failed setup could not delete may be attached by + * another workspace, whose own setup decides whether it works. + */ private readonly setupFailures = new Map(); private readonly listWorkspaces?: () => DaytonaWorkspaceRef[]; readonly getArchiveBehavior: () => CoderWorkspaceArchiveBehavior; @@ -881,12 +885,17 @@ export class DaytonaService { if (ref?.pendingRemovalId) this.removedWorkspaces.set(workspaceId, ref.pendingRemovalId); } - recordSetupFailure(sandboxId: string, reason: string): void { - this.setupFailures.set(sandboxId, reason); + recordSetupFailure(workspaceId: string, reason: string): void { + this.setupFailures.set(workspaceId, reason); + } + + getSetupFailure(workspaceId: string): string | undefined { + return this.setupFailures.get(workspaceId); } - getSetupFailure(sandboxId: string): string | undefined { - return this.setupFailures.get(sandboxId); + /** The workspace was removed: its id never comes back. */ + clearSetupFailure(workspaceId: string): void { + this.setupFailures.delete(workspaceId); } async checkAvailability(): Promise { From 90389461a3c510df4d4ad05b0d620671d779089f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:34:04 +0000 Subject: [PATCH 178/231] fix(daytona): put back the gateway's pins after a host key failure --- src/node/runtime/DaytonaSSHRuntime.test.ts | 27 +++++++++ src/node/runtime/daytonaSshConfig.test.ts | 23 ++++++++ src/node/runtime/daytonaSshConfig.ts | 58 +++++++++++++++---- .../services/daytona/daytona.testHarness.ts | 16 +++++ .../services/daytona/daytonaService.test.ts | 4 ++ src/node/services/daytona/daytonaService.ts | 21 ++++++- 6 files changed, 136 insertions(+), 13 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 45ad20ed538..5d38be5ffb5 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -1902,6 +1902,33 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { expect(rig.writer.repoints).toHaveLength(before.repoints); }); + it("puts back the gateway's pins after a host key failure when the gateway did not move", async () => { + rig.plane.addSandbox({ id: "sb-pins" }); + const local = localShellTransport(rig.rootDir); + let attempts = 0; + // OpenSSH (StrictHostKeyChecking yes) refuses the gateway without its pinned key. + const transport: SSHTransport = { + ...local, + spawnRemoteProcess: (command, options) => { + attempts++; + return JSON.stringify(rig.writer.pins) === JSON.stringify(rig.plane.gateway) + ? local.spawnRemoteProcess(command, options) + : Promise.reject(new RuntimeError("Host key verification failed.", "network")); + }, + }; + const turn = makeRuntime({ sandboxId: "sb-pins" }, rig, { transport }); + expect(await turn.ensureReady()).toEqual({ ready: true }); + rig.writer.deletePins(); + + const stream = await turn.exec("echo ok", { cwd: "/tmp", timeout: 10 }); + + expect(await new Response(stream.stdout).text()).toBe("ok\n"); + expect(attempts).toBe(2); + // The keys /config advertises are pinned, never the one a server offers. + expect(rig.writer.pins).toEqual(rig.plane.gateway); + expect(rig.writer.repoints).toHaveLength(0); + }); + /** The gateway answers a stopped or deleted sandbox with the same refusal as a bad token. */ function refusingTransportWhile(sandboxId: string, refuses: () => boolean): SSHTransport { const local = localShellTransport(rig.rootDir); diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index 820a13f930c..d527f9a2f7d 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -120,6 +120,29 @@ describe("DaytonaSshConfigWriter", () => { expect(await fs.readFile(sshConfigPath, "utf8")).toBe(`${written}Host work\n User me\n`); }); + it("puts back the gateway's pins after known_hosts was deleted or changed, else writes nothing", async () => { + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + const pinned = await fs.readFile(writer.paths.knownHostsPath, "utf8"); + const { ino } = await fs.stat(writer.paths.knownHostsPath); + const include = await fs.readFile(writer.paths.includePath, "utf8"); + + expect(await writer.restoreGatewayPins(GATEWAY)).toBe(false); + expect((await fs.stat(writer.paths.knownHostsPath)).ino).toBe(ino); + + for (const damaged of [ + undefined, + "", + `${pinned}ssh.daytona.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIInjected\n`, + ]) { + if (damaged === undefined) await fs.rm(writer.paths.knownHostsPath); + else await fs.writeFile(writer.paths.knownHostsPath, damaged, "utf8"); + + expect(await writer.restoreGatewayPins(GATEWAY)).toBe(true); + expect(await fs.readFile(writer.paths.knownHostsPath, "utf8")).toBe(pinned); + } + expect(await fs.readFile(writer.paths.includePath, "utf8")).toBe(include); + }); + it("repoints every sandbox at a new gateway in the write that replaces its host keys", async () => { await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); await writer.writeSandbox({ sandboxId: "sb-2", token: "tokenB", gateway: GATEWAY }); diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 6339abb41ff..908146bcd5c 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -296,6 +296,16 @@ function knownHostsLineHosts(line: string): string[] { .map((entry) => /^\[(.+)\]:\d+$/.exec(entry)?.[1] ?? entry); } +/** The known_hosts lines that pin `host` (in either form). */ +function linesPinning(content: string, host: string): string[] { + return content.split("\n").filter((line) => knownHostsLineHosts(line).includes(host)); +} + +function sameLines(lines: string[], expected: string): boolean { + const wanted = expected.split("\n").filter((line) => line.length > 0); + return lines.length === wanted.length && lines.every((line, index) => line === wanted[index]); +} + // One lock per include file across writer instances in this process (withIncludeFileLock adds // the cross-process one): blocks of different sandboxes share it. const includeFileLocks = new MutexMap(); @@ -378,20 +388,48 @@ export class DaytonaSshConfigWriter { blocks.set(toXumDaytonaHost(other.sandboxId), this.renderBlock({ ...other, gateway })); } edit(blocks); - const keptHosts = new Set(hostKeyAliasesIn(blocks)); - keptHosts.delete(gateway.host); - const keptLines = (await this.readKnownHosts()) - .split("\n") - .filter((line) => knownHostsLineHosts(line).some((host) => keptHosts.has(host))); - await writeFileAtomic( - this.paths.knownHostsPath, - keptLines.length > 0 ? `${keptLines.join("\n")}\n${gatewayLines}` : gatewayLines, - { encoding: "utf-8", mode: 0o600 } - ); + await this.writeKnownHosts(blocks, gateway.host, gatewayLines); await this.writeBlocks(blocks); }); } + /** + * known_hosts was deleted or edited while this backend runs: an unchanged gateway would + * otherwise fail every ssh command until a restart. Rewrites the gateway's pins (from + * `/config`, never a key a server offers) when its lines differ, keeping the include file + * and other gateways' pins. True when it wrote. + */ + async restoreGatewayPins(gateway: DaytonaSshGateway): Promise { + const gatewayLines = renderDaytonaKnownHosts(gateway); + this.assertPathsReadLiterallyBySsh(); + let wrote = false; + await this.withIncludeFileLock(async () => { + const current = await this.readKnownHosts(); + if (sameLines(linesPinning(current, gateway.host), gatewayLines)) return; + await this.writeKnownHosts(await this.readBlocks(), gateway.host, gatewayLines); + wrote = true; + }); + return wrote; + } + + /** The gateway's lines replace every line pinning its host; other gateways' pins stay. */ + private async writeKnownHosts( + blocks: Map, + gatewayHost: string, + gatewayLines: string + ): Promise { + const keptHosts = new Set(hostKeyAliasesIn(blocks)); + keptHosts.delete(gatewayHost); + const keptLines = (await this.readKnownHosts()) + .split("\n") + .filter((line) => knownHostsLineHosts(line).some((host) => keptHosts.has(host))); + await writeFileAtomic( + this.paths.knownHostsPath, + keptLines.length > 0 ? `${keptLines.join("\n")}\n${gatewayLines}` : gatewayLines, + { encoding: "utf-8", mode: 0o600 } + ); + } + /** Token refresh: rewrites only the include file, never `~/.ssh/config` or known_hosts. */ async updateToken(input: { sandboxId: string; diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 3cbc5e59a1e..d95a8de818f 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -285,6 +285,7 @@ export class FakeDaytonaSshConfigWriter implements Pick< | "removeSandbox" | "removeSandboxTokens" | "readTokens" + | "restoreGatewayPins" > { /** Current token per sandbox alias, as the include file would hold it. */ readonly tokens = new Map(); @@ -296,6 +297,13 @@ export class FakeDaytonaSshConfigWriter implements Pick< /** The gateway the include file and known_hosts point at, as last written or repointed. */ gateway?: DaytonaSshGateway; readonly repoints: DaytonaSshGateway[] = []; + /** The gateway known_hosts pins. */ + pins?: DaytonaSshGateway; + + /** Acts as if daytona_known_hosts were deleted. */ + deletePins(): void { + this.pins = undefined; + } writeSandbox(input: { sandboxId: string; @@ -305,6 +313,7 @@ export class FakeDaytonaSshConfigWriter implements Pick< if (this.writeError) return Promise.reject(this.writeError); this.fullWrites.push(input.sandboxId); this.gateway = input.gateway; + this.pins = input.gateway; this.tokens.set(input.sandboxId, input.token); return Promise.resolve(); } @@ -330,9 +339,16 @@ export class FakeDaytonaSshConfigWriter implements Pick< repointGateway(gateway: DaytonaSshGateway): Promise { this.repoints.push(gateway); this.gateway = gateway; + this.pins = gateway; return Promise.resolve(); } + restoreGatewayPins(gateway: DaytonaSshGateway): Promise { + if (JSON.stringify(this.pins) === JSON.stringify(gateway)) return Promise.resolve(false); + this.pins = gateway; + return Promise.resolve(true); + } + removeSandbox(sandboxId: string): Promise { this.removed.push(sandboxId); this.tokens.delete(sandboxId); diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index ac0b5b393e8..3317f412c49 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -402,6 +402,7 @@ describe("DaytonaService", () => { sshConfigWriter: { writeSandbox: store, ensureManagedBlock: () => Promise.resolve(), + restoreGatewayPins: () => Promise.resolve(false), updateToken: store, repointGateway: () => Promise.resolve(), removeSandbox: (sandboxId: string) => { @@ -463,6 +464,7 @@ describe("DaytonaService", () => { sshConfigWriter: { writeSandbox: record, ensureManagedBlock: () => Promise.resolve(), + restoreGatewayPins: () => Promise.resolve(false), updateToken: record, removeSandbox: (sandboxId) => { includeFile.delete(sandboxId); @@ -645,6 +647,7 @@ describe("DaytonaService", () => { const writer = { writeSandbox: store, ensureManagedBlock: () => Promise.resolve(), + restoreGatewayPins: () => Promise.resolve(false), updateToken: store, removeSandbox: () => Promise.resolve(), removeSandboxTokens: () => Promise.resolve(), @@ -681,6 +684,7 @@ describe("DaytonaService", () => { const writer = { writeSandbox: () => Promise.resolve(), ensureManagedBlock: () => Promise.resolve(), + restoreGatewayPins: () => Promise.resolve(false), updateToken: () => Promise.resolve(), removeSandbox: () => Promise.resolve(), removeSandboxTokens: () => Promise.resolve(), diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 4ac499f287f..7baa06a2674 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -246,6 +246,7 @@ export interface DaytonaServiceOptions { | "removeSandbox" | "removeSandboxTokens" | "readTokens" + | "restoreGatewayPins" >; fetch?: typeof fetch; now?: () => number; @@ -1045,18 +1046,32 @@ export class DaytonaService { * An ssh command to a started sandbox failed with neither a refused token nor a stop: Daytona * may have moved its gateway or rotated its host keys within the `/config` cache. True when * the gateway changed since `attemptGeneration` (refetching `/config` unless a concurrent - * failure already found the move) and the sandbox's ssh setup now points at the new one. + * failure already found the move) and the sandbox's ssh setup now points at the new one, or + * when the gateway stayed but its pins in known_hosts had to be put back. */ async refreshGatewayAfterSshFailure( sandboxId: string, attemptGeneration: number ): Promise { - if (this.gatewayMoves === attemptGeneration) await this.getGatewayConfig({ force: true }); - if (this.gatewayMoves === attemptGeneration) return false; + if (this.gatewayMoves === attemptGeneration) { + const config = await this.getGatewayConfig({ force: true }); + if (this.gatewayMoves === attemptGeneration) return this.restoreGatewayPins(config); + } await this.ensureSshAccess(sandboxId); return true; } + /** + * The pins come from the `/config` just fetched, never from a key a server offered. Only here, + * after a fresh fetch: a readiness check reads a cached `/config`, and another backend on this + * root may already have pinned the gateway's rotated keys. + */ + private async restoreGatewayPins(config: DaytonaGatewayConfig): Promise { + const restored = await this.sshConfigWriter.restoreGatewayPins(toSshGateway(config)); + if (restored) log.info("Daytona gateway pins were missing or changed; wrote them again"); + return restored; + } + /** The cached `/config` of these settings, fresh or not. */ private cachedGateway(apiUrl: string, generation: number) { const cached = this.gatewayCache; From eb39bf335beeea09419d4cdacb1916f79ff95e1d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:42:36 +0000 Subject: [PATCH 179/231] fix(daytona): gate Daytona access actions on the live API client While Xum reconnects, useAPI() keeps the last client, whose calls fail at once. The Daytona access form drove Save, Test connection and its loaded state from that client, so both buttons stayed enabled and a click produced a ConnectionLostError that outlived the reconnect. Save and Test now run on useLiveAPI() and are disabled while it is null; edits stay on screen, as in Secrets. A save or test error is tagged with the settings load it ran under, so the reload after a reconnect drops one the lost connection caused. --- .../Sections/DaytonaSettingsForms.test.tsx | 86 +++++++++++++++++++ .../Sections/DaytonaSettingsForms.tsx | 73 ++++++++++------ 2 files changed, 135 insertions(+), 24 deletions(-) diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.test.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.test.tsx index f1e8652203f..b6e72861a13 100644 --- a/src/browser/features/Settings/Sections/DaytonaSettingsForms.test.tsx +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.test.tsx @@ -88,6 +88,92 @@ describe("DaytonaAccessForm", () => { expect(view.queryByText(/Could not load Daytona settings/)).toBeNull(); }); + const KEY_CONFIG: DaytonaPublicConfig = { ...ENV_CONFIG, apiKeySet: true, apiKeySource: "env" }; + + /** A client whose calls fail, as the detached link's do, while `live.current` is false. */ + function reconnectingClient() { + const live = { current: true }; + const whileLive = (value: () => Promise) => + live.current ? value() : Promise.reject(new ConnectionLostError()); + const setConfig = mock((_input: Record) => + whileLive(() => Promise.resolve(KEY_CONFIG)) + ); + const testConnection = mock(() => whileLive(() => Promise.resolve({ ok: true as const }))); + const getConfig = mock(() => whileLive(() => Promise.resolve(KEY_CONFIG))); + const client = createTestApiClient({ + daytona: { + getConfig, + setConfig, + testConnection, + }, + }); + const connection = createKeptClientConnection(client); + connection.setStatus("connected"); + const view = render(, { wrapper: connection.Wrapper }); + const button = (name: string) => view.getByRole("button", { name }) as HTMLButtonElement; + const drop = () => { + live.current = false; + connection.setStatus("reconnecting"); + }; + const restore = () => { + live.current = true; + connection.setStatus("connected"); + }; + return { view, button, getConfig, setConfig, testConnection, drop, restore }; + } + + // While Xum reconnects, useAPI() keeps the last client, whose calls fail at once. + test("disables Save and Test while reconnecting, and both work once live again", async () => { + const rig = reconnectingClient(); + const key = await waitFor(() => { + const input = rig.view.getByLabelText("API key") as HTMLInputElement; + expect(input.disabled).toBe(false); + return input; + }); + const user = userEvent.setup({ document: key.ownerDocument }); + await user.type(key, "new-key"); + + rig.drop(); + expect(rig.button("Save").disabled).toBe(true); + expect(rig.button("Test connection").disabled).toBe(true); + await user.click(rig.button("Save")); + await user.click(rig.button("Test connection")); + expect(rig.setConfig).not.toHaveBeenCalled(); + expect(rig.testConnection).not.toHaveBeenCalled(); + // The edit survives the gap, as in other settings sections. + expect(key.value).toBe("new-key"); + + rig.restore(); + await waitFor(() => expect(rig.button("Test connection").disabled).toBe(false)); + await user.click(rig.button("Test connection")); + await waitFor(() => expect(rig.view.getByRole("status").textContent).toContain("Connected")); + await user.click(rig.button("Save")); + await waitFor(() => expect(rig.setConfig).toHaveBeenCalledTimes(1)); + expect(rig.setConfig.mock.calls[0][0]).toEqual({ apiKey: "new-key" }); + expect(rig.view.queryByRole("alert")).toBeNull(); + }); + + // A call the drop cut off fails with the lost connection; the reload after it supersedes it. + test("drops an error the lost connection caused once the settings reload", async () => { + const rig = reconnectingClient(); + await waitFor(() => expect(rig.button("Test connection").disabled).toBe(false)); + let failTest!: (error: Error) => void; + const cutOff = new Promise((_, reject) => (failTest = reject)); + rig.testConnection.mockImplementationOnce(() => cutOff); + const user = userEvent.setup({ document: rig.view.container.ownerDocument }); + await user.click(rig.button("Test connection")); + await waitFor(() => expect(rig.testConnection).toHaveBeenCalledTimes(1)); + + rig.drop(); + failTest(new ConnectionLostError()); + await waitFor(() => expect(rig.view.getByRole("alert")).toBeTruthy()); + + rig.restore(); + await waitFor(() => expect(rig.getConfig).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(rig.view.queryByRole("alert")).toBeNull()); + expect(rig.button("Test connection").disabled).toBe(false); + }); + test("saves a URL the user edited", async () => { const sent = await saveAfter(async (fields, user) => { await user.clear(fields.url); diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx index 7efb7347513..b6a2ce10d0b 100644 --- a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { CheckCircle2, Loader2, XCircle } from "lucide-react"; import { Button } from "@/browser/components/Button/Button"; import { Input } from "@/browser/components/Input/Input"; -import { useAPI, useLiveAPI } from "@/browser/contexts/API"; +import { useLiveAPI, type APIClient } from "@/browser/contexts/API"; import { DAYTONA_DEFAULT_API_URL } from "@/common/constants/daytona"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import type { DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; @@ -34,15 +34,15 @@ function describeKey(config: DaytonaPublicConfig | null): string { return config.apiKeySource === "env" ? "Key set (env)" : "Key set (settings)"; } -type DaytonaApi = NonNullable["api"]>; - /** * The saved public config (never the key itself), loaded each time the connection becomes live: * while Xum reconnects the last client may stay (and come back), and its calls fail at once. + * `loads` counts the loads that succeeded: an action's error belongs to the load it ran under. */ -function useDaytonaPublicConfig(liveApi: DaytonaApi | null) { +function useDaytonaPublicConfig(liveApi: APIClient | null) { const [config, setConfig] = useState(null); const [loadError, setLoadError] = useState(null); + const [loads, setLoads] = useState(0); useEffect(() => { if (!liveApi) return; let active = true; @@ -52,6 +52,7 @@ function useDaytonaPublicConfig(liveApi: DaytonaApi | null) { if (!active) return; setConfig(next); setLoadError(null); + setLoads((previous) => previous + 1); }) .catch((error: unknown) => { if (active) setLoadError(`Could not load Daytona settings: ${getErrorMessage(error)}`); @@ -60,44 +61,65 @@ function useDaytonaPublicConfig(liveApi: DaytonaApi | null) { active = false; }; }, [liveApi]); - return { config, setConfig, loadError }; + return { config, setConfig, loadError, loads }; +} + +/** A result shown only while no reload superseded the load it ran under. */ +interface ActionResult { + loads: number; + value: T; } -function useDaytonaConnectionTest(api: DaytonaApi | null) { - const [test, setTest] = useState({ status: "idle" }); +function currentResult(result: ActionResult | null, loads: number, fallback: T): T { + return result !== null && result.loads === loads ? result.value : fallback; +} + +const IDLE_TEST: TestState = { status: "idle" }; + +function useDaytonaConnectionTest(liveApi: APIClient | null, loads: number) { + const [result, setResult] = useState | null>(null); const runTest = async () => { - if (!api) return; - setTest({ status: "testing" }); + if (!liveApi) return; + const settle = (value: TestState) => setResult({ loads, value }); + settle({ status: "testing" }); try { - const result = await api.daytona.testConnection(); - setTest(result.ok ? { status: "ok" } : { status: "error", message: result.error }); + const outcome = await liveApi.daytona.testConnection(); + settle(outcome.ok ? { status: "ok" } : { status: "error", message: outcome.error }); } catch (testError) { - setTest({ status: "error", message: getErrorMessage(testError) }); + settle({ status: "error", message: getErrorMessage(testError) }); } }; - return { test, runTest, resetTest: () => setTest({ status: "idle" }) }; + return { + test: currentResult(result, loads, IDLE_TEST), + runTest, + resetTest: () => setResult(null), + }; } -/** The saved config plus the save and test-connection calls. */ +/** + * The saved config plus the save and test-connection calls, all on the live client: while Xum + * reconnects, useAPI() keeps the last client, whose calls fail at once (ConnectionLostError). + * An error such a call left goes with the reload that follows the reconnect. + */ function useDaytonaAccess() { - const { api } = useAPI(); - const { config, setConfig, loadError } = useDaytonaPublicConfig(useLiveAPI()); - const { test, runTest, resetTest } = useDaytonaConnectionTest(api); + const liveApi = useLiveAPI(); + const { config, setConfig, loadError, loads } = useDaytonaPublicConfig(liveApi); + const { test, runTest, resetTest } = useDaytonaConnectionTest(liveApi, loads); const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(null); + const [saveError, setSaveError] = useState | null>(null); /** Resolves true when the backend accepted the change. */ const save = async (input: DaytonaAccessInput): Promise => { - if (!api) return false; + if (!liveApi) return false; setSaving(true); setSaveError(null); resetTest(); try { - setConfig(await api.daytona.setConfig(input)); + setConfig(await liveApi.daytona.setConfig(input)); window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.DAYTONA_CONFIG_CHANGED)); return true; } catch (error) { - setSaveError(`Could not save Daytona settings: ${getErrorMessage(error)}`); + setSaveError({ loads, value: `Could not save Daytona settings: ${getErrorMessage(error)}` }); return false; } finally { setSaving(false); @@ -105,10 +127,11 @@ function useDaytonaAccess() { }; return { - loaded: api != null && config !== null, + loaded: config !== null, + live: liveApi !== null, config, saving, - error: saveError ?? loadError, + error: currentResult(saveError, loads, null) ?? loadError, test, save, runTest, @@ -266,6 +289,8 @@ function AccessMessages(props: { test: TestState; error: string | null }) { export function DaytonaAccessForm() { const access = useDaytonaAccess(); const drafts = useDaytonaAccessDrafts(access.config); + // Offline, edits stay on screen but Save and Test are disabled (as in Secrets): they could + // not reach the server. const disabled = !access.loaded || access.saving; const keySet = access.config?.apiKeySet === true; @@ -302,7 +327,7 @@ export function DaytonaAccessForm() { disabled={disabled} /> Date: Thu, 22 Oct 2026 09:51:52 +0000 Subject: [PATCH 180/231] test(daytona): compare the seeded directory's entries in a fixed order --- src/node/runtime/daytonaAgentHomeSeed.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/node/runtime/daytonaAgentHomeSeed.test.ts b/src/node/runtime/daytonaAgentHomeSeed.test.ts index 9b691601e49..675b0369af6 100644 --- a/src/node/runtime/daytonaAgentHomeSeed.test.ts +++ b/src/node/runtime/daytonaAgentHomeSeed.test.ts @@ -106,7 +106,10 @@ describe("agent-home seeding", () => { expect((await fs.lstat(written)).isFile()).toBe(true); expect(await fs.readFile(written, "utf-8")).toBe("export A=1\n"); expect((await fs.stat(written)).mode & 0o777).toBe(0o755); - expect(await fs.readdir(path.join(remoteHome, ".mux"))).toEqual(["tool_env", "tool_pre"]); + expect((await fs.readdir(path.join(remoteHome, ".mux"))).sort()).toEqual([ + "tool_env", + "tool_pre", + ]); }); it("refuses a symlinked directory on the way, writing nothing through it", async () => { From 622aee9846c3fb3813fbf2ef1c773c22246b9360 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:03:41 +0000 Subject: [PATCH 181/231] refactor(daytona): assume one Xum process per data folder Drop the cross-process lock on daytona.json and daytona.conf, the include-file revalidation before ssh access (following tokens another backend wrote), noticing another process's settings save, and the token ownership that kept one backend from rewriting another's blocks. The startup and per-command token reads the redactor needs stay. --- docs/runtime/daytona.mdx | 2 +- .../config/daytonaCredentialsStore.test.ts | 39 ---- src/node/config/daytonaCredentialsStore.ts | 25 +-- src/node/runtime/DaytonaSSHRuntime.test.ts | 52 ----- src/node/runtime/SSH2ConnectionPool.test.ts | 4 +- src/node/runtime/daytonaSshConfig.test.ts | 82 +------- src/node/runtime/daytonaSshConfig.ts | 111 ++-------- .../builtInSkillContent.generated.ts | 2 +- .../services/daytona/daytonaService.test.ts | 192 ------------------ src/node/services/daytona/daytonaService.ts | 163 ++------------- 10 files changed, 44 insertions(+), 628 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index 8475d1f1871..f94eb8c6859 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -73,7 +73,7 @@ Once a minute, Xum also stops running sandboxes that carry this install's label ## SSH setup -Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, so it also filters tokens that another Xum process on the same Xum root wrote there. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered. +Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, in case that read failed at startup. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered. Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts. diff --git a/src/node/config/daytonaCredentialsStore.test.ts b/src/node/config/daytonaCredentialsStore.test.ts index c1f1b7fda08..7c24228a657 100644 --- a/src/node/config/daytonaCredentialsStore.test.ts +++ b/src/node/config/daytonaCredentialsStore.test.ts @@ -4,7 +4,6 @@ import * as os from "os"; import * as path from "path"; import { DAYTONA_DEFAULT_API_URL } from "@/common/constants/daytona"; import { createDaytonaTestRig } from "@/node/services/daytona/daytona.testHarness"; -import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import { DaytonaCredentialsStore } from "./daytonaCredentialsStore"; const KEY = "test-key-not-real"; @@ -38,44 +37,6 @@ describe("DaytonaCredentialsStore", () => { expect((await store.resolve())?.apiKey).toBe(KEY); }); - // Two backends on one Xum root (a desktop app next to `xum server`) each have their own store. - it("keeps both of two backends' concurrent saves", async () => { - const desktop = new DaytonaCredentialsStore(rootDir, {}); - const server = new DaytonaCredentialsStore(rootDir, {}); - await desktop.update({ apiKey: "old-key", apiUrl: "https://old.example.com/api" }); - - await Promise.all([ - desktop.update({ apiKey: KEY }), - server.update({ apiUrl: "https://new.example.com/api" }), - ]); - - expect(await desktop.read()).toEqual({ apiKey: KEY, apiUrl: "https://new.example.com/api" }); - }); - - it("waits for another process's save before reading the file", async () => { - // Held by this process, the file lock looks to the store like another backend mid-save. - const release = await acquireCrossProcessLock({ - lockPath: path.join(rootDir, "daytona.json.lock"), - acquireTimeoutMs: 1_000, - staleMs: 60_000, - timeoutMessage: "test holder", - }); - const store = new DaytonaCredentialsStore(rootDir, {}); - let settled = false; - const saving = store.update({ apiKey: KEY }).finally(() => { - settled = true; - }); - try { - await new Promise((resolve) => setTimeout(resolve, 400)); - expect(settled).toBe(false); - expect(await store.read()).toEqual({}); - } finally { - await release(); - } - await saving; - expect(await store.read()).toEqual({ apiKey: KEY }); - }); - it("leaves omitted fields alone and clears fields set to an empty string", async () => { const store = new DaytonaCredentialsStore(rootDir, {}); await store.update({ apiKey: KEY, target: "us" }); diff --git a/src/node/config/daytonaCredentialsStore.ts b/src/node/config/daytonaCredentialsStore.ts index 699775c1c4b..0b160ecc6de 100644 --- a/src/node/config/daytonaCredentialsStore.ts +++ b/src/node/config/daytonaCredentialsStore.ts @@ -8,7 +8,6 @@ import { isPlainObject } from "@/common/utils/isPlainObject"; import { isLoopbackHost } from "@/common/utils/loopbackHost"; import { log } from "@/node/services/log"; import { ensurePrivateDir, isErrnoWithCode } from "@/node/utils/fs"; -import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import writeFileAtomic from "@/node/utils/writeFileAtomic"; /** @@ -187,29 +186,13 @@ export class DaytonaCredentialsStore { return run; } - /** - * Another backend on this Xum root (a desktop app next to `xum server`) has its own - * updateChain: the file lock spans the whole read-modify-write, or one backend's save of the - * key could be overwritten by the other's save of the URL, pairing the old key with the new - * deployment. - */ private async applyUpdate(patch: DaytonaCredentialsPatch): Promise { await ensurePrivateDir(this.rootDir); - const release = await acquireCrossProcessLock({ - lockPath: `${this.filePath}.lock`, - acquireTimeoutMs: 30_000, - staleMs: 60_000, - timeoutMessage: "Another Xum process is saving Daytona settings.", + const next = applyPatch(await this.read(), patch); + await writeFileAtomic(this.filePath, JSON.stringify(next, null, 2), { + encoding: "utf-8", + mode: 0o600, }); - try { - const next = applyPatch(await this.read(), patch); - await writeFileAtomic(this.filePath, JSON.stringify(next, null, 2), { - encoding: "utf-8", - mode: 0o600, - }); - } finally { - await release(); - } } /** diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 5d38be5ffb5..025db255c6e 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -1731,58 +1731,6 @@ describe("DaytonaSSHRuntime ssh token redaction", () => { const result = await runtimeHelpers.execBuffered(runtime, "true", { cwd: "/tmp", timeout: 10 }); expect(result.exitCode).toBe(0); }); - - it("revokes the token another backend wrote as a sandbox's first alias once it replaces it", async () => { - rig.plane.addSandbox({ id: "sb-theirs-first" }); - await rig.service.loadPersistedTokens(); - // After this backend's startup load, another one sets the sandbox up first. - rig.writer.tokens.set("sb-theirs-first", "other-backend-first-token"); - - await rig.service.ensureSshAccess("sb-theirs-first"); - - expect(rig.writer.tokens.get("sb-theirs-first")).not.toBe("other-backend-first-token"); - expect(rig.writer.fullWrites).toEqual(["sb-theirs-first"]); - expect(rig.plane.revokedTokens).toEqual(["other-backend-first-token"]); - }); - - it("follows the include file another backend sharing the root edits", async () => { - rig.plane.addSandbox({ id: "sb-both" }); - await rig.service.ensureSshAccess("sb-both"); - const ours = rig.writer.tokens.get("sb-both"); - - // Another backend archives a workspace on the sandbox and removes its alias. - rig.writer.tokens.delete("sb-both"); - await rig.service.ensureSshAccess("sb-both"); - expect(rig.writer.fullWrites).toEqual(["sb-both", "sb-both"]); - const rewritten = rig.writer.tokens.get("sb-both"); - expect(rewritten).toBeDefined(); - expect(rewritten).not.toBe(ours); - - // Another backend mints and writes its own token; ssh now sends that one. - const theirs = "other-backend-token-5e2b"; - rig.writer.tokens.set("sb-both", theirs); - const runtime = makeRuntime({ sandboxId: "sb-both" }, rig, { - transport: { - ...localShellTransport(rig.rootDir), - spawnRemoteProcess: () => - Promise.reject( - new RuntimeError(`${theirs}@127.0.0.1: Permission denied (publickey).`, "network") - ), - }, - }); - const error = await runtime.exec("true", { cwd: "/tmp", timeout: 10 }).then( - () => new Error("exec unexpectedly succeeded"), - (rejection: unknown) => rejection - ); - expect(String(error)).not.toContain(theirs); - expect(String(error)).toContain(DAYTONA_TOKEN_PLACEHOLDER); - - // Held as the sandbox's token: a release revokes it with the block. - rig.writer.tokens.set("sb-both", theirs); - await rig.service.ensureSshAccess("sb-both"); - await rig.service.releaseSshAccess("sb-both"); - expect(rig.plane.revokedTokens).toContain(theirs); - }); }); describe("DaytonaSSHRuntime reconnect after a stop", () => { diff --git a/src/node/runtime/SSH2ConnectionPool.test.ts b/src/node/runtime/SSH2ConnectionPool.test.ts index 16eb7840bff..9accfd5a479 100644 --- a/src/node/runtime/SSH2ConnectionPool.test.ts +++ b/src/node/runtime/SSH2ConnectionPool.test.ts @@ -238,9 +238,7 @@ describe.skipIf(process.platform === "win32")( ); expect(String(refused)).toContain("Host denied"); - await writer.repointGateway({ ...gateway, hostKeys: [publicKeyLine(hostKey)] }, [ - { sandboxId: "sb-1", token: "tokenA" }, - ]); + await writer.repointGateway({ ...gateway, hostKeys: [publicKeyLine(hostKey)] }); const entry = await new SSH2ConnectionPool().acquireConnection(daytona, { maxWaitMs: 0 }); entry.client.end(); }); diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index d527f9a2f7d..93b0f04281c 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -8,7 +8,6 @@ import { XUM_DAYTONA_SSH_BLOCK_START, toXumDaytonaHost, } from "@/common/constants/daytona"; -import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import { DaytonaSshConfigWriter, hasInheritedRemoteForward, @@ -153,8 +152,7 @@ describe("DaytonaSshConfigWriter", () => { }; // sb-1 is refreshed first; sb-2 still has a turn on the shared known_hosts file. - const owned = [{ sandboxId: "sb-2", token: "tokenB" }]; - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA2", gateway: moved, owned }); + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA2", gateway: moved }); const include = await fs.readFile(writer.paths.includePath, "utf8"); expect(include).not.toContain("ssh.daytona.test"); @@ -172,86 +170,12 @@ describe("DaytonaSshConfigWriter", () => { ]); // Moved back without a token refresh: every block follows, tokens kept. - await writer.repointGateway(GATEWAY, [...owned, { sandboxId: "sb-1", token: "tokenA2" }]); + await writer.repointGateway(GATEWAY); expect(await fs.readFile(writer.paths.includePath, "utf8")).not.toContain("ssh2.daytona.test"); expect(await writer.readTokens()).toHaveLength(2); expect(await fs.readFile(writer.paths.knownHostsPath, "utf8")).toContain("GatewayKey"); }); - // Another backend on this root (maybe on another account and deployment) wrote sb-2: only - // blocks holding the caller's own tokens follow its gateway, and sb-2 keeps its pins. - it("leaves a block it does not own, and its pinned keys, alone when the gateway changes", async () => { - const other = { - host: "ssh.other.test", - port: 2200, - hostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOtherKey"], - }; - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - await writer.writeSandbox({ sandboxId: "sb-3", token: "tokenA3", gateway: GATEWAY }); - await writer.writeSandbox({ sandboxId: "sb-2", token: "tokenB", gateway: other }); - const otherBlock = /Host sb-2\.xum--daytona\n(?: {2}.*\n?)*/.exec( - await fs.readFile(writer.paths.includePath, "utf8") - )?.[0]; - - // A stale holder of sb-2's old token cannot claim the block either. - const owned = [ - { sandboxId: "sb-3", token: "tokenA3" }, - { sandboxId: "sb-2", token: "tokenB-old" }, - ]; - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA2", gateway: GATEWAY, owned }); - await writer.repointGateway(GATEWAY, owned); - - const include = await fs.readFile(writer.paths.includePath, "utf8"); - expect(otherBlock).toContain("HostName ssh.other.test"); - expect(include).toContain(otherBlock ?? "missing"); - const knownHosts = await fs.readFile(writer.paths.knownHostsPath, "utf8"); - expect(knownHosts).toContain( - "ssh.daytona.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGatewayKey" - ); - expect(knownHosts).toContain("ssh.other.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOtherKey"); - expect(knownHosts).toContain( - "[ssh.other.test]:2200 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOtherKey" - ); - - // Once nothing points at the other gateway, its pins go. - await writer.removeSandbox("sb-2"); - await writer.repointGateway(GATEWAY, owned); - expect(await fs.readFile(writer.paths.knownHostsPath, "utf8")).not.toContain("OtherKey"); - }); - - it("waits for another backend's include file update before reading the file", async () => { - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - const updates = [ - () => writer.writeSandbox({ sandboxId: "sb-2", token: "tokenB", gateway: GATEWAY }), - () => writer.updateToken({ sandboxId: "sb-1", token: "tokenA2", gateway: GATEWAY }), - () => writer.repointGateway(GATEWAY, []), - () => writer.removeSandbox("sb-1"), - ]; - for (const update of updates) { - // Held by this process, the file lock looks to the writer like another backend mid-write. - const release = await acquireCrossProcessLock({ - lockPath: `${writer.paths.includePath}.lock`, - acquireTimeoutMs: 1_000, - staleMs: 60_000, - timeoutMessage: "test holder", - }); - const before = await fs.readFile(writer.paths.includePath, "utf8"); - let settled = false; - const running = update().finally(() => { - settled = true; - }); - try { - await new Promise((resolve) => setTimeout(resolve, 300)); - expect(settled).toBe(false); - expect(await fs.readFile(writer.paths.includePath, "utf8")).toBe(before); - } finally { - await release(); - } - await running; - } - expect(await writer.readTokens()).toEqual([{ sandboxId: "sb-2", token: "tokenB" }]); - }); - it("reads back the tokens the include file holds, for a later Xum process", async () => { const later = new DaytonaSshConfigWriter({ xumHome, sshConfigPath }); expect(await later.readTokens()).toEqual([]); @@ -299,7 +223,7 @@ describe("DaytonaSshConfigWriter", () => { for (const update of [ () => tokenWriter.writeSandbox(input), () => tokenWriter.updateToken(input), - () => tokenWriter.repointGateway(GATEWAY, []), + () => tokenWriter.repointGateway(GATEWAY), ]) { // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime await expect(update()).rejects.toThrow( diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 908146bcd5c..9e307e7b594 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -13,7 +13,6 @@ import { ensureManagedSSHConfigBlock } from "@/node/runtime/muxSshConfigWriter"; import { log } from "@/node/services/log"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; import { ensurePrivateDir, isErrnoWithCode } from "@/node/utils/fs"; -import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import writeFileAtomic from "@/node/utils/writeFileAtomic"; /** @@ -281,33 +280,7 @@ function tokensIn(blocks: Map): SandboxToken[] { return tokens; } -function hostKeyAliasesIn(blocks: Map): string[] { - return [...blocks.values()].flatMap( - (block) => /^\s*HostKeyAlias\s+(\S+)\s*$/m.exec(block)?.[1] ?? [] - ); -} - -/** The hosts a known_hosts line (as renderDaytonaKnownHosts writes it) pins: `h` or `[h]:port`. */ -function knownHostsLineHosts(line: string): string[] { - const field = line.trim().split(/\s+/)[0] ?? ""; - return field - .split(",") - .filter((entry) => entry.length > 0) - .map((entry) => /^\[(.+)\]:\d+$/.exec(entry)?.[1] ?? entry); -} - -/** The known_hosts lines that pin `host` (in either form). */ -function linesPinning(content: string, host: string): string[] { - return content.split("\n").filter((line) => knownHostsLineHosts(line).includes(host)); -} - -function sameLines(lines: string[], expected: string): boolean { - const wanted = expected.split("\n").filter((line) => line.length > 0); - return lines.length === wanted.length && lines.every((line, index) => line === wanted[index]); -} - -// One lock per include file across writer instances in this process (withIncludeFileLock adds -// the cross-process one): blocks of different sandboxes share it. +// One lock per include file across writer instances: blocks of different sandboxes share it. const includeFileLocks = new MutexMap(); export interface DaytonaSshConfigWriterOptions { @@ -326,17 +299,13 @@ export class DaytonaSshConfigWriter { this.sshConfigPath = options.sshConfigPath; } - /** - * First write for a sandbox: known_hosts, its include block, and the managed ssh block. The - * other blocks in `owned` (the caller's own tokens) follow the gateway; see installGateway. - */ + /** First write for a sandbox: known_hosts, its include block, and the managed ssh block. */ async writeSandbox(input: { sandboxId: string; token: string; gateway: DaytonaSshGateway; - owned?: readonly SandboxToken[]; }): Promise { - await this.installGateway(input.gateway, input.owned ?? [], (blocks) => + await this.installGateway(input.gateway, (blocks) => blocks.set(toXumDaytonaHost(input.sandboxId), this.renderBlock(input)) ); await this.ensureManagedBlock(); @@ -360,74 +329,51 @@ export class DaytonaSshConfigWriter { }); } - /** Daytona moved its gateway: repoint the caller's own blocks (keeping their tokens). */ - async repointGateway(gateway: DaytonaSshGateway, owned: readonly SandboxToken[]): Promise { - await this.installGateway(gateway, owned, () => undefined); + /** Daytona moved its gateway: repoint every sandbox's block (keeping its token) and pins. */ + async repointGateway(gateway: DaytonaSshGateway): Promise { + await this.installGateway(gateway, () => undefined); } /** - * known_hosts is shared by every alias: a gateway change repoints the caller's other blocks - * (each keeps its token) in the same step, or another sandbox's turn would dial the old host - * with new pins. Only blocks still holding a token in `owned` move: another backend on this - * root may have written the rest for another account (and deployment) after a settings - * change, and its block would keep its token, so that backend would never repair it. Pins of - * a gateway some remaining block still dials stay for the same reason. + * known_hosts is shared by every alias: a gateway change repoints them all (each keeps its + * token) in the same step, or another sandbox's turn would dial the old host with new pins. */ private async installGateway( gateway: DaytonaSshGateway, - owned: readonly SandboxToken[], edit: (blocks: Map) => void ): Promise { - const gatewayLines = renderDaytonaKnownHosts(gateway); + const knownHosts = renderDaytonaKnownHosts(gateway); this.assertPathsReadLiterallyBySsh(); - const ownedTokens = new Map(owned.map(({ sandboxId, token }) => [sandboxId, token])); await this.withIncludeFileLock(async () => { const blocks = await this.readBlocks(); for (const other of tokensIn(blocks)) { - if (ownedTokens.get(other.sandboxId) !== other.token) continue; blocks.set(toXumDaytonaHost(other.sandboxId), this.renderBlock({ ...other, gateway })); } edit(blocks); - await this.writeKnownHosts(blocks, gateway.host, gatewayLines); + await this.writeKnownHosts(knownHosts); await this.writeBlocks(blocks); }); } /** - * known_hosts was deleted or edited while this backend runs: an unchanged gateway would - * otherwise fail every ssh command until a restart. Rewrites the gateway's pins (from - * `/config`, never a key a server offers) when its lines differ, keeping the include file - * and other gateways' pins. True when it wrote. + * known_hosts was deleted or edited while Xum runs: an unchanged gateway would otherwise fail + * every ssh command until a restart. Rewrites the pins (from `/config`, never a key a server + * offers) when they differ. True when it wrote. */ async restoreGatewayPins(gateway: DaytonaSshGateway): Promise { - const gatewayLines = renderDaytonaKnownHosts(gateway); + const knownHosts = renderDaytonaKnownHosts(gateway); this.assertPathsReadLiterallyBySsh(); let wrote = false; await this.withIncludeFileLock(async () => { - const current = await this.readKnownHosts(); - if (sameLines(linesPinning(current, gateway.host), gatewayLines)) return; - await this.writeKnownHosts(await this.readBlocks(), gateway.host, gatewayLines); + if ((await this.readKnownHosts()) === knownHosts) return; + await this.writeKnownHosts(knownHosts); wrote = true; }); return wrote; } - /** The gateway's lines replace every line pinning its host; other gateways' pins stay. */ - private async writeKnownHosts( - blocks: Map, - gatewayHost: string, - gatewayLines: string - ): Promise { - const keptHosts = new Set(hostKeyAliasesIn(blocks)); - keptHosts.delete(gatewayHost); - const keptLines = (await this.readKnownHosts()) - .split("\n") - .filter((line) => knownHostsLineHosts(line).some((host) => keptHosts.has(host))); - await writeFileAtomic( - this.paths.knownHostsPath, - keptLines.length > 0 ? `${keptLines.join("\n")}\n${gatewayLines}` : gatewayLines, - { encoding: "utf-8", mode: 0o600 } - ); + private async writeKnownHosts(content: string): Promise { + await writeFileAtomic(this.paths.knownHostsPath, content, { encoding: "utf-8", mode: 0o600 }); } /** Token refresh: rewrites only the include file, never `~/.ssh/config` or known_hosts. */ @@ -513,27 +459,10 @@ export class DaytonaSshConfigWriter { } } - /** - * Every read-modify-write of the include file (and known_hosts) runs under this. Another - * backend on the same Xum root (a desktop app next to `xum server`) has its own MutexMap: - * without the file lock, two writers read the same file and the later rename drops the - * other's alias. Callers never nest it. - */ + /** Every read-modify-write of the include file (and known_hosts) runs under this. */ private async withIncludeFileLock(fn: () => Promise): Promise { await ensurePrivateDir(this.paths.dir); - await includeFileLocks.withLock(this.paths.includePath, async () => { - const release = await acquireCrossProcessLock({ - lockPath: `${this.paths.includePath}.lock`, - acquireTimeoutMs: 30_000, - staleMs: 60_000, - timeoutMessage: "Another Xum process is updating the Daytona ssh config.", - }); - try { - await fn(); - } finally { - await release(); - } - }); + await includeFileLocks.withLock(this.paths.includePath, fn); } private async readBlocks(): Promise> { diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index d071f76d3c3..4549736d9ff 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10110,7 +10110,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "## SSH setup", "", - "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, so it also filters tokens that another Xum process on the same Xum root wrote there. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered.", + "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes or releases the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, in case that read failed at startup. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered.", "", "Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts.", "", diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 3317f412c49..5d795152a17 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -214,98 +214,6 @@ describe("DaytonaService", () => { } }); - it("mints under credentials another Xum process saved, and not after its own change", async () => { - const mintKeys: string[] = []; - service = new DaytonaService({ - credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), - rootDir, - fetch: ((input: string | URL | Request, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - if (init?.method === "POST" && url.includes("/ssh-access")) { - mintKeys.push((init.headers as Record).Authorization); - } - return plane.fetch(input, init); - }) as typeof fetch, - now: () => clock, - }); - const before = await service.getAccount(); - const held = await service.getSshToken("sb-1"); - - // Another backend sharing this root saves another key. - await new DaytonaCredentialsStore(rootDir, {}).update({ apiKey: "other" }); - - expect((await service.getSshToken("sb-1")).token).not.toBe(held.token); - expect(mintKeys).toEqual(["Bearer k", "Bearer other"]); - expect(service.isAccountCurrent(before)).toBe(false); - - // This process's own change is not taken for another one. - await service.setConfig({ apiKey: "third" }); - const after = await service.getAccount(); - await service.getSshToken("sb-1"); - expect(service.isAccountCurrent(after)).toBe(true); - expect(mintKeys).toEqual(["Bearer k", "Bearer other", "Bearer third"]); - }); - - it("keeps a lease on its account when another process saves another key", async () => { - const lookupKeys: string[] = []; - service = new DaytonaService({ - credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), - rootDir, - fetch: ((input: string | URL | Request, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - if ((init?.method ?? "GET") === "GET" && url.endsWith("/sandbox/sb-1")) { - lookupKeys.push((init?.headers as Record).Authorization); - } - return plane.fetch(input, init); - }) as typeof fetch, - now: () => clock, - }); - const generation = async () => (await service.getAccount()).generation; - await service.getSshToken("sb-1"); - const before = await generation(); - - await service.withAccountLease(async () => { - await service.getSandboxState("sb-1"); - await new DaytonaCredentialsStore(rootDir, {}).update({ apiKey: "other" }); - await service.getSandboxState("sb-1"); - expect(await generation()).toBe(before); - }); - expect(lookupKeys).toEqual(["Bearer k", "Bearer k"]); - - // The first resolve after the lease takes the new account and retires the old one once. - await service.getSandboxState("sb-1"); - await service.getSandboxState("sb-1"); - expect(lookupKeys.slice(2)).toEqual(["Bearer other", "Bearer other"]); - expect(await generation()).toBe(before + 1); - }); - - it("takes the account another process saved while idle from a lease's first call", async () => { - const lookupKeys: string[] = []; - service = new DaytonaService({ - credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), - rootDir, - fetch: ((input: string | URL | Request, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - if ((init?.method ?? "GET") === "GET" && url.endsWith("/sandbox/sb-1")) { - lookupKeys.push((init?.headers as Record).Authorization); - } - return plane.fetch(input, init); - }) as typeof fetch, - now: () => clock, - }); - plane.states = ["started"]; - await service.getSandboxState("sb-1"); - const before = (await service.getAccount()).generation; - - // Another backend sharing this root saves another key while no lease runs here. - await new DaytonaCredentialsStore(rootDir, {}).update({ apiKey: "other" }); - await service.withAccountLease(() => service.getSandboxState("sb-1")); - - expect(lookupKeys).toEqual(["Bearer k", "Bearer other"]); - await service.getSandboxState("sb-1"); - expect((await service.getAccount()).generation).toBe(before + 1); - }); - it("fails a lease whose settings another process made unusable while idle", async () => { plane.states = ["started"]; await service.getSandboxState("sb-1"); @@ -679,41 +587,6 @@ describe("DaytonaService", () => { expect(plane.revoked).toEqual(["earlier-process-token", minted ?? ""]); }); - it("loads the include file's tokens before ssh access when the startup load failed", async () => { - let failedReads = 1; - const writer = { - writeSandbox: () => Promise.resolve(), - ensureManagedBlock: () => Promise.resolve(), - restoreGatewayPins: () => Promise.resolve(false), - updateToken: () => Promise.resolve(), - removeSandbox: () => Promise.resolve(), - removeSandboxTokens: () => Promise.resolve(), - - readTokens: () => - failedReads-- > 0 - ? Promise.reject(new Error("EIO: i/o error, read")) - : Promise.resolve([{ sandboxId: "sb-1", token: "earlier-process-token" }]), - repointGateway: () => Promise.resolve(), - }; - service = new DaytonaService({ - credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), - rootDir, - fetch: plane.fetch, - now: () => clock, - sshConfigWriter: writer, - }); - expect( - await service.loadPersistedTokens().then( - () => "loaded", - () => "failed" - ) - ).toBe("failed"); - - // The retry knows the earlier token, so the new one replaces and revokes it. - await service.ensureSshAccess("sb-1"); - expect(plane.revoked).toEqual(["earlier-process-token"]); - }); - it("puts back a removed ~/.ssh/config block on ssh access while the cached token is fresh", async () => { const sshConfigPath = path.join(rootDir, "home", ".ssh", "config"); service = new DaytonaService({ @@ -836,31 +709,6 @@ describe("DaytonaService", () => { return { planeA, planeB, backend, writer, switchToB, blocks }; } - // Another backend on this root switched accounts: this one cannot revoke the old tokens, so - // their aliases must leave the shared include file before it retires the connections, or a - // fresh ssh from an alias would keep reaching the old account's sandbox. - it("drops the old account's aliases from the include file when another process switches", async () => { - const rig = accountsOnOneRoot(); - const desktop = rig.backend(); - const server = rig.backend(); - await server.loadPersistedTokens(); - await desktop.ensureSshAccess("sb-1"); - await desktop.ensureSshAccess("sb-3"); - expect(Object.keys(await rig.blocks())).toEqual(["sb-1", "sb-3"]); - - await server.setConfig(rig.switchToB); - await server.ensureSshAccess("sb-1"); - await server.ensureSshAccess("sb-2"); - const serverBlocks = await rig.blocks(); - expect(Object.keys(serverBlocks).sort()).toEqual(["sb-1", "sb-2", "sb-3"]); - - // A status probe is enough for the desktop to notice. - await desktop.getSandboxState("sb-2"); - - const { "sb-3": _desktopOnly, ...kept } = serverBlocks; - expect(await rig.blocks()).toEqual(kept); - }); - // The old key may already be invalid: its tokens stay live until they expire, so the aliases // that carry them must not outlive the connections the switch retires. it("drops the old account's aliases before retiring connections even when revoking fails", async () => { @@ -882,44 +730,4 @@ describe("DaytonaService", () => { expect(aliasesAtRetire).toEqual([[], []]); expect(await rig.blocks()).toEqual({}); }); - - // The desktop's mint started under account A; the server then switched to B and wrote its - // own blocks for B's gateway. The desktop's late gateway install must not move B's blocks - // (which keep B's token, so the server would never repair them) back to A's gateway. - it("leaves another account's blocks alone when an old-account gateway install lands late", async () => { - const rig = accountsOnOneRoot(); - const desktop = rig.backend(); - const server = rig.backend(); - await server.loadPersistedTokens(); - await desktop.ensureSshAccess("sb-1"); - await desktop.ensureSshAccess("sb-4"); - let mintStarted!: () => void; - const started = new Promise((resolve) => (mintStarted = resolve)); - let releaseMint!: () => void; - const released = new Promise((resolve) => (releaseMint = resolve)); - rig.planeA.onRequest.set("POST /sandbox/sb-3/ssh-access", () => { - mintStarted(); - return released; - }); - const lateInstall = desktop.ensureSshAccess("sb-3"); - await started; - - await server.setConfig(rig.switchToB); - await server.ensureSshAccess("sb-1"); - await server.ensureSshAccess("sb-2"); - const serverBlocks = await rig.blocks(); - releaseMint(); - await lateInstall; - - const after = await rig.blocks(); - expect(after["sb-1"]).toBe(serverBlocks["sb-1"]); - expect(after["sb-2"]).toBe(serverBlocks["sb-2"]); - expect(after["sb-1"]).toEndWith("@ssh.b.daytona.test"); - expect(after["sb-3"]).toMatch(/^a-tok\d+@ssh\.daytona\.test$/); - expect(after["sb-4"]).toMatch(/^a-tok\d+@ssh\.daytona\.test$/); - // Each gateway keeps its pins while a block dials it. - const knownHosts = await fs.readFile(rig.writer().paths.knownHostsPath, "utf8"); - expect(knownHosts).toContain("ssh.b.daytona.test ssh-ed25519"); - expect(knownHosts).toContain("ssh.daytona.test ssh-ed25519"); - }); }); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 7baa06a2674..961e4eb139d 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -1,4 +1,3 @@ -import { createHash } from "crypto"; import * as path from "path"; import type { DaytonaListSandboxesResult, @@ -325,14 +324,6 @@ function toSshGateway(config: DaytonaGatewayConfig) { }; } -/** The account credentials select, hashed so this cache holds no key; "" when none is set. */ -function fingerprintAccount(credentials: DaytonaResolvedCredentials | null): string { - if (!credentials) return ""; - return createHash("sha256") - .update(JSON.stringify([credentials.apiUrl, credentials.apiKey])) - .digest("hex"); -} - /** What the ssh setup depends on; key order from Daytona is not significant. */ function gatewayIdentity(config: DaytonaGatewayConfig): string { const { host, port, hostKeys } = toSshGateway(config); @@ -370,12 +361,6 @@ export class DaytonaService { private readonly tokenRequests = new Map>(); /** Bumped by every settings change, so a mint that spans one can tell. */ private credentialsGeneration = 0; - /** - * A hash of the account (API URL and key) last resolved. Another Xum process sharing this - * root may save other credentials; the store's lock does not tell this one, so a resolve - * that sees another account retires what this process cached for the old one. - */ - private accountFingerprint?: string; /** * The credentials last resolved. While an account lease or this process's settings save * runs, every resolve returns these, so an operation never mixes two accounts when another @@ -397,13 +382,6 @@ export class DaytonaService { private readonly readyAt = new Map(); /** Whether the include file was read once, seeding the tokens an earlier process left. */ private persistedTokensLoaded = false; - /** - * The cached tokens this process minted, or took over from an earlier run before any account - * change: the only include blocks it changes besides the one sandbox it writes (exact token - * match, as removeSandboxTokens does). A token it merely follows was written by another - * backend, which may be on another account by now. - */ - private readonly ownedTokens = new Set(); /** * Old-account blocks an account retirement could not remove (an unwritable include file). * In memory only: until each is removed, or rewritten for the new account, ssh to its @@ -458,18 +436,12 @@ export class DaytonaService { } /** - * Register every token the include file holds now with the redactor. The file is shared by - * every Xum backend on this root, and ssh sends whatever token it holds: one an earlier - * process left (ssh keeps sending it until Xum replaces it) or one another backend wrote - * since. So every ssh to a sandbox (ensureSshAccess, the runtime's commands) reads it first, - * and a failed read fails that ssh. Startup reads it too but goes on when the read fails. - * A missing file holds nothing. + * Register every token the include file holds with the redactor: ssh sends whatever token + * the file holds, including one an earlier run left (ssh keeps sending it until Xum replaces + * it). So every ssh to a sandbox reads it first, and a failed read fails that ssh. Startup + * reads it too but goes on when the read fails. A missing file holds nothing. */ async loadPersistedTokens(): Promise { - await this.readIncludeFileTokens(); - } - - private async readIncludeFileTokens(): Promise> { let entries: Array<{ sandboxId: string; token: string }>; try { entries = await this.sshConfigWriter.readTokens(); @@ -479,76 +451,15 @@ export class DaytonaService { }); throw error; } - const tokens = new Map(); for (const { sandboxId, token } of entries) { rememberDaytonaToken(token); - tokens.set(sandboxId, token); - // An earlier process's tokens: holding them lets a replacement, a settings change or an - // archive revoke them. The file has no expiry, so they are due for refresh at once. They - // are this process's own only while it has seen no account change: they then belong to - // the account it runs for (the saved credentials are shared by every backend on the root). + // An earlier run's tokens: holding them lets a replacement, a settings change or an + // archive revoke them. The file has no expiry, so they are due for refresh at once. if (!this.persistedTokensLoaded && !this.tokens.has(sandboxId)) { - this.cacheNewToken( - sandboxId, - { token, expiresAt: new Date(0).toISOString() }, - { owned: this.credentialsGeneration === 0 } - ); + this.cacheNewToken(sandboxId, { token, expiresAt: new Date(0).toISOString() }); } } this.persistedTokensLoaded = true; - return tokens; - } - - /** - * This process's view of the include file goes stale when another backend edits it: one - * that released the sandbox's access removed the alias (write it again), one that minted - * wrote its own token (hold that one, which ssh now sends). Skipped when this process - * changed the sandbox's access during the read, which then may predate its own write. - */ - private async revalidateSshAccess(sandboxId: string): Promise { - const generation = this.sshTokenGeneration(sandboxId); - const releases = this.accessReleases.get(sandboxId); - const fileToken = (await this.readIncludeFileTokens()).get(sandboxId); - const unchanged = - generation === this.sshTokenGeneration(sandboxId) && - releases === this.accessReleases.get(sandboxId); - if (!unchanged) return; - if (this.sshConfigured.has(sandboxId)) this.followIncludeFile(sandboxId, fileToken); - else this.holdFirstSeenToken(sandboxId, fileToken); - } - - private followIncludeFile(sandboxId: string, fileToken: string | undefined): void { - const cached = this.tokens.get(sandboxId); - if (fileToken === undefined) { - this.sshConfigured.delete(sandboxId); - this.readyAt.delete(sandboxId); - // Due at once: the mint writes the alias again and revokes this token. - if (cached) this.tokens.set(sandboxId, { ...cached, expiresAt: new Date(0).toISOString() }); - return; - } - // Its expiry is unknown; it was minted after this process's, so that expiry is a bound. - if (cached && cached.token !== fileToken) { - this.cacheNewToken( - sandboxId, - { token: fileToken, expiresAt: cached.expiresAt }, - { owned: false } - ); - } - } - - /** - * Another backend set the sandbox up first: held as due at once, the mint about to run - * replaces and revokes it (that backend's next command is refused and mints its own). Only - * for the sandbox this process is about to use: holding another backend's tokens for others - * would revoke them, live, on a settings change here. - */ - private holdFirstSeenToken(sandboxId: string, fileToken: string | undefined): void { - if (fileToken === undefined || this.tokens.has(sandboxId)) return; - this.cacheNewToken( - sandboxId, - { token: fileToken, expiresAt: new Date(0).toISOString() }, - { owned: false } - ); } getPublicConfig(): Promise { @@ -601,8 +512,6 @@ export class DaytonaService { const previousClient = await this.getClientIfConfigured(); await this.credentialsStore.update(patch); this.credentialsSnapshot = await this.readSavedCredentials(); - this.accountFingerprint = - this.credentialsSnapshot && fingerprintAccount(this.credentialsSnapshot.credentials); // Revoke the old account's tokens with the old credentials, which may not work for the // new ones. const tokens = await this.retireAccountCaches(); @@ -631,11 +540,10 @@ export class DaytonaService { this.credentialsGeneration++; this.provenOwned.clear(); const tokens = [...this.tokens]; - const owned = this.ownedSshTokens(); + const owned = tokens.map(([sandboxId, access]) => ({ sandboxId, token: access.token })); const connected = this.sandboxesWithSsh(); this.gatewayCache = undefined; this.tokens.clear(); - this.ownedTokens.clear(); this.sshConfigured.clear(); this.readyAt.clear(); this.staleAliases.push(...owned); @@ -683,13 +591,6 @@ export class DaytonaService { this.staleAliases = this.staleAliases.filter((alias) => alias.sandboxId !== sandboxId); } - /** The include blocks this process may change besides the one it writes (see ownedTokens). */ - private ownedSshTokens(): SandboxToken[] { - return [...this.tokens] - .filter(([, access]) => this.ownedTokens.has(access.token)) - .map(([sandboxId, access]) => ({ sandboxId, token: access.token })); - } - private async readSavedCredentials(): Promise< { credentials: DaytonaResolvedCredentials | null } | undefined > { @@ -701,21 +602,6 @@ export class DaytonaService { } } - /** - * Another Xum process saved credentials of another account: drop this process's caches of - * the old one. Its tokens are not revoked: they expire, and the old key may be gone. - */ - private async noticeAccount(credentials: DaytonaResolvedCredentials | null): Promise { - // This process's own save sets the fingerprint and retires (and revokes) itself. - if (this.settingsChange) return; - const fingerprint = fingerprintAccount(credentials); - const previous = this.accountFingerprint; - this.accountFingerprint = fingerprint; - if (previous === undefined || previous === fingerprint) return; - log.info("Daytona settings changed in another Xum process; dropping cached SSH access"); - await this.retireAccountCaches(); - } - private async getClientIfConfigured(): Promise { try { return (await this.getClient()).client; @@ -942,7 +828,6 @@ export class DaytonaService { const heldNow = pinned(); if (heldNow) return heldNow.credentials; this.credentialsSnapshot = { credentials }; - await this.noticeAccount(credentials); return credentials; } catch (error) { if (error instanceof DaytonaConfigError) { @@ -1063,8 +948,7 @@ export class DaytonaService { /** * The pins come from the `/config` just fetched, never from a key a server offered. Only here, - * after a fresh fetch: a readiness check reads a cached `/config`, and another backend on this - * root may already have pinned the gateway's rotated keys. + * after a fresh fetch: a readiness check reads a cached `/config`. */ private async restoreGatewayPins(config: DaytonaGatewayConfig): Promise { const restored = await this.sshConfigWriter.restoreGatewayPins(toSshGateway(config)); @@ -1095,7 +979,7 @@ export class DaytonaService { this.tokens.set(sandboxId, { ...access, expiresAt: new Date(0).toISOString() }); } try { - await this.sshConfigWriter.repointGateway(toSshGateway(config), this.ownedSshTokens()); + await this.sshConfigWriter.repointGateway(toSshGateway(config)); } catch (error) { // Each sandbox's next ssh access rewrites its setup anyway. log.warn("Could not repoint the Daytona ssh setup at the moved gateway", { @@ -1497,7 +1381,6 @@ export class DaytonaService { */ ensureSshAccess(sandboxId: string): Promise { return this.withAccountLease(async () => { - await this.revalidateSshAccess(sandboxId); // Fails before a mint when the control plane cannot say where the gateway is. await this.getGatewayConfig(); // A cached token was written by the mint that produced it, so only a mint writes files. @@ -1509,12 +1392,7 @@ export class DaytonaService { if (this.sshConfigured.has(sandboxId)) { await this.sshConfigWriter.updateToken({ sandboxId, token, gateway }); } else { - await this.sshConfigWriter.writeSandbox({ - sandboxId, - token, - gateway, - owned: this.ownedSshTokens(), - }); + await this.sshConfigWriter.writeSandbox({ sandboxId, token, gateway }); } // The new account's block replaced the old token: this sandbox is no longer blocked // on a removal that may keep failing. @@ -1535,14 +1413,7 @@ export class DaytonaService { return this.tokenGenerations.get(sandboxId) ?? 0; } - private cacheNewToken( - sandboxId: string, - access: DaytonaSshAccess, - options: { owned: boolean } - ): void { - const replaced = this.tokens.get(sandboxId)?.token; - if (replaced !== undefined) this.ownedTokens.delete(replaced); - if (options.owned) this.ownedTokens.add(access.token); + private cacheNewToken(sandboxId: string, access: DaytonaSshAccess): void { this.tokens.set(sandboxId, access); this.tokenGenerations.set(sandboxId, ++this.lastTokenGeneration); } @@ -1619,8 +1490,6 @@ export class DaytonaService { sandboxId: string, options: DaytonaSshTokenOptions = {} ): Promise { - // Read first: another Xum process may have saved another account's credentials. - await this.resolveCredentials(); const cached = this.tokens.get(sandboxId); if (cached && !this.tokenNeedsRefresh(sandboxId)) return cached; const inFlight = this.tokenRequests.get(sandboxId); @@ -1656,7 +1525,7 @@ export class DaytonaService { } const outdatedAfterWrite = this.settleOutdatedMint(mint, true); if (outdatedAfterWrite) return outdatedAfterWrite; - this.cacheNewToken(sandboxId, access, { owned: true }); + this.cacheNewToken(sandboxId, access); if (previous && previous.token !== access.token) { await this.revokeQuietly(client, sandboxId, previous.token); } @@ -1701,10 +1570,7 @@ export class DaytonaService { wroteIncludeFile: boolean ): Promise { await this.revokeQuietly(client, sandboxId, access.token); - // Token-qualified: another backend may have written the alias since. - if (wroteIncludeFile) { - await this.sshConfigWriter.removeSandboxTokens([{ sandboxId, token: access.token }]); - } + if (wroteIncludeFile) await this.sshConfigWriter.removeSandbox(sandboxId); throw new Error( `Xum released its SSH access to Daytona sandbox "${sandboxId}" while a token was minted` ); @@ -1715,7 +1581,6 @@ export class DaytonaService { const cached = this.tokens.get(sandboxId); this.tokens.delete(sandboxId); if (!cached) return; - this.ownedTokens.delete(cached.token); const { client } = await this.getClient(); await this.revokeQuietly(client, sandboxId, cached.token); } From b881e1613afbe2cb7a51a33bc57e47fbf7bec8a7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:08:21 +0000 Subject: [PATCH 182/231] refactor(daytona): make a settings save only revoke tokens and clear caches A save is still refused while Daytona operations run. It now revokes the tokens this process minted and clears its caches. Drop the credentials snapshot and idle refresh, the stale-alias list and its ssh refusal, the pooled-connection retirement on save, the account a create pinned for its cleanup, and the mint retry across a settings change. Connections already open may reach the old account until they end. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 83 ----- src/node/runtime/DaytonaSSHRuntime.ts | 35 +-- src/node/runtime/daytonaSshConfig.ts | 16 - .../services/daytona/daytona.testHarness.ts | 8 - .../services/daytona/daytonaService.test.ts | 158 ---------- src/node/services/daytona/daytonaService.ts | 296 +++--------------- 6 files changed, 42 insertions(+), 554 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 025db255c6e..ad5a3d7ae07 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -810,21 +810,6 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { expect((await rig.service.setConfig({ apiKey: "new-key" })).apiKeySource).toBe("settings"); }); - it("deletes its sandbox in the account that created it if the settings still change", async () => { - // Defense behind the refusal above: setup fails, and cleans up where it created. - spyOn(rig.service, "isAccountCurrent").mockReturnValue(false); - - const failure = await makeRuntime({ sandboxId: "xum-moved", shared: false, ...SMALL }) - .postCreateSetup(initParams()) - .then( - () => undefined, - (error: unknown) => error - ); - - expect(String(failure)).toContain("Daytona settings changed"); - expect(rig.plane.find("xum-moved")).toBeUndefined(); - }); - it("never deletes a sandbox that took its name before the create request", async () => { // Another Xum install created this name between validateBeforePersist and the create. const other = rig.plane.addSandbox({ @@ -1663,74 +1648,6 @@ describe("DaytonaSSHRuntime ssh token redaction", () => { expect(String(error)).not.toContain(stale); expect(String(error)).toContain(`${DAYTONA_TOKEN_PLACEHOLDER}@127.0.0.1: Permission denied`); }); - - // A settings change must not leave an alias behind that a UI runtime's next ssh (which never - // runs ensureReady) would use to reach the old account's sandbox with a still-live token. - it("refuses ssh to an old account's alias until the settings change removed it", async () => { - execSpy.mockRestore(); - rig.plane.addSandbox({ id: "sb-old" }); - rig.addAccount("new-key"); - await rig.service.ensureSshAccess("sb-old"); - const oldToken = rig.writer.tokens.get("sb-old"); - const unwritable = () => new Error("EACCES: permission denied, open 'daytona.conf'"); - const removeTokens = spyOn(rig.writer, "removeSandboxTokens") - .mockImplementationOnce(() => Promise.reject(unwritable())) - .mockImplementationOnce(() => Promise.reject(unwritable())); - await rig.service.setConfig({ apiKey: "new-key" }); - expect(rig.writer.tokens.get("sb-old")).toBe(oldToken); - let spawned = 0; - const local = localShellTransport(rig.rootDir); - const runtime = makeRuntime({ sandboxId: "sb-old" }, rig, { - transport: { - ...local, - spawnRemoteProcess: (command, options) => { - spawned++; - return local.spawnRemoteProcess(command, options); - }, - }, - }); - - const refused = await runtime.exec("true", { cwd: "/tmp", timeout: 10 }).then( - () => new Error("exec unexpectedly succeeded"), - (rejection: unknown) => rejection - ); - expect(String(refused)).toContain("old Daytona account"); - expect(String(refused)).not.toContain(oldToken ?? "missing"); - expect(spawned).toBe(0); - expect(removeTokens).toHaveBeenCalledTimes(2); - - // Writable again: the next attempt removes the alias first, then goes on. - const result = await runtimeHelpers.execBuffered(runtime, "true", { cwd: "/tmp", timeout: 10 }); - expect(result.exitCode).toBe(0); - expect(removeTokens).toHaveBeenCalledTimes(3); - expect(rig.writer.tokens.has("sb-old")).toBe(false); - expect(spawned).toBe(1); - }); - - // Writing the sandbox's block for the new account replaces the old token, which is all the - // removal was for: a removal that keeps failing does not block that sandbox for good. - it("lets the new account set a sandbox up again while the old alias cleanup fails", async () => { - execSpy.mockRestore(); - rig.plane.addSandbox({ id: "sb-both" }); - const next = rig.addAccount("new-key"); - next.tokenPrefix = "new-tok"; - next.addSandbox({ id: "sb-both" }); - await rig.service.ensureSshAccess("sb-both"); - const oldToken = rig.writer.tokens.get("sb-both"); - spyOn(rig.writer, "removeSandboxTokens").mockImplementation(() => - Promise.reject(new Error("EACCES: permission denied, open 'daytona.conf'")) - ); - await rig.service.setConfig({ apiKey: "new-key" }); - - await rig.service.ensureSshAccess("sb-both"); - - expect(rig.writer.tokens.get("sb-both")).not.toBe(oldToken); - const runtime = makeRuntime({ sandboxId: "sb-both" }, rig, { - transport: localShellTransport(rig.rootDir), - }); - const result = await runtimeHelpers.execBuffered(runtime, "true", { cwd: "/tmp", timeout: 10 }); - expect(result.exitCode).toBe(0); - }); }); describe("DaytonaSSHRuntime reconnect after a stop", () => { diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 784c3fd0f23..0c4e37e04de 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -52,7 +52,6 @@ import { notOwnedSandboxNotice, unsupportedDaytonaSandboxKind, type UnsupportedDaytonaSandboxKind, - type DaytonaAccount, type DaytonaService, } from "@/node/services/daytona/daytonaService"; import { applyArchiveSettingToIdleSandbox } from "@/node/services/daytona/daytonaLifecycleHooks"; @@ -73,8 +72,6 @@ export type DaytonaRuntimeService = Pick< | "checkAvailability" | "getSandboxState" | "withAccountLease" - | "getAccount" - | "isAccountCurrent" | "buildLabels" | "getDefaultTarget" | "createSandbox" @@ -92,7 +89,6 @@ export type DaytonaRuntimeService = Pick< | "gatewayGeneration" | "refreshGatewayAfterSshFailure" | "loadPersistedTokens" - | "requireCurrentSshAlias" | "findSnapshot" | "getGatewayConfig" | "sandboxReferencesElsewhere" @@ -130,8 +126,6 @@ const DAYTONA_AUTO_ARCHIVE_MINUTES = 1440; const DAYTONA_AUTO_DELETE_DISABLED = -1; const SANDBOX_NAME_MAX_BRANCH_LENGTH = 40; -const SETTINGS_CHANGED_DURING_SETUP = - "Daytona settings changed while the sandbox was set up; create the workspace again"; const UNSUPPORTED_SANDBOXES: Record = { windows: { @@ -188,8 +182,6 @@ interface CreateAttempt { outcome: CreateOutcome; /** The labels the last create request carried. */ labels?: Record; - /** The account the create requests went to: cleanup deletes there, whatever Settings say. */ - account?: DaytonaAccount; } /** @@ -523,7 +515,6 @@ export class DaytonaSSHRuntime extends SSHRuntime { /** What a recovery from this command's failure must compare against (see SshAttempt). */ private async prepareSshAttempt(sandboxId: string | undefined): Promise { if (!sandboxId) return { tokenGeneration: 0, gatewayGeneration: 0 }; - await this.daytonaService.requireCurrentSshAlias(sandboxId); // ssh may send a token an earlier process left in the include file: the redactor must // know it before ssh can quote it. Not every command runs after ensureReady. await this.daytonaService.loadPersistedTokens(); @@ -967,19 +958,13 @@ export class DaytonaSSHRuntime extends SSHRuntime { try { if (creates) { params.initLogger.logStep(`Creating Daytona sandbox "${sandboxId}"...`); - create.account = await this.daytonaService.getAccount(); await this.createSandbox(sandboxId, params, create); - this.throwIfSettingsChanged(create); params.initLogger.logStep("Daytona sandbox created"); } await this.connectAndSeed(sandboxId, creates, params); await this.prepareWorkspaceDirectory(params); - this.throwIfSettingsChanged(create); } catch (error) { - // After a switch of account the step's own error (often "not found") would mislead. - const errorMsg = this.settingsChanged(create) - ? SETTINGS_CHANGED_DURING_SETUP - : redactDaytonaTokens(getErrorMessage(error)); + const errorMsg = redactDaytonaTokens(getErrorMessage(error)); log.error("Daytona sandbox setup failed", { sandboxId, error: errorMsg }); params.initLogger.logStderr(`Daytona sandbox setup failed: ${errorMsg}`); // This workspace's later turns report this reason instead of "deleted outside Xum", and @@ -992,18 +977,6 @@ export class DaytonaSSHRuntime extends SSHRuntime { } } - /** - * Readiness, ssh and the checkout use the current settings, so a setup that spans a change - * would go on in another account (or deployment) than its sandbox's: it fails instead. - */ - private settingsChanged(create: CreateAttempt): boolean { - return create.account !== undefined && !this.daytonaService.isAccountCurrent(create.account); - } - - private throwIfSettingsChanged(create: CreateAttempt): void { - if (this.settingsChanged(create)) throw new Error(SETTINGS_CHANGED_DURING_SETUP); - } - /** Wait until the sandbox runs, write ssh access, and seed the home of a box Xum created. */ private async connectAndSeed( sandboxId: string, @@ -1052,7 +1025,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { return; } initLogger.logStep(`Deleting the sandbox "${sandboxId}" this setup created...`); - if ((await this.daytonaService.deleteSandbox(sandboxId, create.account)) === "notOwned") { + if ((await this.daytonaService.deleteSandbox(sandboxId)) === "notOwned") { log.warn("Not deleting a Daytona sandbox without this install's label", { sandboxId }); initLogger.logStderr(notOwnedSandboxNotice(sandboxId)); } @@ -1074,7 +1047,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { /** After a create of unknown outcome: the sandbox has this install's and workspace's labels. */ private async carriesLabels(sandboxId: string, create: CreateAttempt): Promise { const { labels } = create; - const { sandbox } = await this.daytonaService.getSandboxState(sandboxId, create.account); + const { sandbox } = await this.daytonaService.getSandboxState(sandboxId); if (!sandbox || !labels) return false; return [DAYTONA_LABEL_INSTANCE, DAYTONA_LABEL_WORKSPACE_ID].every( (key) => labels[key] !== undefined && sandbox.labels?.[key] === labels[key] @@ -1088,7 +1061,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { ): Promise { create.outcome = "unknown"; create.labels = request.labels; - await this.daytonaService.createSandbox(request, create.account); + await this.daytonaService.createSandbox(request); create.outcome = "created"; } diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 9e307e7b594..e5b0d06fea6 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -394,22 +394,6 @@ export class DaytonaSshConfigWriter { }); } - /** - * Remove each alias whose block still holds the given token. Another backend that rewrote - * the alias (a new token, maybe of another account) keeps its block. - */ - async removeSandboxTokens(held: readonly SandboxToken[]) { - if (held.length === 0) return; - const stale = new Map(held.map(({ sandboxId, token }) => [toXumDaytonaHost(sandboxId), token])); - await this.withIncludeFileLock(async () => { - const blocks = await this.readBlocks(); - const removed = tokensIn(blocks) - .map(({ sandboxId, token }) => ({ alias: toXumDaytonaHost(sandboxId), token })) - .filter(({ alias, token }) => stale.get(alias) === token && blocks.delete(alias)); - if (removed.length > 0) await this.writeBlocks(blocks); - }); - } - private async readKnownHosts(): Promise { try { return await fs.readFile(this.paths.knownHostsPath, "utf-8"); diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index d95a8de818f..09928b52c80 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -283,7 +283,6 @@ export class FakeDaytonaSshConfigWriter implements Pick< | "updateToken" | "repointGateway" | "removeSandbox" - | "removeSandboxTokens" | "readTokens" | "restoreGatewayPins" > { @@ -355,13 +354,6 @@ export class FakeDaytonaSshConfigWriter implements Pick< return Promise.resolve(); } - removeSandboxTokens(held: ReadonlyArray<{ sandboxId: string; token: string }>): Promise { - for (const { sandboxId, token } of held) { - if (this.tokens.get(sandboxId) === token) this.tokens.delete(sandboxId); - } - return Promise.resolve(); - } - readTokens(): Promise> { return Promise.resolve([...this.tokens].map(([sandboxId, token]) => ({ sandboxId, token }))); } diff --git a/src/node/services/daytona/daytonaService.test.ts b/src/node/services/daytona/daytonaService.test.ts index 5d795152a17..7db86d04e42 100644 --- a/src/node/services/daytona/daytonaService.test.ts +++ b/src/node/services/daytona/daytonaService.test.ts @@ -7,7 +7,6 @@ import { XUM_DAYTONA_SSH_BLOCK_START } from "@/common/constants/daytona"; import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; import { log } from "@/node/services/log"; import { DaytonaApiError } from "./daytonaClient"; -import { FakeDaytonaControlPlane } from "./daytona.testHarness"; import { DAYTONA_BUSY_MESSAGE, DAYTONA_LABEL_INSTANCE, @@ -214,71 +213,6 @@ describe("DaytonaService", () => { } }); - it("fails a lease whose settings another process made unusable while idle", async () => { - plane.states = ["started"]; - await service.getSandboxState("sb-1"); - const requests = plane.calls.length; - - // A hand edit (or another backend) saves an API URL that must not get the key. - await fs.writeFile( - path.join(rootDir, "daytona.json"), - JSON.stringify({ apiKey: "k", apiUrl: "http://remote.example.com/api" }) - ); - const failure = await service - .withAccountLease(() => service.getSandboxState("sb-1")) - .then( - () => undefined, - (error: unknown) => error - ); - - expect(failure).toBeInstanceOf(DaytonaApiError); - expect((failure as DaytonaApiError).kind).toBe("not_configured"); - expect(plane.calls.length).toBe(requests); - }); - - it("leaves a resolve during its own settings save to that save", async () => { - const revokeKeys: string[] = []; - const store = new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }); - service = new DaytonaService({ - credentialsStore: store, - rootDir, - fetch: ((input: string | URL | Request, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - if (init?.method === "DELETE" && url.includes("/ssh-access")) { - revokeKeys.push((init.headers as Record).Authorization); - } - return plane.fetch(input, init); - }) as typeof fetch, - now: () => clock, - }); - const held = await service.getSshToken("sb-1"); - const before = (await service.getAccount()).generation; - // Hold the save after the new key is on disk, before setConfig goes on. - let releaseSave = () => undefined as void; - const saveGate = new Promise((resolve) => (releaseSave = resolve)); - let saved = () => undefined as void; - const savedOnDisk = new Promise((resolve) => (saved = resolve)); - const realUpdate = store.update.bind(store); - spyOn(store, "update").mockImplementation(async (patch) => { - const result = await realUpdate(patch); - saved(); - await saveGate; - return result; - }); - - const change = service.setConfig({ apiKey: "new-key" }); - await savedOnDisk; - const token = service.getSshToken("sb-1"); - await service.checkAvailability(); - releaseSave(); - await change; - await token; - - expect(plane.revoked).toEqual([held.token]); - expect(revokeKeys).toEqual(["Bearer k"]); - expect((await service.getAccount()).generation).toBe(before + 1); - }); - /** A service whose first token mint waits until `releaseMint`; `includeFile` is its writer. */ function serviceWithHeldMint() { let mintStarted!: () => void; @@ -317,7 +251,6 @@ describe("DaytonaService", () => { includeFile.delete(sandboxId); return Promise.resolve(); }, - removeSandboxTokens: () => Promise.resolve(), readTokens: () => Promise.resolve([]), }, @@ -383,7 +316,6 @@ describe("DaytonaService", () => { order.push(`repoint ${gateway.host} ${gateway.hostKeys.join(",")}`); return Promise.resolve(); }, - removeSandboxTokens: () => Promise.resolve(), readTokens: () => Promise.resolve([...includeFile].map(([sandboxId, token]) => ({ sandboxId, token }))), @@ -509,15 +441,6 @@ describe("DaytonaService", () => { expect(staleAtRetire).toEqual([true]); }); - it("retires the pooled ssh connections of its sandboxes on a settings change", async () => { - const { service: changing, retired } = serviceRecordingWrites(); - await changing.ensureSshAccess("sb-1"); - - await changing.setConfig({ target: "eu" }); - - expect(retired).toEqual(["sb-1"]); - }); - it("retires a released sandbox's ssh connections once its include block is gone", async () => { const { service: releasing, order } = serviceRecordingWrites(); await releasing.ensureSshAccess("sb-1"); @@ -558,7 +481,6 @@ describe("DaytonaService", () => { restoreGatewayPins: () => Promise.resolve(false), updateToken: store, removeSandbox: () => Promise.resolve(), - removeSandboxTokens: () => Promise.resolve(), readTokens: () => Promise.resolve([...includeFile].map(([sandboxId, token]) => ({ sandboxId, token }))), @@ -650,84 +572,4 @@ describe("DaytonaService", () => { expect(labels[DAYTONA_LABEL_INSTANCE]).toBe(again[DAYTONA_LABEL_INSTANCE]); expect(labels[DAYTONA_LABEL_PROJECT]).toBe("my-project"); }); - - /** - * Backends on one Xum root (a desktop app next to `xum server`) with two accounts: key-a on - * Daytona's default deployment, key-b on its own, with its own ssh gateway. They share the - * saved settings and the real include file under a temp home. - */ - function accountsOnOneRoot() { - const planes = new Map([ - ["Bearer key-a", new FakeDaytonaControlPlane(() => clock)], - ["Bearer key-b", new FakeDaytonaControlPlane(() => clock)], - ]); - for (const [auth, accountPlane] of planes) { - accountPlane.tokenPrefix = `${auth.slice(-1)}-tok`; - for (const id of ["sb-1", "sb-2", "sb-3", "sb-4"]) { - accountPlane.addSandbox({ id, state: "started" }); - } - } - const planeA = planes.get("Bearer key-a")!; - const planeB = planes.get("Bearer key-b")!; - planeB.gateway = { - host: "ssh.b.daytona.test", - port: 22, - hostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAccountBKey"], - }; - const sshConfigPath = path.join(rootDir, "home", ".ssh", "config"); - const writer = () => new DaytonaSshConfigWriter({ xumHome: rootDir, sshConfigPath }); - const backend = ( - options: { retireSshConnections?: (sandboxId: string) => Promise } = {} - ) => - new DaytonaService({ - credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "key-a" }), - rootDir, - sshConfigWriter: writer(), - fetch: ((input: string | URL | Request, init?: RequestInit) => { - const url = new URL(input instanceof Request ? input.url : input.toString()); - const auth = (init?.headers as Record | undefined)?.Authorization; - // GET /config is public: the URL names the deployment. - const deployment = url.hostname === "b.daytona.test" ? planeB : planeA; - return (auth === undefined ? deployment : planes.get(auth)!).fetch(input, init); - }) as typeof fetch, - now: () => clock, - ...options, - }); - const switchToB = { apiKey: "key-b", apiUrl: "https://b.daytona.test/api" }; - /** Token and gateway host per alias in the include file. */ - const blocks = async () => { - const include = await fs.readFile(writer().paths.includePath, "utf8").catch(() => ""); - return Object.fromEntries( - [...include.matchAll(/^Host (\S+)\.xum--daytona\n((?: {2}.*\n?)*)/gm)].map( - ([, sandboxId, body]) => [ - sandboxId, - `${/User (\S+)/.exec(body)?.[1] ?? "?"}@${/HostName (\S+)/.exec(body)?.[1] ?? "?"}`, - ] - ) - ); - }; - return { planeA, planeB, backend, writer, switchToB, blocks }; - } - - // The old key may already be invalid: its tokens stay live until they expire, so the aliases - // that carry them must not outlive the connections the switch retires. - it("drops the old account's aliases before retiring connections even when revoking fails", async () => { - const rig = accountsOnOneRoot(); - const aliasesAtRetire: string[][] = []; - const service = rig.backend({ - retireSshConnections: async () => { - aliasesAtRetire.push(Object.keys(await rig.blocks())); - }, - }); - await service.ensureSshAccess("sb-1"); - await service.ensureSshAccess("sb-2"); - rig.planeA.failingRoutes.add("DELETE /sandbox/sb-1/ssh-access"); - rig.planeA.failingRoutes.add("DELETE /sandbox/sb-2/ssh-access"); - - await service.setConfig(rig.switchToB); - - expect(rig.planeA.revokedTokens).toEqual([]); - expect(aliasesAtRetire).toEqual([[], []]); - expect(await rig.blocks()).toEqual({}); - }); }); diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 961e4eb139d..457e2faf2be 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -17,11 +17,7 @@ import { type DaytonaResolvedCredentials, } from "@/node/config/daytonaCredentialsStore"; import { loadOrCreateInstallationId } from "@/node/config/installationIdentity"; -import { - DaytonaSshConfigWriter, - getDaytonaSshPaths, - type SandboxToken, -} from "@/node/runtime/daytonaSshConfig"; +import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; import { retireSshConnections } from "@/node/runtime/transports"; import { rememberDaytonaToken } from "@/node/runtime/daytonaTokenRedaction"; import { @@ -174,15 +170,6 @@ function sandboxStateError(sandboxId: string, sandbox: DaytonaSandbox): DaytonaA ); } -/** - * The client of the settings at one moment, stamped with their generation: work that must stay - * in one Daytona account (a create and its cleanup) holds it across a settings change. - */ -export interface DaytonaAccount { - client: DaytonaClient; - generation: number; -} - export const DAYTONA_BUSY_MESSAGE = "Daytona is busy creating, starting, deleting or checking sandboxes. Save these settings " + "again when it is done."; @@ -192,19 +179,6 @@ async function settled(promise: Promise): Promise { await Promise.allSettled([promise]); } -interface MintStamp { - generation: number; - release: number; -} - -interface OutdatedMintInput { - client: DaytonaClient; - sandboxId: string; - access: DaytonaSshAccess; - stamp: MintStamp; - options: DaytonaSshTokenOptions; -} - function startTimeoutError(sandboxId: string, sandbox: DaytonaSandbox): DaytonaApiError { return new DaytonaApiError( "timeout", @@ -243,7 +217,6 @@ export interface DaytonaServiceOptions { | "updateToken" | "repointGateway" | "removeSandbox" - | "removeSandboxTokens" | "readTokens" | "restoreGatewayPins" >; @@ -345,13 +318,11 @@ export class DaytonaService { private gatewayMoves = 0; private gatewayCache?: { apiUrl: string; - generation: number; fetchedAt: number; config: DaytonaGatewayConfig; }; private gatewayRequest?: { apiUrl: string; - generation: number; promise: Promise; }; private readonly tokens = new Map(); @@ -359,18 +330,8 @@ export class DaytonaService { private readonly tokenGenerations = new Map(); private lastTokenGeneration = 0; private readonly tokenRequests = new Map>(); - /** Bumped by every settings change, so a mint that spans one can tell. */ - private credentialsGeneration = 0; - /** - * The credentials last resolved. While an account lease or this process's settings save - * runs, every resolve returns these, so an operation never mixes two accounts when another - * process saves new ones mid-way; the first resolve once idle reads the store again. - */ - private credentialsSnapshot?: { credentials: DaytonaResolvedCredentials | null }; /** Operations running under withAccountLease; setConfig refuses while any runs. */ private accountLeases = 0; - /** The settings read a lease claimed while idle runs first. */ - private idleRefresh?: Promise; /** The settings change being saved; new leases wait for it. */ private settingsChange?: Promise; /** Per sandbox: bumped by every release of its SSH access, so a mint that spans one can tell. */ @@ -382,13 +343,6 @@ export class DaytonaService { private readonly readyAt = new Map(); /** Whether the include file was read once, seeding the tokens an earlier process left. */ private persistedTokensLoaded = false; - /** - * Old-account blocks an account retirement could not remove (an unwritable include file). - * In memory only: until each is removed, or rewritten for the new account, ssh to its - * sandbox is refused (requireCurrentSshAlias), as it would reach the old account's sandbox - * with a token that stays live until it expires. - */ - private staleAliases: SandboxToken[] = []; private readonly readyRequests = new Map(); /** * When this process last proved a sandbox carries this install's label (never one that does @@ -473,19 +427,8 @@ export class DaytonaService { * settings change; one that starts while a change is saved waits for it. Leases nest. */ async withAccountLease(fn: () => Promise): Promise { - for (;;) { - while (this.settingsChange) await settled(this.settingsChange); - // A held lease pins the account; the outer one must not pin a stale snapshot, so it - // first takes what another Xum process may have saved since this one last read. - if (this.accountLeases > 0) break; - // Shared, so concurrent claimers resume (and start their operations) in call order. - this.idleRefresh ??= this.refreshIdleCredentials().finally(() => { - this.idleRefresh = undefined; - }); - await this.idleRefresh; - // No await between this check and the claim (as in setConfig). - if (!this.settingsChange) break; - } + // No await between this check and the claim (as in setConfig). + while (this.settingsChange) await settled(this.settingsChange); this.accountLeases++; try { return await fn(); @@ -508,13 +451,20 @@ export class DaytonaService { } } + /** + * Tokens and gateway data belong to the old account or deployment: revoke the tokens with the + * old credentials (which may not work for the new ones) and drop the caches. Connections + * already open may reach the old account until they end. + */ private async applyConfig(patch: DaytonaCredentialsPatch): Promise { const previousClient = await this.getClientIfConfigured(); await this.credentialsStore.update(patch); - this.credentialsSnapshot = await this.readSavedCredentials(); - // Revoke the old account's tokens with the old credentials, which may not work for the - // new ones. - const tokens = await this.retireAccountCaches(); + const tokens = [...this.tokens]; + this.tokens.clear(); + this.sshConfigured.clear(); + this.readyAt.clear(); + this.provenOwned.clear(); + this.gatewayCache = undefined; if (previousClient) { await Promise.all( tokens.map(([sandboxId, access]) => @@ -525,83 +475,6 @@ export class DaytonaService { return this.credentialsStore.getPublicConfig(); } - /** - * Tokens and gateway data belong to the old account or deployment. A mint in flight - * finishes under the old settings: it sees the bump and starts over. Returns the tokens - * dropped, for the caller to revoke. - * - * The old account's aliases leave the shared include file before the connections are retired - * (as releaseSshAccess does), whether or not the caller then manages to revoke the tokens: a - * fresh ssh from an alias would otherwise keep reaching the old sandbox until its token - * expires. Only blocks still holding a token this process owns go: another backend may have - * rewritten an alias, maybe for the new account. - */ - private async retireAccountCaches(): Promise> { - this.credentialsGeneration++; - this.provenOwned.clear(); - const tokens = [...this.tokens]; - const owned = tokens.map(([sandboxId, access]) => ({ sandboxId, token: access.token })); - const connected = this.sandboxesWithSsh(); - this.gatewayCache = undefined; - this.tokens.clear(); - this.sshConfigured.clear(); - this.readyAt.clear(); - this.staleAliases.push(...owned); - await this.removeStaleAliases(); - await this.retirePooledSsh(connected); - return tokens; - } - - /** Remove the stale aliases (see staleAliases); a failure keeps them for the next attempt. */ - private async removeStaleAliases(): Promise { - const removing = this.staleAliases; - if (removing.length === 0) return; - try { - await this.sshConfigWriter.removeSandboxTokens(removing); - } catch (error) { - log.warn("Could not remove the old Daytona account's ssh aliases", { - error: getErrorMessage(error), - }); - return; - } - // A retirement during the removal may have added more. - this.staleAliases = this.staleAliases.filter((alias) => !removing.includes(alias)); - } - - /** - * Run before every ssh to the sandbox: refuses while its include block may still hold an old - * account's token (see staleAliases), retrying the removal first. Nothing to do, and no - * I/O, in the common case. - */ - async requireCurrentSshAlias(sandboxId: string): Promise { - if (this.staleAliases.length === 0) return; - await this.removeStaleAliases(); - if (this.staleAliases.some((alias) => alias.sandboxId === sandboxId)) { - throw new Error( - `Xum could not remove the old Daytona account's ssh alias for sandbox "${sandboxId}" ` + - `after a settings change, so it will not ssh to it until it can. ` + - `Check that ${getDaytonaSshPaths(this.rootDir).includePath} is writable.` - ); - } - } - - /** The sandbox's block now holds a token of the current account. */ - private replacedStaleAlias(sandboxId: string): void { - if (this.staleAliases.length === 0) return; - this.staleAliases = this.staleAliases.filter((alias) => alias.sandboxId !== sandboxId); - } - - private async readSavedCredentials(): Promise< - { credentials: DaytonaResolvedCredentials | null } | undefined - > { - try { - return { credentials: await this.credentialsStore.resolve() }; - } catch (error) { - log.debug("Could not read the saved Daytona settings", { error: getErrorMessage(error) }); - return undefined; - } - } - private async getClientIfConfigured(): Promise { try { return (await this.getClient()).client; @@ -802,33 +675,10 @@ export class DaytonaService { return { available: true }; } - /** The idle read before a lease; unusable settings fail the lease's own first call. */ - private async refreshIdleCredentials(): Promise { - try { - await this.resolveCredentials(); - } catch (error) { - log.debug("Could not read the Daytona settings before an operation", { - error: getErrorMessage(error), - }); - // The lease must not pin the account this process used before: unusable settings must - // make Daytona unusable, so its first resolve reads them again and fails. - this.credentialsSnapshot = undefined; - } - } - /** Unusable settings (an http DAYTONA_API_URL) throw a `not_configured` DaytonaApiError. */ private async resolveCredentials(): Promise { - const pinned = () => - (this.accountLeases > 0 || this.settingsChange !== undefined) && this.credentialsSnapshot; - const held = pinned(); - if (held) return held.credentials; try { - const credentials = await this.credentialsStore.resolve(); - // A lease or save that started during the read keeps the account it started with. - const heldNow = pinned(); - if (heldNow) return heldNow.credentials; - this.credentialsSnapshot = { credentials }; - return credentials; + return await this.credentialsStore.resolve(); } catch (error) { if (error instanceof DaytonaConfigError) { throw new DaytonaApiError("not_configured", error.message); @@ -856,31 +706,14 @@ export class DaytonaService { }; } - /** Stamped before the lookup, so a change during it reads as a change. */ - async getAccount(): Promise { - const generation = this.credentialsGeneration; - const { client } = await this.getClient(); - return { client, generation }; - } - - /** Whether the settings changed since `account` was taken. */ - isAccountCurrent(account: DaytonaAccount): boolean { - return account.generation === this.credentialsGeneration; - } - - private async clientOf(account: DaytonaAccount | undefined): Promise { - return account?.client ?? (await this.getClient()).client; - } - /** `force` refetches a cached `/config` that is still fresh (an in-flight fetch is shared). */ async getGatewayConfig(options: { force?: boolean } = {}): Promise { - const generation = this.credentialsGeneration; const { client, credentials } = await this.getClient(); - const cached = this.cachedGateway(credentials.apiUrl, generation); + const cached = this.cachedGateway(credentials.apiUrl); if (cached && !options.force && this.now() - cached.fetchedAt < DAYTONA_GATEWAY_CONFIG_TTL_MS) { return cached.config; } - return this.sharedGatewayRefresh(client, credentials.apiUrl, generation, cached); + return this.sharedGatewayRefresh(client, credentials.apiUrl, cached); } /** @@ -890,31 +723,25 @@ export class DaytonaService { private sharedGatewayRefresh( client: DaytonaClient, apiUrl: string, - generation: number, cached: ReturnType ): Promise { const inFlight = this.gatewayRequest; - if (inFlight?.apiUrl === apiUrl && inFlight.generation === generation) { - return inFlight.promise; - } - const promise = this.refreshGateway(client, apiUrl, generation, cached).finally(() => { + if (inFlight?.apiUrl === apiUrl) return inFlight.promise; + const promise = this.refreshGateway(client, apiUrl, cached).finally(() => { if (this.gatewayRequest?.promise === promise) this.gatewayRequest = undefined; }); - this.gatewayRequest = { apiUrl, generation, promise }; + this.gatewayRequest = { apiUrl, promise }; return promise; } private async refreshGateway( client: DaytonaClient, apiUrl: string, - generation: number, cached: ReturnType ): Promise { const config = await client.getConfig(); - // Fetched across a settings change: never cache it for lookups under the new settings. - if (this.credentialsGeneration !== generation) return config; const moved = cached && gatewayIdentity(cached.config) !== gatewayIdentity(config); - this.gatewayCache = { apiUrl, generation, fetchedAt: this.now(), config }; + this.gatewayCache = { apiUrl, fetchedAt: this.now(), config }; if (moved) { await this.retireSshSetup(config); this.gatewayMoves++; @@ -957,9 +784,9 @@ export class DaytonaService { } /** The cached `/config` of these settings, fresh or not. */ - private cachedGateway(apiUrl: string, generation: number) { + private cachedGateway(apiUrl: string) { const cached = this.gatewayCache; - return cached?.apiUrl === apiUrl && cached.generation === generation ? cached : undefined; + return cached?.apiUrl === apiUrl ? cached : undefined; } /** @@ -1106,10 +933,9 @@ export class DaytonaService { } async getSandboxState( - sandboxId: string, - account?: DaytonaAccount + sandboxId: string ): Promise<{ state: DaytonaSandboxState; sandbox?: DaytonaSandbox }> { - const client = await this.clientOf(account); + const { client } = await this.getClient(); try { const sandbox = await client.getSandbox(sandboxId); return { state: mapDaytonaSandboxState(sandbox.state), sandbox }; @@ -1170,11 +996,8 @@ export class DaytonaService { return (await this.resolveCredentials())?.target; } - async createSandbox( - request: DaytonaCreateSandboxRequest, - account?: DaytonaAccount - ): Promise { - return (await this.clientOf(account)).createSandbox(request); + async createSandbox(request: DaytonaCreateSandboxRequest): Promise { + return (await this.getClient()).client.createSandbox(request); } async resizeSandbox(sandboxId: string, request: DaytonaResizeRequest): Promise { @@ -1251,11 +1074,8 @@ export class DaytonaService { * naming a sandbox someone else holds. So only a sandbox with this install's label is * deleted; "notOwned" leaves it, and its access, to the caller. */ - async deleteSandbox( - sandboxId: string, - account?: DaytonaAccount - ): Promise<"deleted" | "notOwned"> { - const client = await this.clientOf(account); + async deleteSandbox(sandboxId: string): Promise<"deleted" | "notOwned"> { + const { client } = await this.getClient(); if ((await this.ownershipWith(client, sandboxId)) === "notOwned") return "notOwned"; this.provenOwned.delete(sandboxId); // Revoke first: Daytona refuses to revoke a token of a sandbox that no longer exists. @@ -1287,13 +1107,10 @@ export class DaytonaService { } private proveOwnership(sandboxId: string): Promise<"owned" | "notOwned" | "missing"> { - const generation = this.credentialsGeneration; const request = (async () => { const ownership = await this.ownershipWith((await this.getClient()).client, sandboxId); - // A proof from before a settings change belongs to the old account. if (ownership !== "owned") this.provenOwned.delete(sandboxId); - else if (generation === this.credentialsGeneration) - this.provenOwned.set(sandboxId, this.now()); + else this.provenOwned.set(sandboxId, this.now()); return ownership; })().finally(() => this.ownershipRequests.delete(sandboxId)); this.ownershipRequests.set(sandboxId, request); @@ -1386,20 +1203,14 @@ export class DaytonaService { // A cached token was written by the mint that produced it, so only a mint writes files. await this.getSshToken(sandboxId, { onNewToken: async ({ token }) => { - // Looked up after the mint, so a settings change before it cannot pair this token with - // the old deployment's gateway; one after it makes the mint start over. const gateway = toSshGateway(await this.getGatewayConfig()); if (this.sshConfigured.has(sandboxId)) { await this.sshConfigWriter.updateToken({ sandboxId, token, gateway }); - } else { - await this.sshConfigWriter.writeSandbox({ sandboxId, token, gateway }); + return; } - // The new account's block replaced the old token: this sandbox is no longer blocked - // on a removal that may keep failing. - this.replacedStaleAlias(sandboxId); + await this.sshConfigWriter.writeSandbox({ sandboxId, token, gateway }); }, }); - await this.requireCurrentSshAlias(sandboxId); await this.sshConfigWriter.ensureManagedBlock(); this.sshConfigured.add(sandboxId); }); @@ -1509,13 +1320,14 @@ export class DaytonaService { previous: DaytonaSshAccess | undefined, options: DaytonaSshTokenOptions ): Promise { - const stamp = this.accessStamp(sandboxId); + const release = this.accessReleases.get(sandboxId) ?? 0; + const released = () => (this.accessReleases.get(sandboxId) ?? 0) !== release; const { client } = await this.getClient(); const access = await client.createSshAccess(sandboxId, DAYTONA_SSH_TOKEN_TTL_MINUTES); rememberDaytonaToken(access.token); - const mint = { client, sandboxId, access, stamp, options }; - const outdated = this.settleOutdatedMint(mint, false); - if (outdated) return outdated; + // A mint that spans a release of the sandbox's access is never cached or kept in the + // include file: the caller's access was taken away. + if (released()) return this.discardReleasedMint(client, sandboxId, access, false); try { await options.onNewToken?.(access); } catch (error) { @@ -1523,8 +1335,7 @@ export class DaytonaService { await this.revokeQuietly(client, sandboxId, access.token); throw error; } - const outdatedAfterWrite = this.settleOutdatedMint(mint, true); - if (outdatedAfterWrite) return outdatedAfterWrite; + if (released()) return this.discardReleasedMint(client, sandboxId, access, true); this.cacheNewToken(sandboxId, access); if (previous && previous.token !== access.token) { await this.revokeQuietly(client, sandboxId, previous.token); @@ -1532,37 +1343,6 @@ export class DaytonaService { return access; } - /** What a mint must still match when it completes: the settings and the sandbox's access. */ - private accessStamp(sandboxId: string): MintStamp { - return { - generation: this.credentialsGeneration, - release: this.accessReleases.get(sandboxId) ?? 0, - }; - } - - /** - * Undefined while the mint is current. A mint that spans a settings change or a release of - * the sandbox's access is never cached or kept in the include file: its token is revoked - * with the client that minted it. After a settings change it mints again under the new - * settings; after a release it fails, since the caller's access was taken away. - */ - private settleOutdatedMint( - mint: OutdatedMintInput, - wroteIncludeFile: boolean - ): Promise | undefined { - const { client, sandboxId, access, stamp, options } = mint; - if ((this.accessReleases.get(sandboxId) ?? 0) !== stamp.release) { - return this.discardReleasedMint(client, sandboxId, access, wroteIncludeFile); - } - if (this.credentialsGeneration !== stamp.generation) { - // The retry's onNewToken overwrites whatever this one wrote. - return this.revokeQuietly(client, sandboxId, access.token).then(() => - this.mintSshToken(sandboxId, undefined, options) - ); - } - return undefined; - } - private async discardReleasedMint( client: DaytonaClient, sandboxId: string, From 80e4a552932d8e094d8f727205b7cd404d8b6160 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:11:51 +0000 Subject: [PATCH 183/231] refactor(daytona): move the agent home copy to a follow-up --- docs/runtime/daytona.mdx | 4 +- src/node/runtime/DaytonaSSHRuntime.test.ts | 24 -- src/node/runtime/DaytonaSSHRuntime.ts | 29 +- src/node/runtime/daytonaAgentHomeSeed.test.ts | 250 ------------- src/node/runtime/daytonaAgentHomeSeed.ts | 336 ------------------ .../builtInSkillContent.generated.ts | 4 +- tests/runtime/daytonaRuntime.test.ts | 5 +- 7 files changed, 10 insertions(+), 642 deletions(-) delete mode 100644 src/node/runtime/daytonaAgentHomeSeed.test.ts delete mode 100644 src/node/runtime/daytonaAgentHomeSeed.ts diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index f94eb8c6859..671230d871a 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -42,13 +42,13 @@ Small is the default. Xum offers only these sizes, which keeps costs predictable 1. In the new-workspace controls, choose **Daytona** as the runtime. 2. Select **New**, then pick a snapshot and a size. -3. Xum creates the sandbox, waits until it starts, copies your user-level [tool hooks](/hooks/tools) and skills into it, clones the project, and runs the [init hook](/hooks/init). +3. Xum creates the sandbox, waits until it starts, clones the project, and runs the [init hook](/hooks/init). Xum labels each sandbox it creates with `xum.instance`, `xum.workspaceId`, and `xum.project`. If Daytona rejects the size for a snapshot, Xum creates the sandbox at the snapshot's size and then resizes it. ## Use an existing sandbox -Select **Existing** and pick a sandbox from your organization. Xum treats an attached sandbox as yours: it never copies files into its home directory, stops it, or deletes it. It does start the sandbox when a workspace needs it. Several Xum workspaces can share one sandbox; each gets its own directory. +Select **Existing** and pick a sandbox from your organization. Xum treats an attached sandbox as yours: it never stops it or deletes it. It does start the sandbox when a workspace needs it. Several Xum workspaces can share one sandbox; each gets its own directory. ## Lifecycle diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index ad5a3d7ae07..7ed78777cc0 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -66,11 +66,6 @@ function makeRuntime( workspaceId: options.workspaceId ?? "ws-1", projectPath: options.projectPath, workspaceName: options.workspaceName, - // Empty by default: tests that seed write files here. - agentHomeSeedSources: { - xumHome: path.join(testRig.rootDir, "host-xum"), - home: path.join(testRig.rootDir, "host-home"), - }, }); } @@ -898,25 +893,6 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { sshDelete.mockRestore(); } }); - - it("seeds the agent home of a created sandbox but never of an attached one", async () => { - const hostXum = path.join(rig.rootDir, "host-xum"); - await fs.mkdir(hostXum, { recursive: true }); - await fs.writeFile(path.join(hostXum, "tool_env"), "export JOBS=4\n"); - const seedCalls = () => execSpy.mock.calls.filter(([, command]) => command === "sh -s"); - - await makeRuntime({ sandboxId: "xum-seeded", shared: false, ...SMALL }).postCreateSetup( - initParams() - ); - expect(seedCalls()).toHaveLength(1); - expect(seedCalls()[0][2].stdin).toContain('"$HOME/.mux/tool_env"'); - - rig.plane.addSandbox({ id: "sb-users", state: "stopped" }); - await makeRuntime({ sandboxId: "sb-users", shared: true }).postCreateSetup(initParams()); - expect(seedCalls()).toHaveLength(1); - expect(rig.plane.countCalls("POST /sandbox")).toBe(1); - expect(rig.plane.find("sb-users")?.state).toBe("started"); - }); }); describe("DaytonaSSHRuntime.deleteWorkspace", () => { diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 0c4e37e04de..d775a6225d1 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -9,7 +9,6 @@ * host alias (`.xum--daytona`) is then known before persist, like Coder's. */ -import * as os from "os"; import * as path from "path"; import { randomBytes } from "crypto"; import type { @@ -37,7 +36,6 @@ import { toXumDaytonaHost, type DaytonaSizePreset, } from "@/common/constants/daytona"; -import { getXumHome } from "@/common/constants/paths"; import type { DaytonaSandboxConfig, DaytonaSandboxState } from "@/common/orpc/schemas/daytona"; import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime } from "@/common/types/runtime"; @@ -57,7 +55,6 @@ import { import { applyArchiveSettingToIdleSandbox } from "@/node/services/daytona/daytonaLifecycleHooks"; import { log } from "@/node/services/log"; import { execBuffered } from "@/node/utils/runtime/helpers"; -import { seedAgentHome, type AgentHomeSeedSources } from "./daytonaAgentHomeSeed"; import { expandTildeForSSH } from "./tildeExpansion"; import { redactChildStderr, @@ -116,8 +113,6 @@ export interface DaytonaSSHRuntimeOptions { workspacePath?: string; /** Labels the created sandbox, so the reconciler can tell which workspace it belongs to. */ workspaceId?: string; - /** Host directories the agent home is seeded from (default: the Xum home and user home). */ - agentHomeSeedSources?: AgentHomeSeedSources; } /** Idle sandboxes stop after 30 minutes and archive after a day stopped; never auto-delete. */ @@ -322,7 +317,6 @@ export class DaytonaSSHRuntime extends SSHRuntime { private readonly daytonaConfig: DaytonaSandboxConfig; private readonly daytonaService: DaytonaRuntimeService; private readonly workspaceId?: string; - private readonly agentHomeSeedSources?: AgentHomeSeedSources; /** * Set once ensureReady runs its checks on this instance. Only such a runtime (a turn's or a * terminal's) restarts a sandbox that stopped under it; UI and background runtimes never @@ -360,7 +354,6 @@ export class DaytonaSSHRuntime extends SSHRuntime { this.sshTransport = transport; this.daytonaService = daytonaService; this.workspaceId = options?.workspaceId; - this.agentHomeSeedSources = options?.agentHomeSeedSources; } private sandboxMissing(sandboxId: string): EnsureReadyResult { @@ -937,8 +930,8 @@ export class DaytonaSSHRuntime extends SSHRuntime { } /** - * Provision the sandbox (unless attaching), wait until it runs, configure ssh, seed the - * agent home and prepare the workspace directory. initWorkspace then runs the normal SSH + * Provision the sandbox (unless attaching), wait until it runs, configure ssh and prepare + * the workspace directory. initWorkspace then runs the normal SSH * sync, worktree creation and init hook. If anything fails after the create request was * sent, the sandbox this call created is deleted again so it cannot linger and bill. */ @@ -961,7 +954,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { await this.createSandbox(sandboxId, params, create); params.initLogger.logStep("Daytona sandbox created"); } - await this.connectAndSeed(sandboxId, creates, params); + await this.connect(sandboxId, params); await this.prepareWorkspaceDirectory(params); } catch (error) { const errorMsg = redactDaytonaTokens(getErrorMessage(error)); @@ -977,26 +970,14 @@ export class DaytonaSSHRuntime extends SSHRuntime { } } - /** Wait until the sandbox runs, write ssh access, and seed the home of a box Xum created. */ - private async connectAndSeed( - sandboxId: string, - seed: boolean, - params: WorkspaceInitParams - ): Promise { + /** Wait until the sandbox runs and write ssh access. */ + private async connect(sandboxId: string, params: WorkspaceInitParams): Promise { const { initLogger, abortSignal } = params; initLogger.logStep("Waiting for the sandbox to start and configuring SSH..."); await this.daytonaService.ensureSandboxReady(sandboxId, { onStarting: () => initLogger.logStep("Starting sandbox..."), signal: abortSignal, }); - if (!seed) return; - initLogger.logStep("Copying agent hooks and global skills into the sandbox..."); - const count = await seedAgentHome( - this, - this.agentHomeSeedSources ?? { xumHome: getXumHome(), home: os.homedir() }, - abortSignal - ); - initLogger.logStep(`Copied ${count} agent file(s)`); } /** git clone does not create the parent directory of the workspace. */ diff --git a/src/node/runtime/daytonaAgentHomeSeed.test.ts b/src/node/runtime/daytonaAgentHomeSeed.test.ts deleted file mode 100644 index 675b0369af6..00000000000 --- a/src/node/runtime/daytonaAgentHomeSeed.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; -import { execFile } from "child_process"; -import * as fs from "fs/promises"; -import * as os from "os"; -import * as path from "path"; -import { promisify } from "util"; -import { buildAgentHomeSeedScript, collectAgentHomeSeedFiles } from "./daytonaAgentHomeSeed"; - -const execFileAsync = promisify(execFile); - -let root: string; -let hostXum: string; -let hostHome: string; -let remoteHome: string; - -beforeEach(async () => { - root = await fs.mkdtemp(path.join(os.tmpdir(), "xum-seed-test-")); - hostXum = path.join(root, "host", ".xum"); - hostHome = path.join(root, "host"); - remoteHome = path.join(root, "remote"); - await fs.mkdir(hostXum, { recursive: true }); - await fs.mkdir(remoteHome, { recursive: true }); -}); - -afterEach(async () => { - await fs.rm(root, { recursive: true, force: true }); -}); - -/** Run the generated script with sh as the remote would, with HOME at the fake remote home. */ -async function runSeedScript(script: string): Promise { - const child = execFileAsync("sh", ["-s"], { env: { ...process.env, HOME: remoteHome } }); - child.child.stdin?.end(script); - await child; -} - -/** The script's failure, or null when it succeeded. */ -async function seedFailure(script: string): Promise<{ stderr?: string } | null> { - try { - await runSeedScript(script); - return null; - } catch (error) { - return error as { stderr?: string }; - } -} - -describe("agent-home seeding", () => { - it("recreates hooks and global skills where the remote lookups read them", async () => { - await fs.writeFile(path.join(hostXum, "tool_env"), "export JOBS=4\n"); - await fs.writeFile(path.join(hostXum, "tool_pre"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); - await fs.mkdir(path.join(hostXum, "skills", "deploy", "scripts"), { recursive: true }); - await fs.writeFile( - path.join(hostXum, "skills", "deploy", "SKILL.md"), - "# deploy 'quoted' $x\n" - ); - await fs.writeFile(path.join(hostXum, "skills", "deploy", "scripts", "run.sh"), "echo hi\n", { - mode: 0o755, - }); - await fs.mkdir(path.join(hostHome, ".agents", "skills", "lint"), { recursive: true }); - await fs.writeFile(path.join(hostHome, ".agents", "skills", "lint", "SKILL.md"), "# lint\n"); - // A broken symlink must be skipped, not fail the seed. - await fs.symlink(path.join(root, "nowhere"), path.join(hostXum, "skills", "broken")); - - const files = await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux"); - await runSeedScript(buildAgentHomeSeedScript(files)); - - const read = (rel: string) => fs.readFile(path.join(remoteHome, rel), "utf-8"); - expect(await read(".mux/tool_env")).toBe("export JOBS=4\n"); - expect(await read(".mux/skills/deploy/SKILL.md")).toBe("# deploy 'quoted' $x\n"); - expect(await read(".agents/skills/lint/SKILL.md")).toBe("# lint\n"); - const toolPreMode = (await fs.stat(path.join(remoteHome, ".mux/tool_pre"))).mode; - const scriptMode = (await fs.stat(path.join(remoteHome, ".mux/skills/deploy/scripts/run.sh"))) - .mode; - const envMode = (await fs.stat(path.join(remoteHome, ".mux/tool_env"))).mode; - expect(toolPreMode & 0o111).not.toBe(0); - expect(scriptMode & 0o111).not.toBe(0); - expect(envMode & 0o111).toBe(0); - }); - - // A snapshot may already hold a seed path as a link, e.g. `tool_env -> ~/.ssh/authorized_keys`. - describe("a snapshot that already holds a seed destination", () => { - const seed = (remotePath: string) => - buildAgentHomeSeedScript([ - { remotePath, content: Buffer.from("export JOBS=4\n"), executable: true }, - ]); - - it("replaces a destination symlink with a regular file, leaving its target alone", async () => { - const keys = path.join(remoteHome, ".ssh", "authorized_keys"); - await fs.mkdir(path.dirname(keys), { recursive: true }); - await fs.writeFile(keys, "ssh-ed25519 AAAA user\n", { mode: 0o600 }); - await fs.mkdir(path.join(remoteHome, ".mux")); - await fs.symlink(keys, path.join(remoteHome, ".mux", "tool_env")); - // A dangling link would otherwise create its target. - await fs.symlink(path.join(root, "created"), path.join(remoteHome, ".mux", "tool_pre")); - - await runSeedScript( - buildAgentHomeSeedScript([ - { remotePath: "~/.mux/tool_env", content: Buffer.from("export A=1\n"), executable: true }, - { remotePath: "~/.mux/tool_pre", content: Buffer.from("exit 0\n"), executable: true }, - ]) - ); - - expect(await fs.readFile(keys, "utf-8")).toBe("ssh-ed25519 AAAA user\n"); - expect((await fs.stat(keys)).mode & 0o777).toBe(0o600); - expect(await Bun.file(path.join(root, "created")).exists()).toBe(false); - const written = path.join(remoteHome, ".mux", "tool_env"); - expect((await fs.lstat(written)).isFile()).toBe(true); - expect(await fs.readFile(written, "utf-8")).toBe("export A=1\n"); - expect((await fs.stat(written)).mode & 0o777).toBe(0o755); - expect((await fs.readdir(path.join(remoteHome, ".mux"))).sort()).toEqual([ - "tool_env", - "tool_pre", - ]); - }); - - it("refuses a symlinked directory on the way, writing nothing through it", async () => { - const elsewhere = path.join(root, "elsewhere"); - await fs.mkdir(elsewhere); - await fs.symlink(elsewhere, path.join(remoteHome, ".mux")); - - const failure = await seedFailure(seed("~/.mux/skills/deploy/SKILL.md")); - - expect(failure?.stderr).toContain("is a symlink"); - expect(await fs.readdir(elsewhere)).toEqual([]); - }); - - it("refuses a destination that is a directory instead of writing into it", async () => { - await fs.mkdir(path.join(remoteHome, ".mux", "tool_env"), { recursive: true }); - - const failure = await seedFailure(seed("~/.mux/tool_env")); - - expect(failure?.stderr).toContain("is a directory"); - expect(await fs.readdir(path.join(remoteHome, ".mux", "tool_env"))).toEqual([]); - }); - }); - - it("collects nothing when the host has no agent files", async () => { - expect(await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux")).toEqual( - [] - ); - }); - - it("skips a file larger than the remaining seed budget, unread, and copies the rest", async () => { - const huge = path.join(hostXum, "skills", "data", "model.bin"); - await fs.mkdir(path.dirname(huge), { recursive: true }); - await fs.writeFile(path.join(hostXum, "skills", "data", "SKILL.md"), "# data\n"); - // Sparse: 64 MiB on paper, no disk used. - await fs.writeFile(huge, ""); - await fs.truncate(huge, 64 * 1024 * 1024); - // Files after the oversized one still fit, in the same root and in the next one. - await fs.mkdir(path.join(hostXum, "skills", "deploy"), { recursive: true }); - await fs.writeFile(path.join(hostXum, "skills", "deploy", "SKILL.md"), "# deploy\n"); - await fs.mkdir(path.join(hostHome, ".agents", "skills", "lint"), { recursive: true }); - await fs.writeFile(path.join(hostHome, ".agents", "skills", "lint", "SKILL.md"), "# lint\n"); - const readFile = spyOn(fs, "readFile"); - try { - const files = await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux"); - - expect(files.map((file) => file.remotePath)).toEqual([ - "~/.mux/skills/data/SKILL.md", - "~/.mux/skills/deploy/SKILL.md", - "~/.agents/skills/lint/SKILL.md", - ]); - expect( - readFile.mock.calls.map(([file]) => (typeof file === "string" ? file : "")) - ).not.toContain(huge); - } finally { - readFile.mockRestore(); - } - }); - - it("builds the script for a file of more base64 lines than a call takes arguments", () => { - // Node refuses ~125k spread arguments (a 10 MiB file); Bun only ~640k, so 40 MiB here. - const content = Buffer.alloc(40 * 1024 * 1024); - const script = buildAgentHomeSeedScript([ - { remotePath: "~/.mux/skills/big/data.bin", content, executable: false }, - ]); - expect(script.length).toBeGreaterThan(content.length); - }); - - it("follows symlinks only inside the skill being copied, except a linked skill itself", async () => { - const secrets = path.join(hostHome, ".ssh"); - await fs.mkdir(secrets, { recursive: true }); - await fs.writeFile(path.join(secrets, "id_ed25519"), "PRIVATE KEY\n"); - const skill = path.join(hostXum, "skills", "deploy"); - await fs.mkdir(skill, { recursive: true }); - await fs.writeFile(path.join(skill, "SKILL.md"), "# deploy\n"); - // Links that escape the skill: a directory and a file. - await fs.symlink(secrets, path.join(skill, "creds")); - await fs.symlink(path.join(secrets, "id_ed25519"), path.join(skill, "key")); - // A link that stays inside the skill. - await fs.symlink(path.join(skill, "SKILL.md"), path.join(skill, "README.md")); - // A skill directory linked into the skills root, as plugin installs and users do. - const linkedTarget = path.join(root, "plugins", "lint"); - await fs.mkdir(linkedTarget, { recursive: true }); - await fs.writeFile(path.join(linkedTarget, "SKILL.md"), "# lint\n"); - await fs.symlink(linkedTarget, path.join(hostXum, "skills", "lint")); - - const files = await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux"); - - expect(files.map((file) => file.remotePath).sort()).toEqual([ - "~/.mux/skills/deploy/README.md", - "~/.mux/skills/deploy/SKILL.md", - "~/.mux/skills/lint/SKILL.md", - ]); - expect(files.some((file) => file.content.toString().includes("PRIVATE KEY"))).toBe(false); - }); - - it("copies a directory of a skills root only when it is a skill", async () => { - const secrets = path.join(hostHome, ".ssh"); - await fs.mkdir(secrets, { recursive: true }); - await fs.writeFile(path.join(secrets, "id_rsa"), "PRIVATE KEY\n"); - const skills = path.join(hostXum, "skills"); - await fs.mkdir(skills, { recursive: true }); - // A root entry linked to a directory that is no skill. - await fs.symlink(secrets, path.join(skills, "keys")); - // A plain directory without SKILL.md. - await fs.mkdir(path.join(skills, "notes")); - await fs.writeFile(path.join(skills, "notes", "todo.txt"), "todo\n"); - // A SKILL.md that is a link out of its directory does not make it a skill. - await fs.mkdir(path.join(skills, "borrowed")); - await fs.symlink(path.join(secrets, "id_rsa"), path.join(skills, "borrowed", "SKILL.md")); - await fs.writeFile(path.join(skills, "borrowed", "extra.txt"), "extra\n"); - // A skill whose name discovery refuses. - await fs.mkdir(path.join(skills, "Not_A_Skill")); - await fs.writeFile(path.join(skills, "Not_A_Skill", "SKILL.md"), "# no\n"); - // A linked real skill is still copied. - const linkedTarget = path.join(root, "plugins", "lint"); - await fs.mkdir(linkedTarget, { recursive: true }); - await fs.writeFile(path.join(linkedTarget, "SKILL.md"), "# lint\n"); - await fs.symlink(linkedTarget, path.join(skills, "lint")); - - const files = await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux"); - - expect(files.map((file) => file.remotePath)).toEqual(["~/.mux/skills/lint/SKILL.md"]); - }); - - it("copies only skill directories of a skills root that is itself a link", async () => { - const secrets = path.join(hostHome, ".ssh"); - await fs.mkdir(path.join(secrets, "keys"), { recursive: true }); - await fs.writeFile(path.join(secrets, "id_rsa"), "PRIVATE KEY\n"); - await fs.writeFile(path.join(secrets, "keys", "id_ed25519"), "PRIVATE KEY\n"); - await fs.mkdir(path.join(secrets, "lint")); - await fs.writeFile(path.join(secrets, "lint", "SKILL.md"), "# lint\n"); - await fs.symlink(secrets, path.join(hostXum, "skills")); - - const files = await collectAgentHomeSeedFiles({ xumHome: hostXum, home: hostHome }, "~/.mux"); - - expect(files.map((file) => file.remotePath)).toEqual(["~/.mux/skills/lint/SKILL.md"]); - }); -}); diff --git a/src/node/runtime/daytonaAgentHomeSeed.ts b/src/node/runtime/daytonaAgentHomeSeed.ts deleted file mode 100644 index a6a05985204..00000000000 --- a/src/node/runtime/daytonaAgentHomeSeed.ts +++ /dev/null @@ -1,336 +0,0 @@ -import * as fs from "fs/promises"; -import * as path from "path"; -import { SkillNameSchema } from "@/common/orpc/schemas"; -import { getErrorMessage } from "@/common/utils/errors"; -import { log } from "@/node/services/log"; -import { isErrnoWithCode } from "@/node/utils/fs"; -import { execBuffered } from "@/node/utils/runtime/helpers"; -import type { Runtime } from "./Runtime"; -import { expandTildeForSSH } from "./tildeExpansion"; - -/** - * Seeds a Xum-created Daytona sandbox with the host's user-global agent files, so hooks and - * global skills behave as they do locally. Destinations are the paths the remote lookups read: - * hooks.ts and agentSkillsService resolve user-global files under `runtime.getXumHome()` - * (`~/.mux` on SSH remotes) and universal skills under `~/.agents/skills`. - * - * Xum owns the box, so existing files are overwritten. Attached (shared) sandboxes are never - * seeded: they belong to the user. - */ - -const SEEDED_HOOK_FILES = ["tool_env", "tool_pre", "tool_post"] as const; - -/** - * A box seed is a convenience: a file larger than what is left of this is skipped, and files - * that still fit are copied, so one large file does not drop every later skill. - */ -const MAX_SEED_BYTES = 16 * 1024 * 1024; -const MAX_SKILL_DIR_DEPTH = 8; -const SEED_TIMEOUT_SECONDS = 120; -const HEREDOC_MARKER = "XUM_DAYTONA_SEED_EOF"; - -export interface AgentHomeSeedFile { - /** Remote path, `~/`-relative. */ - remotePath: string; - content: Buffer; - executable: boolean; -} - -export interface AgentHomeSeedSources { - /** Host Xum home (holds tool_env, tool_pre, tool_post and skills/). */ - xumHome: string; - /** Host user home (holds .agents/skills). */ - home: string; -} - -interface SeedBudget { - remainingBytes: number; - /** A file did not fit. */ - skipped: boolean; -} - -function isSpent(budget: SeedBudget): boolean { - return budget.remainingBytes <= 0; -} - -async function readRegularFile( - filePath: string, - budget: SeedBudget -): Promise<{ content: Buffer; mode: number } | null> { - try { - // stat follows symlinks: plugin installs link skills into the skills directory. - const stat = await fs.stat(filePath); - if (!stat.isFile()) return null; - // Check before reading: a large file must not be loaded only to be dropped. - if (stat.size > budget.remainingBytes) { - log.debug("Skipping a file past the Daytona seed size limit", { filePath, size: stat.size }); - budget.skipped = true; - return null; - } - return { content: await fs.readFile(filePath), mode: stat.mode }; - } catch (error) { - if (isErrnoWithCode(error, "ENOENT")) return null; - log.warn("Skipping an unreadable file while seeding a Daytona sandbox", { - filePath, - error: getErrorMessage(error), - }); - return null; - } -} - -function addFile( - files: AgentHomeSeedFile[], - budget: SeedBudget, - remotePath: string, - file: { content: Buffer; mode: number } -): void { - if (file.content.length > budget.remainingBytes) { - budget.skipped = true; - return; - } - budget.remainingBytes -= file.content.length; - files.push({ remotePath, content: file.content, executable: (file.mode & 0o111) !== 0 }); -} - -/** Directory entries, sorted; null when the directory is missing or unreadable. */ -async function readDirectory(hostDir: string): Promise { - try { - return (await fs.readdir(hostDir)).sort(); - } catch (error) { - if (!isErrnoWithCode(error, "ENOENT") && !isErrnoWithCode(error, "ENOTDIR")) { - log.warn("Skipping an unreadable directory while seeding a Daytona sandbox", { - hostDir, - error: getErrorMessage(error), - }); - } - return null; - } -} - -/** The real path and its stat (links followed); null for a broken link or a missing path. */ -async function resolveEntry( - hostPath: string -): Promise<{ real: string; stat: Awaited> } | null> { - try { - const real = await fs.realpath(hostPath); - return { real, stat: await fs.stat(real) }; - } catch (error) { - // Broken symlinks are common in skill directories; skip them. - log.debug("Skipping a missing path while seeding a Daytona sandbox", { - hostPath, - error: getErrorMessage(error), - }); - return null; - } -} - -function isInside(realPath: string, realRoot: string): boolean { - const relative = path.relative(realRoot, realPath); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -/** - * A skills root (`~/.xum/skills`, `~/.agents/skills`). Only its skill directories are copied - * (see isSkillDirectory). Each may be a symlink (users and plugin installs link skills, and - * skill discovery follows them); inside a skill, links are followed only while they stay - * within that skill's real directory, so a link to `~/.ssh` is never copied into the sandbox. - */ -async function collectSkillsRoot( - files: AgentHomeSeedFile[], - budget: SeedBudget, - hostRoot: string, - remoteRoot: string -): Promise { - const entries = await readDirectory(hostRoot); - if (!entries) return; - for (const entry of entries) { - if (isSpent(budget)) return; - await collectSkillsRootEntry(files, budget, { - hostPath: path.join(hostRoot, entry), - remotePath: path.posix.join(remoteRoot, entry), - }); - } -} - -/** - * Whether a directory of a skills root is a skill as discovery sees one: a valid skill name and - * a regular SKILL.md. SKILL.md must also stay inside the real directory, so a root entry linked - * to `~/.ssh` (or holding a SKILL.md link to elsewhere) does not become a copy boundary. - */ -async function isSkillDirectory(name: string, realDir: string): Promise { - if (!SkillNameSchema.safeParse(name).success) return false; - const skillFile = await resolveEntry(path.join(realDir, "SKILL.md")); - return skillFile !== null && skillFile.stat.isFile() && isInside(skillFile.real, realDir); -} - -/** - * A skill directory (its real path bounds what it may link to). Discovery reads nothing else - * of a skills root, and the root itself may be a link to `~/.ssh`, so other entries (files - * included) are skipped. - */ -async function collectSkillsRootEntry( - files: AgentHomeSeedFile[], - budget: SeedBudget, - entry: { hostPath: string; remotePath: string } -): Promise { - const resolved = await resolveEntry(entry.hostPath); - if (!resolved) return; - const isSkill = - resolved.stat.isDirectory() && - (await isSkillDirectory(path.basename(entry.hostPath), resolved.real)); - if (!isSkill) { - log.warn( - "Skipping a skills directory entry that is not a skill while seeding a Daytona sandbox", - { - hostPath: entry.hostPath, - } - ); - return; - } - await collectDirectory(files, budget, resolved.real, entry.remotePath, 1, resolved.real); -} - -async function collectDirectory( - files: AgentHomeSeedFile[], - budget: SeedBudget, - hostDir: string, - remoteDir: string, - depth: number, - skillRoot: string -): Promise { - if (depth > MAX_SKILL_DIR_DEPTH || isSpent(budget)) return; - const entries = await readDirectory(hostDir); - if (!entries) return; - for (const entry of entries) { - const resolved = await resolveEntry(path.join(hostDir, entry)); - if (!resolved) continue; - if (!isInside(resolved.real, skillRoot)) { - log.warn("Skipping a symlink that leaves its skill while seeding a Daytona sandbox", { - hostPath: path.join(hostDir, entry), - }); - continue; - } - const remotePath = path.posix.join(remoteDir, entry); - if (resolved.stat.isDirectory()) { - await collectDirectory(files, budget, resolved.real, remotePath, depth + 1, skillRoot); - } else { - await collectFile(files, budget, resolved, remotePath); - } - } -} - -async function collectFile( - files: AgentHomeSeedFile[], - budget: SeedBudget, - resolved: { real: string; stat: Awaited> }, - remotePath: string -): Promise { - if (!resolved.stat.isFile()) return; - const file = await readRegularFile(resolved.real, budget); - if (file) addFile(files, budget, remotePath, file); -} - -/** The host files to copy; missing files and directories are skipped. */ -export async function collectAgentHomeSeedFiles( - sources: AgentHomeSeedSources, - remoteXumHome: string -): Promise { - const files: AgentHomeSeedFile[] = []; - const budget: SeedBudget = { remainingBytes: MAX_SEED_BYTES, skipped: false }; - for (const name of SEEDED_HOOK_FILES) { - const file = await readRegularFile(path.join(sources.xumHome, name), budget); - if (file) addFile(files, budget, path.posix.join(remoteXumHome, name), file); - } - await collectSkillsRoot( - files, - budget, - path.join(sources.xumHome, "skills"), - path.posix.join(remoteXumHome, "skills") - ); - await collectSkillsRoot( - files, - budget, - path.join(sources.home, ".agents", "skills"), - "~/.agents/skills" - ); - if (budget.skipped) { - log.warn("Daytona agent-home seed hit its size limit; some files were not copied", { - limitBytes: MAX_SEED_BYTES, - }); - } - return files; -} - -/** - * Shell helpers of the seed script. The snapshot may already hold a seed path, and a write or - * chmod that followed a link there (`tool_env -> ~/.ssh/authorized_keys`) would clobber the - * link's target. So nothing follows a link: - * - a directory below the home that is a symlink fails the seed: removing it could drop - * snapshot content, and writing through it would land anywhere; - * - each file is written to a fresh `mktemp` file in its own directory, chmodded there, and - * renamed over the destination. A destination symlink is removed first, so the rename - * replaces it (Xum owns the box and overwrites its seed files) instead of `mv` moving the - * file into a directory the link points at; a real directory there fails the seed. - */ -const SEED_SCRIPT_PRELUDE = [ - "set -e", - "xum_seed_tmp=", - `trap 'rm -f "$xum_seed_tmp"' EXIT`, - `xum_seed_refuse() { echo "Refusing to seed $1: $2" >&2; exit 1; }`, - `xum_seed_dir() { if [ -L "$1" ]; then xum_seed_refuse "$1" "it is a symlink"; fi; [ -d "$1" ] || mkdir "$1"; }`, - `xum_seed_open() { if [ -d "$1" ] && [ ! -L "$1" ]; then xum_seed_refuse "$1" "it is a directory"; fi; xum_seed_tmp=$(mktemp "$2/.xum-seed.XXXXXX"); }`, - `xum_seed_close() { chmod "$2" "$xum_seed_tmp"; if [ -L "$1" ]; then rm -f "$1"; fi; mv -f "$xum_seed_tmp" "$1"; xum_seed_tmp=; }`, -]; - -/** The `~/`-relative directories from the home down to `remoteDir`, each one level deeper. */ -function directoriesBelowHome(remoteDir: string): string[] { - if (!remoteDir.startsWith("~/")) { - throw new Error(`Agent seed paths must be under the sandbox home: ${remoteDir}`); - } - const parts = remoteDir.slice(2).split("/").filter(Boolean); - return parts.map((_, index) => `~/${parts.slice(0, index + 1).join("/")}`); -} - -/** One POSIX sh script that writes every file (sent on stdin: no argv size limit applies). */ -export function buildAgentHomeSeedScript(files: readonly AgentHomeSeedFile[]): string { - const lines = [...SEED_SCRIPT_PRELUDE]; - const checkedDirs = new Set(); - for (const file of files) { - const target = expandTildeForSSH(file.remotePath); - const remoteDir = path.posix.dirname(file.remotePath); - for (const dir of directoriesBelowHome(remoteDir)) { - if (checkedDirs.has(dir)) continue; - checkedDirs.add(dir); - lines.push(`xum_seed_dir ${expandTildeForSSH(dir)}`); - } - lines.push(`xum_seed_open ${target} ${expandTildeForSSH(remoteDir)}`); - lines.push(`base64 -d > "$xum_seed_tmp" <<'${HEREDOC_MARKER}'`); - // Line-wrapped base64 never contains the marker. A loop, not a spread: a spread passes - // every line as an argument, and Node throws a RangeError past ~125k (a 10 MiB file). - for (const line of file.content.toString("base64").match(/.{1,76}/g) ?? []) lines.push(line); - lines.push(HEREDOC_MARKER); - lines.push(`xum_seed_close ${target} ${file.executable ? "755" : "644"}`); - } - return `${lines.join("\n")}\n`; -} - -export async function seedAgentHome( - runtime: Runtime, - sources: AgentHomeSeedSources, - abortSignal?: AbortSignal -): Promise { - const files = await collectAgentHomeSeedFiles(sources, runtime.getXumHome()); - if (files.length === 0) return 0; - const result = await execBuffered(runtime, "sh -s", { - cwd: "/tmp", - timeout: SEED_TIMEOUT_SECONDS, - abortSignal, - stdin: buildAgentHomeSeedScript(files), - }); - if (result.exitCode !== 0) { - throw new Error( - `Failed to copy agent files into the sandbox: ${result.stderr.trim() || `exit ${result.exitCode}`}` - ); - } - return files.length; -} diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 4549736d9ff..245415ca0a0 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10079,13 +10079,13 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "1. In the new-workspace controls, choose **Daytona** as the runtime.", "2. Select **New**, then pick a snapshot and a size.", - "3. Xum creates the sandbox, waits until it starts, copies your user-level [tool hooks](/hooks/tools) and skills into it, clones the project, and runs the [init hook](/hooks/init).", + "3. Xum creates the sandbox, waits until it starts, clones the project, and runs the [init hook](/hooks/init).", "", "Xum labels each sandbox it creates with `xum.instance`, `xum.workspaceId`, and `xum.project`. If Daytona rejects the size for a snapshot, Xum creates the sandbox at the snapshot's size and then resizes it.", "", "## Use an existing sandbox", "", - "Select **Existing** and pick a sandbox from your organization. Xum treats an attached sandbox as yours: it never copies files into its home directory, stops it, or deletes it. It does start the sandbox when a workspace needs it. Several Xum workspaces can share one sandbox; each gets its own directory.", + "Select **Existing** and pick a sandbox from your organization. Xum treats an attached sandbox as yours: it never stops it or deletes it. It does start the sandbox when a workspace needs it. Several Xum workspaces can share one sandbox; each gets its own directory.", "", "## Lifecycle", "", diff --git a/tests/runtime/daytonaRuntime.test.ts b/tests/runtime/daytonaRuntime.test.ts index d3f9ad18397..a56033b80f5 100644 --- a/tests/runtime/daytonaRuntime.test.ts +++ b/tests/runtime/daytonaRuntime.test.ts @@ -45,7 +45,6 @@ describeDaytona("Daytona runtime (real control plane)", () => { projectPath, workspaceName, workspaceId: `daytona-test-${workspaceName}`, - agentHomeSeedSources: { xumHome: path.join(tempRoot, "xum"), home: tempRoot }, }); } @@ -55,7 +54,6 @@ describeDaytona("Daytona runtime (real control plane)", () => { process.env.HOME = tempRoot; const xumHome = path.join(tempRoot, "xum"); await fs.mkdir(xumHome, { recursive: true }); - await fs.writeFile(path.join(xumHome, "tool_env"), "export XUM_DAYTONA_TEST=1\n"); service = new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(xumHome), rootDir: xumHome, @@ -114,11 +112,10 @@ describeDaytona("Daytona runtime (real control plane)", () => { expect((await runtime.initWorkspace(initParams)).success).toBe(true); expect(await runtime.ensureReady()).toEqual({ ready: true }); - const echo = await execBuffered(runtime, "cat ~/.mux/tool_env && echo ok", { + const echo = await execBuffered(runtime, "echo ok", { cwd: created.workspacePath, timeout: 60, }); - expect(echo.stdout).toContain("XUM_DAYTONA_TEST=1"); expect(echo.stdout).toContain("ok"); const filePath = `${created.workspacePath}/note.txt`; From 4ccb7f0a7597a9830c9b28609073fef974060004 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:13:55 +0000 Subject: [PATCH 184/231] refactor(daytona): stop checking the snapshot state before create --- src/node/runtime/DaytonaSSHRuntime.test.ts | 57 ++++--------------- src/node/runtime/DaytonaSSHRuntime.ts | 20 +------ .../services/daytona/daytona.testHarness.ts | 1 - src/node/services/daytona/daytonaClient.ts | 4 -- src/node/services/daytona/daytonaService.ts | 18 ++---- 5 files changed, 15 insertions(+), 85 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 7ed78777cc0..08be5cd6570 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -491,22 +491,16 @@ describe("DaytonaSSHRuntime Windows and GPU sandboxes", () => { expect(rig.plane.countCalls("POST /sandbox")).toBe(0); }); - it.each([ - [{ name: "default-snap", sandboxClass: "windows" }, '"default-snap" creates Windows'], - [{ name: "default-snap", state: "inactive" }, '"default-snap" is inactive'], - ])( - "refuses a deployment default Xum cannot use when no snapshot is chosen (%o)", - async (snapshot, refusal) => { - rig.plane.snapshots.push(snapshot); - rig.plane.defaultSnapshot = "default-snap"; - const runtime = makeRuntime({}); - const finalized = await runtime.finalizeConfig("b", sshDaytonaConfig({})); - if (!finalized.success) throw new Error(finalized.error); - const refused = await runtime.validateBeforePersist("b", finalized.data); - expect(refused.success ? "" : refused.error).toContain(refusal); - expect(rig.plane.countCalls("POST /sandbox")).toBe(0); - } - ); + it("refuses a Windows deployment default when no snapshot is chosen", async () => { + rig.plane.snapshots.push({ name: "default-snap", sandboxClass: "windows" }); + rig.plane.defaultSnapshot = "default-snap"; + const runtime = makeRuntime({}); + const finalized = await runtime.finalizeConfig("b", sshDaytonaConfig({})); + if (!finalized.success) throw new Error(finalized.error); + const refused = await runtime.validateBeforePersist("b", finalized.data); + expect(refused.success ? "" : refused.error).toContain('"default-snap" creates Windows'); + expect(rig.plane.countCalls("POST /sandbox")).toBe(0); + }); it("neither offers nor attaches a sandbox in an error state", async () => { rig.plane.addSandbox({ id: "sb-broken", state: "build_failed" }); @@ -525,37 +519,6 @@ describe("DaytonaSSHRuntime Windows and GPU sandboxes", () => { expect(listed.ok && listed.sandboxes.map((s) => s.id)).toEqual(["sb-ok"]); }); - it("neither offers nor accepts a snapshot that is not active", async () => { - rig.plane.snapshots.push( - { name: "snap-building", state: "building" }, - { name: "snap-inactive", state: "inactive" }, - { name: "snap-active", state: "active" }, - // No state reported: accepted, as before Daytona reported one. - { name: "snap-unknown" } - ); - const runtime = makeRuntime({}); - const validate = async (snapshot: string) => { - const finalized = await runtime.finalizeConfig("b", sshDaytonaConfig({ snapshot })); - if (!finalized.success) return finalized; - return runtime.validateBeforePersist("b", finalized.data); - }; - const error = (result: Awaited>) => - result.success ? "" : result.error; - - expect(error(await validate("snap-building"))).toContain("building"); - const inactive = error(await validate("snap-inactive")); - expect(inactive).toContain("inactive"); - expect(inactive).toContain("activate"); - expect((await validate("snap-active")).success).toBe(true); - expect((await validate("snap-unknown")).success).toBe(true); - const listed = await rig.service.listSnapshots(); - expect(listed.ok && listed.snapshots.map((s) => s.name)).toEqual([ - "snap-active", - "snap-unknown", - ]); - expect(rig.plane.countCalls("POST /sandbox")).toBe(0); - }); - it("offers neither Windows nor GPU snapshots and sandboxes", async () => { rig.plane.addSandbox({ id: "sb-win", sandboxClass: "windows" }); rig.plane.addSandbox({ id: "sb-gpu", sandboxClass: "linux-vm", gpu: 1 }); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index d775a6225d1..f5277ca722b 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -256,23 +256,6 @@ function makeStatusEmitter(statusSink: EnsureReadyOptions["statusSink"]): Status return (phase, detail) => statusSink?.({ phase, runtimeType: "ssh", detail }); } -/** - * An unreported disk is left as it is: the snapshot's may be larger than the preset's, and - * sending the preset would then be a shrink Daytona refuses, failing (and deleting) the sandbox. - */ -/** - * Only an active snapshot creates sandboxes (SnapshotState: building, pending, pulling, - * snapshotting, inactive, error, build_failed, removing do not); none reported passes. - */ -function snapshotStateRefusal(snapshot: string, state: string | undefined): string | undefined { - if (!state || state === "active") return undefined; - const next = - state === "inactive" - ? "activate it in Daytona or pick another snapshot" - : "pick another snapshot"; - return `Daytona snapshot "${snapshot}" is ${state}, not active; ${next}`; -} - /** Why an existing sandbox cannot be attached, before the workspace is persisted. */ function attachRefusal( sandboxId: string, @@ -918,8 +901,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { const { label, why } = UNSUPPORTED_SANDBOXES[found.unsupported]; return Err(`Daytona snapshot "${snapshot}" creates ${label} sandboxes. ${why}`); } - const refusal = snapshotStateRefusal(snapshot, found.state); - return refusal ? Err(refusal) : Ok(undefined); + return Ok(undefined); } /** The sandbox may not exist yet, so only compute the path (as Coder does). */ diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 09928b52c80..34d741f63ee 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -47,7 +47,6 @@ export class FakeDaytonaControlPlane { sandboxClass?: string; gpu?: number; listedOnly?: boolean; - state?: string; }> = []; /** The ssh gateway GET /config advertises; a test moves it or rotates its host keys. */ gateway: DaytonaSshGateway = { diff --git a/src/node/services/daytona/daytonaClient.ts b/src/node/services/daytona/daytonaClient.ts index 4a393d1e42f..7e58d7b3cb3 100644 --- a/src/node/services/daytona/daytonaClient.ts +++ b/src/node/services/daytona/daytonaClient.ts @@ -83,15 +83,12 @@ const SnapshotSchema = z.object({ sandboxClass: z.string().nullish(), /** GPU units of the sandboxes it creates, as on a sandbox. */ gpu: z.number().nullish(), - /** SnapshotState: only "active" creates sandboxes (building, inactive, error, ...). */ - state: z.string().nullish(), }); export interface DaytonaSnapshot { name: string; sandboxClass?: string; gpu?: number; - state?: string; } function toSnapshot(item: z.infer): DaytonaSnapshot { @@ -99,7 +96,6 @@ function toSnapshot(item: z.infer): DaytonaSnapshot { name: item.name, ...(item.sandboxClass ? { sandboxClass: item.sandboxClass } : {}), ...(item.gpu ? { gpu: item.gpu } : {}), - ...(item.state ? { state: item.state } : {}), }; } diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 457e2faf2be..4b827b90e75 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -149,11 +149,6 @@ const RAW_STATE_MAP: Record = { ...Object.fromEntries(DESTROYING_STATE_NAMES.map((state) => [state, "error" as const])), }; -/** Only an active snapshot creates sandboxes; one with no reported state is taken as active. */ -function isActiveSnapshot(snapshot: DaytonaSnapshot): boolean { - return !snapshot.state || snapshot.state === "active"; -} - /** Collapse Daytona's ~20 sandbox states to the five Xum acts on; unknown values are errors. */ export function mapDaytonaSandboxState(raw: string | undefined): ObservedSandboxState { return (raw ? RAW_STATE_MAP[raw] : undefined) ?? "error"; @@ -878,7 +873,7 @@ export class DaytonaService { return { ok: true, snapshots: snapshots - .filter((s) => !unsupportedDaytonaSandboxKind(s) && isActiveSnapshot(s)) + .filter((s) => !unsupportedDaytonaSandboxKind(s)) .map(({ name }) => ({ name })), }; } catch (error) { @@ -887,13 +882,13 @@ export class DaytonaService { } /** - * The snapshot named `name`, with the unsupported kind of sandbox it creates (if any) and its state; undefined when + * The snapshot named `name`, with the unsupported kind of sandbox it creates (if any); undefined when * Daytona has none by that name. The list is the fallback for one the lookup does not find * by name (it serves the snapshot picker). */ async findSnapshot( name: string - ): Promise<{ unsupported?: UnsupportedDaytonaSandboxKind; state?: string } | undefined> { + ): Promise<{ unsupported?: UnsupportedDaytonaSandboxKind } | undefined> { const { client } = await this.getClient(); let snapshot: DaytonaSnapshot | undefined; try { @@ -902,12 +897,7 @@ export class DaytonaService { if (!isDaytonaNotFound(error)) throw error; snapshot = (await client.listSnapshots()).find((listed) => listed.name === name); } - return ( - snapshot && { - unsupported: unsupportedDaytonaSandboxKind(snapshot), - ...(snapshot.state ? { state: snapshot.state } : {}), - } - ); + return snapshot && { unsupported: unsupportedDaytonaSandboxKind(snapshot) }; } /** Stable per Xum install, so the reconciler only ever touches this install's sandboxes. */ From dabcd5d9c66894f6d3c4cdbc03d4f92b4670f62e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:14:59 +0000 Subject: [PATCH 185/231] test(daytona): keep the token out of an ssh error that gets no retry --- src/node/runtime/DaytonaSSHRuntime.test.ts | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 08be5cd6570..78793fafc06 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -1553,6 +1553,34 @@ describe("DaytonaSSHRuntime ssh token redaction", () => { expect(String(error)).toContain(`${DAYTONA_TOKEN_PLACEHOLDER}@127.0.0.1: Permission denied`); }); + it("keeps the token out of an error that gets no retry", async () => { + rig.plane.addSandbox({ id: "sb-tok" }); + await rig.service.ensureSshAccess("sb-tok"); + const token = rig.writer.tokens.get("sb-tok"); + if (!token) throw new Error("expected a minted token"); + let spawns = 0; + // A UI runtime never restarts the sandbox, so a dropped connection is not retried. + const runtime = makeRuntime({ sandboxId: "sb-tok" }, rig, { + transport: { + ...localShellTransport(rig.rootDir), + spawnRemoteProcess: () => { + spawns++; + return Promise.reject( + new RuntimeError(`Connection closed by user ${token} 127.0.0.1 port 22`, "network") + ); + }, + }, + }); + + const error = await runtime.exec("true", { cwd: "/tmp", timeout: 10 }).then( + () => new Error("exec unexpectedly succeeded"), + (rejection: unknown) => rejection + ); + expect(spawns).toBe(1); + expect(String(error)).not.toContain(token); + expect(String(error)).toContain(DAYTONA_TOKEN_PLACEHOLDER); + }); + it("runs no ssh command before the include file's tokens are loaded, retrying a failed load", async () => { const stale = "earlier-process-token-unloaded-9c1d"; rig.plane.addSandbox({ id: "sb-old" }); From 2cac8ed5ad9637225a23c367f51cf6ff8ab84914 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:16:15 +0000 Subject: [PATCH 186/231] refactor(daytona): race shared requests with the existing abort helper --- src/node/services/daytona/daytonaService.ts | 30 ++++++++++++++------- src/node/utils/abort.ts | 22 --------------- 2 files changed, 21 insertions(+), 31 deletions(-) diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 4b827b90e75..ef0c5314052 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -26,7 +26,8 @@ import { type RuntimeConfig, } from "@/common/types/runtime"; import { log } from "@/node/services/log"; -import { raceAbort, sleepWithAbort } from "@/node/utils/abort"; +import { sleepWithAbort } from "@/node/utils/abort"; +import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; import { DaytonaApiError, @@ -1093,7 +1094,9 @@ export class DaytonaService { if (provenAt !== undefined && this.now() - provenAt < DAYTONA_READY_CACHE_MS) return "owned"; // Concurrent checks share one read, so they reach the shared start together. const request = this.ownershipRequests.get(sandboxId) ?? this.proveOwnership(sandboxId); - return raceAbort(request, options.signal); + const raced = await raceWithAbortAndTimeout(request, { signal: options.signal }); + if (raced.kind !== "ok") throw new Error("Aborted"); + return raced.value; } private proveOwnership(sandboxId: string): Promise<"owned" | "notOwned" | "missing"> { @@ -1147,16 +1150,25 @@ export class DaytonaService { * block). Concurrent callers for one sandbox share a single request; each still gets its own * `onStarting` call. Throws a DaytonaApiError (`not_found` when the sandbox is gone). */ - ensureSandboxReady(sandboxId: string, options: DaytonaEnsureReadyOptions = {}): Promise { + async ensureSandboxReady( + sandboxId: string, + options: DaytonaEnsureReadyOptions = {} + ): Promise { const request = this.readyRequests.get(sandboxId) ?? this.startReadyRequest(sandboxId); const { onStarting, signal } = options; - if (!onStarting || signal?.aborted) return raceAbort(request.promise, signal); // Per waiter, so the same callback passed twice is two waiters. - const listener = () => onStarting(); - request.onStarting.add(listener); - if (request.starting) listener(); - // A waiter that stopped waiting (cancelled) hears no later start: nothing would report its end. - return raceAbort(request.promise, signal).finally(() => request.onStarting.delete(listener)); + const listener = onStarting && !signal?.aborted ? () => onStarting() : undefined; + if (listener) { + request.onStarting.add(listener); + if (request.starting) listener(); + } + try { + const raced = await raceWithAbortAndTimeout(request.promise, { signal }); + if (raced.kind !== "ok") throw new Error("Aborted"); + } finally { + // A waiter that stopped waiting (cancelled) hears no later start: nothing would report its end. + if (listener) request.onStarting.delete(listener); + } } private startReadyRequest(sandboxId: string): SandboxReadyRequest { diff --git a/src/node/utils/abort.ts b/src/node/utils/abort.ts index 20d2ae5ed51..d55c36094ce 100644 --- a/src/node/utils/abort.ts +++ b/src/node/utils/abort.ts @@ -1,5 +1,3 @@ -import { getErrorMessage } from "@/common/utils/errors"; - /** * AbortSignal helpers. * @@ -69,23 +67,3 @@ export async function sleepWithAbort(ms: number, abortSignal?: AbortSignal): Pro abortSignal?.addEventListener("abort", onAbort, { once: true }); }); } - -/** Resolve with `promise`, or reject as soon as `signal` aborts (the shared work keeps going). */ -export function raceAbort(promise: Promise, signal: AbortSignal | undefined): Promise { - if (!signal) return promise; - if (signal.aborted) return Promise.reject(new Error("Aborted")); - return new Promise((resolve, reject) => { - const onAbort = () => reject(new Error("Aborted")); - signal.addEventListener("abort", onAbort, { once: true }); - promise.then( - (value) => { - signal.removeEventListener("abort", onAbort); - resolve(value); - }, - (error: unknown) => { - signal.removeEventListener("abort", onAbort); - reject(error instanceof Error ? error : new Error(getErrorMessage(error))); - } - ); - }); -} From fd0891c276619353d5b97f53e404b8bfdb9fe45d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:17:04 +0000 Subject: [PATCH 187/231] refactor(daytona): use the shared not-found check --- src/node/runtime/DaytonaSSHRuntime.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index f5277ca722b..89dc83a295c 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -42,7 +42,11 @@ import { isSSHRuntime } from "@/common/types/runtime"; import type { Result } from "@/common/types/result"; import { Err, Ok } from "@/common/types/result"; import { getErrorMessage } from "@/common/utils/errors"; -import { DaytonaApiError, type DaytonaSandbox } from "@/node/services/daytona/daytonaClient"; +import { + DaytonaApiError, + isDaytonaNotFound, + type DaytonaSandbox, +} from "@/node/services/daytona/daytonaClient"; import { DAYTONA_LABEL_INSTANCE, DAYTONA_LABEL_WORKSPACE_ID, @@ -1505,7 +1509,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { await this.daytonaService.ensureSandboxReady(sandboxId, { signal: abortSignal }); return "ready"; } catch (error) { - if (error instanceof DaytonaApiError && error.kind === "not_found") return "missing"; + if (isDaytonaNotFound(error)) return "missing"; // A sandbox on its way out (a failed setup deleting it) has no work left to check. if (await this.isBeingDestroyed(sandboxId)) return "missing"; return { From d0796678485b8d5fb3d1d1062f0f2f9ba23ebf70 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:18:51 +0000 Subject: [PATCH 188/231] refactor(ssh): share the workspace parent directory setup with Coder --- src/node/runtime/CoderSSHRuntime.ts | 18 +----------------- src/node/runtime/DaytonaSSHRuntime.ts | 18 +----------------- src/node/runtime/SSHRuntime.ts | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 34 deletions(-) diff --git a/src/node/runtime/CoderSSHRuntime.ts b/src/node/runtime/CoderSSHRuntime.ts index 82158b99979..9423d0156f5 100644 --- a/src/node/runtime/CoderSSHRuntime.ts +++ b/src/node/runtime/CoderSSHRuntime.ts @@ -29,9 +29,6 @@ import type { CoderService, WorkspaceStatusResult } from "@/node/services/coderS import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import { log } from "@/node/services/log"; -import { execBuffered } from "@/node/utils/runtime/helpers"; -import { expandTildeForSSH } from "./tildeExpansion"; -import * as path from "path"; import { getErrorMessage } from "@/common/utils/errors"; export interface CoderSSHRuntimeConfig extends SSHRuntimeConfig { @@ -901,20 +898,7 @@ export class CoderSSHRuntime extends SSHRuntime { throw new Error(`Failed to configure SSH for Coder: ${errorMsg}`); } - // Create parent directory for workspace (git clone won't create it) // This must happen after ensureXumCoderSSHConfig() so SSH is configured - initLogger.logStep("Preparing workspace directory..."); - const parentDir = path.posix.dirname(params.workspacePath); - const mkdirResult = await execBuffered(this, `mkdir -p ${expandTildeForSSH(parentDir)}`, { - cwd: "/tmp", - timeout: 10, - abortSignal, - }); - if (mkdirResult.exitCode !== 0) { - const errorMsg = mkdirResult.stderr || mkdirResult.stdout || "Unknown error"; - log.error("Failed to create workspace parent directory", { parentDir, error: errorMsg }); - initLogger.logStderr(`Failed to prepare workspace directory: ${errorMsg}`); - throw new Error(`Failed to prepare workspace directory: ${errorMsg}`); - } + await this.prepareWorkspaceParentDirectory(params); } } diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 89dc83a295c..401fee5eb93 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -59,7 +59,6 @@ import { import { applyArchiveSettingToIdleSandbox } from "@/node/services/daytona/daytonaLifecycleHooks"; import { log } from "@/node/services/log"; import { execBuffered } from "@/node/utils/runtime/helpers"; -import { expandTildeForSSH } from "./tildeExpansion"; import { redactChildStderr, redactDaytonaError, @@ -941,7 +940,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { params.initLogger.logStep("Daytona sandbox created"); } await this.connect(sandboxId, params); - await this.prepareWorkspaceDirectory(params); + await this.prepareWorkspaceParentDirectory(params); } catch (error) { const errorMsg = redactDaytonaTokens(getErrorMessage(error)); log.error("Daytona sandbox setup failed", { sandboxId, error: errorMsg }); @@ -966,21 +965,6 @@ export class DaytonaSSHRuntime extends SSHRuntime { }); } - /** git clone does not create the parent directory of the workspace. */ - private async prepareWorkspaceDirectory(params: WorkspaceInitParams): Promise { - params.initLogger.logStep("Preparing workspace directory..."); - const parentDir = path.posix.dirname(params.workspacePath); - const mkdirResult = await execBuffered(this, `mkdir -p ${expandTildeForSSH(parentDir)}`, { - cwd: "/tmp", - timeout: 10, - abortSignal: params.abortSignal, - }); - if (mkdirResult.exitCode !== 0) { - const errorMsg = mkdirResult.stderr || mkdirResult.stdout || "Unknown error"; - throw new Error(`Failed to prepare workspace directory: ${errorMsg}`); - } - } - private async deleteCreatedSandbox( sandboxId: string, create: CreateAttempt, diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index 50f89d8dbc1..4758de8474a 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -2575,6 +2575,23 @@ export class SSHRuntime extends RemoteRuntime { } } + /** For hosts provisioned in postCreateSetup (Coder, Daytona): git clone does not create the parent. */ + protected async prepareWorkspaceParentDirectory(params: WorkspaceInitParams): Promise { + params.initLogger.logStep("Preparing workspace directory..."); + const parentDir = path.posix.dirname(params.workspacePath); + const mkdirResult = await execBuffered(this, `mkdir -p ${expandTildeForSSH(parentDir)}`, { + cwd: "/tmp", + timeout: 10, + abortSignal: params.abortSignal, + }); + if (mkdirResult.exitCode !== 0) { + const errorMsg = mkdirResult.stderr || mkdirResult.stdout || "Unknown error"; + log.error("Failed to create workspace parent directory", { parentDir, error: errorMsg }); + params.initLogger.logStderr(`Failed to prepare workspace directory: ${errorMsg}`); + throw new Error(`Failed to prepare workspace directory: ${errorMsg}`); + } + } + async initWorkspace(params: WorkspaceInitParams): Promise { // Disable git hooks for untrusted projects (prevents post-checkout execution) const nhp = gitNoHooksPrefix(params.trusted); From 25126d00080cbff74e8fa11abfb3c279a08aa884 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:04:38 +0000 Subject: [PATCH 189/231] refactor(daytona): drop the existing-sandbox picker --- .../features/ChatInput/CreationControls.tsx | 1 - src/browser/features/ChatInput/utils.ts | 19 +- .../Runtime/DaytonaControls.stories.tsx | 36 +--- .../features/Runtime/DaytonaControls.tsx | 179 ++---------------- .../Sections/DaytonaSettingsForms.tsx | 3 +- .../Settings/Sections/GeneralSection.tsx | 5 +- src/browser/hooks/useDaytonaResources.test.ts | 26 +-- src/browser/hooks/useDaytonaResources.ts | 73 ++----- .../hooks/useDraftWorkspaceSettings.test.tsx | 10 +- .../hooks/useDraftWorkspaceSettings.ts | 2 +- src/browser/stories/mocks/orpc.ts | 4 - src/browser/utils/runtimeOptionDefaults.ts | 4 +- 12 files changed, 58 insertions(+), 304 deletions(-) diff --git a/src/browser/features/ChatInput/CreationControls.tsx b/src/browser/features/ChatInput/CreationControls.tsx index 7a67aeec944..e6ad78041ea 100644 --- a/src/browser/features/ChatInput/CreationControls.tsx +++ b/src/browser/features/ChatInput/CreationControls.tsx @@ -1025,7 +1025,6 @@ function CreationControlsContent(props: CreationControlsProps) { }) } disabled={props.disabled} - hasError={props.runtimeFieldError === "ssh"} />
)} diff --git a/src/browser/features/ChatInput/utils.ts b/src/browser/features/ChatInput/utils.ts index e38a69aa668..38134408987 100644 --- a/src/browser/features/ChatInput/utils.ts +++ b/src/browser/features/ChatInput/utils.ts @@ -19,7 +19,6 @@ import { } from "@/common/utils/tools/mcpPromptCommandKey"; import type { ParsedRuntime } from "@/common/types/runtime"; import type { CoderWorkspaceConfig } from "@/common/orpc/schemas/coder"; -import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; import { buildAgentSkillMetadata, dedupeAgentSkillRefs, @@ -37,8 +36,7 @@ export type CreationRuntimeValidationError = | { mode: "ssh"; kind: "missingHost" } | { mode: "ssh"; kind: "missingCoderWorkspace" } | { mode: "ssh"; kind: "missingCoderTemplate" } - | { mode: "ssh"; kind: "missingCoderPreset" } - | { mode: "ssh"; kind: "missingDaytonaSandbox" }; + | { mode: "ssh"; kind: "missingCoderPreset" }; export interface SkillInvocation { descriptor: InvocableSkill; @@ -472,18 +470,6 @@ function validateCoderRuntime( return null; } -/** - * A new sandbox needs nothing more (snapshot and size have defaults); attaching (shared) - * needs the picked sandbox. - */ -function validateDaytonaRuntime( - daytona: DaytonaSandboxConfig -): CreationRuntimeValidationError | null { - return daytona.shared === true && isBlank(daytona.sandboxId) - ? { mode: "ssh", kind: "missingDaytonaSandbox" } - : null; -} - export function validateCreationRuntime( runtime: ParsedRuntime, coderPresetCount: number @@ -493,7 +479,8 @@ export function validateCreationRuntime( } if (runtime.mode === "ssh") { - if (runtime.daytona) return validateDaytonaRuntime(runtime.daytona); + // Xum creates the sandbox, and snapshot and size have defaults. + if (runtime.daytona) return null; if (runtime.coder) return validateCoderRuntime(runtime.coder, coderPresetCount); return runtime.host.trim() ? null : { mode: "ssh", kind: "missingHost" }; } diff --git a/src/browser/features/Runtime/DaytonaControls.stories.tsx b/src/browser/features/Runtime/DaytonaControls.stories.tsx index bc5e58d91d0..25a63bd7461 100644 --- a/src/browser/features/Runtime/DaytonaControls.stories.tsx +++ b/src/browser/features/Runtime/DaytonaControls.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { useState } from "react"; -import { expect, userEvent, within } from "storybook/test"; +import { expect, within } from "storybook/test"; import { lightweightMeta, PIXEL_DISABLED } from "@/browser/stories/meta.js"; import { EMPTY_DAYTONA_RESOURCES, @@ -18,10 +18,6 @@ const READY_RESOURCES: DaytonaResources = { target: null, }, snapshots: ["daytonaio/sandbox:0.4.3", "xum-node-22-with-a-long-snapshot-name"], - sandboxes: [ - { id: "sb-1", name: "shared-build-box", state: "started", cpu: 4, memoryGiB: 8 }, - { id: "sb-2", name: "nightly-ci", state: "stopped" }, - ], }; const PHONE_VIEWPORT = { @@ -50,19 +46,13 @@ type Story = StoryObj; function StatefulForm(props: { initialConfig: DaytonaSandboxConfig; resources: DaytonaResources; - allowExisting: boolean; /** Phone stories force the width: the test-runner plays at desktop size. */ width?: number; }) { const [config, setConfig] = useState(props.initialConfig); return (
- +
); } @@ -70,7 +60,7 @@ function StatefulForm(props: { /** New sandbox: snapshot (Daytona default first) and a size preset, as on workspace creation. */ export const NewSandbox: Story = { globals: { viewport: { value: "desktop", isRotated: false } }, - render: () => , + render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByRole("combobox", { name: "Daytona snapshot" })).toHaveTextContent( @@ -94,7 +84,6 @@ export const NewSandboxPhone: Story = { diskGiB: 40, }} resources={READY_RESOURCES} - allowExisting width={390} /> ), @@ -104,23 +93,6 @@ export const NewSandboxPhone: Story = { }, }; -/** Existing: picking a sandbox attaches the workspace to it (shared, never deleted by Xum). */ -export const ExistingSandbox: Story = { - globals: { viewport: { value: "desktop", isRotated: false } }, - render: () => , - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("button", { name: "Existing" })); - await userEvent.click(canvas.getByRole("combobox", { name: "Existing Daytona sandbox" })); - await userEvent.click( - await within(document.body).findByRole("option", { name: /shared-build-box/ }) - ); - await expect( - canvas.getByRole("combobox", { name: "Existing Daytona sandbox" }) - ).toHaveTextContent("shared-build-box"); - }, -}; - /** No API key yet: the form points to Settings instead of offering controls that would fail. */ export const NoApiKeyPhone: Story = { globals: { viewport: { value: "phone390", isRotated: false } }, @@ -136,7 +108,6 @@ export const NoApiKeyPhone: Story = { target: null, }, }} - allowExisting width={390} /> ), @@ -158,7 +129,6 @@ export const ListErrors: Story = { snapshots: [], snapshotsError: "Daytona rejected the API key (401: Invalid credentials)", }} - allowExisting /> ), }; diff --git a/src/browser/features/Runtime/DaytonaControls.tsx b/src/browser/features/Runtime/DaytonaControls.tsx index 5d2d848b044..2fd80d77780 100644 --- a/src/browser/features/Runtime/DaytonaControls.tsx +++ b/src/browser/features/Runtime/DaytonaControls.tsx @@ -1,7 +1,6 @@ /** * Daytona sandbox controls for the SSH-based Daytona runtime: create a sandbox from a snapshot - * at a size preset, or attach to an existing sandbox. Used by workspace creation and by the - * project defaults in Settings > Runtimes. + * at a size preset. Used by workspace creation and by the project defaults in Settings > Runtimes. */ import { Loader2 } from "lucide-react"; import { cn } from "@/common/lib/utils"; @@ -14,7 +13,6 @@ import { } from "@/common/constants/daytona"; import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; import { useDaytonaResources, type DaytonaResources } from "@/browser/hooks/useDaytonaResources"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/Tooltip/Tooltip"; import { Select, SelectContent, @@ -26,13 +24,6 @@ import { // Radix Select reserves the empty string, so "use Daytona's default snapshot" needs a token. const DEFAULT_SNAPSHOT_VALUE = "__daytona_default__"; -type DaytonaMode = "new" | "existing"; - -/** Attaching (shared) is the only way a creation config names a sandbox up front. */ -function getMode(config: DaytonaSandboxConfig): DaytonaMode { - return config.shared === true ? "existing" : "new"; -} - export function getDaytonaSizePresetId(config: DaytonaSandboxConfig): DaytonaSizePresetId { return findDaytonaSizePreset(config)?.id ?? DAYTONA_DEFAULT_SIZE_PRESET_ID; } @@ -78,36 +69,6 @@ const ROW_CLASS = "flex min-h-7 items-center gap-2"; const LABEL_CLASS = "text-muted-foreground w-16 shrink-0 text-xs"; const TRIGGER_CLASS = "h-7 min-w-0 flex-1 text-xs"; -function ModeButton(props: { - mode: DaytonaMode; - active: boolean; - disabled?: boolean; - tooltip: string; - onSelect: (mode: DaytonaMode) => void; -}) { - return ( - - - - - {props.tooltip} - - ); -} - function SnapshotRow(props: { config: DaytonaSandboxConfig; onConfigChange: (config: DaytonaSandboxConfig) => void; @@ -194,101 +155,15 @@ function SizeRow(props: { ); } -function ExistingSandboxRow(props: { - config: DaytonaSandboxConfig; - onConfigChange: (config: DaytonaSandboxConfig) => void; - resources: DaytonaResources; - disabled?: boolean; -}) { - const { sandboxes, sandboxesError, loadingSandboxes } = props.resources; - const placeholder = sandboxesError - ? "Error loading sandboxes" - : sandboxes.length === 0 - ? "No sandboxes found" - : "Select sandbox..."; - return ( - <> -
- - {loadingSandboxes ? ( - - ) : ( - - )} -
- {sandboxesError && ( -

- Could not list sandboxes: {sandboxesError} -

- )} - - ); -} - export interface DaytonaSandboxFormProps { config: DaytonaSandboxConfig; onConfigChange: (config: DaytonaSandboxConfig) => void; resources: DaytonaResources; - /** Creation only: attach to an existing sandbox. Project defaults always describe a new one. */ - allowExisting: boolean; disabled?: boolean; - hasError?: boolean; className?: string; } -function DaytonaModeToggle(props: { - config: DaytonaSandboxConfig; - onConfigChange: (config: DaytonaSandboxConfig) => void; - disabled?: boolean; -}) { - const mode = getMode(props.config); - const handleModeChange = (next: DaytonaMode) => { - if (next === mode) return; - // Existing starts unselected; New keeps nothing from the attached sandbox. - props.onConfigChange(next === "existing" ? { shared: true } : {}); - }; - return ( -
- - -
- ); -} - export function DaytonaSandboxForm(props: DaytonaSandboxFormProps) { - const mode = props.allowExisting ? getMode(props.config) : "new"; const ready = props.resources.config?.apiKeySet === true; return ( @@ -296,7 +171,7 @@ export function DaytonaSandboxForm(props: DaytonaSandboxFormProps) { className={cn( "flex w-full flex-col gap-1.5 rounded-md border p-2", props.className ?? "max-w-[22rem]", - props.hasError === true ? "border-red-500" : "border-border-medium" + "border-border-medium" )} data-testid="daytona-sandbox-form" > @@ -304,35 +179,17 @@ export function DaytonaSandboxForm(props: DaytonaSandboxFormProps) { ) : ( <> - {props.allowExisting && ( - - )} - {mode === "new" ? ( - <> - - - - ) : ( - - )} + + )}
@@ -341,11 +198,9 @@ export function DaytonaSandboxForm(props: DaytonaSandboxFormProps) { /** * The workspace-creation form. Mounted only while Daytona is the picked runtime, so the - * snapshot and sandbox lists (account API calls) load only then. + * snapshot list (an account API call) loads only then. */ -export function DaytonaCreationForm( - props: Omit -) { - const resources = useDaytonaResources({ loadSandboxes: true }); - return ; +export function DaytonaCreationForm(props: Omit) { + const resources = useDaytonaResources(); + return ; } diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx index b6a2ce10d0b..779de7d6deb 100644 --- a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx @@ -350,7 +350,7 @@ export function DaytonaAccessForm() { * other runtime option defaults so the creation controls start from them. */ export function DaytonaDefaultsForm(props: { projectPath: string; disabled?: boolean }) { - const resources = useDaytonaResources({ loadSandboxes: false }); + const resources = useDaytonaResources(); const defaults = readDaytonaOptionDefaults(useRuntimeOptionDefaults(props.projectPath)); return ( ); diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index bc5c92c650d..174ad4b7c44 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -1152,9 +1152,8 @@ export function GeneralSection() {
Remote box on archive
- Action to take on dedicated Coder workspaces and Daytona sandboxes when archiving a - chat. Delete is permanent; a Daytona sandbox with uncommitted or unpushed work is - stopped instead. + Action to take on dedicated Coder workspaces when archiving a chat. Delete is + permanent. Archiving a Daytona chat always deletes the sandbox it created.
props.onChange(event.target.value)} - placeholder={props.placeholder} - disabled={props.disabled} - className="min-w-0" - /> - - ); -} -function AccessActions(props: { - disabled: boolean; - dirty: boolean; - saving: boolean; - keySet: boolean; - /** Only a key saved in Settings can be removed here; an env key is not Xum's to drop. */ - canRemoveKey: boolean; - testing: boolean; - onSave: () => void; - onTest: () => void; - onRemoveKey: () => void; -}) { - return ( -
- - - {props.canRemoveKey && ( - - )} -
- ); + return { api, config, saving, error, test, save, runTest }; } function AccessMessages(props: { test: TestState; error: string | null }) { @@ -288,98 +104,67 @@ function AccessMessages(props: { test: TestState; error: string | null }) { */ export function DaytonaAccessForm() { const access = useDaytonaAccess(); - const drafts = useDaytonaAccessDrafts(access.config); - // Offline, edits stay on screen but Save and Test are disabled (as in Secrets): they could - // not reach the server. - const disabled = !access.loaded || access.saving; + const [apiKey, setApiKey] = useState(""); + const disabled = !access.config || access.saving || !access.api; const keySet = access.config?.apiKeySet === true; - const save = async (input: DaytonaAccessInput) => { - if (await access.save(input)) drafts.reset(); + const save = async (value: string) => { + if (await access.save(value)) setApiKey(""); }; return (
- - - - void save(toAccessInput(drafts))} - onTest={() => void access.runTest()} - onRemoveKey={() => void save({ apiKey: "" })} - /> +
+
+ + {/* Beside the label, not inside it, so the field's name stays the label alone. */} + + {describeKey(access.config)} + +
+ setApiKey(event.target.value)} + placeholder={keySet ? "Enter a new key to replace it" : "Paste a Daytona API key"} + disabled={disabled} + className="min-w-0" + /> +
+
+ + + {/* Only a key saved in Settings can be removed here; an env key is not Xum's to drop. */} + {access.config?.apiKeySource === "settings" && ( + + )} +

- Without a key here, Xum uses DAYTONA_API_KEY and DAYTONA_API_URL from its own environment. + Without a key here, Xum uses DAYTONA_API_KEY from its own environment. DAYTONA_API_URL and + DAYTONA_TARGET there pick a self-hosted deployment and a default region.

); } - -/** - * Project defaults for new Daytona workspaces (snapshot and size), saved with the project's - * other runtime option defaults so the creation controls start from them. - */ -export function DaytonaDefaultsForm(props: { projectPath: string; disabled?: boolean }) { - const resources = useDaytonaResources(); - const defaults = readDaytonaOptionDefaults(useRuntimeOptionDefaults(props.projectPath)); - return ( - - updateRuntimeOptionDefaults(props.projectPath, (prev) => - writeDaytonaOptionDefaults(prev, next) - ) - } - resources={resources} - disabled={props.disabled} - /> - ); -} - -/** The Daytona row's body: access in the global scope, defaults in a project scope. */ -export function DaytonaRuntimeSettings(props: { - projectPath: string | null; - enabled: boolean; - disabled: boolean; -}) { - if (!props.projectPath) return ; - if (!props.enabled) return null; - return ( - - ); -} diff --git a/src/browser/features/Settings/Sections/RuntimesSection.tsx b/src/browser/features/Settings/Sections/RuntimesSection.tsx index 9a3a603a7a9..ec619a8c227 100644 --- a/src/browser/features/Settings/Sections/RuntimesSection.tsx +++ b/src/browser/features/Settings/Sections/RuntimesSection.tsx @@ -10,7 +10,7 @@ import { resolveCoderAvailability, } from "@/browser/features/Runtime/CoderControls"; import { RuntimeConfigInput } from "@/browser/components/RuntimeConfigInput/RuntimeConfigInput"; -import { DaytonaRuntimeSettings } from "./DaytonaSettingsForms"; +import { DaytonaAccessForm } from "./DaytonaSettingsForms"; import { Select, SelectContent, @@ -773,12 +773,8 @@ export function RuntimesSection() { disabled={rowDisabled || coderAvailability.state !== "available"} /> ) : null} - {runtime.id === "daytona" ? ( - + {runtime.id === "daytona" && !selectedProjectPath ? ( + ) : null} diff --git a/src/browser/hooks/useDaytonaResources.test.ts b/src/browser/hooks/useDaytonaResources.test.ts deleted file mode 100644 index 4e42c643bc0..00000000000 --- a/src/browser/hooks/useDaytonaResources.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { restoreDomGlobals, saveDomGlobals } from "../../../tests/ui/domGlobals"; -import { createElement, type ComponentProps, type ReactNode } from "react"; -import { act, cleanup, render, renderHook, waitFor } from "@testing-library/react"; -import { GlobalWindow } from "happy-dom"; -import { APIProvider } from "@/browser/contexts/API"; -import { createKeptClientConnection } from "@/browser/contexts/connection.testHarness"; -import { ConnectionLostError } from "@/browser/contexts/connectionLink"; -import { createTestApiClient } from "@/browser/testUtils"; -import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; -import type { DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; -import { DaytonaSandboxForm } from "@/browser/features/Runtime/DaytonaControls"; -import { useDaytonaResources } from "./useDaytonaResources"; - -describe("useDaytonaResources", () => { - beforeEach(() => { - saveDomGlobals(); - globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; - globalThis.document = globalThis.window.document; - }); - - afterEach(() => { - cleanup(); - mock.restore(); - restoreDomGlobals(); - }); - - test("reloads the config and snapshots when Daytona access is saved in Settings", async () => { - let apiKeySet = false; - const getConfig = mock( - (): Promise => - Promise.resolve({ - apiKeySet, - apiKeySource: apiKeySet ? "settings" : null, - apiUrl: "https://app.daytona.io/api", - target: null, - }) - ); - const client = createTestApiClient({ - daytona: { - getConfig, - listSnapshots: () => Promise.resolve({ ok: true as const, snapshots: [{ name: "base" }] }), - }, - }); - const wrapper = (props: { children: ReactNode }) => - createElement(APIProvider, { client } as ComponentProps, props.children); - const { result } = renderHook(() => useDaytonaResources(), { wrapper }); - await waitFor(() => expect(result.current.config?.apiKeySet).toBe(false)); - expect(result.current.snapshots).toEqual([]); - - // Settings covers the creation controls, which stay mounted while the key is saved. - apiKeySet = true; - act(() => { - window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.DAYTONA_CONFIG_CHANGED)); - }); - - await waitFor(() => expect(result.current.snapshots).toEqual(["base"])); - expect(result.current.config?.apiKeySet).toBe(true); - }); - test("loads once the connection is live again after a load that failed while reconnecting", async () => { - let live = false; - const whenLive = (value: T): Promise => - live ? Promise.resolve(value) : Promise.reject(new ConnectionLostError()); - const client = createTestApiClient({ - daytona: { - getConfig: () => - whenLive({ - apiKeySet: true, - apiKeySource: "settings", - apiUrl: "https://app.daytona.io/api", - target: null, - }), - listSnapshots: () => whenLive({ ok: true as const, snapshots: [{ name: "base" }] }), - }, - }); - const connection = createKeptClientConnection(client); - const { result } = renderHook(() => useDaytonaResources(), { wrapper: connection.Wrapper }); - // A reconnect gap is not a Daytona error: the form waits instead of reporting one. - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); - expect(result.current.configError).toBeNull(); - - live = true; - connection.setStatus("connected"); - - await waitFor(() => expect(result.current.snapshots).toEqual(["base"])); - expect(result.current.config?.apiKeySet).toBe(true); - expect(result.current.configError).toBeNull(); - }); - - // Kept, the old account's config would keep the form ready with its snapshots. - test("drops what the previous load found when a reload cannot read the config", async () => { - let failing = false; - const client = createTestApiClient({ - daytona: { - getConfig: (): Promise => - failing - ? Promise.reject(new Error("backend unreachable")) - : Promise.resolve({ - apiKeySet: true, - apiKeySource: "settings", - apiUrl: "https://app.daytona.io/api", - target: null, - }), - listSnapshots: () => Promise.resolve({ ok: true as const, snapshots: [{ name: "base" }] }), - }, - }); - const wrapper = (props: { children: ReactNode }) => - createElement(APIProvider, { client } as ComponentProps, props.children); - const { result } = renderHook(() => useDaytonaResources(), { wrapper }); - await waitFor(() => expect(result.current.snapshots).toEqual(["base"])); - - failing = true; - act(() => { - window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.DAYTONA_CONFIG_CHANGED)); - }); - - await waitFor(() => expect(result.current.configError).toBe("backend unreachable")); - expect(result.current.config).toBeNull(); - expect(result.current.snapshots).toEqual([]); - const form = render( - createElement(DaytonaSandboxForm, { - config: {}, - onConfigChange: () => undefined, - resources: result.current, - }) - ); - expect(form.getByRole("alert").textContent).toContain("backend unreachable"); - expect(form.queryByText("base")).toBeNull(); - }); -}); diff --git a/src/browser/hooks/useDaytonaResources.ts b/src/browser/hooks/useDaytonaResources.ts index 502fa210149..acf82684acd 100644 --- a/src/browser/hooks/useDaytonaResources.ts +++ b/src/browser/hooks/useDaytonaResources.ts @@ -1,12 +1,10 @@ /** - * Daytona data for the creation controls and Settings: the public credential view (never the - * key) and snapshots for the snapshot select. - * The runtime config itself is owned by the caller (selectedRuntime.daytona or project defaults). + * Daytona data for the creation controls: the public credential view (never the key) and + * snapshots for the snapshot select. The runtime config itself is owned by the caller. */ import { useEffect, useState } from "react"; -import { useAPI, useLiveAPI } from "@/browser/contexts/API"; +import { useAPI } from "@/browser/contexts/API"; import type { DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; -import { CUSTOM_EVENTS } from "@/common/constants/events"; import { getErrorMessage } from "@/common/utils/errors"; export interface DaytonaResources { @@ -19,7 +17,7 @@ export interface DaytonaResources { loadingSnapshots: boolean; } -export const EMPTY_DAYTONA_RESOURCES: DaytonaResources = { +const EMPTY_DAYTONA_RESOURCES: DaytonaResources = { config: null, configError: null, snapshots: [], @@ -27,62 +25,23 @@ export const EMPTY_DAYTONA_RESOURCES: DaytonaResources = { loadingSnapshots: false, }; -/** - * Changes each time Daytona access is saved in Settings. Settings covers views that stay - * mounted (the creation controls), so what they loaded from the old access must reload. - */ -export function useDaytonaConfigVersion(): number { - const [version, setVersion] = useState(0); - useEffect(() => { - const bump = () => setVersion((previous) => previous + 1); - window.addEventListener(CUSTOM_EVENTS.DAYTONA_CONFIG_CHANGED, bump); - return () => window.removeEventListener(CUSTOM_EVENTS.DAYTONA_CONFIG_CHANGED, bump); - }, []); - return version; -} - -/** What a load depends on: another API connection or saved access starts from empty. */ -interface DaytonaLoadKey { - api: unknown; - configVersion: number; -} - -interface LoadedDaytonaResources extends DaytonaLoadKey { - resources: DaytonaResources; -} - -/** `loaded`'s resources if they came from the load `key` names, else empty ones. */ -function resourcesFor(loaded: LoadedDaytonaResources | null, key: DaytonaLoadKey) { - return loaded !== null && loaded.api === key.api && loaded.configVersion === key.configVersion - ? loaded.resources - : EMPTY_DAYTONA_RESOURCES; -} - export function useDaytonaResources(): DaytonaResources { const { api } = useAPI(); - // Loads run on the live client only. While Xum reconnects `api` can stay the last client, - // whose calls fail at once, and the same object may come back: a load keyed on it alone would - // keep that failure until something else changed. - const liveApi = useLiveAPI(); - const configVersion = useDaytonaConfigVersion(); - // One state object so consumers get a stable identity until something actually loads. - const [loaded, setLoaded] = useState(null); + const [resources, setResources] = useState(EMPTY_DAYTONA_RESOURCES); useEffect(() => { - if (!liveApi) return; + if (!api) return; let active = true; const update = (patch: Partial) => { - if (!active) return; - const key = { api, configVersion }; - setLoaded((prev) => ({ ...key, resources: { ...resourcesFor(prev, key), ...patch } })); + if (active) setResources((prev) => ({ ...prev, ...patch })); }; const load = async () => { - const config = await liveApi.daytona.getConfig(); + const config = await api.daytona.getConfig(); update({ config, configError: null }); if (!config.apiKeySet) return; update({ loadingSnapshots: true }); - const result = await liveApi.daytona.listSnapshots(); + const result = await api.daytona.listSnapshots(); update( result.ok ? { @@ -106,9 +65,7 @@ export function useDaytonaResources(): DaytonaResources { return () => { active = false; }; - }, [api, liveApi, configVersion]); + }, [api]); - // A reload never shows what the previous load found: a failed or keyless reload would - // otherwise leave the form ready with the old account's config and snapshots. - return resourcesFor(loaded, { api, configVersion }); + return resources; } diff --git a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx index 0d9c277fd35..1236f779d8f 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx +++ b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx @@ -6,10 +6,6 @@ import { APIProvider, type APIClient } from "@/browser/contexts/API"; import { ProjectProvider } from "@/browser/contexts/ProjectContext"; import { ThinkingProvider } from "@/browser/contexts/ThinkingContext"; import { CODER_RUNTIME_PLACEHOLDER } from "@/common/types/runtime"; -import { - readDaytonaOptionDefaults, - readRuntimeOptionDefaults, -} from "@/browser/utils/runtimeOptionDefaults"; import { useDraftWorkspaceSettings } from "./useDraftWorkspaceSettings"; import { createTestApiClient, createTestPreferencesConfig } from "@/browser/testUtils"; import { getAppConfigStore, getUserPreferences } from "@/browser/stores/AppConfigStore"; @@ -361,35 +357,6 @@ describe("useDraftWorkspaceSettings", () => { }); }); - test("restores the draft's Daytona choices after switching runtimes, without saving them as defaults", async () => { - const projectPath = "/tmp/project"; - const projectDefaults = { - ssh: { daytona: { snapshot: "base", cpu: 2, memoryGiB: 4, diskGiB: 10 } }, - }; - const wrapper = createWrapper(projectPath, runtimeConfigPrefs(projectPath, projectDefaults)); - const { result } = renderHook( - () => useDraftWorkspaceSettings(projectPath, ["main"], "main", draftId), - { wrapper } - ); - await waitFor(() => expect(result.current.daytonaConfigFallback.snapshot).toBe("base")); - - // A larger sandbox picked in this draft, then another runtime. - const picked = { snapshot: "base", cpu: 4, memoryGiB: 8, diskGiB: 20 }; - act(() => { - result.current.setSelectedRuntime({ mode: "ssh", host: "placeholder", daytona: picked }); - }); - act(() => { - result.current.setSelectedRuntime({ mode: "worktree" }); - }); - - // Switching back to Daytona starts from this fallback. - expect(result.current.daytonaConfigFallback).toEqual(picked); - // The draft's pick never becomes a project default. - expect(readDaytonaOptionDefaults(readRuntimeOptionDefaults(projectPath))).toEqual( - projectDefaults.ssh.daytona - ); - }); - test("exposes persisted SSH host as fallback when leaving Coder", async () => { const projectPath = "/tmp/project"; diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index bb18840c3fe..4616abe77a5 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -15,7 +15,6 @@ import { import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; import type { RuntimeChoice } from "@/browser/utils/runtimeUi"; import { - readDaytonaOptionDefaults, readOptionField, readSshOptionDefaults, type RuntimeOptionDefaults, @@ -171,21 +170,6 @@ const mergeRememberedRuntimeConfig = ( } }; -/** The draft's last Daytona choice, else the project's Daytona defaults. */ -function readDraftDaytonaFallback( - execution: { lastDaytona?: DaytonaSandboxConfig } | null | undefined, - defaults: RuntimeOptionDefaults -): DaytonaSandboxConfig { - return execution?.lastDaytona ?? readDaytonaOptionDefaults(defaults); -} - -/** Only in the draft: Daytona project defaults change only in Settings > Runtimes. */ -function rememberedDaytonaPatch(runtime: ParsedRuntime): { lastDaytona?: DaytonaSandboxConfig } { - return runtime.mode === RUNTIME_MODE.SSH && runtime.daytona - ? { lastDaytona: runtime.daytona } - : {}; -} - /** The default runtime-type button: Coder and Daytona are SSH sub-choices. */ const resolveDefaultRuntimeChoice = ( settingsDefaultRuntime: RuntimeChoice, @@ -263,8 +247,6 @@ export function useDraftWorkspaceSettings( coderConfigFallback: CoderWorkspaceConfig; /** Preserves the last SSH host when leaving Coder so the input stays populated. */ sshHostFallback: string; - /** Project Daytona defaults (Settings > Runtimes) that start a Daytona selection. */ - daytonaConfigFallback: DaytonaSandboxConfig; /** Set the currently selected runtime (discriminated union) */ setSelectedRuntime: (runtime: ParsedRuntime) => void; setTrunkBranch: (branch: string) => void; @@ -451,14 +433,12 @@ export function useDraftWorkspaceSettings( // `setSelectedRuntime` changed on every keystroke and re-rendered CreationControls (#5937). // useDraftWorkspaceSettings.compiler.test.tsx guards their identity. // Uses discriminated union: SSH has host, Docker has image. - // Parsed into a fresh object, so it too must follow the last hook to keep its identity. - const daytonaConfigFallback = readDraftDaytonaFallback(execution, lastRuntimeConfigs); const defaultRuntime = buildRuntimeForMode( defaultRuntimeMode, { host: defaultSshHost, coder: defaultSshCoder, - daytona: daytonaDefault ? daytonaConfigFallback : undefined, + daytona: daytonaDefault ? {} : undefined, }, defaultDockerImage, lastShareCredentials, @@ -484,10 +464,7 @@ export function useDraftWorkspaceSettings( devcontainerShareCredentials: lastDevcontainerShareCredentials, }); - getCreationDraftStore().updateExecution(draftId, { - runtime: mergedRuntime, - ...rememberedDaytonaPatch(mergedRuntime), - }); + getCreationDraftStore().updateExecution(draftId, { runtime: mergedRuntime }); // Persist host/image/coder so they're remembered when switching modes. // Avoid wiping the remembered value when the UI switches modes with an empty field. @@ -541,7 +518,6 @@ export function useDraftWorkspaceSettings( }, coderConfigFallback, sshHostFallback, - daytonaConfigFallback, setSelectedRuntime, setTrunkBranch, getRuntimeString, diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index ceb85c11bff..27c90c66663 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -588,12 +588,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl coderTemplatesResult, coderPresetsResult = new Map(), coderWorkspacesResult, - daytonaConfig: initialDaytonaConfig = { - apiKeySet: false, - apiKeySource: null, - apiUrl: "https://app.daytona.io/api", - target: null, - }, + daytonaConfig: initialDaytonaConfig = { apiKeySet: false, apiKeySource: null }, daytonaTestConnectionResult = { ok: true }, daytonaSnapshotsResult = { ok: true, snapshots: [] }, agentSkills = [], @@ -2431,14 +2426,12 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }, daytona: { getConfig: () => Promise.resolve(daytonaConfig), - setConfig: (input: { apiKey?: string; apiUrl?: string; target?: string }) => { + setConfig: (input: { apiKey?: string }) => { const keyCleared = input.apiKey === ""; const keySet = input.apiKey !== undefined && !keyCleared; daytonaConfig = { apiKeySet: keySet || (!keyCleared && daytonaConfig.apiKeySet), apiKeySource: keySet ? "settings" : keyCleared ? null : daytonaConfig.apiKeySource, - apiUrl: input.apiUrl?.trim() ? input.apiUrl.trim() : daytonaConfig.apiUrl, - target: input.target === undefined ? daytonaConfig.target : input.target.trim() || null, }; return Promise.resolve(daytonaConfig); }, diff --git a/src/browser/utils/runtimeOptionDefaults.ts b/src/browser/utils/runtimeOptionDefaults.ts index ff0c578c771..73e22a91c10 100644 --- a/src/browser/utils/runtimeOptionDefaults.ts +++ b/src/browser/utils/runtimeOptionDefaults.ts @@ -5,10 +5,6 @@ import { } from "@/browser/stores/AppConfigStore"; import type { UserPreferences } from "@/common/config/schemas/userPreferences"; import type { CoderWorkspaceConfig } from "@/common/orpc/schemas/coder"; -import { - DaytonaSandboxConfigSchema, - type DaytonaSandboxConfig, -} from "@/common/orpc/schemas/daytona"; import { isManagedSshPlaceholder, RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime"; export type RuntimeOptionDefaults = Partial>; @@ -173,30 +169,3 @@ export function writeSshCoderDefaultsPreservingMode( coderConfig: nextCoderConfig, }); } - -/** - * Project Daytona defaults (snapshot and size preset), set in Settings > Runtimes and used to - * prefill new Daytona workspaces. Only the creation-time fields: a sandbox id would point - * every new workspace at one sandbox. - */ -export function readDaytonaOptionDefaults(defaults: RuntimeOptionDefaults): DaytonaSandboxConfig { - const parsed = DaytonaSandboxConfigSchema.safeParse( - readOptionValue(defaults, RUNTIME_MODE.SSH, "daytona") - ); - if (!parsed.success) return {}; - const { snapshot, cpu, memoryGiB, diskGiB } = parsed.data; - return { snapshot, cpu, memoryGiB, diskGiB }; -} - -export function writeDaytonaOptionDefaults( - prev: RuntimeOptionDefaults, - next: DaytonaSandboxConfig -): RuntimeOptionDefaults { - const { snapshot, cpu, memoryGiB, diskGiB } = next; - return writeOptionValue(prev, RUNTIME_MODE.SSH, "daytona", { - snapshot, - cpu, - memoryGiB, - diskGiB, - }); -} diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index 4b078731c33..4610475cb41 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -188,13 +188,6 @@ export const CUSTOM_EVENTS = { * Detail: { enabled: boolean } */ LLM_DEBUG_LOGS_CHANGED: "mux:llmDebugLogsChanged", - - /** - * Event emitted when Daytona access (API key, URL or target) is saved in Settings. Settings - * covers views that stay mounted, such as the creation controls, which reload from it. - * Detail: none - */ - DAYTONA_CONFIG_CHANGED: "mux:daytonaConfigChanged", } as const; /** @@ -247,7 +240,6 @@ export interface CustomEventPayloads { [CUSTOM_EVENTS.CLOSE_AGENT_PICKER]: never; // No payload [CUSTOM_EVENTS.AGENTS_REFRESH_REQUESTED]: never; // No payload [CUSTOM_EVENTS.SKILLS_REFRESH_REQUESTED]: never; // No payload - [CUSTOM_EVENTS.DAYTONA_CONFIG_CHANGED]: never; // No payload [CUSTOM_EVENTS.OPEN_MODEL_SELECTOR]: never; // No payload [CUSTOM_EVENTS.MUX_GATEWAY_SESSION_EXPIRED]: never; // No payload [CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH]: { diff --git a/src/common/orpc/schemas/daytona.ts b/src/common/orpc/schemas/daytona.ts index ab2cb70ba7b..5d248e1a864 100644 --- a/src/common/orpc/schemas/daytona.ts +++ b/src/common/orpc/schemas/daytona.ts @@ -40,8 +40,6 @@ export type DaytonaApiKeySource = z.infer; export const DaytonaPublicConfigSchema = z.object({ apiKeySet: z.boolean(), apiKeySource: DaytonaApiKeySourceSchema.nullable(), - apiUrl: z.string(), - target: z.string().nullable(), }); export type DaytonaPublicConfig = z.infer; @@ -97,12 +95,8 @@ export const daytona = { output: DaytonaPublicConfigSchema, }, setConfig: { - // Each field: omitted = unchanged, empty string = cleared. - input: z.object({ - apiKey: z.string().optional(), - apiUrl: z.string().optional(), - target: z.string().optional(), - }), + // apiKey: omitted = unchanged, empty string = cleared. + input: z.object({ apiKey: z.string().optional() }), output: DaytonaPublicConfigSchema, }, testConnection: { diff --git a/src/common/orpc/schemas/drafts.ts b/src/common/orpc/schemas/drafts.ts index 06daa1c519c..91e0371a730 100644 --- a/src/common/orpc/schemas/drafts.ts +++ b/src/common/orpc/schemas/drafts.ts @@ -280,11 +280,6 @@ export const CreationDraftExecutionSchema = z.object({ agent: z.object({ id: z.string().min(1), scope: AgentDefinitionScopeSchema }).nullable(), runtime: ParsedRuntimeSchema.nullable(), trunkBranch: z.string().nullable(), - /** - * The draft's last Daytona choice (a snapshot, a size), restored when - * the user switches to another runtime and back. Never written to the project defaults. - */ - lastDaytona: DaytonaSandboxConfigSchema.optional(), }); /** diff --git a/src/node/config/daytonaCredentialsStore.test.ts b/src/node/config/daytonaCredentialsStore.test.ts index a13e3662586..fdb181184fa 100644 --- a/src/node/config/daytonaCredentialsStore.test.ts +++ b/src/node/config/daytonaCredentialsStore.test.ts @@ -3,7 +3,6 @@ import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; import { DAYTONA_DEFAULT_API_URL } from "@/common/constants/daytona"; -import { createDaytonaTestRig } from "@/node/services/daytona/daytona.testHarness"; import { DaytonaCredentialsStore } from "./daytonaCredentialsStore"; const KEY = "test-key-not-real"; @@ -21,47 +20,45 @@ describe("DaytonaCredentialsStore", () => { it("stores the key in a 0600 file and never exposes it through the public config", async () => { const store = new DaytonaCredentialsStore(rootDir, {}); - await store.update({ apiKey: KEY, apiUrl: "https://byoc.example.com/api/", target: "eu" }); + await store.update({ apiKey: KEY }); const stat = await fs.stat(path.join(rootDir, "daytona.json")); expect(stat.mode & 0o777).toBe(0o600); const publicConfig = await store.getPublicConfig(); - expect(publicConfig).toEqual({ - apiKeySet: true, - apiKeySource: "settings", - apiUrl: "https://byoc.example.com/api", - target: "eu", - }); + expect(publicConfig).toEqual({ apiKeySet: true, apiKeySource: "settings" }); expect(JSON.stringify(publicConfig)).not.toContain(KEY); expect((await store.resolve())?.apiKey).toBe(KEY); }); - it("leaves omitted fields alone and clears fields set to an empty string", async () => { + it("leaves an omitted key alone and clears a key set to an empty string", async () => { const store = new DaytonaCredentialsStore(rootDir, {}); - await store.update({ apiKey: KEY, target: "us" }); - await store.update({ target: "" }); + await store.update({ apiKey: KEY }); + await store.update({}); expect(await store.read()).toEqual({ apiKey: KEY }); await store.update({ apiKey: " " }); - expect(await store.getPublicConfig()).toEqual({ - apiKeySet: false, - apiKeySource: null, - apiUrl: DAYTONA_DEFAULT_API_URL, - target: null, - }); + expect(await store.getPublicConfig()).toEqual({ apiKeySet: false, apiKeySource: null }); }); - it("falls back to the server environment, and a key from settings wins over it", async () => { - const env = { DAYTONA_API_KEY: "env-key", DAYTONA_API_URL: "https://env.example.com/api" }; + it("takes the URL and target from the environment, and a key from settings wins over it", async () => { + const env = { + DAYTONA_API_KEY: "env-key", + DAYTONA_API_URL: "https://env.example.com/api/", + DAYTONA_TARGET: "eu", + }; const store = new DaytonaCredentialsStore(rootDir, env); expect(await store.resolve()).toEqual({ apiKey: "env-key", apiKeySource: "env", apiUrl: "https://env.example.com/api", + target: "eu", }); await store.update({ apiKey: KEY }); expect(await store.resolve()).toMatchObject({ apiKey: KEY, apiKeySource: "settings" }); + expect((await new DaytonaCredentialsStore(rootDir, {}).resolve())?.apiUrl).toBe( + DAYTONA_DEFAULT_API_URL + ); }); it("reads a corrupted file as empty so the user can re-enter the key", async () => { @@ -72,144 +69,31 @@ describe("DaytonaCredentialsStore", () => { expect((await store.resolve())?.apiKey).toBe(KEY); }); - it("keeps the saved settings when the file cannot be read for an update", async () => { - const store = new DaytonaCredentialsStore(rootDir, {}); - await store.update({ apiKey: KEY, apiUrl: "https://byoc.example.com/api", target: "eu" }); - const file = path.join(rootDir, "daytona.json"); - - // A read that fails for another reason than absence (here: a permission problem). - await fs.chmod(file, 0o000); - let refusal: unknown; - try { - await store.update({ target: "us" }); - } catch (error) { - refusal = error; - } finally { - await fs.chmod(file, 0o600); - } - - expect(refusal).toBeInstanceOf(Error); - expect(await store.resolve()).toMatchObject({ - apiKey: KEY, - apiUrl: "https://byoc.example.com/api", - target: "eu", - }); - }); - - // The file selects account A; DAYTONA_API_KEY/DAYTONA_API_URL name account B. Taking an - // unreadable file as empty would silently run create, start and delete on B. - it("refuses to fall back to the environment when the saved settings cannot be read", async () => { - const env = { DAYTONA_API_KEY: "env-key", DAYTONA_API_URL: "https://env.example.com/api" }; - const store = new DaytonaCredentialsStore(rootDir, env); - await store.update({ apiKey: KEY, apiUrl: "https://byoc.example.com/api" }); - const file = path.join(rootDir, "daytona.json"); - - await fs.chmod(file, 0o000); - try { - // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime - await expect(store.resolve()).rejects.toThrow(/Could not read the Daytona settings/); - // Settings shows the read error instead of claiming the environment's key is in use. - // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime - await expect(store.getPublicConfig()).rejects.toThrow(/Could not read the Daytona settings/); - } finally { - await fs.chmod(file, 0o600); - } - expect(await store.resolve()).toMatchObject({ apiKey: KEY, apiKeySource: "settings" }); - }); - - it("runs no Daytona operation on the environment's account after a failed settings read", async () => { - const rig = await createDaytonaTestRig({ apiKey: "env-key" }); - try { - const saved = rig.addAccount(KEY); - await rig.service.account.setConfig({ apiKey: KEY }); - const file = path.join(rig.rootDir, "daytona.json"); - await fs.chmod(file, 0o000); - try { - // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime - await expect( - rig.service.account.withAccountLease(() => rig.service.sandboxes.listInstanceSandboxes()) - ).rejects.toThrow(/Could not read the Daytona settings/); - expect((await rig.service.account.listSnapshots()).ok).toBe(false); - } finally { - await fs.chmod(file, 0o600); - } - expect(rig.plane.calls).toEqual([]); - await rig.service.account.withAccountLease(() => - rig.service.sandboxes.listInstanceSandboxes() - ); - expect(saved.calls.length).toBeGreaterThan(0); - expect(rig.plane.calls).toEqual([]); - } finally { - await rig.cleanup(); - } - }); - - it("refuses a stored API URL that does not validate instead of sending the key elsewhere", async () => { - for (const apiUrl of ["not a url", "http://byoc.example.com/api"]) { - await fs.writeFile( - path.join(rootDir, "daytona.json"), - JSON.stringify({ apiKey: KEY, apiUrl }), - "utf-8" - ); - for (const env of [{}, { DAYTONA_API_URL: "https://env.example.com/api" }]) { - const store = new DaytonaCredentialsStore(rootDir, env); - // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime - await expect(store.resolve()).rejects.toThrow(/saved in Settings/); - // Shown as written, so the user sees what to fix. - expect((await store.getPublicConfig()).apiUrl).toBe(apiUrl); - } - } - - // Clearing it is the user's choice of the default deployment. - const store = new DaytonaCredentialsStore(rootDir, {}); - await store.update({ apiUrl: "" }); - expect((await store.resolve())?.apiUrl).toBe(DAYTONA_DEFAULT_API_URL); - }); - - it("accepts plain http only for a loopback host, where no network sees the key", async () => { - const store = new DaytonaCredentialsStore(rootDir, {}); - for (const apiUrl of ["http://byoc.example.com/api", "http://10.0.0.5/api"]) { - // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime - await expect(store.update({ apiUrl })).rejects.toThrow(/https/); - } - for (const apiUrl of [ - "http://127.0.0.1:3000/api", - "http://localhost/api", - "http://[::1]:8080/api", - ]) { - await store.update({ apiUrl }); - expect((await store.read()).apiUrl).toBe(apiUrl); - } - }); - - it("refuses every DAYTONA_API_URL that does not validate instead of using the default", async () => { - await fs.writeFile( - path.join(rootDir, "daytona.json"), - JSON.stringify({ apiKey: KEY }), - "utf-8" - ); - // The environment names the deployment, so another one must not get the key instead. - for (const envUrl of ["http://byoc.example.com/api", "not a url", "ftp://byoc.example.com"]) { - const fromEnv = new DaytonaCredentialsStore(rootDir, { DAYTONA_API_URL: envUrl }); - // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime - await expect(fromEnv.resolve()).rejects.toThrow(/DAYTONA_API_URL/); - expect((await fromEnv.getPublicConfig()).apiUrl).toBe(envUrl); - } - }); - - it("rejects an API URL that is not plain http(s)", async () => { - const store = new DaytonaCredentialsStore(rootDir, {}); + it("refuses a DAYTONA_API_URL that is not https or a loopback http host", async () => { + const resolveWith = (apiUrl: string) => + new DaytonaCredentialsStore(rootDir, { + DAYTONA_API_KEY: KEY, + DAYTONA_API_URL: apiUrl, + }).resolve(); for (const apiUrl of [ "not a url", - "file:///etc/passwd", + "ftp://byoc.example.com", + "http://byoc.example.com/api", + "http://10.0.0.5/api", "https://user:pw@example.com/api", // WHATWG URL parsing reads these as host "api". "http:///api", "https:api", ]) { // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime - await expect(store.update({ apiUrl })).rejects.toThrow(/Daytona API URL/); + await expect(resolveWith(apiUrl)).rejects.toThrow(/DAYTONA_API_URL/); + } + for (const apiUrl of [ + "http://127.0.0.1:3000/api", + "http://localhost/api", + "http://[::1]:8080/api", + ]) { + expect((await resolveWith(apiUrl))?.apiUrl).toBe(apiUrl); } - expect(await store.read()).toEqual({}); }); }); diff --git a/src/node/config/daytonaCredentialsStore.ts b/src/node/config/daytonaCredentialsStore.ts index 0b160ecc6de..d6eeb272bed 100644 --- a/src/node/config/daytonaCredentialsStore.ts +++ b/src/node/config/daytonaCredentialsStore.ts @@ -21,8 +21,6 @@ export const DAYTONA_CREDENTIALS_FILE = "daytona.json"; export interface DaytonaStoredCredentials { apiKey?: string; - apiUrl?: string; - target?: string; } /** Credentials ready for a client call. Only node code ever sees `apiKey`. */ @@ -33,11 +31,9 @@ export interface DaytonaResolvedCredentials { target?: string; } -/** Each field: undefined = unchanged, empty (after trim) = cleared. */ +/** undefined = unchanged, empty (after trim) = cleared. */ export interface DaytonaCredentialsPatch { apiKey?: string; - apiUrl?: string; - target?: string; } function nonEmpty(value: unknown): string | undefined { @@ -60,20 +56,20 @@ function parseApiUrl(value: string): URL { try { parsed = new URL(value); } catch { - throw new DaytonaConfigError(`Daytona API URL is not a valid URL: ${value}`); + throw new DaytonaConfigError(`DAYTONA_API_URL is not a valid URL: ${value}`); } if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { - throw new DaytonaConfigError("Daytona API URL must use http or https"); + throw new DaytonaConfigError("DAYTONA_API_URL must use http or https"); } // `new URL` turns "http:///api" and "https:api" into host "api": require a written host. if (!/^https?:\/\/[^/?#\\]/i.test(value)) { - throw new DaytonaConfigError(`Daytona API URL has no host: ${value}`); + throw new DaytonaConfigError(`DAYTONA_API_URL has no host: ${value}`); } // Every authenticated call sends the API key as a bearer token: over plain http anyone on // the network path could read it. Only a loopback host keeps it on this machine. if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) { throw new DaytonaConfigError( - "Daytona API URL must use https (http is accepted only for localhost and 127.0.0.1)" + "DAYTONA_API_URL must use https (http is accepted only for localhost and 127.0.0.1)" ); } return parsed; @@ -83,67 +79,30 @@ function normalizeApiUrl(value: string): string { const parsed = parseApiUrl(value); if (parsed.username || parsed.password || parsed.search || parsed.hash) { throw new DaytonaConfigError( - "Daytona API URL must not contain credentials, a query or a fragment" + "DAYTONA_API_URL must not contain credentials, a query or a fragment" ); } return parsed.toString().replace(/\/+$/, ""); } -/** Why an API URL cannot be used; undefined when it is unset or valid. */ -function apiUrlProblem(value: string | undefined): string | undefined { - if (!value) return undefined; - try { - normalizeApiUrl(value); - return undefined; - } catch (error) { - return getErrorMessage(error); - } -} - -const CREDENTIAL_FIELDS = ["apiKey", "apiUrl", "target"] as const; - -/** The non-empty string fields of a credentials file, or undefined when it is not an object. */ -function parseStoredCredentials(raw: string): DaytonaStoredCredentials | undefined { - let parsed: unknown; +/** The saved key, or undefined when the file does not hold one. */ +function parseStoredKey(raw: string): string | undefined { try { - parsed = JSON.parse(raw); + const parsed: unknown = JSON.parse(raw); + return isPlainObject(parsed) ? nonEmpty(parsed.apiKey) : undefined; } catch { return undefined; } - if (!isPlainObject(parsed)) return undefined; - const stored: DaytonaStoredCredentials = {}; - for (const field of CREDENTIAL_FIELDS) { - const value = nonEmpty(parsed[field]); - if (value) stored[field] = value; - } - return stored; -} - -/** `stored` with the patch's fields set; an empty string clears a field. */ -function applyPatch( - stored: DaytonaStoredCredentials, - patch: DaytonaCredentialsPatch -): DaytonaStoredCredentials { - const next = { ...stored }; - for (const field of CREDENTIAL_FIELDS) { - const value = patch[field]; - if (value === undefined) continue; - const trimmed = nonEmpty(value); - if (trimmed === undefined) { - delete next[field]; - } else { - next[field] = field === "apiUrl" ? normalizeApiUrl(trimmed) : trimmed; - } - } - return next; } +/** + * Only the API key is saved here. The deployment URL and the default target (region) come from + * the Xum server's environment (DAYTONA_API_URL, DAYTONA_TARGET). + */ export class DaytonaCredentialsStore { readonly filePath: string; private readonly rootDir: string; private readonly env: NodeJS.ProcessEnv; - // Serializes read-modify-write updates; writeFileAtomic alone only orders the final writes. - private updateChain: Promise = Promise.resolve(); constructor(rootDir?: string, env: NodeJS.ProcessEnv = process.env) { this.rootDir = rootDir ?? getXumHome(); @@ -151,79 +110,50 @@ export class DaytonaCredentialsStore { this.env = env; } - /** - * A missing or malformed file reads as empty (self-healing): the user can re-enter the key. - * A read that fails for another reason (an I/O error, a permission problem) throws, for every - * caller: an update writes the whole file back, and a resolve that took it as empty would - * fall back to DAYTONA_API_KEY, another account than the one the saved settings select. - */ + /** A missing, unreadable or malformed file reads as empty (self-healing): the user can re-enter the key. */ async read(): Promise { let raw: string; try { raw = await fs.readFile(this.filePath, "utf-8"); } catch (error) { - if (isErrnoWithCode(error, "ENOENT")) return {}; - throw new Error( - `Could not read the Daytona settings in ${this.filePath}: ${getErrorMessage(error)}`, - { cause: error } - ); - } - const stored = parseStoredCredentials(raw); - if (!stored) { - // Never log the content: it holds the API key. - log.warn("Ignoring malformed Daytona credentials file", { filePath: this.filePath }); + if (!isErrnoWithCode(error, "ENOENT")) { + log.warn("Could not read the Daytona credentials file", { + filePath: this.filePath, + error: getErrorMessage(error), + }); + } return {}; } - return stored; - } - - update(patch: DaytonaCredentialsPatch): Promise { - const run = this.updateChain.then(() => this.applyUpdate(patch)); - // The caller gets the rejection through `run`; the chain only needs to keep going. - this.updateChain = run.catch((error: unknown) => { - log.debug("Daytona credentials update failed", { error: getErrorMessage(error) }); - }); - return run; + const apiKey = parseStoredKey(raw); + return apiKey ? { apiKey } : {}; } - private async applyUpdate(patch: DaytonaCredentialsPatch): Promise { + async update(patch: DaytonaCredentialsPatch): Promise { + if (patch.apiKey === undefined) return; + const apiKey = nonEmpty(patch.apiKey); await ensurePrivateDir(this.rootDir); - const next = applyPatch(await this.read(), patch); - await writeFileAtomic(this.filePath, JSON.stringify(next, null, 2), { + await writeFileAtomic(this.filePath, JSON.stringify(apiKey ? { apiKey } : {}, null, 2), { encoding: "utf-8", mode: 0o600, }); } /** - * Settings win over the Xum server's own environment (DAYTONA_API_KEY / DAYTONA_API_URL), so a - * key entered in the UI is the one used. Returns null when no API key is available. + * A key saved in Settings wins over DAYTONA_API_KEY, so a key entered in the UI is the one + * used. Returns null when no API key is available. An invalid DAYTONA_API_URL throws instead of + * falling back to the default, which would send the key to another deployment. */ async resolve(): Promise { const stored = await this.read(); - const envKey = nonEmpty(this.env.DAYTONA_API_KEY); - const apiKey = stored.apiKey ?? envKey; + const apiKey = stored.apiKey ?? nonEmpty(this.env.DAYTONA_API_KEY); if (!apiKey) return null; - // The URL in use (saved, else DAYTONA_API_URL) names the deployment the key belongs to: an - // invalid one (an http URL saved before http was refused, a hand edit, a typo in the env) - // must not fall back to another URL or the default, which would send the key elsewhere. - // So Daytona stays unusable until the user fixes it. - const { source, value } = this.configuredApiUrl(stored); - const problem = apiUrlProblem(value); - if (problem) { - throw new DaytonaConfigError( - source === "settings" - ? `The Daytona API URL saved in Settings cannot be used (${problem}); ` + - "fix or clear it in Settings > Runtimes" - : `DAYTONA_API_URL cannot be used (${problem}); ` + - "fix it, or set the API URL in Settings > Runtimes" - ); - } + const envUrl = nonEmpty(this.env.DAYTONA_API_URL); + const target = nonEmpty(this.env.DAYTONA_TARGET); return { apiKey, apiKeySource: stored.apiKey ? "settings" : "env", - apiUrl: this.resolveApiUrl(stored), - ...(stored.target ? { target: stored.target } : {}), + apiUrl: envUrl ? normalizeApiUrl(envUrl) : DAYTONA_DEFAULT_API_URL, + ...(target ? { target } : {}), }; } @@ -234,25 +164,6 @@ export class DaytonaCredentialsStore { return { apiKeySet: stored.apiKey !== undefined || envKeySet, apiKeySource: stored.apiKey ? "settings" : envKeySet ? "env" : null, - apiUrl: this.resolveApiUrl(stored), - target: stored.target ?? null, }; } - - /** The saved URL wins over DAYTONA_API_URL; neither set means Daytona's default. */ - private configuredApiUrl(stored: DaytonaStoredCredentials): { - source: "settings" | "env" | "default"; - value?: string; - } { - if (stored.apiUrl) return { source: "settings", value: stored.apiUrl }; - const envUrl = nonEmpty(this.env.DAYTONA_API_URL); - return envUrl ? { source: "env", value: envUrl } : { source: "default" }; - } - - /** An invalid URL is shown as written, so the user sees what to fix (resolve refuses it). */ - private resolveApiUrl(stored: DaytonaStoredCredentials): string { - const { value } = this.configuredApiUrl(stored); - if (!value) return DAYTONA_DEFAULT_API_URL; - return apiUrlProblem(value) ? value : normalizeApiUrl(value); - } } diff --git a/src/node/orpc/routerDaytona.test.ts b/src/node/orpc/routerDaytona.test.ts index 2f0dea1d285..6a61f5a2615 100644 --- a/src/node/orpc/routerDaytona.test.ts +++ b/src/node/orpc/routerDaytona.test.ts @@ -5,6 +5,7 @@ import * as path from "path"; import { createRouterClient } from "@orpc/server"; import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; +import { DAYTONA_BUSY_MESSAGE } from "@/node/services/daytona/daytonaAccount"; import { DaytonaService } from "@/node/services/daytona/daytonaService"; import type { ORPCContext } from "./context"; import { router } from "./router"; @@ -20,7 +21,7 @@ describe("daytona procedures", () => { await fs.rm(rootDir, { recursive: true, force: true }); }); - test("an invalid API URL reaches the client as a bad request with its reason", async () => { + test("a settings refusal reaches the client as a bad request with its reason", async () => { const daytonaService = new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(rootDir, {}), rootDir, @@ -28,11 +29,17 @@ describe("daytona procedures", () => { const context = { daytonaService } as unknown as ORPCContext; const client = createRouterClient(router(), { context }); - const error = await client.daytona.setConfig({ apiUrl: "not a url" }).then( + let release!: () => void; + const lease = daytonaService.account.withAccountLease( + () => new Promise((resolve) => (release = resolve)) + ); + const error = await client.daytona.setConfig({ apiKey: "k" }).then( () => null, (rejection: Error) => rejection ); + release(); + await lease; expect(error).toMatchObject({ code: "BAD_REQUEST" }); - expect(error?.message).toContain("not a valid URL"); + expect(error?.message).toContain(DAYTONA_BUSY_MESSAGE); }); }); diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 7fc017c65b5..cc93754c179 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10051,21 +10051,21 @@ export const BUILTIN_SKILL_FILES: Record> = { "## Set up access", "", "1. Open **Settings > Runtimes** and find the **Daytona** row.", - "2. Paste your API key and click **Save**. Change **API URL** only for a self-hosted (BYOC) Daytona install. It must use `https://`, so that no one on the network can read the API key; `http://` works only for a loopback host (`localhost`, `127.0.0.1` or `::1`). A saved URL (or a `DAYTONA_API_URL`) that is not accepted keeps Daytona unavailable until you fix it; Xum never sends the key to another deployment instead. **Target** picks a region; leave it empty for Daytona's default.", + "2. Paste your API key and click **Save**.", "3. Click **Test connection**.", "", "Xum stores the key in `~/.xum/daytona.json` (under `XUM_ROOT` when it is set) with file mode `0600`. The settings page never shows a saved key again: to change it, enter a new one, or click **Remove key**.", "", - "Without a saved key, Xum uses `DAYTONA_API_KEY` and `DAYTONA_API_URL` from its own environment. A saved key wins over the environment.", + "Without a saved key, Xum uses `DAYTONA_API_KEY` from its own environment. A saved key wins over the environment.", + "", + "For a self-hosted (BYOC) Daytona install, set `DAYTONA_API_URL` in the environment of the Xum server. It must use `https://`, so that no one on the network can read the API key; `http://` works only for a loopback host (`localhost`, `127.0.0.1` or `::1`). A `DAYTONA_API_URL` that is not accepted keeps Daytona unavailable until you fix it; Xum never sends the key to another deployment instead. `DAYTONA_TARGET` picks a default region; without it, Daytona uses its default.", "", "", " Do not put your Daytona API key in **Secrets**. Xum passes secrets to agent turns and terminals,", " so an agent could read the key and create or delete sandboxes in your organization.", "", "", - "## Project defaults", - "", - "In **Settings > Runtimes**, change the scope from **All** to a project and turn on **Override project settings** to set the project's default **Snapshot** and **Size** for new Daytona workspaces. The new-workspace controls start from these values.", + "## Sizes", "", "| Size | vCPU | Memory | Disk |", "| ------ | ---- | ------ | ------ |", diff --git a/src/node/services/daytona/daytonaAccount.ts b/src/node/services/daytona/daytonaAccount.ts index 51e822912a9..7ddc1f84363 100644 --- a/src/node/services/daytona/daytonaAccount.ts +++ b/src/node/services/daytona/daytonaAccount.ts @@ -184,7 +184,7 @@ export class DaytonaAccount { }; } - /** The default target (region) from Settings > Runtimes, if one is set. */ + /** The default target (region) from DAYTONA_TARGET, if one is set. */ async getDefaultTarget(): Promise { return (await this.resolveCredentials())?.target; } diff --git a/src/node/services/daytona/daytonaSsh.test.ts b/src/node/services/daytona/daytonaSsh.test.ts index 0ee32680737..031cf0b0963 100644 --- a/src/node/services/daytona/daytonaSsh.test.ts +++ b/src/node/services/daytona/daytonaSsh.test.ts @@ -73,7 +73,7 @@ describe("DaytonaSsh", () => { it("revokes the SSH tokens it holds when the settings change", async () => { const held = await service.ssh.getSshToken("sb-1"); - await service.account.setConfig({ target: "eu" }); + await service.account.setConfig({ apiKey: "k" }); expect(plane.revoked).toEqual([held.token]); // The next request mints a fresh token instead of reusing the revoked one. expect((await service.ssh.getSshToken("sb-1")).token).not.toBe(held.token); @@ -84,7 +84,7 @@ describe("DaytonaSsh", () => { plane.revokeEchoesRequest = true; const warn = spyOn(log, "warn"); try { - await service.account.setConfig({ target: "eu" }); + await service.account.setConfig({ apiKey: "k" }); const logged = JSON.stringify(warn.mock.calls); expect(logged).toContain("Failed to revoke a Daytona SSH token"); expect(logged).not.toContain(held.token); @@ -144,7 +144,7 @@ describe("DaytonaSsh", () => { const access = held.service.ssh.ensureSshAccess("sb-1"); await held.started; - const refusal = await held.service.account.setConfig({ target: "eu" }).then( + const refusal = await held.service.account.setConfig({ apiKey: "k" }).then( () => undefined, (error: unknown) => error ); @@ -153,7 +153,7 @@ describe("DaytonaSsh", () => { await access; expect(held.written).toEqual(["tok1"]); - expect((await held.service.account.setConfig({ target: "eu" })).target).toBe("eu"); + expect((await held.service.account.setConfig({ apiKey: "k" })).apiKeySource).toBe("settings"); // The change revokes the old settings' token. expect(plane.revoked).toEqual(["tok1"]); }); @@ -232,7 +232,7 @@ describe("DaytonaSsh", () => { const access = racing.ssh.ensureSshAccess("sb-1"); await fetched; - const refusal = await racing.account.setConfig({ target: "eu" }).then( + const refusal = await racing.account.setConfig({ apiKey: "k" }).then( () => undefined, (error: unknown) => error ); @@ -377,7 +377,7 @@ describe("DaytonaSsh", () => { expect(includeFile.has("sb-1")).toBe(true); // A failed revoke would otherwise leave a working old-account token behind the alias. - await changing.account.setConfig({ target: "eu" }); + await changing.account.setConfig({ apiKey: "k" }); expect(includeFile.has("sb-1")).toBe(false); }); @@ -418,7 +418,7 @@ describe("DaytonaSsh", () => { expect(plane.revoked).toEqual(["earlier-process-token"]); // After another restart, a settings change revokes what it loaded. - await (await startProcess()).account.setConfig({ target: "eu" }); + await (await startProcess()).account.setConfig({ apiKey: "k" }); expect(plane.revoked).toEqual(["earlier-process-token", minted ?? ""]); }); From 78ba90fd380790d26afd2cf8582279a8ad2d7fa9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:53:08 +0000 Subject: [PATCH 205/231] refactor(daytona): drop the event-driven runtime status refresh Runtime-status chips refresh on their regular poll again: no listener for a turn's runtime-start events and no refetch after opening a terminal. --- .../components/AppLoader/AppLoader.tsx | 3 - src/browser/hooks/useOpenTerminal.ts | 2 - src/browser/stores/RuntimeStatusStore.test.ts | 45 -------------- src/browser/stores/WorkspaceStore.ts | 10 ---- src/browser/utils/terminal.test.ts | 59 ------------------- src/browser/utils/terminal.ts | 5 +- 6 files changed, 1 insertion(+), 123 deletions(-) delete mode 100644 src/browser/utils/terminal.test.ts diff --git a/src/browser/components/AppLoader/AppLoader.tsx b/src/browser/components/AppLoader/AppLoader.tsx index 9bc812c77d1..a5bbf45ac09 100644 --- a/src/browser/components/AppLoader/AppLoader.tsx +++ b/src/browser/components/AppLoader/AppLoader.tsx @@ -236,9 +236,6 @@ function AppLoaderInner() { gitStatusStore.subscribeToFileModifications((listener) => workspaceStore.subscribeFileModifyingTool(listener) ); - runtimeStatusStore.subscribeToRuntimeStatusEvents((listener) => - workspaceStoreInstance.subscribeRuntimeStatusEvents(listener) - ); setStoresSynced(true); } else { diff --git a/src/browser/hooks/useOpenTerminal.ts b/src/browser/hooks/useOpenTerminal.ts index f77a5311b77..368693fd7e6 100644 --- a/src/browser/hooks/useOpenTerminal.ts +++ b/src/browser/hooks/useOpenTerminal.ts @@ -1,6 +1,5 @@ import { useCallback } from "react"; import { useAPI } from "@/browser/contexts/API"; -import { getRuntimeStatusStoreInstance } from "@/browser/stores/RuntimeStatusStore"; import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime, isDevcontainerRuntime } from "@/common/types/runtime"; import { @@ -72,7 +71,6 @@ export function useOpenTerminal() { await openTerminalPopout(api, workspaceId, session.sessionId); } else { await api.terminal.openNative({ workspaceId }); - getRuntimeStatusStoreInstance().refreshAfterTerminalOpen(workspaceId); } } catch (err) { if (err instanceof TerminalPopupBlockedError && createdSessionId != null) { diff --git a/src/browser/stores/RuntimeStatusStore.test.ts b/src/browser/stores/RuntimeStatusStore.test.ts index 05055abb096..a1912be6b6c 100644 --- a/src/browser/stores/RuntimeStatusStore.test.ts +++ b/src/browser/stores/RuntimeStatusStore.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; import { createTestApiClient } from "@/browser/testUtils"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import type { RuntimeStatusEvent } from "@/common/types/stream"; import { RuntimeStatusStore, type RuntimeStatus } from "./RuntimeStatusStore"; const DEVCONTAINER_RUNTIME = { @@ -135,48 +134,4 @@ describe("RuntimeStatusStore refresh triggers (#5573)", () => { expect(getRuntimeStatuses).toHaveBeenCalledTimes(calls); }); - - test("a turn that starts a displayed Daytona sandbox refetches when the start begins and ends", async () => { - await setupDisplayed( - new Map([ - [ - "ws-1", - createMetadata("ws-1", { - runtimeConfig: { - type: "ssh", - host: "sb-1.xum--daytona", - srcBaseDir: "~/mux", - daytona: { sandboxId: "sb-1" }, - }, - }), - ], - ]) - ); - let emit: (event: RuntimeStatusEvent) => void = () => undefined; - store.subscribeToRuntimeStatusEvents((listener) => { - emit = listener; - return () => undefined; - }); - const runtimeEvent = (phase: RuntimeStatusEvent["phase"]): RuntimeStatusEvent => ({ - type: "runtime-status", - workspaceId: "ws-1", - phase, - runtimeType: "ssh", - source: "runtime", - }); - - // A turn on a running sandbox: nothing changed. - emit(runtimeEvent("checking")); - emit(runtimeEvent("ready")); - await settle(); - expect(getRuntimeStatuses).toHaveBeenCalledTimes(1); - - emit(runtimeEvent("starting")); - await settle(); - expect(getRuntimeStatuses).toHaveBeenCalledTimes(2); - - emit(runtimeEvent("ready")); - await settle(); - expect(getRuntimeStatuses).toHaveBeenCalledTimes(3); - }); }); diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 8d940d60fa3..bb567e4bd93 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -1203,9 +1203,6 @@ export class WorkspaceStore { private fileModifyingToolMs = new Map(); private fileModifyingToolSubs = new MapStore(); - // RuntimeStatusStore listens: a turn starting a stopped runtime changes its badge. - private runtimeStatusEventListeners = new Set<(event: RuntimeStatusEvent) => void>(); - // Idle callbacks keep high-frequency init logs from blocking the renderer. private deltaIdleHandles = new Map(); private idleBumpPreludes = new Map void>(); @@ -1441,8 +1438,6 @@ export class WorkspaceStore { "runtime-status": (workspaceId, aggregator, data) => { applyWorkspaceChatEventToAggregator(aggregator, data); this.states.bump(workspaceId); - const event = data as RuntimeStatusEvent; - for (const listener of this.runtimeStatusEventListeners) listener(event); }, "auto-compaction-triggered": (workspaceId) => { // Informational event from backend auto-compaction monitor. @@ -5067,11 +5062,6 @@ export class WorkspaceStore { }); } - subscribeRuntimeStatusEvents(listener: (event: RuntimeStatusEvent) => void): () => void { - this.runtimeStatusEventListeners.add(listener); - return () => this.runtimeStatusEventListeners.delete(listener); - } - /** * Get when a file-modifying tool last completed for this workspace. * Returns undefined if no tools have completed since last clear. diff --git a/src/browser/utils/terminal.test.ts b/src/browser/utils/terminal.test.ts deleted file mode 100644 index 278c03f807b..00000000000 --- a/src/browser/utils/terminal.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; -import { useRuntimeStatusStoreRaw, type RuntimeStatus } from "@/browser/stores/RuntimeStatusStore"; -import { createTestApiClient } from "@/browser/testUtils"; -import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import { createTerminalSession } from "./terminal"; - -const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); - -const DAYTONA_WORKSPACE: FrontendWorkspaceMetadata = { - id: "ws-day", - name: "ws-day", - projectName: "project", - projectPath: "/home/user/project", - namedWorkspacePath: "~/src/project/ws-day", - runtimeConfig: { - type: "ssh", - host: "sb-1.xum--daytona", - srcBaseDir: "~/src", - daytona: { sandboxId: "sb-1" }, - }, -}; - -describe("createTerminalSession", () => { - let unsubscribe: (() => void) | undefined; - - afterEach(() => { - unsubscribe?.(); - const store = useRuntimeStatusStoreRaw(); - store.syncWorkspaces(new Map()); - store.setClient(null); - }); - - test("refreshes the runtime status of a Daytona workspace the terminal may have started", async () => { - const getRuntimeStatuses = mock((input: { workspaceIds: string[] }) => - Promise.resolve( - Object.fromEntries(input.workspaceIds.map((id): [string, RuntimeStatus] => [id, "stopped"])) - ) - ); - const create = mock(() => - Promise.resolve({ sessionId: "s-1", workspaceId: "ws-day", cols: 80, rows: 24 }) - ); - const client = createTestApiClient({ - workspace: { getRuntimeStatuses }, - terminal: { create }, - }); - const store = useRuntimeStatusStoreRaw(); - store.setClient(client); - store.syncWorkspaces(new Map([["ws-day", DAYTONA_WORKSPACE]])); - unsubscribe = store.subscribeKey("ws-day", () => undefined); - await settle(); - const fetchesBefore = getRuntimeStatuses.mock.calls.length; - - // The backend brought the stopped sandbox up before it opened the shell. - await createTerminalSession(client, "ws-day"); - await settle(); - - expect(getRuntimeStatuses.mock.calls.length).toBe(fetchesBefore + 1); - }); -}); diff --git a/src/browser/utils/terminal.ts b/src/browser/utils/terminal.ts index dd04ddcdbea..82702193d28 100644 --- a/src/browser/utils/terminal.ts +++ b/src/browser/utils/terminal.ts @@ -11,7 +11,6 @@ import type { AppRouter } from "@/node/orpc/router"; import { isIosStandaloneWebApp } from "@/browser/utils/env"; import { resolveBrowserAssetUrl } from "@/browser/utils/frontendBasePath"; import { showTerminalDialog } from "@/browser/utils/terminalDialogStore"; -import { getRuntimeStatusStoreInstance } from "@/browser/stores/RuntimeStatusStore"; type APIClient = RouterClient; @@ -123,12 +122,10 @@ export async function createTerminalSession( workspaceId: string, options?: TerminalSessionCreateOptions ): Promise<{ sessionId: string; workspaceId: string; cols: number; rows: number }> { - const session = await api.terminal.create({ + return api.terminal.create({ workspaceId, cols: DEFAULT_TERMINAL_SIZE.cols, rows: DEFAULT_TERMINAL_SIZE.rows, initialCommand: options?.initialCommand, }); - getRuntimeStatusStoreInstance().refreshAfterTerminalOpen(workspaceId); - return session; } From fa777ff0f58200b9c09d2ee96c5831bcb281b2c1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:53:35 +0000 Subject: [PATCH 206/231] refactor(daytona): map sandbox states onto the existing runtime status chip getRuntimeStatuses maps Daytona states to running, stopped or unknown, so the status schema stays the four devcontainer values and the footer reuses the devcontainer chip with a Sandbox label. Also drops the RuntimeStatusStore event-refresh members left by the previous commit. --- .../ChatPane/WorkspaceFooterBar.tsx | 9 ++- src/browser/stores/RuntimeStatusStore.ts | 62 +------------------ src/browser/utils/runtimeUi.ts | 44 ++++--------- src/common/orpc/schemas/runtime.ts | 8 +-- src/node/services/workspaceService.ts | 10 +-- 5 files changed, 25 insertions(+), 108 deletions(-) diff --git a/src/browser/components/ChatPane/WorkspaceFooterBar.tsx b/src/browser/components/ChatPane/WorkspaceFooterBar.tsx index fb0b1a39680..c69fd38cf0a 100644 --- a/src/browser/components/ChatPane/WorkspaceFooterBar.tsx +++ b/src/browser/components/ChatPane/WorkspaceFooterBar.tsx @@ -6,7 +6,7 @@ import { updateUserPreferences, useUserPreferences } from "@/browser/stores/AppC import { isDevcontainerRuntime, isSSHRuntime, type RuntimeConfig } from "@/common/types/runtime"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { ProjectConfig } from "@/common/types/project"; -import { getDaytonaStatusChip, getDevcontainerStatusChip } from "@/browser/utils/runtimeUi"; +import { getRuntimeStatusChip } from "@/browser/utils/runtimeUi"; import { formatTokens } from "@/common/utils/tokens/tokenMeterUtils"; import { hasWorkspaceRepository } from "@/browser/utils/workspaceCapabilities"; import { isMultiProject } from "@/common/utils/multiProject"; @@ -138,8 +138,7 @@ function DriftModeToggle(props: { showLabel: boolean }) { ); } -// Devcontainer and Daytona sandbox status chips share one shape. -type DevcontainerChip = ReturnType; +type DevcontainerChip = ReturnType; function WorkspaceBranchControls(props: { workspaceId: string; @@ -430,9 +429,9 @@ export const WorkspaceFooterBar: React.FC = (props) => const runtimeStatus = useRuntimeStatus(props.workspaceId); const devcontainerChip = isDevcontainerRuntime(props.runtimeConfig) - ? getDevcontainerStatusChip(runtimeStatus) + ? getRuntimeStatusChip(runtimeStatus, "Devcontainer") : isSSHRuntime(props.runtimeConfig) && props.runtimeConfig.daytona != null - ? getDaytonaStatusChip(runtimeStatus) + ? getRuntimeStatusChip(runtimeStatus, "Sandbox") : null; // One JS source for the phone layout, so the hidden drift label and the tap-to-toggle diff // numbers can never disagree about the width. diff --git a/src/browser/stores/RuntimeStatusStore.ts b/src/browser/stores/RuntimeStatusStore.ts index 9d2cf6814e8..9da5f4107cf 100644 --- a/src/browser/stores/RuntimeStatusStore.ts +++ b/src/browser/stores/RuntimeStatusStore.ts @@ -2,13 +2,11 @@ import { useSyncExternalStore } from "react"; import type { APIClient } from "@/browser/contexts/API"; import { type FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { isDevcontainerRuntime, isSSHRuntime, type RuntimeConfig } from "@/common/types/runtime"; -import type { WorkspaceRuntimeStatus } from "@/common/orpc/schemas/runtime"; -import type { RuntimeStatusEvent } from "@/common/types/stream"; import { MapStore } from "./MapStore"; import { RefreshController } from "@/browser/utils/RefreshController"; import { getStatusInputChanges } from "@/browser/utils/statusRefreshInputs"; -export type RuntimeStatus = WorkspaceRuntimeStatus; +export type RuntimeStatus = "running" | "stopped" | "unknown" | "unsupported"; /** Runtimes with a start/stop lifecycle the UI shows: devcontainers and Daytona sandboxes. */ export function hasRuntimeStatus(runtimeConfig: RuntimeConfig | undefined): boolean { @@ -51,9 +49,6 @@ export class RuntimeStatusStore { private workspaceMetadata = new Map(); private isActive = true; private readonly refreshController: RefreshController; - /** Workspaces whose runtime a turn is starting: their status changes again once it is up. */ - private readonly startingWorkspaces = new Set(); - private runtimeEventsUnsubscribe: (() => void) | null = null; constructor() { this.refreshController = new RefreshController({ @@ -136,55 +131,6 @@ export class RuntimeStatusStore { } } - /** - * A turn's ensureReady starting a stopped devcontainer or Daytona sandbox changes its status, - * and only the workspace chat stream tells. Idempotent: only the first call subscribes. - */ - subscribeToRuntimeStatusEvents( - subscribe: (listener: (event: RuntimeStatusEvent) => void) => () => void - ): void { - if (this.runtimeEventsUnsubscribe) { - return; - } - this.runtimeEventsUnsubscribe = subscribe((event) => this.handleRuntimeStatusEvent(event)); - } - - private handleRuntimeStatusEvent(event: RuntimeStatusEvent): void { - if (event.source !== "runtime" || !this.tracksRuntimeStart(event)) { - return; - } - this.refreshIfDisplayed(event.workspaceId); - } - - /** - * Opening a terminal brings a stopped Daytona sandbox up first (the backend runs ensureReady), - * outside any turn and so without runtime-status events: refetch a displayed one. - */ - refreshAfterTerminalOpen(workspaceId: string): void { - this.refreshIfDisplayed(workspaceId); - } - - private refreshIfDisplayed(workspaceId: string): void { - if ( - hasRuntimeStatus(this.workspaceMetadata.get(workspaceId)?.runtimeConfig) && - this.statuses.hasKeySubscribers(workspaceId) - ) { - this.refreshController.requestImmediate(); - } - } - - /** Whether the event begins a start ("starting") or ends one; every turn reports "ready". */ - private tracksRuntimeStart(event: RuntimeStatusEvent): boolean { - if (event.phase === "starting") { - this.startingWorkspaces.add(event.workspaceId); - return true; - } - if (event.phase !== "ready" && event.phase !== "error") { - return false; - } - return this.startingWorkspaces.delete(event.workspaceId); - } - invalidateWorkspace(workspaceId: string): void { this.statusCache.delete(workspaceId); this.statuses.delete(workspaceId); @@ -193,9 +139,6 @@ export class RuntimeStatusStore { dispose(): void { this.isActive = false; - this.runtimeEventsUnsubscribe?.(); - this.runtimeEventsUnsubscribe = null; - this.startingWorkspaces.clear(); this.statusCache.clear(); this.statuses.clear(); this.refreshController.dispose(); @@ -238,8 +181,7 @@ export class RuntimeStatusStore { let runtimeStatusStoreInstance: RuntimeStatusStore | null = null; -/** The shared store, for non-React callers (useRuntimeStatusStoreRaw in components). */ -export function getRuntimeStatusStoreInstance(): RuntimeStatusStore { +function getRuntimeStatusStoreInstance(): RuntimeStatusStore { runtimeStatusStoreInstance ??= new RuntimeStatusStore(); return runtimeStatusStoreInstance; } diff --git a/src/browser/utils/runtimeUi.ts b/src/browser/utils/runtimeUi.ts index ec1c6892dd7..47780209548 100644 --- a/src/browser/utils/runtimeUi.ts +++ b/src/browser/utils/runtimeUi.ts @@ -263,48 +263,30 @@ export interface RuntimeStatusChip { className: string; } -// Theme success tokens: raw emerald text was below WCAG AA in the light themes (#6882). -const RUNNING_CHIP_CLASS = "bg-success/10 text-success-text"; -// The /50 muted tint left even foreground text at 4.17:1 in flexoki-dark (#6544). -const STOPPED_CHIP_CLASS = "bg-muted/15 text-foreground"; - /** - * Returns the titlebar chip presentation for a devcontainer runtime status, - * or null when the status is indeterminate and no chip should be shown. - * Only `running` and `stopped` produce a chip; `unknown`, `unsupported`, + * Returns the titlebar chip presentation for a devcontainer or Daytona sandbox status + * (`name` is "Devcontainer" or "Sandbox"), or null when the status is indeterminate and no chip + * should be shown. Only `running` and `stopped` produce a chip; `unknown`, `unsupported`, * and null (not yet loaded) suppress the chip to avoid misreporting. */ -export function getDevcontainerStatusChip(status: RuntimeStatus | null): RuntimeStatusChip | null { +export function getRuntimeStatusChip( + status: RuntimeStatus | null, + name: string +): RuntimeStatusChip | null { switch (status) { case "running": return { - label: "Devcontainer Running", - className: RUNNING_CHIP_CLASS, + label: `${name} Running`, + // Theme success tokens: raw emerald text was below WCAG AA in the light themes (#6882). + className: "bg-success/10 text-success-text", }; case "stopped": return { - label: "Devcontainer Stopped", - className: STOPPED_CHIP_CLASS, + label: `${name} Stopped`, + // The /50 muted tint left even foreground text at 4.17:1 in flexoki-dark (#6544). + className: "bg-muted/15 text-foreground", }; default: return null; } } - -const DAYTONA_STATUS_CHIPS: Record = { - running: { label: "Sandbox Running", className: RUNNING_CHIP_CLASS }, - starting: { label: "Sandbox Starting", className: "bg-warning/10 text-warning" }, - stopped: { label: "Sandbox Stopped", className: STOPPED_CHIP_CLASS }, - archived: { label: "Sandbox Archived", className: STOPPED_CHIP_CLASS }, - error: { label: "Sandbox Error", className: "bg-destructive/10 text-destructive" }, - unknown: null, - unsupported: null, -}; - -/** - * Titlebar chip for a Daytona sandbox status, or null while unknown. Daytona auto-stops idle - * sandboxes, so stopped and archived are normal states (the next turn starts the sandbox). - */ -export function getDaytonaStatusChip(status: RuntimeStatus | null): RuntimeStatusChip | null { - return status ? DAYTONA_STATUS_CHIPS[status] : null; -} diff --git a/src/common/orpc/schemas/runtime.ts b/src/common/orpc/schemas/runtime.ts index 4eb716b5d16..da76ce9d8c1 100644 --- a/src/common/orpc/schemas/runtime.ts +++ b/src/common/orpc/schemas/runtime.ts @@ -58,16 +58,10 @@ export const RuntimeAvailabilitySchema = z.object({ // Daytona is `ssh` + a sub-config, not a RuntimeMode; optional so mode-keyed maps still fit. daytona: RuntimeAvailabilityStatusSchema.optional(), }); -/** - * Passive runtime status for the UI (never starts anything): devcontainers report running or - * stopped; Daytona sandboxes add starting, archived and error. - */ +/** Passive runtime status for the UI (never starts anything). */ export const WorkspaceRuntimeStatusSchema = z.enum([ "running", "stopped", - "starting", - "archived", - "error", "unknown", "unsupported", ]); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 287db343f91..32aaf6ee31b 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1056,14 +1056,14 @@ function withEffectiveHeartbeatInterval( return { ...settings, intervalMs: settings.intervalMs ?? defaultIntervalMs }; } +// Daytona auto-stops and archives idle sandboxes; both are "stopped" (the next turn starts it). const DAYTONA_RUNTIME_STATUS: Record = { started: "running", stopped: "stopped", - starting: "starting", - archived: "archived", - error: "error", - // Deleted outside Xum: ensureReady refuses it, so show it as an error, not unknown. - missing: "error", + archived: "stopped", + starting: "unknown", + error: "unknown", + missing: "unknown", }; /** From f7839649c8f59cc725cfebaad49238ca91d27673 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:53:52 +0000 Subject: [PATCH 207/231] refactor(daytona): trim DaytonaClient error and pagination hardening Daytona API keys (dtn_ + hex) and SSH tokens (nanoid with _ and - removed) are URL-safe, so redaction replaces them as written. Server messages are redacted and cut to length without HTML stripping. listSnapshots stops at totalPages or on a short page, with no duplicate-name filter. One function maps an HTTP status to its kind and description. --- .../services/daytona/daytonaClient.test.ts | 98 ++++--------------- src/node/services/daytona/daytonaClient.ts | 73 ++++---------- 2 files changed, 41 insertions(+), 130 deletions(-) diff --git a/src/node/services/daytona/daytonaClient.test.ts b/src/node/services/daytona/daytonaClient.test.ts index 8a52e409e2f..27724ba9191 100644 --- a/src/node/services/daytona/daytonaClient.test.ts +++ b/src/node/services/daytona/daytonaClient.test.ts @@ -71,35 +71,18 @@ describe("DaytonaClient", () => { } }); - it("redacts the request's secrets that a failed response or fetch error echoes", async () => { - // Characters a query string encodes, so an echoed request URI carries the token encoded. - const token = "secret/ssh+token value"; - const echo = (request: RecordedRequest) => - `${request.method} ${request.url.toString()} ${request.headers.Authorization} ${token}`; - const responses = [ - (request: RecordedRequest) => jsonResponse(400, { message: `Invalid: ${echo(request)}` }), - (request: RecordedRequest) => new Response(`

${echo(request)}

`, { status: 502 }), - (request: RecordedRequest) => jsonResponse(500, { message: [echo(request), "again"] }), - ]; - const secrets = [token, encodeURIComponent(token), "secret%2Fssh%2Btoken+value", API_KEY]; - for (const respond of responses) { - const { fetch } = fakeFetch(respond); - const client = new DaytonaClient({ apiUrl: API_URL, apiKey: API_KEY, fetch }); - const error = await captureError(client.revokeSshAccess("sb-1", token)); - for (const secret of secrets) { - expect(error.message).not.toContain(secret); - expect(error.serverMessage ?? "").not.toContain(secret); - } - expect(error.message).toContain("DELETE /sandbox/sb-1/ssh-access"); - } - - // A fetch failure that names the request URL. - const failingFetch = ((input: URL) => - Promise.reject(new TypeError(`fetch failed for ${input.href}`))) as typeof fetch; - const failing = new DaytonaClient({ apiUrl: API_URL, apiKey: API_KEY, fetch: failingFetch }); - const transport = await captureError(failing.revokeSshAccess("sb-1", token)); - expect(transport.kind).toBe("unreachable"); - for (const secret of secrets) expect(transport.message).not.toContain(secret); + it("redacts the request's secrets that a failed response echoes", async () => { + const token = "secretSshToken42"; + const { fetch } = fakeFetch((request) => + jsonResponse(400, { + message: `Invalid: ${request.url.href} ${request.headers.Authorization}`, + }) + ); + const client = new DaytonaClient({ apiUrl: API_URL, apiKey: API_KEY, fetch }); + const error = await captureError(client.revokeSshAccess("sb-1", token)); + expect(error.message).not.toContain(token); + expect(error.message).not.toContain(API_KEY); + expect(error.message).toContain("DELETE /sandbox/sb-1/ssh-access"); // Any endpoint: a token minted earlier that another call's error echoes. rememberDaytonaToken("minted-earlier-token-q7z"); @@ -112,25 +95,14 @@ describe("DaytonaClient", () => { expect(otherError.message).not.toContain("minted-earlier-token-q7z"); }); - it("reduces an HTML error page or a long message to a short plain-text excerpt", async () => { - const page = ` -

502 Bad Gateway

-

nginx

`; - const htmlClient = new DaytonaClient({ - apiUrl: API_URL, - apiKey: API_KEY, - fetch: fakeFetch(() => new Response(page, { status: 502 })).fetch, - }); - const htmlError = await captureError(htmlClient.listSandboxes()); - expect(htmlError.serverMessage).toBe("502 Bad Gateway nginx"); - - const longClient = new DaytonaClient({ + it("cuts a long server message to a short excerpt", async () => { + const client = new DaytonaClient({ apiUrl: API_URL, apiKey: API_KEY, - fetch: fakeFetch(() => jsonResponse(500, { message: `${"x".repeat(500)}` })).fetch, + fetch: fakeFetch(() => jsonResponse(500, { message: "x".repeat(500) })).fetch, }); - const longError = await captureError(longClient.listSandboxes()); - expect(longError.serverMessage).toBe(`${"x".repeat(200)}...`); + const error = await captureError(client.listSandboxes()); + expect(error.serverMessage).toBe(`${"x".repeat(200)}...`); }); it("fails with a timeout error instead of hanging on a stuck control plane", async () => { @@ -211,41 +183,13 @@ describe("DaytonaClient", () => { it("walks snapshot pages until totalPages", async () => { const { fetch, requests } = fakeFetch((request) => { const page = Number(request.url.searchParams.get("page")); - return jsonResponse(200, { - items: [{ name: `snap-${page}`, id: `id-${page}` }], - total: 2, - page, - totalPages: 2, - }); - }); - const client = new DaytonaClient({ apiUrl: API_URL, apiKey: API_KEY, fetch }); - expect(await client.listSnapshots()).toEqual([{ name: "snap-1" }, { name: "snap-2" }]); - expect(requests).toHaveLength(2); - }); - - it("walks full snapshot pages when the totals are omitted, stopping on a short page", async () => { - // 100 per page (the limit Xum asks for), then 3; totalPages is optional in the response. - const { fetch, requests } = fakeFetch((request) => { - const page = Number(request.url.searchParams.get("page")); - const count = page === 1 ? 100 : page === 2 ? 3 : 0; - const items = Array.from({ length: count }, (_, i) => ({ name: `snap-${page}-${i}` })); - return jsonResponse(200, { items }); + const items = Array.from({ length: 100 }, (_, i) => ({ name: `snap-${page}-${i}` })); + return jsonResponse(200, { items, total: 200, page, totalPages: 2 }); }); const client = new DaytonaClient({ apiUrl: API_URL, apiKey: API_KEY, fetch }); const snapshots = await client.listSnapshots(); - expect(snapshots).toHaveLength(103); - expect(snapshots.at(-1)).toEqual({ name: "snap-2-2" }); - expect(requests).toHaveLength(2); - }); - - it("stops at a full page that repeats the last one (a server that ignores page)", async () => { - const { fetch, requests } = fakeFetch(() => - jsonResponse(200, { - items: Array.from({ length: 100 }, (_, i) => ({ name: `snap-${i}` })), - }) - ); - const client = new DaytonaClient({ apiUrl: API_URL, apiKey: API_KEY, fetch }); - expect(await client.listSnapshots()).toHaveLength(100); + expect(snapshots).toHaveLength(200); + expect(snapshots.at(-1)).toEqual({ name: "snap-2-99" }); expect(requests).toHaveLength(2); }); }); diff --git a/src/node/services/daytona/daytonaClient.ts b/src/node/services/daytona/daytonaClient.ts index a5a64874a91..9f3bd4630b6 100644 --- a/src/node/services/daytona/daytonaClient.ts +++ b/src/node/services/daytona/daytonaClient.ts @@ -159,22 +159,14 @@ const DAYTONA_API_KEY_PLACEHOLDER = ""; type Redact = (text: string) => string; -/** A secret as an echo may carry it: raw, or encoded as a path or query component. */ -function encodedForms(secret: string): string[] { - const queryForm = new URLSearchParams({ s: secret }).toString().slice("s=".length); - // Encoded forms first: raw text left in one would otherwise survive as a partial match. - return [...new Set([queryForm, encodeURIComponent(secret), secret])]; -} - +// Daytona's API keys (dtn_ + hex) and SSH tokens (nanoid without _ and -) are URL-safe, so a +// query or path echo carries them as written: no encoded form to redact. function createRedactor(apiKey: string | undefined, secrets: string[]): Redact { const replacements: Array<[string, string]> = []; for (const secret of secrets) { - if (!secret) continue; - for (const form of encodedForms(secret)) replacements.push([form, DAYTONA_TOKEN_PLACEHOLDER]); - } - if (apiKey) { - for (const form of encodedForms(apiKey)) replacements.push([form, DAYTONA_API_KEY_PLACEHOLDER]); + if (secret) replacements.push([secret, DAYTONA_TOKEN_PLACEHOLDER]); } + if (apiKey) replacements.push([apiKey, DAYTONA_API_KEY_PLACEHOLDER]); return (text) => { let redacted = text; for (const [secret, placeholder] of replacements) { @@ -191,17 +183,10 @@ const PAGE_LIMIT = 100; const MAX_SERVER_MESSAGE_CHARS = 200; -/** - * Error text ends up in toasts and turn errors: a proxy or a non-Daytona URL can answer with a - * whole HTML page, so drop tags, collapse whitespace and keep a short excerpt. - */ +/** Error text ends up in toasts and turn errors, so keep a short excerpt. */ function toPlainExcerpt(text: string, redact: Redact): string | undefined { // Redacted before the cut, so a truncated secret cannot survive as a prefix. - const plain = redact(text) - .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, " ") - .replace(/<[^>]*>/g, " ") - .replace(/\s+/g, " ") - .trim(); + const plain = redact(text).trim(); if (!plain) return undefined; return plain.length > MAX_SERVER_MESSAGE_CHARS ? `${plain.slice(0, MAX_SERVER_MESSAGE_CHARS)}...` @@ -227,24 +212,17 @@ function extractServerMessage(bodyText: string, redact: Redact): string | undefi return undefined; } -function kindForStatus(status: number): DaytonaErrorKind { - if (status === 401 || status === 403) return "unauthorized"; - if (status === 404) return "not_found"; - if (status === 400) return "bad_request"; - return "server"; -} - -function describeStatus(kind: DaytonaErrorKind, status: number): string { - switch (kind) { - case "unauthorized": - return `Daytona rejected the API key (HTTP ${status})`; - case "not_found": - return "Daytona resource not found (HTTP 404)"; - case "bad_request": - return "Daytona rejected the request (HTTP 400)"; - default: - return `Daytona request failed (HTTP ${status})`; +function describeStatus(status: number): { kind: DaytonaErrorKind; description: string } { + if (status === 401 || status === 403) { + return { kind: "unauthorized", description: `Daytona rejected the API key (HTTP ${status})` }; + } + if (status === 404) { + return { kind: "not_found", description: "Daytona resource not found (HTTP 404)" }; + } + if (status === 400) { + return { kind: "bad_request", description: "Daytona rejected the request (HTTP 400)" }; } + return { kind: "server", description: `Daytona request failed (HTTP ${status})` }; } function buildUrl(apiUrl: string, pathName: string, query: RequestOptions["query"] = {}): URL { @@ -261,9 +239,9 @@ function httpError( label: string, redact: Redact ): DaytonaApiError { - const kind = kindForStatus(status); + const { kind, description } = describeStatus(status); const serverMessage = extractServerMessage(bodyText, redact); - const base = `${describeStatus(kind, status)} for ${label}`; + const base = `${description} for ${label}`; return new DaytonaApiError( kind, serverMessage ? `${base}: ${serverMessage}` : base, @@ -519,26 +497,15 @@ export class DaytonaClient { async listSnapshots(signal?: AbortSignal): Promise { const snapshots: DaytonaSnapshot[] = []; - const seen = new Set(); for (let page = 1; page <= MAX_PAGES; page++) { const raw = await this.request("GET", "/snapshots", { query: { page, limit: PAGE_LIMIT }, signal, }); const parsed = this.parse(SnapshotPageSchema, raw, "GET /snapshots"); - // A page with nothing new ends the walk: a server that ignores `page` repeats page 1. - const fresh = parsed.items.map(toSnapshot).filter((snapshot) => !seen.has(snapshot.name)); - for (const snapshot of fresh) seen.add(snapshot.name); - snapshots.push(...fresh); - if (fresh.length === 0 || isLastPage(parsed, page)) return snapshots; + snapshots.push(...parsed.items.map(toSnapshot)); + if (parsed.items.length < PAGE_LIMIT || page >= (parsed.totalPages ?? Infinity)) break; } return snapshots; } } - -/** totalPages is optional: without it, a page shorter than the limit is the last one. */ -function isLastPage(parsed: { items: unknown[]; totalPages?: number }, page: number): boolean { - return parsed.totalPages === undefined - ? parsed.items.length < PAGE_LIMIT - : page >= parsed.totalPages; -} From 35bea147c10e8cdacbefe5c361e9df06f2f4748e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:53:53 +0000 Subject: [PATCH 208/231] refactor(daytona): drop the remote-browser refusal for Daytona editor opens Keep the backend-resolved ~ checkout path; inline its two one-line helpers. --- src/browser/utils/openInEditor.test.ts | 41 ++------------------------ src/browser/utils/openInEditor.ts | 38 ++---------------------- 2 files changed, 5 insertions(+), 74 deletions(-) diff --git a/src/browser/utils/openInEditor.test.ts b/src/browser/utils/openInEditor.test.ts index ef381038cf4..98af6d176eb 100644 --- a/src/browser/utils/openInEditor.test.ts +++ b/src/browser/utils/openInEditor.test.ts @@ -51,7 +51,7 @@ describe("openInEditor", () => { // navigations and close() calls, mirroring a real popup. function createBrowserModeWindow( calls: OpenCall[], - opts?: { popupBlocked?: boolean; denyEditorPlaceholders?: boolean; hostname?: string } + opts?: { popupBlocked?: boolean; denyEditorPlaceholders?: boolean } ) { const placeholder = { closed: false, @@ -68,7 +68,7 @@ describe("openInEditor", () => { }); const windowValue = { localStorage: { getItem: () => null }, - location: { hostname: opts?.hostname ?? "localhost" }, + location: { hostname: "localhost" }, open: (url: string, target?: string) => { calls.push([url, target]); const wrapperBlocked = @@ -127,43 +127,6 @@ describe("openInEditor", () => { daytona: { sandboxId: "sb-1" }, }; - test("remote browser: refuses before recording or launching", async () => { - const calls: OpenCall[] = []; - const { windowValue, placeholder } = createBrowserModeWindow(calls, { - hostname: "xum.example.com", - }); - const recordEditorOpen = mock(() => - Promise.resolve({ success: true as const, data: undefined }) - ); - const api = createTestApiClient({ general: { recordEditorOpen } }); - - const result = await withWindow(windowValue, () => - openInEditor({ api, workspaceId, targetPath: parentDir, runtimeConfig: daytonaConfig }) - ); - - expect(result.success).toBe(false); - expect(recordEditorOpen).not.toHaveBeenCalled(); - expect(placeholder.navigations).toEqual([]); - expect(placeholder.closed).toBe(true); - }); - - test("a browser on the server's machine opens the sandbox", async () => { - const calls: OpenCall[] = []; - const { windowValue, placeholder } = createBrowserModeWindow(calls); - - const result = await withWindow(windowValue, () => - openInEditor({ - api: createApiStub(), - workspaceId, - targetPath: parentDir, - runtimeConfig: daytonaConfig, - }) - ); - - expect(result).toEqual({ success: true }); - expect(placeholder.navigations[0]).toContain("ssh-remote+xum-daytona-sb-1"); - }); - // The default srcBaseDir (~/mux) is persisted unresolved: the sandbox may not exist yet when // the workspace is created, so only the backend can resolve it once the sandbox is up. test("opens the path the backend resolved for a checkout under ~", async () => { diff --git a/src/browser/utils/openInEditor.ts b/src/browser/utils/openInEditor.ts index fe11374d526..0ab9b2b382c 100644 --- a/src/browser/utils/openInEditor.ts +++ b/src/browser/utils/openInEditor.ts @@ -176,7 +176,7 @@ export async function openInEditor(args: OpenInEditorArgs): Promise string | null; } -function pathToResolve(remote: RemoteEditorPath | undefined): string | undefined { - return remote?.path; -} - -function resolvedRemotePath(data: { remotePath: string } | null | undefined): string | undefined { - return data?.remotePath; -} - function remoteDeepLink( deepLink: string, remote: RemoteEditorPath | undefined, @@ -276,28 +268,6 @@ function remoteDeepLink( return remote.deepLinkFor(resolvedPath) ?? deepLink; } -/** - * A browser on another machine cannot open a Daytona sandbox: the deep link launches the editor - * there, but the alias, token and pinned host keys exist only in the Xum server's - * ~/.ssh/config. Refused before recording, so no editor-open marker is left for an editor that - * cannot connect. Recording the open (recordEditorOpen) starts the sandbox and refreshes its - * token. - */ -function launchDaytonaFromThisMachine( - args: OpenInEditorArgs, - launch: () => Promise -): Promise { - const isDaytona = isSSHRuntime(args.runtimeConfig) && args.runtimeConfig.daytona != null; - if (isDaytona && isBrowserModeNow() && !isLocalhost(window.location.hostname)) { - return Promise.resolve({ - success: false, - error: - "Daytona sandboxes open in an editor only from the Xum desktop app or a browser on the Xum server's machine.", - }); - } - return launch(); -} - async function openInEditorWithLaunch( args: OpenInEditorArgs, editorConfig: EditorConfig, @@ -445,9 +415,7 @@ async function openInEditorWithLaunch( } // The backend resolves only an SSH workspace's path under ~ (see RemoteEditorPath). - return launchDaytonaFromThisMachine(args, () => - launch(deepLink, { path: args.targetPath, deepLinkFor }) - ); + return launch(deepLink, { path: args.targetPath, deepLinkFor }); } // Custom editor: From a47ea68b89b84c1360ead52b3210ab2fe6fbae08 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:57:00 +0000 Subject: [PATCH 209/231] refactor(daytona): keep the touched Daytona UI functions within the CRAP floor Split the API key field and actions out of DaytonaAccessForm, keep the scope check in DaytonaRuntimeSettings so RuntimesSection does not grow, and keep openInEditor's two path helpers: inlining them raised its CC from 22 to 24. --- .../Sections/DaytonaSettingsForms.tsx | 135 ++++++++++++------ .../Settings/Sections/RuntimesSection.tsx | 6 +- src/browser/utils/openInEditor.ts | 12 +- 3 files changed, 101 insertions(+), 52 deletions(-) diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx index 72cfb86d85f..e36aafaaf93 100644 --- a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx @@ -97,6 +97,77 @@ function AccessMessages(props: { test: TestState; error: string | null }) { ); } +function ApiKeyField(props: { + config: DaytonaPublicConfig | null; + value: string; + onChange: (value: string) => void; + disabled: boolean; +}) { + return ( +
+
+ + {/* Beside the label, not inside it, so the field's name stays the label alone. */} + + {describeKey(props.config)} + +
+ props.onChange(event.target.value)} + placeholder={ + props.config?.apiKeySet === true + ? "Enter a new key to replace it" + : "Paste a Daytona API key" + } + disabled={props.disabled} + className="min-w-0" + /> +
+ ); +} + +function AccessActions(props: { + disabled: boolean; + dirty: boolean; + saving: boolean; + keySet: boolean; + /** Only a key saved in Settings can be removed here; an env key is not Xum's to drop. */ + canRemoveKey: boolean; + testing: boolean; + onSave: () => void; + onTest: () => void; + onRemoveKey: () => void; +}) { + return ( +
+ + + {props.canRemoveKey && ( + + )} +
+ ); +} + /** * Daytona access for this Xum install (global scope). The key is write-only: the backend * never returns it, so the field only ever holds a new key the user is typing. Stored in its @@ -106,7 +177,6 @@ export function DaytonaAccessForm() { const access = useDaytonaAccess(); const [apiKey, setApiKey] = useState(""); const disabled = !access.config || access.saving || !access.api; - const keySet = access.config?.apiKeySet === true; const save = async (value: string) => { if (await access.save(value)) setApiKey(""); @@ -114,52 +184,18 @@ export function DaytonaAccessForm() { return (
-
-
- - {/* Beside the label, not inside it, so the field's name stays the label alone. */} - - {describeKey(access.config)} - -
- setApiKey(event.target.value)} - placeholder={keySet ? "Enter a new key to replace it" : "Paste a Daytona API key"} - disabled={disabled} - className="min-w-0" - /> -
-
- - - {/* Only a key saved in Settings can be removed here; an env key is not Xum's to drop. */} - {access.config?.apiKeySource === "settings" && ( - - )} -
+ + void save(apiKey.trim())} + onTest={() => void access.runTest()} + onRemoveKey={() => void save("")} + />

Without a key here, Xum uses DAYTONA_API_KEY from its own environment. DAYTONA_API_URL and @@ -168,3 +204,8 @@ export function DaytonaAccessForm() {

); } + +/** The Daytona row's body: access in the global scope only. */ +export function DaytonaRuntimeSettings(props: { projectPath: string | null }) { + return props.projectPath ? null : ; +} diff --git a/src/browser/features/Settings/Sections/RuntimesSection.tsx b/src/browser/features/Settings/Sections/RuntimesSection.tsx index ec619a8c227..1e3f84d5617 100644 --- a/src/browser/features/Settings/Sections/RuntimesSection.tsx +++ b/src/browser/features/Settings/Sections/RuntimesSection.tsx @@ -10,7 +10,7 @@ import { resolveCoderAvailability, } from "@/browser/features/Runtime/CoderControls"; import { RuntimeConfigInput } from "@/browser/components/RuntimeConfigInput/RuntimeConfigInput"; -import { DaytonaAccessForm } from "./DaytonaSettingsForms"; +import { DaytonaRuntimeSettings } from "./DaytonaSettingsForms"; import { Select, SelectContent, @@ -773,8 +773,8 @@ export function RuntimesSection() { disabled={rowDisabled || coderAvailability.state !== "available"} /> ) : null} - {runtime.id === "daytona" && !selectedProjectPath ? ( - + {runtime.id === "daytona" ? ( + ) : null} diff --git a/src/browser/utils/openInEditor.ts b/src/browser/utils/openInEditor.ts index 0ab9b2b382c..abcf9a5d5be 100644 --- a/src/browser/utils/openInEditor.ts +++ b/src/browser/utils/openInEditor.ts @@ -176,7 +176,7 @@ export async function openInEditor(args: OpenInEditorArgs): Promise string | null; } +function pathToResolve(remote: RemoteEditorPath | undefined): string | undefined { + return remote?.path; +} + +function resolvedRemotePath(data: { remotePath: string } | null | undefined): string | undefined { + return data?.remotePath; +} + function remoteDeepLink( deepLink: string, remote: RemoteEditorPath | undefined, From e2f58bfbd8b655e30a9ce218c3910c60211ab4be Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:00:39 +0000 Subject: [PATCH 210/231] docs(daytona): describe the Coder-like removal and the logged kept sandbox --- docs/runtime/daytona.mdx | 6 +++--- .../services/agentSkills/builtInSkillContent.generated.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index 64a0a7fe31d..b3d50435728 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -57,13 +57,13 @@ Xum uses only sandboxes it creates: a workspace cannot use an existing sandbox. | Idle | Daytona stops the sandbox after 30 minutes and archives it after 1 day stopped | | Sub-agent with a forked workspace | A new worktree in the same sandbox, not a second sandbox | | Archive | Delete the sandbox at once, without a check for uncommitted work | -| Delete | Check for uncommitted or unpushed work, then delete the sandbox | +| Delete | Delete the sandbox; a running one is first checked for uncommitted or unpushed work | | Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs | | Sandbox no longer exists | The workspace reports it; Xum does not recreate it | -Each sandbox belongs to the workspace that created it. Archiving or deleting that workspace deletes the sandbox, even while forks of it (sub-agent worktrees) still use it; **Settings > General > Remote box on archive** does not apply to Daytona. Unarchiving a workspace whose sandbox was deleted does not bring the sandbox back, so push or copy any work you need before you archive. An agent cannot archive the workspace that created a sandbox: it asks you to do it. Archiving or deleting a fork removes only its worktree and never stops or deletes the sandbox. Before any delete, Xum checks that the sandbox still carries this install's `xum.instance` label; a sandbox without it (for example one that took the name while Xum's create failed, or one whose label was removed) is kept, and Xum says so. +Each sandbox belongs to the workspace that created it. Archiving or deleting that workspace deletes the sandbox, even while forks of it (sub-agent worktrees) still use it; **Settings > General > Remote box on archive** does not apply to Daytona. Unarchiving a workspace whose sandbox was deleted does not bring the sandbox back, so push or copy any work you need before you archive. An agent cannot archive the workspace that created a sandbox: it asks you to do it. Archiving or deleting a fork removes only its worktree and never stops or deletes the sandbox. Before any delete, Xum checks that the sandbox still carries this install's `xum.instance` label; a sandbox without it (for example one that took the name while Xum's create failed, or one whose label was removed) is kept, and Xum notes it in its log. -If the sandbox is still being destroyed (for example after a failed setup), delete waits up to a minute for Daytona to finish. A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard. +A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard. Xum does not stop or delete sandboxes that no workspace uses (for example one whose delete failed); Daytona stops them after 30 minutes idle. Delete them in the Daytona dashboard. diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index cc93754c179..826d178d535 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10094,13 +10094,13 @@ export const BUILTIN_SKILL_FILES: Record> = { "| Idle | Daytona stops the sandbox after 30 minutes and archives it after 1 day stopped |", "| Sub-agent with a forked workspace | A new worktree in the same sandbox, not a second sandbox |", "| Archive | Delete the sandbox at once, without a check for uncommitted work |", - "| Delete | Check for uncommitted or unpushed work, then delete the sandbox |", + "| Delete | Delete the sandbox; a running one is first checked for uncommitted or unpushed work |", "| Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs |", "| Sandbox no longer exists | The workspace reports it; Xum does not recreate it |", "", - "Each sandbox belongs to the workspace that created it. Archiving or deleting that workspace deletes the sandbox, even while forks of it (sub-agent worktrees) still use it; **Settings > General > Remote box on archive** does not apply to Daytona. Unarchiving a workspace whose sandbox was deleted does not bring the sandbox back, so push or copy any work you need before you archive. An agent cannot archive the workspace that created a sandbox: it asks you to do it. Archiving or deleting a fork removes only its worktree and never stops or deletes the sandbox. Before any delete, Xum checks that the sandbox still carries this install's `xum.instance` label; a sandbox without it (for example one that took the name while Xum's create failed, or one whose label was removed) is kept, and Xum says so.", + "Each sandbox belongs to the workspace that created it. Archiving or deleting that workspace deletes the sandbox, even while forks of it (sub-agent worktrees) still use it; **Settings > General > Remote box on archive** does not apply to Daytona. Unarchiving a workspace whose sandbox was deleted does not bring the sandbox back, so push or copy any work you need before you archive. An agent cannot archive the workspace that created a sandbox: it asks you to do it. Archiving or deleting a fork removes only its worktree and never stops or deletes the sandbox. Before any delete, Xum checks that the sandbox still carries this install's `xum.instance` label; a sandbox without it (for example one that took the name while Xum's create failed, or one whose label was removed) is kept, and Xum notes it in its log.", "", - "If the sandbox is still being destroyed (for example after a failed setup), delete waits up to a minute for Daytona to finish. A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard.", + "A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard.", "", "Xum does not stop or delete sandboxes that no workspace uses (for example one whose delete failed); Daytona stops them after 30 minutes idle. Delete them in the Daytona dashboard.", "", From 1e30f44c15bb518838baa37a07738a3b1a1fe8c4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:39:53 +0000 Subject: [PATCH 211/231] refactor(ssh): drop Include expansion from the ssh2 config parser Daytona aliases resolve from Xum's include file alone (-F for OpenSSH, the configFile argument for ssh2), and that file holds no Include lines, so the Include, glob and argument-splitting code never ran for a Daytona host. Other ssh2 hosts go back to main's behavior. --- src/node/runtime/sshConfigParser.test.ts | 496 +---------------------- src/node/runtime/sshConfigParser.ts | 375 +---------------- 2 files changed, 21 insertions(+), 850 deletions(-) diff --git a/src/node/runtime/sshConfigParser.test.ts b/src/node/runtime/sshConfigParser.test.ts index 386c280ac05..2939adcc57a 100644 --- a/src/node/runtime/sshConfigParser.test.ts +++ b/src/node/runtime/sshConfigParser.test.ts @@ -1,4 +1,3 @@ -import { spawnSync } from "child_process"; import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; @@ -6,23 +5,6 @@ import { XUM_DAYTONA_SSH_BLOCK_START } from "@/common/constants/daytona"; import { DaytonaSshConfigWriter } from "./daytonaSshConfig"; import { resolveSSHConfig } from "./sshConfigParser"; -const hasOpenSsh = spawnSync("ssh", ["-V"]).status === 0; - -/** What OpenSSH resolves for `host` from `configPath` alone: the parity reference. */ -function opensshOptions(configPath: string, host: string) { - const output = spawnSync("ssh", ["-G", "-F", configPath, host], { encoding: "utf8" }).stdout; - return (key: string) => - output - .split("\n") - .find((line) => line.startsWith(`${key} `)) - ?.slice(key.length + 1); -} - -function opensshUserAndProxy(configPath: string, host: string) { - const value = opensshOptions(configPath, host); - return { user: value("user"), proxyCommand: value("proxycommand") }; -} - describe("resolveSSHConfig", () => { test("applies Host + Match host proxy rules", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-ssh-config-")); @@ -121,45 +103,16 @@ describe("resolveSSHConfig", () => { } } - test("resolves a Daytona sandbox through the managed Host-scoped Include", async () => { - await withTempHome(async (homeDir) => { - await fs.writeFile(path.join(homeDir, ".ssh", "config"), "Host work\n User me\n", "utf8"); - const writer = new DaytonaSshConfigWriter({ - xumHome: path.join(homeDir, ".xum"), - sshConfigPath: path.join(homeDir, ".ssh", "config"), - }); - await writer.writeSandbox({ - sandboxId: "sb-1", - token: "tokenA", - gateway: { host: "ssh.daytona.test", port: 2222, hostKeys: ["ssh-ed25519 AAAA"] }, - }); - - const resolved = await resolveSSHConfig("sb-1.xum--daytona"); - - expect(resolved.user).toBe("tokenA"); - expect(resolved.hostName).toBe("ssh.daytona.test"); - expect(resolved.port).toBe(2222); - expect((await resolveSSHConfig("work")).user).toBe("me"); - }); - }); - - // The Daytona block goes first in the file; the user's global options, top-level Include and - // Host entries after it must resolve as they did before for every other host. + // The Daytona block goes first in the file; the user's global options and Host entries after + // it must resolve as they did before for every other host. test("resolves other hosts the same after Xum puts its Daytona block on top", async () => { await withTempHome(async (homeDir) => { const configPath = path.join(homeDir, ".ssh", "config"); - await fs.mkdir(path.join(homeDir, ".ssh", "conf.d")); - await fs.writeFile( - path.join(homeDir, ".ssh", "conf.d", "team.conf"), - "Host team-*\n User teamuser\n Port 2200\n", - "utf8" - ); await fs.writeFile( configPath, [ "User globaluser", "ServerAliveInterval 30", - `Include ${path.join(homeDir, ".ssh", "conf.d", "*")}`, "Host box", " HostName 10.0.0.1", " ProxyCommand nc %h %p", @@ -170,35 +123,30 @@ describe("resolveSSHConfig", () => { ].join("\n"), "utf8" ); - const hosts = ["box", "team-a", "plain.example.com"]; - const snapshot = async () => - Promise.all( - hosts.map(async (host) => ({ - parsed: await resolveSSHConfig(host), - openssh: hasOpenSsh - ? spawnSync("ssh", ["-G", "-F", configPath, host], { encoding: "utf8" }).stdout - : "", - })) - ); + const hosts = ["box", "plain.example.com"]; + const snapshot = () => Promise.all(hosts.map((host) => resolveSSHConfig(host))); const before = await snapshot(); - await new DaytonaSshConfigWriter({ + const writer = new DaytonaSshConfigWriter({ xumHome: path.join(homeDir, ".xum"), sshConfigPath: configPath, - }).writeSandbox({ + }); + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", - gateway: { host: "ssh.daytona.test", port: 22, hostKeys: ["ssh-ed25519 AAAA"] }, + gateway: { host: "ssh.daytona.test", port: 2222, hostKeys: ["ssh-ed25519 AAAA"] }, }); expect((await fs.readFile(configPath, "utf8")).startsWith(XUM_DAYTONA_SSH_BLOCK_START)).toBe( true ); expect(await snapshot()).toEqual(before); - expect(before[0].parsed.user).toBe("globaluser"); - expect(before[1].parsed.port).toBe(2200); - expect(before[2].parsed.port).toBe(2222); - expect((await resolveSSHConfig("sb-1.xum--daytona")).user).toBe("tokenA"); + expect(before[0].user).toBe("globaluser"); + expect(before[1].port).toBe(2222); + const daytona = await resolveSSHConfig("sb-1.xum--daytona", writer.paths.includePath); + expect(daytona.user).toBe("tokenA"); + expect(daytona.hostName).toBe("ssh.daytona.test"); + expect(daytona.port).toBe(2222); }); }); @@ -222,423 +170,13 @@ describe("resolveSSHConfig", () => { gateway: { host: "ssh.daytona.test", port: 22, hostKeys: ["ssh-ed25519 AAAA"] }, }); - expect((await resolveSSHConfig("sb-1.xum--daytona")).userKnownHostsFile).toBe( - writer.paths.knownHostsPath - ); + const daytona = await resolveSSHConfig("sb-1.xum--daytona", writer.paths.includePath); + expect(daytona.userKnownHostsFile).toBe(writer.paths.knownHostsPath); + expect(daytona.hostKeyAlias).toBe("ssh.daytona.test"); expect((await resolveSSHConfig("plain")).userKnownHostsFile).toBe(path.join(homeDir, "kh1")); expect((await resolveSSHConfig("quoted-first")).userKnownHostsFile).toBe( path.join(homeDir, "J S", "kh") ); }); }); - - test("resolves relative and wildcard Includes and keeps later lines in the enclosing block", async () => { - await withTempHome(async (homeDir) => { - await fs.mkdir(path.join(homeDir, ".ssh", "conf.d")); - await fs.writeFile( - path.join(homeDir, ".ssh", "conf.d", "10-box.conf"), - // Lines before the first Host apply to the enclosing block, as in OpenSSH. - "HostName 10.0.0.5\nHost other\n User otheruser\n", - "utf8" - ); - await fs.writeFile( - path.join(homeDir, ".ssh", "config"), - ["Host box", " Include conf.d/*.conf", " User boxuser", ""].join("\n"), - "utf8" - ); - - const resolved = await resolveSSHConfig("box"); - - expect(resolved.hostName).toBe("10.0.0.5"); - expect(resolved.user).toBe("boxuser"); - }); - }); - - // OpenSSH globs an Include's whole pathname (glob(3), sorted), not only its file name. - test("reads every file an Include's directory wildcard matches, in sorted order", async () => { - await withTempHome(async (homeDir) => { - // Written out of order: the sorted first match sets HostName (the first value wins). - for (const [dir, hostName] of [ - ["b", "10.0.0.2"], - ["a", "10.0.0.1"], - ]) { - await fs.mkdir(path.join(homeDir, ".ssh", "conf.d", dir), { recursive: true }); - await fs.writeFile( - path.join(homeDir, ".ssh", "conf.d", dir, "hosts.conf"), - `Host box\n HostName ${hostName}\nHost only-${dir}\n User user-${dir}\n`, - "utf8" - ); - } - await fs.writeFile( - path.join(homeDir, ".ssh", "config"), - "Include conf.d/*/hosts.conf\n", - "utf8" - ); - - expect((await resolveSSHConfig("box")).hostName).toBe("10.0.0.1"); - expect((await resolveSSHConfig("only-a")).user).toBe("user-a"); - expect((await resolveSSHConfig("only-b")).user).toBe("user-b"); - }); - }); - - test("expands wildcards in several segments of an absolute Include path", async () => { - await withTempHome(async (homeDir) => { - const dir = path.join(homeDir, "teams", "team-1", "nested"); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(path.join(dir, "box.conf"), "Host box\n User teamuser\n", "utf8"); - await fs.writeFile( - path.join(homeDir, ".ssh", "config"), - `Include ${path.join(homeDir, "te?ms", "team-*", "nested", "*.conf")}\n`, - "utf8" - ); - - expect((await resolveSSHConfig("box")).user).toBe("teamuser"); - }); - }); - - test("includes nothing for an Include pattern no path matches", async () => { - await withTempHome(async (homeDir) => { - await fs.mkdir(path.join(homeDir, ".ssh", "conf.d", "a"), { recursive: true }); - await fs.writeFile( - path.join(homeDir, ".ssh", "conf.d", "a", "hosts.conf"), - "Host box\n User wrong\n", - "utf8" - ); - await fs.writeFile( - path.join(homeDir, ".ssh", "config"), - ["Include conf.d/*/other.conf missing/*/hosts.conf", "Host box", " User right", ""].join( - "\n" - ), - "utf8" - ); - - expect((await resolveSSHConfig("box")).user).toBe("right"); - }); - }); - - test("expands glob(3) bracket expressions in an Include path", async () => { - await withTempHome(async (homeDir) => { - const confDir = path.join(homeDir, ".ssh", "conf.d"); - await fs.mkdir(confDir); - // Each file sets User for its own host; the pattern decides which ones are read. - for (const name of ["a", "b", "c", "m", "x", "[odd"]) { - await fs.writeFile( - path.join(confDir, `${name}.conf`), - `Host host-${name}\n User user-${name}\n`, - "utf8" - ); - } - await fs.writeFile( - path.join(homeDir, ".ssh", "config"), - [ - "Include conf.d/[ab].conf conf.d/[k-n].conf conf.d/[!a-m].conf conf.d/[odd.conf", - "Host *", - " User none", - "", - ].join("\n"), - "utf8" - ); - - const user = async (name: string) => (await resolveSSHConfig(`host-${name}`)).user; - expect(await user("a")).toBe("user-a"); - expect(await user("b")).toBe("user-b"); - expect(await user("m")).toBe("user-m"); - expect(await user("x")).toBe("user-x"); - // An unclosed "[" is a literal character. - expect(await user("[odd")).toBe("user-[odd"); - expect(await user("c")).toBe("none"); - }); - }); - - // OpenSSH's glob(3) unescapes every segment, with or without a real wildcard in it. - test("reads an Include's escaped glob characters as the literal file names OpenSSH opens", async () => { - // Windows file names cannot hold "*" or "?". - if (process.platform === "win32") return; - await withTempHome(async (homeDir) => { - const confDir = path.join(homeDir, ".ssh", "conf.d"); - await fs.mkdir(confDir); - const files = ["foo*", "foo?", "foo[", "foo\\", "fooX", "foo[*"]; - for (const [i, name] of files.entries()) { - await fs.writeFile(path.join(confDir, name), `Host host-${i}\n User user-${i}\n`); - } - const configPath = path.join(homeDir, ".ssh", "config"); - // Four backslashes in the config text name one: the config parser and glob(3) each unescape. - const patterns = ["foo\\*", "foo\\?", "foo\\[", "foo\\\\\\\\", "fo\\o\\X", "foo[\\*"]; - await fs.writeFile( - configPath, - `${patterns.map((pattern) => `Include ${confDir}/${pattern}`).join("\n")}\nHost *\n User none\n` - ); - - for (const i of files.keys()) { - expect((await resolveSSHConfig(`host-${i}`)).user).toBe(`user-${i}`); - if (hasOpenSsh) expect(opensshOptions(configPath, `host-${i}`)("user")).toBe(`user-${i}`); - } - }); - }); - - // glob(3) matches a leading "." with a literal one, escaped or not, never with a wildcard - // or a bracket expression. - test("matches dot files with an escaped leading dot in an Include pattern", async () => { - // Windows file names and paths differ (and the escape is a separator there). - if (process.platform === "win32") return; - await withTempHome(async (homeDir) => { - // Absolute: OpenSSH resolves a relative Include against the real user's ~/.ssh. - const confDir = path.join(homeDir, ".ssh", "conf.d"); - await fs.mkdir(confDir); - for (const [name, host] of [ - [".escaped.conf", "escaped"], - [".plain.conf", "plain"], - [".bracket.conf", "bracket"], - ]) { - await fs.writeFile(path.join(confDir, name), `Host ${host}\n User user-${host}\n`, "utf8"); - } - const configPath = path.join(homeDir, ".ssh", "config"); - await fs.writeFile( - configPath, - [ - `Include ${confDir}/\\.e*.conf ${confDir}/.p*.conf ${confDir}/[.]b*.conf`, - "Host *", - " User none", - "", - ].join("\n"), - "utf8" - ); - - for (const [host, user] of [ - ["escaped", "user-escaped"], - ["plain", "user-plain"], - ["bracket", "none"], - ]) { - expect((await resolveSSHConfig(host)).user).toBe(user); - if (hasOpenSsh) expect(opensshOptions(configPath, host)("user")).toBe(user); - } - }); - }); - - test("matches an Include wildcard as glob(3) does: ? is one character, * skips dot files", async () => { - await withTempHome(async (homeDir) => { - const confDir = path.join(homeDir, ".ssh", "conf.d"); - await fs.mkdir(confDir); - for (const [name, host] of [ - ["1.conf", "one"], - ["12.conf", "two"], - [".hidden.conf", "hidden"], - ]) { - await fs.writeFile(path.join(confDir, name), `Host ${host}\n User user-${host}\n`, "utf8"); - } - await fs.writeFile( - path.join(homeDir, ".ssh", "config"), - ["Include conf.d/1?.conf conf.d/*hidden.conf", "Host *", " User none", ""].join("\n"), - "utf8" - ); - - expect((await resolveSSHConfig("one")).user).toBe("none"); - expect((await resolveSSHConfig("two")).user).toBe("user-two"); - expect((await resolveSSHConfig("hidden")).user).toBe("none"); - }); - }); - - test("matches glob(3) named classes in an Include path; an unknown class matches nothing", async () => { - await withTempHome(async (homeDir) => { - const confDir = path.join(homeDir, ".ssh", "conf.d"); - await fs.mkdir(confDir); - for (const [name, host] of [ - ["1", "one"], - ["a", "letter"], - ]) { - await fs.writeFile( - path.join(confDir, `${name}.conf`), - `Host ${host}\n User user-${host}\n` - ); - } - await fs.writeFile( - path.join(homeDir, ".ssh", "config"), - [ - "Include conf.d/[[:digit:]].conf conf.d/[[:bogus:]a].conf", - "Host *", - " User none", - "", - ].join("\n") - ); - - expect((await resolveSSHConfig("one")).user).toBe("user-one"); - expect((await resolveSSHConfig("letter")).user).toBe("none"); - }); - }); - - test("expands environment variables and local %-tokens in an Include path (OpenSSH 9.9+)", async () => { - const variable = "XUM_SSH_CONFIG_TEST_DIR"; - await withTempHome(async (homeDir) => { - const confDir = path.join(homeDir, ".ssh", "conf.d"); - await fs.mkdir(confDir); - process.env[variable] = confDir; - const unset = `\${XUM_SSH_CONFIG_TEST_UNSET}`; - await fs.mkdir(path.join(confDir, unset)); - const files = [ - ["env.conf", "env-host"], - ["home.conf", "home-host"], - ["named-host.conf", "named-host"], - // Only a literal reading of an unset variable or the port token finds these files. - [`${unset}/unset.conf`, "unset-host"], - ["%p.conf", "port-host"], - ]; - for (const [file, host] of files) { - await fs.writeFile(path.join(confDir, file), `Host ${host}\n User user-${host}\n`); - } - await fs.writeFile( - path.join(homeDir, ".ssh", "config"), - [ - `Include \${${variable}}/env.conf %d/.ssh/conf.d/home.conf conf.d/%n.conf`, - `Include conf.d/${unset}/unset.conf conf.d/%p.conf`, - "Host *", - " User none", - "", - ].join("\n") - ); - - try { - expect((await resolveSSHConfig("env-host")).user).toBe("user-env-host"); - expect((await resolveSSHConfig("home-host")).user).toBe("user-home-host"); - expect((await resolveSSHConfig("named-host")).user).toBe("user-named-host"); - expect((await resolveSSHConfig("unset-host")).user).toBe("none"); - expect((await resolveSSHConfig("port-host")).user).toBe("none"); - } finally { - Reflect.deleteProperty(process.env, variable); - } - }); - }); - - // OpenSSH 8.6+ splits config arguments with argv_split (misc.c): backslash escapes, quotes - // anywhere in an argument, and a # that starts an argument ends the line. - test("reads Include paths with escaped spaces and quotes as OpenSSH does", async () => { - await withTempHome(async (homeDir) => { - const sshDir = path.join(homeDir, ".ssh"); - const files = { - a: path.join(sshDir, "conf dir", "a.conf"), - b: path.join(sshDir, 'say "hi"', "b.conf"), - c: path.join(sshDir, "single dir", "c.conf"), - }; - for (const [host, file] of Object.entries(files)) { - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `Host ${host}\n User ${host}user\n`, "utf8"); - } - const configPath = path.join(sshDir, "config"); - await fs.writeFile( - configPath, - [ - // Absolute: ssh -G resolves a relative Include against the real ~/.ssh. - `Include ${sshDir}/conf\\ dir/a.conf`, - `Include "${sshDir}/say \\"hi\\"/b.conf"`, - `Include ${sshDir}/'single dir'/c.conf # not a path`, - "Host *", - " User fallback", - "", - ].join("\n"), - "utf8" - ); - - for (const host of ["a", "b", "c"]) { - expect((await resolveSSHConfig(host)).user).toBe(`${host}user`); - if (hasOpenSsh) { - expect(opensshUserAndProxy(configPath, host).user).toBe(`${host}user`); - } - } - }); - }); - - test("applies an Include inside a Host block only to the hosts that block matches", async () => { - await withTempHome(async (homeDir) => { - const included = path.join(homeDir, ".ssh", "gated.conf"); - await fs.writeFile(included, "Host *\n User gateduser\n ProxyCommand nc %h %p\n", "utf8"); - const configPath = path.join(homeDir, ".ssh", "config"); - await fs.writeFile( - configPath, - ["Host gated !gated-not", ` Include ${included}`, "Host *", " User fallback", ""].join( - "\n" - ), - "utf8" - ); - - const resolve = async (host: string) => { - const { user, proxyCommand } = await resolveSSHConfig(host); - return { user, proxyCommand }; - }; - expect(await resolve("gated")).toEqual({ user: "gateduser", proxyCommand: "nc %h %p" }); - for (const host of ["elsewhere", "gated-not"]) { - expect(await resolve(host)).toEqual({ user: "fallback", proxyCommand: undefined }); - } - if (hasOpenSsh) { - for (const host of ["gated", "elsewhere", "gated-not"]) { - expect(await resolve(host)).toEqual(opensshUserAndProxy(configPath, host)); - } - } - }); - }); - - // OpenSSH restores the including file's scope when an Include returns. - test("keeps lines after a top-level Include global when the included file ends in a Host block", async () => { - await withTempHome(async (homeDir) => { - const included = path.join(homeDir, ".ssh", "inc.conf"); - await fs.writeFile(included, "Host other\n User otheruser\n", "utf8"); - const configPath = path.join(homeDir, ".ssh", "config"); - await fs.writeFile(configPath, `Include ${included}\nPort 2222\n`, "utf8"); - - expect((await resolveSSHConfig("target")).port).toBe(2222); - if (hasOpenSsh) expect(opensshOptions(configPath, "target")("port")).toBe("2222"); - }); - }); - - test("restores the includer's block after every file an Include reads", async () => { - await withTempHome(async (homeDir) => { - const incDir = path.join(homeDir, ".ssh", "inc"); - await fs.mkdir(incDir); - await fs.writeFile(path.join(incDir, "a"), "Host other\n User otheruser\n", "utf8"); - await fs.writeFile(path.join(incDir, "b"), "Port 2222\n", "utf8"); - const separate = path.join(homeDir, ".ssh", "c.conf"); - await fs.writeFile(separate, "User cuser\n", "utf8"); - const configPath = path.join(homeDir, ".ssh", "config"); - await fs.writeFile( - configPath, - // One glob matching two files, then two arguments on one Include line. - ["Host target", ` Include ${incDir}/*`, ` Include ${incDir}/a ${separate}`, ""].join( - "\n" - ), - "utf8" - ); - - const resolved = await resolveSSHConfig("target"); - expect(resolved.port).toBe(2222); - expect(resolved.user).toBe("cuser"); - if (hasOpenSsh) { - const openssh = opensshOptions(configPath, "target"); - expect(openssh("port")).toBe("2222"); - expect(openssh("user")).toBe("cuser"); - } - }); - }); - - test("restores the includer's block after an Include nested in an included file", async () => { - await withTempHome(async (homeDir) => { - const inner = path.join(homeDir, ".ssh", "b.conf"); - const outer = path.join(homeDir, ".ssh", "a.conf"); - await fs.writeFile(inner, "Host other\n User otheruser\n", "utf8"); - await fs.writeFile(outer, `Include ${inner}\nUser x\n`, "utf8"); - const configPath = path.join(homeDir, ".ssh", "config"); - await fs.writeFile( - configPath, - ["Host target", ` Include ${outer}`, "Host *", " User fallback", ""].join("\n"), - "utf8" - ); - - expect((await resolveSSHConfig("target")).user).toBe("x"); - expect((await resolveSSHConfig("elsewhere")).user).toBe("fallback"); - if (hasOpenSsh) { - for (const host of ["target", "elsewhere"]) { - expect((await resolveSSHConfig(host)).user).toBe( - opensshUserAndProxy(configPath, host).user - ); - } - } - }); - }); }); diff --git a/src/node/runtime/sshConfigParser.ts b/src/node/runtime/sshConfigParser.ts index c1e69013cfe..9d92f2a6b4f 100644 --- a/src/node/runtime/sshConfigParser.ts +++ b/src/node/runtime/sshConfigParser.ts @@ -251,380 +251,14 @@ function toStringArray(value: ComputedConfigValue | undefined): string[] { return []; } -// OpenSSH's READCONF_MAX_DEPTH. -const MAX_INCLUDE_DEPTH = 16; -const INCLUDE_LINE_PATTERN = /^\s*include(?:\s*=\s*|\s+)(.+?)\s*$/i; -const SECTION_LINE_PATTERN = /^\s*(?:host|match)(?:\s*=\s*|\s+)\S/i; -const HOST_LINE_PATTERN = /^\s*host(?:\s*=\s*|\s+)(.+?)\s*$/i; -const MATCH_ALL_LINE_PATTERN = /^\s*match(?:\s*=\s*|\s+)all\s*$/i; -// Matches every host, so lines after it apply as top-level lines do. -const GLOBAL_SCOPE_HEADER = "Host *"; - -/** - * A config line's arguments, split as OpenSSH 8.6+ does (misc.c's argv_split): quotes may - * appear anywhere in an argument, a backslash escapes a quote, a backslash or (unquoted) a - * space, and an argument that starts with # ends the line. An unclosed quote makes OpenSSH - * reject the line: it yields no arguments here. - */ -function splitIncludeArgs(value: string): string[] { - const args: string[] = []; - let index = 0; - while (index < value.length) { - if (isConfigBlank(value[index])) { - index++; - continue; - } - if (value[index] === "#") break; - const read = readConfigArg(value, index); - if (!read) return []; - if (read.arg) args.push(read.arg); - index = read.end; - } - return args; -} - -function readConfigArg(value: string, start: number): { arg: string; end: number } | undefined { - let arg = ""; - let quote = ""; - let index = start; - while (index < value.length && (quote || !isConfigBlank(value[index]))) { - const step = configArgStep(value, index, quote); - arg += step.text; - quote = step.quote; - index += step.length; - } - return quote ? undefined : { arg, end: index }; -} - -/** One character (or escape pair) of an argument: the text it adds and the quote after it. */ -function configArgStep( - value: string, - index: number, - quote: string -): { text: string; quote: string; length: number } { - const char = value[index]; - const next = value[index + 1]; - if (char === "\\" && isConfigEscape(next, quote)) return { text: next, quote, length: 2 }; - if (quote) - return { text: char === quote ? "" : char, quote: char === quote ? "" : quote, length: 1 }; - if (char === '"' || char === "'") return { text: "", quote: char, length: 1 }; - return { text: char, quote, length: 1 }; -} - -function isConfigBlank(char: string): boolean { - return char === " " || char === "\t"; -} - -function isConfigEscape(next: string | undefined, quote: string): boolean { - return next === "'" || next === '"' || next === "\\" || (!quote && next === " "); -} - -/** - * An Include argument with its `${VAR}` references and %-tokens expanded, as OpenSSH 9.9+ - * does (readconf.c's percent_dollar_expand). Undefined skips the argument, as OpenSSH does - * on an expansion error: an unset variable or an unknown token. The tokens that depend on - * options set earlier in the parse (%h after a HostName, %k, %p, %r, %C, %j) are not tracked - * by this pass and skip it too: skipping can only miss an option, where a guess could read - * another host's file. - */ -function expandIncludeArg(arg: string, homeDir: string, host: string): string | undefined { - const localHost = os.hostname(); - const tokens: Record = { - "%": "%", - d: homeDir, - i: process.getuid?.().toString(), - L: localHost.split(".")[0], - l: localHost, - n: host, - u: getDefaultUsername(), - }; - let failed = false; - // `${NAME}` (an unclosed `${` fails) or `%` and the character after it. - const expanded = arg.replace( - /\$\{([^}]*)(\})?|%(.?)/gs, - (match, name: string | undefined, close: string | undefined, token: string | undefined) => { - const variable = close ? process.env[name ?? ""] : undefined; - const value = token === undefined ? variable : tokens[token]; - if (value === undefined) failed = true; - return value ?? match; - } - ); - if (failed) { - log.debug("Skipping an SSH config Include argument that does not expand", { include: arg }); - return undefined; - } - return expanded; -} - -async function resolveIncludePaths( - value: string, - homeDir: string, - host: string -): Promise { - const files: string[] = []; - for (const arg of splitIncludeArgs(value)) { - const tokenized = expandIncludeArg(arg, homeDir, host); - if (tokenized === undefined) continue; - const expanded = expandHomePath(tokenized, homeDir); - // OpenSSH resolves relative user-config Include paths against ~/.ssh. - const absolute = path.isAbsolute(expanded) ? expanded : path.join(homeDir, ".ssh", expanded); - files.push(...(await globPath(absolute))); - } - return files; -} - -/** - * The paths `pattern` names, as OpenSSH's glob(3) of an Include: any segment may hold a - * wildcard, matches are existing paths sorted, and no match includes nothing. A pattern - * without wildcards names its one path, read (or skipped when missing) like any other. - */ -async function globPath(pattern: string): Promise { - // Normalized so a Windows path split on path.sep keeps its "/" separators apart too. - const normalized = path.normalize(pattern); - const { root } = path.parse(normalized); - let matches = [root]; - for (const segment of normalized.slice(root.length).split(path.sep).filter(Boolean)) { - const { matcher, literal, literalLeadingDot } = segmentPattern(segment); - if (!matcher) { - matches = matches.map((dir) => path.join(dir, literal)); - continue; - } - const next: string[][] = []; - for (const dir of matches) { - next.push(await matchingEntries(dir, segment, { matcher, literalLeadingDot })); - } - matches = next.flat(); - } - return matches.sort(); -} - -async function matchingEntries( - dir: string, - segment: string, - pattern: { matcher: RegExp; literalLeadingDot: boolean } -): Promise { - try { - const entries = await fs.readdir(dir); - // glob(3) matches a leading "." only with a literal one (escaped or not), never with a - // wildcard or a bracket expression. - return entries - .filter( - (entry) => - (pattern.literalLeadingDot || !entry.startsWith(".")) && pattern.matcher.test(entry) - ) - .map((entry) => path.join(dir, entry)); - } catch (error) { - log.debug("Failed to expand SSH config Include", { - include: path.join(dir, segment), - error: getErrorMessage(error), - }); - return []; - } -} - -const REGEXP_SPECIAL = /[\\^$.*+?()[\]{}|/-]/g; -const escapeRegExp = (text: string) => text.replace(REGEXP_SPECIAL, "\\$&"); - -/** - * One path segment of a glob(3) pattern as a RegExp: `*`, `?` (one character), bracket - * expressions (`[ab]`, `[a-z]`, negated by a leading `!` or `^`; an unclosed `[` is literal) - * and `\` escapes. Without a wildcard there is no matcher, only the unescaped `literal` name - * glob(3) opens. `literalLeadingDot`: the pattern starts with a literal "." once unescaped. - */ -function segmentPattern(segment: string): { - matcher?: RegExp; - literal: string; - literalLeadingDot: boolean; -} { - let source = ""; - let literal = ""; - let wildcard = false; - let literalLeadingDot = false; - for (let i = 0; i < segment.length; i++) { - const token = segmentToken(segment, i); - if (i === 0) literalLeadingDot = !token.wildcard && segment[token.end] === "."; - source += token.source; - wildcard ||= token.wildcard; - if (!token.wildcard) literal += segment[token.end]; - i = token.end; - } - const matcher = wildcard ? new RegExp(`^${source}$`, "s") : undefined; - return { matcher, literal, literalLeadingDot }; -} - -const WILDCARD_SOURCES = new Map([ - ["*", ".*"], - ["?", "."], -]); - -/** The RegExp source of the pattern element at `i`, and the index of its last character. */ -function segmentToken( - segment: string, - i: number -): { source: string; end: number; wildcard: boolean } { - const wildcardSource = WILDCARD_SOURCES.get(segment[i]); - if (wildcardSource !== undefined) return { source: wildcardSource, end: i, wildcard: true }; - const bracket = segment[i] === "[" ? bracketExpression(segment, i) : undefined; - if (bracket) return { ...bracket, wildcard: true }; - const end = segment[i] === "\\" && i + 1 < segment.length ? i + 1 : i; - return { source: escapeRegExp(segment[end]), end, wildcard: false }; -} - -/** The bracket expression opening at `start`, or undefined when it never closes. */ -function bracketExpression( - segment: string, - start: number -): { source: string; end: number } | undefined { - const negated = segment[start + 1] === "!" || segment[start + 1] === "^"; - let i = start + (negated ? 2 : 1); - const items: Array = []; - // A "]" right after the opening (and its negation) is a member, not the close. - for (let first = true; i < segment.length && (first || segment[i] !== "]"); first = false) { - const member = bracketMember(segment, i); - items.push(member.source); - i = member.next; - } - if (i >= segment.length) return undefined; - return { source: bracketSource(items, negated), end: i }; -} - -/** An unknown named class makes the whole pattern match nothing, as glob(3) refuses it. */ -function bracketSource(items: Array, negated: boolean): string { - if (items.includes(undefined)) return "(?!)"; - return `[${negated ? "^" : ""}${items.join("")}]`; -} - -/** glob(3)'s named classes (`[[:digit:]]`) as RegExp class contents, for the C locale. */ -const NAMED_CLASSES = new Map([ - ["alnum", "0-9A-Za-z"], - ["alpha", "A-Za-z"], - ["blank", " \\t"], - ["cntrl", "\\x00-\\x1f\\x7f"], - ["digit", "0-9"], - ["graph", "\\x21-\\x7e"], - ["lower", "a-z"], - ["print", "\\x20-\\x7e"], - ["punct", "!-/:-@\\[-`{-~"], - ["space", "\\t-\\r "], - ["upper", "A-Z"], - ["xdigit", "0-9A-Fa-f"], -]); - -/** - * The named class (`[:digit:]`) at `i`: as in glob(3), `[:` opens one only when its first `:` - * is followed by `]`. Its source is undefined for an unknown class name. - */ -function namedClass( - segment: string, - i: number -): { source: string | undefined; next: number } | undefined { - if (!segment.startsWith("[:", i)) return undefined; - const colon = segment.indexOf(":", i + 2); - if (colon === -1 || segment[colon + 1] !== "]") return undefined; - return { source: NAMED_CLASSES.get(segment.slice(i + 2, colon)), next: colon + 2 }; -} - -/** One character, range or named class of a bracket expression, and the index after it. */ -function bracketMember(segment: string, i: number): { source: string | undefined; next: number } { - const named = namedClass(segment, i); - if (named) return named; - const range = segment[i + 1] === "-" && i + 2 < segment.length && segment[i + 2] !== "]"; - if (!range) return { source: escapeRegExp(segment[i]), next: i + 1 }; - const [low, high] = [segment[i], segment[i + 2]]; - // A reversed range matches nothing, as in glob(3); a RegExp would refuse it. - return { source: low <= high ? `${escapeRegExp(low)}-${escapeRegExp(high)}` : "", next: i + 3 }; -} - -/** - * Whether the section `header` (undefined: before any Host/Match) applies to `host`. OpenSSH - * reads an Include inside an inactive block as matching nothing (readconf.c's - * SSHCONF_NEVERMATCH), so such an include is skipped. A Host block uses the same pattern - * matching as `compute`. Of Match blocks only `Match all` counts: the other criteria (exec, - * user, canonical, final, the host after HostName) are not known here, and skipping an - * include can only miss an option, where including it could apply one (a ProxyCommand) to a - * host OpenSSH leaves alone. - */ -function sectionAppliesTo(header: string | undefined, host: string): boolean { - if (header === undefined) return true; - const hostLine = HOST_LINE_PATTERN.exec(header); - if (hostLine) return glob(splitIncludeArgs(hostLine[1]), host); - return MATCH_ALL_LINE_PATTERN.test(header); -} - -/** The expanded content of each file one Include line names, in order. */ -async function readIncludedFiles( - value: string, - homeDir: string, - depth: number, - host: string, - scope: string | undefined -): Promise { - const contents: string[] = []; - for (const file of await resolveIncludePaths(value, homeDir, host)) { - try { - const content = await fs.readFile(file, "utf8"); - contents.push(await expandIncludes(content, homeDir, depth, host, scope)); - } catch (error) { - // OpenSSH skips missing include files silently. - if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { - log.debug("Failed to read SSH config Include", { - include: file, - error: getErrorMessage(error), - }); - } - } - } - return contents; -} - -/** - * Inline `Include` directives for resolving `host`, which the ssh-config package does not - * resolve (Xum's Daytona block relies on one). An Include inside a block that does not apply - * to `host` is skipped, as OpenSSH does. OpenSSH restores the includer's scope when an Include - * returns, so each file an Include reads is followed by the current block's header again, or - * by `Host *` at the top level, and later lines stay where they were written. `scope` is the - * header a nested file is read in (undefined at the top level). - */ -async function expandIncludes( - content: string, - homeDir: string, - depth: number, - host: string, - scope?: string -): Promise { - const out: string[] = []; - let sectionHeader = scope; - for (const line of content.split(/\r?\n/)) { - if (SECTION_LINE_PATTERN.test(line)) { - sectionHeader = line.trim(); - out.push(line); - continue; - } - const includeMatch = INCLUDE_LINE_PATTERN.exec(line); - if (!includeMatch) { - out.push(line); - continue; - } - if (depth >= MAX_INCLUDE_DEPTH) { - log.debug("SSH config Include nesting too deep; ignoring", { include: includeMatch[1] }); - continue; - } - if (!sectionAppliesTo(sectionHeader, host)) continue; - const files = await readIncludedFiles(includeMatch[1], homeDir, depth + 1, host, sectionHeader); - // OpenSSH restores the scope after each file, so one file's last block never captures the next. - out.push(...files.flatMap((file) => [file, sectionHeader ?? GLOBAL_SCOPE_HEADER])); - } - return out.join("\n"); -} - -/** The user's ssh config with its Includes expanded for `host`. */ -async function loadSSHConfig(host: string, configFile?: string): Promise { +/** `configFile` replaces ~/.ssh/config, as OpenSSH's `-F` does. */ +async function loadSSHConfig(configFile?: string): Promise { const homeDir = getHomeDir(); const configPath = configFile ?? path.join(homeDir, ".ssh", "config"); try { const content = await fs.readFile(configPath, "utf8"); - const parsed = SSHConfig.parse(await expandIncludes(content, homeDir, 0, host)); + const parsed = SSHConfig.parse(content); return parsed; } catch (error) { if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { @@ -637,7 +271,6 @@ async function loadSSHConfig(host: string, configFile?: string): Promise Date: Sun, 25 Oct 2026 00:51:21 +0000 Subject: [PATCH 212/231] refactor(daytona): stop retiring ssh connections and discarding mints across a release Drop the retirement machinery (ssh2 retireConnection, OpenSSH master stops and ControlPath bumps, deterministic reverse-forward paths, the onSshHostRetired listener in the bash AI proxy). Access is released only when Xum deletes the sandbox, which ends its connections anyway; after a gateway move, pooled connections keep the old gateway until they close, and new ones read the new files. forgetHealth/resetConnectionHealth stay for the token-replace retry. Also drop the mint-across-release check: a token minted while its sandbox is deleted expires within the hour. --- src/node/runtime/SSH2ConnectionPool.test.ts | 64 -------- src/node/runtime/SSH2ConnectionPool.ts | 106 +++---------- src/node/runtime/sshConnectionPool.ts | 17 +-- .../transports/OpenSSHTransport.test.ts | 143 +----------------- .../runtime/transports/OpenSSHTransport.ts | 82 +--------- src/node/runtime/transports/index.ts | 26 +--- .../bashAiProxy/bashAiProxyService.test.ts | 26 +--- .../bashAiProxy/bashAiProxyService.ts | 5 - .../services/bashAiProxy/reverseForwards.ts | 31 +--- .../services/bashAiProxy/sshForwardTarget.ts | 4 +- src/node/services/daytona/daytonaService.ts | 7 - src/node/services/daytona/daytonaSsh.test.ts | 83 +--------- src/node/services/daytona/daytonaSsh.ts | 66 +------- 13 files changed, 39 insertions(+), 621 deletions(-) diff --git a/src/node/runtime/SSH2ConnectionPool.test.ts b/src/node/runtime/SSH2ConnectionPool.test.ts index 9accfd5a479..0ff73dd5585 100644 --- a/src/node/runtime/SSH2ConnectionPool.test.ts +++ b/src/node/runtime/SSH2ConnectionPool.test.ts @@ -296,70 +296,6 @@ describe.skipIf(process.platform === "win32")( } }); - it("a retired connection carries no new work and closes once its channels do", async () => { - const { config } = await startServer((conn) => { - conn.on("authentication", (ctx) => ctx.accept()); - conn.on("error", () => undefined); - }); - const pool = new SSH2ConnectionPool(); - const first = await pool.acquireConnection(config); - const slot = pool.reserveChannel(config, first); - let closed = false; - const firstClosed = new Promise((resolve) => - first.client.once("close", () => { - closed = true; - resolve(); - }) - ); - - pool.retireConnection(config); - const second = await pool.acquireConnection(config, { maxWaitMs: 0 }); - expect(second === first).toBe(false); - // A command still running on it keeps it open. - expect(closed).toBe(false); - slot.release(); - await firstClosed; - second.client.end(); - }); - - it("never pools a connection that was still connecting when its host was retired", async () => { - // The first connection's authentication waits until the test lets it through. - let releaseFirst!: () => void; - const firstAuth = new Promise((resolve) => (releaseFirst = resolve)); - const serverConns: Array<{ closed: boolean }> = []; - const { config } = await startServer((conn) => { - const record = { closed: false }; - serverConns.push(record); - const held = serverConns.length === 1 ? firstAuth : Promise.resolve(); - conn.on("authentication", (ctx) => void held.then(() => ctx.accept())); - conn.on("close", () => (record.closed = true)); - conn.on("error", () => undefined); - }); - const pool = new SSH2ConnectionPool(); - - // Joined by both waiters, then retired (a settings change) before it authenticates. - const waiters = [ - pool.acquireConnection(config), - pool.acquireConnection(config, { maxWaitMs: 0 }), - ]; - const settle = async (done: () => boolean) => { - for (let i = 0; i < 400 && !done(); i++) await new Promise((r) => setTimeout(r, 5)); - }; - await settle(() => serverConns.length > 0); - pool.retireConnection(config); - releaseFirst(); - const [a, b] = await Promise.all(waiters); - - // Both got the connection made after the retirement; the old one was closed. - expect(a).toBe(b); - expect(serverConns).toHaveLength(2); - await settle(() => serverConns[0].closed); - expect(serverConns[0].closed).toBe(true); - expect(serverConns[1].closed).toBe(false); - expect(await pool.acquireConnection(config)).toBe(a); - a.client.end(); - }); - it("a retired client's late error does not put the host into backoff", async () => { const { config } = await startServer((conn) => { conn.on("authentication", (ctx) => ctx.accept()); diff --git a/src/node/runtime/SSH2ConnectionPool.ts b/src/node/runtime/SSH2ConnectionPool.ts index 9188b7bbb55..306d6f6356b 100644 --- a/src/node/runtime/SSH2ConnectionPool.ts +++ b/src/node/runtime/SSH2ConnectionPool.ts @@ -117,8 +117,6 @@ interface SSH2ConnectionEntry { idleTimer?: ReturnType; /** Exec/shell channels still open on this connection (see trackChannel). */ openChannels: number; - /** Out of the pool (retireConnection): it closes when its last channel does. */ - retired?: boolean; } /** @@ -369,20 +367,10 @@ async function resolvePrivateKeys(identityFiles: string[]): Promise { return keys; } -/** A pooled connect whose host was retired (retireConnection) before it finished. */ -class RetiredConnectError extends Error { - constructor(key: string) { - super(`SSH connection ${key} was retired while it connected`); - this.name = "RetiredConnectError"; - } -} - export class SSH2ConnectionPool { private health = new Map(); private inflight = new Map>(); private connections = new Map(); - /** Bumped by retireConnection: a connect that started before then is never pooled. */ - private retirements = new Map(); private idleTimeoutMs = IDLE_TIMEOUT_MS; async acquireConnection( @@ -446,11 +434,20 @@ export class SSH2ConnectionPool { throw budgetExceeded; } - // The connect is shared, so it takes no caller's abort signal: one caller aborting - // must not fail every waiter (#5101). Each caller races only its own wait against its - // abort and budget below. A connect nobody waits for any more still finishes (bounded - // by readyTimeout) and is cached, then closes after the idle timeout like any other. - const inflight = this.inflight.get(key) ?? this.startConnect(key, config, timeoutMs); + let inflight = this.inflight.get(key); + if (!inflight) { + // The connect is shared, so it takes no caller's abort signal: one caller aborting + // must not fail every waiter (#5101). Each caller races only its own wait against its + // abort and budget below. A connect nobody waits for any more still finishes (bounded + // by readyTimeout) and is cached, then closes after the idle timeout like any other. + inflight = this.connect(config, timeoutMs); + this.inflight.set(key, inflight); + // Attach no-op catch to prevent unhandled rejection when singleflighted + // promise rejects before any caller awaits it. Actual errors are + // propagated to callers via the await below. + // eslint-disable-next-line @typescript-eslint/no-empty-function + void inflight.catch(() => {}).finally(() => this.inflight.delete(key)); + } try { const entry = await waitForAbortable( @@ -570,8 +567,6 @@ export class SSH2ConnectionPool { // dead client only held the event loop open until it fired as a no-op (#5507). if (entry.openChannels === 0 && this.connections.get(key) === entry) { this.touchConnection(entry, key); - } else if (entry.openChannels === 0 && entry.retired) { - this.endClient(entry); } }; return { @@ -603,79 +598,18 @@ export class SSH2ConnectionPool { } this.connections.delete(key); - this.endClient(entry); - } - - /** - * The host's credentials or gateway changed (Daytona settings, a moved gateway): later - * acquires connect anew instead of reusing this client. Commands still running on it finish; - * it closes with its last channel. - */ - retireConnection(config: SSHConnectionConfig): void { - const key = makeConnectionKey(config); - this.health.delete(key); - // A connect in flight may authenticate with the old credentials: it closes instead of - // joining the pool, and its waiters get a fresh one (startConnect). - this.retirements.set(key, this.retirementOf(key) + 1); - const entry = this.connections.get(key); - if (!entry) return; - this.connections.delete(key); - if (entry.idleTimer) clearTimeout(entry.idleTimer); - entry.retired = true; - if (entry.openChannels === 0) this.endClient(entry); - } - - /** The shared connect of `key`, connecting once more for its waiters if a retirement overtook it. */ - private startConnect( - key: string, - config: SSHConnectionConfig, - timeoutMs: number - ): Promise { - const started = this.connect(config, timeoutMs).catch((error: unknown) => { - if (error instanceof RetiredConnectError) return this.connect(config, timeoutMs); - throw error; - }); - this.inflight.set(key, started); - // Attach no-op catch to prevent unhandled rejection when singleflighted - // promise rejects before any caller awaits it. Actual errors are - // propagated to callers via the await in acquireConnection. - // eslint-disable-next-line @typescript-eslint/no-empty-function - void started.catch(() => {}).finally(() => this.inflight.delete(key)); - return started; - } - private retirementOf(key: string): number { - return this.retirements.get(key) ?? 0; - } - - /** A connect that started before its host was retired closes instead of joining the pool. */ - private throwIfRetiredSince(key: string, retirement: number, entry: SSH2ConnectionEntry): void { - if (this.retirementOf(key) === retirement) return; - this.endClient(entry); - throw new RetiredConnectError(key); - } - - /** A retired connect says nothing about the host's health. */ - private reportConnectFailure(config: SSHConnectionConfig, error: unknown): void { - if (!(error instanceof RetiredConnectError)) this.reportFailure(config, getErrorMessage(error)); - } - - private endClient(entry: SSH2ConnectionEntry): void { try { entry.client.end(); - } catch (error) { - log.debug("Ignoring an error while closing an ssh2 connection", { - error: getErrorMessage(error), - }); + } catch { + // Ignore errors closing the connection } if (entry.proxyProcess?.exitCode === null) { try { entry.proxyProcess.kill(); - } catch (error) { - log.debug("Ignoring an error while killing an ssh2 ProxyCommand", { - error: getErrorMessage(error), - }); + } catch { + // Ignore errors killing proxy } } } @@ -700,7 +634,6 @@ export class SSH2ConnectionPool { mode: "pooled" | "dedicated" = "pooled" ): Promise { const key = makeConnectionKey(config); - const retirement = this.retirementOf(key); try { const resolved = await resolveSSHConfig(config.host, sshConfigFileFor(config.host)); const resolvedConfig: ResolvedSSHConfig = { @@ -906,7 +839,6 @@ export class SSH2ConnectionPool { // `registered` also keeps a dedicated client's later errors out of the host health. registered = true; if (mode === "dedicated") return entry; - this.throwIfRetiredSince(key, retirement, entry); this.connections.set(key, entry); entry.idleTimer = setTimeout(() => { this.closeIdleConnection(key, entry); @@ -943,7 +875,7 @@ export class SSH2ConnectionPool { const agentForFallback = shouldTryAgentOnly ? undefined : agent; return await attemptConnection(fallbackIdentityFiles, agentForFallback); } catch (error) { - this.reportConnectFailure(config, error); + this.reportFailure(config, getErrorMessage(error)); throw error; } } diff --git a/src/node/runtime/sshConnectionPool.ts b/src/node/runtime/sshConnectionPool.ts index c99ceb6cbbb..37e7192bfdb 100644 --- a/src/node/runtime/sshConnectionPool.ts +++ b/src/node/runtime/sshConnectionPool.ts @@ -683,22 +683,7 @@ export const sshConnectionPool = new SSHConnectionPool(); export function getControlPath(config: SSHConnectionConfig): string { const key = makeConnectionKey(config); const hash = hashKey(key); - const retirements = controlPathRetirements.get(key) ?? 0; - return path.join(os.tmpdir(), `mux-ssh-${hash}${retirements > 0 ? `-r${retirements}` : ""}`); -} - -/** Per connection key: how often its ControlPaths were retired in this process. */ -const controlPathRetirements = new Map(); - -/** - * Move the host's later commands to new ControlPaths (credentials or gateway changed). A - * ControlMaster still connecting at an old path when its host was retired listens there once - * it is up, after the retirement stopped the masters it could see; no command uses it, and it - * exits after ControlPersist. - */ -export function retireControlPath(config: SSHConnectionConfig): void { - const key = makeConnectionKey(config); - controlPathRetirements.set(key, (controlPathRetirements.get(key) ?? 0) + 1); + return path.join(os.tmpdir(), `mux-ssh-${hash}`); } /** diff --git a/src/node/runtime/transports/OpenSSHTransport.test.ts b/src/node/runtime/transports/OpenSSHTransport.test.ts index 917f9c3e209..3d84534de1c 100644 --- a/src/node/runtime/transports/OpenSSHTransport.test.ts +++ b/src/node/runtime/transports/OpenSSHTransport.test.ts @@ -1,15 +1,11 @@ import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; -import * as fs from "fs/promises"; -import * as os from "os"; -import * as path from "path"; import type { IPty } from "@lydell/node-pty"; import { toXumDaytonaHost } from "@/common/constants/daytona"; import * as ptySpawn from "../ptySpawn"; import { RuntimeError, isRuntimeTransportError } from "../Runtime"; import { sshConnectionPool } from "../sshConnectionPool"; import { getDaytonaSshConfigPath } from "../daytonaSshConfig"; -import { OpenSSHTransport, retireOpenSSHMasters } from "./OpenSSHTransport"; -import { retireSshConnections } from "./index"; +import { OpenSSHTransport } from "./OpenSSHTransport"; const BACKOFF_MESSAGE = "SSH connection to example.test is in backoff for 2s. Last error: Connection refused"; @@ -74,140 +70,3 @@ describe("OpenSSHTransport terminals on a Daytona alias", () => { expect(configFile).toBeLessThan(args.indexOf(host)); }); }); - -describe("retireOpenSSHMasters", () => { - it("stops every exec ControlMaster of the host that has a socket", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-fake-ssh-")); - const calls = path.join(dir, "calls"); - // A fake ssh on PATH records its arguments, one call per line. - await fs.writeFile(path.join(dir, "ssh"), `#!/bin/sh\necho "$@" >> ${calls}\n`, { - mode: 0o755, - }); - const originalPath = process.env.PATH; - process.env.PATH = `${dir}:${originalPath ?? ""}`; - const config = { host: toXumDaytonaHost("sb-retire") }; - const base = sshConnectionPool.getControlPath(config); - // Two masters are up: the default path and one exec shard. - await fs.writeFile(base, ""); - await fs.writeFile(`${base}-2`, ""); - try { - await retireOpenSSHMasters(config); - - const lines = (await fs.readFile(calls, "utf-8")).trim().split("\n").sort(); - expect(lines).toEqual([ - `-F /dev/null -S ${base} -O stop ${config.host}`, - `-F /dev/null -S ${base}-2 -O stop ${config.host}`, - ]); - } finally { - process.env.PATH = originalPath; - await fs.rm(base, { force: true }); - await fs.rm(`${base}-2`, { force: true }); - await fs.rm(dir, { recursive: true, force: true }); - } - }); - - it("never sends a later command to a master that was still connecting at the retirement", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-fake-ssh-")); - const calls = path.join(dir, "calls"); - await fs.writeFile(path.join(dir, "ssh"), `#!/bin/sh\necho "$@" >> ${calls}\n`, { - mode: 0o755, - }); - const originalPath = process.env.PATH; - process.env.PATH = `${dir}:${originalPath ?? ""}`; - spyOn(sshConnectionPool, "acquireConnection").mockResolvedValue(undefined); - const config = { host: toXumDaytonaHost("sb-connecting") }; - const base = sshConnectionPool.getControlPath(config); - const oldPaths = [base, ...Array.from({ length: 8 }, (_, shard) => `${base}-${shard}`)]; - try { - // No socket yet: a master is still authenticating with the old credentials. - await retireOpenSSHMasters(config); - // It finishes after the retirement and listens at its old path. - for (const oldPath of oldPaths) await fs.writeFile(oldPath, ""); - - const transport = new OpenSSHTransport(config); - for (let i = 0; i < 8; i++) { - const { process: child } = await transport.spawnRemoteProcess("true", {}); - await new Promise((resolve) => child.once("close", resolve)); - } - - const used = (await fs.readFile(calls, "utf-8")) - .trim() - .split("\n") - .map((line) => /ControlPath=(\S+)/.exec(line)?.[1]); - expect(used).toHaveLength(8); - for (const controlPath of used) expect(oldPaths).not.toContain(controlPath); - } finally { - process.env.PATH = originalPath; - for (const oldPath of oldPaths) await fs.rm(oldPath, { force: true }); - await fs.rm(dir, { recursive: true, force: true }); - } - }); -}); - -describe("OpenSSH reverse forwards", () => { - it("ends a forward an earlier process left when its host is retired", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-fake-ssh-")); - // A fake ssh: `-N` runs a master that holds its ControlPath until it exits; the control - // commands (`-O check|forward|exit|stop`) act on the master at `-S`. - await fs.writeFile( - path.join(dir, "ssh"), - `#!/bin/sh -socket=""; op=""; prev=""; master=no -for arg in "$@"; do - case "$prev" in -S) socket="$arg" ;; -O) op="$arg" ;; esac - case "$arg" in ControlPath=*) socket="\${arg#ControlPath=}" ;; -N) master=yes ;; esac - prev="$arg" -done -if [ "$master" = yes ]; then - echo $$ > "$socket.pid"; echo $$ > "${dir}/master.pid"; : > "$socket" - trap 'rm -f "$socket" "$socket.pid"; exit 0' TERM - while :; do sleep 0.05; done -fi -case "$op" in - check|forward) [ -e "$socket" ] ;; - exit) [ -e "$socket.pid" ] && kill "$(cat "$socket.pid")" ;; - *) exit 0 ;; -esac -`, - { mode: 0o755 } - ); - const originalPath = process.env.PATH; - process.env.PATH = `${dir}:${originalPath ?? ""}`; - spyOn(sshConnectionPool, "acquireConnection").mockResolvedValue(undefined); - const config = { host: toXumDaytonaHost("sb-forwarded") }; - let masterPid: number | undefined; - try { - const forward = await new OpenSSHTransport(config).openReverseForward(40123, 40124); - const pid = Number(await fs.readFile(path.join(dir, "master.pid"), "utf-8")); - masterPid = pid; - expect(isAlive(pid)).toBe(true); - // A restart: the new process holds no handle to the forward, which it adopts. - void forward; - - await retireSshConnections(config); - - await waitFor(() => !isAlive(pid)); - expect(isAlive(pid)).toBe(false); - } finally { - if (masterPid !== undefined && isAlive(masterPid)) process.kill(masterPid); - process.env.PATH = originalPath; - await fs.rm(dir, { recursive: true, force: true }); - } - }); -}); - -function isAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise { - const deadline = Date.now() + timeoutMs; - while (!condition() && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 20)); - } -} diff --git a/src/node/runtime/transports/OpenSSHTransport.ts b/src/node/runtime/transports/OpenSSHTransport.ts index 3de80bb17f1..16672a6ea86 100644 --- a/src/node/runtime/transports/OpenSSHTransport.ts +++ b/src/node/runtime/transports/OpenSSHTransport.ts @@ -1,6 +1,5 @@ import { spawn } from "child_process"; -import { createHash } from "crypto"; -import * as fs from "fs/promises"; +import { randomBytes } from "crypto"; import * as os from "os"; import * as path from "path"; @@ -11,17 +10,14 @@ import { redactDaytonaHostStderr } from "../daytonaTokenRedaction"; import { appendDaytonaOpenSSHArgs, appendOpenSSHHostKeyPolicyArgs, - retireControlPath, sshConnectionPool, type SSHConnectionConfig, } from "../sshConnectionPool"; import type { SpawnResult } from "../RemoteRuntime"; import { RuntimeError } from "../Runtime"; -import { getXumHome } from "@/common/constants/paths"; import { assert } from "@/common/utils/assert"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; -import { isErrnoWithCode } from "@/node/utils/fs"; import { ReverseForwardRefusedError, type SSHTransport, @@ -56,78 +52,6 @@ function getShardedControlPath(config: SSHConnectionConfig): string { return `${baseControlPath}-${nextShard}`; } -/** - * The host's credentials or gateway changed (Daytona settings, a moved gateway): stop its exec - * ControlMasters, so the next command connects anew instead of multiplexing onto the old - * connection. `-O stop` makes a master remove its socket and refuse new sessions while running - * ones finish. Reverse forwards keep their own connections. - */ -export async function retireOpenSSHMasters(config: SSHConnectionConfig): Promise { - const base = sshConnectionPool.getControlPath(config); - // Before stopping: a command spawned meanwhile, or a master still connecting, is never shared. - retireControlPath(config); - sshConnectionPool.forgetHealth(config); - const shards = Array.from({ length: OPENSSH_EXEC_SHARD_COUNT }, (_, shard) => `${base}-${shard}`); - await Promise.all( - [base, ...shards].map(async (controlPath) => { - if (!(await pathExists(controlPath))) return; - const result = await runSshClient( - ["-F", "/dev/null", "-S", controlPath, "-O", "stop", config.host], - config.host - ); - if (result.code !== 0) { - log.debug("[ssh] could not stop a ControlMaster", { controlPath, stderr: result.stderr }); - } - }) - ); -} - -/** - * The ControlPath of the host's reverse-forward master, per Xum root and host. Not random: a - * forward outlives a crash or a restart of the process that opened it (the next one adopts it), - * and only a path a later process can compute lets it end that forward. - */ -function reverseForwardControlPath(config: SSHConnectionConfig): string { - const key = [ - getXumHome(), - os.userInfo().username, - config.host, - config.port ?? 22, - config.identityFile ?? "", - ].join("\0"); - const hash = createHash("sha256").update(key).digest("hex").slice(0, 16); - return path.join(os.tmpdir(), `xum-fwd-${hash}`); -} - -/** - * End the host's reverse-forward master, whether this process opened it or adopted it from an - * earlier one: a retired host's forward would keep reaching the old machine, and its remote - * port would keep reaching the bash AI proxy. - */ -export async function closeOpenSSHReverseForward(config: SSHConnectionConfig): Promise { - const controlPath = reverseForwardControlPath(config); - if (!(await pathExists(controlPath))) return; - const result = await runSshClient( - ["-F", "/dev/null", "-S", controlPath, "-O", "exit", config.host], - config.host - ); - if (result.code !== 0) { - log.debug("[ssh] could not end a reverse forward", { controlPath, stderr: result.stderr }); - } - // A socket left without a master would keep the next master from listening there. - await fs.rm(controlPath, { force: true }); -} - -async function pathExists(filePath: string): Promise { - try { - await fs.lstat(filePath); - return true; - } catch (error) { - if (isErrnoWithCode(error, "ENOENT")) return false; - throw error; - } -} - export class OpenSSHTransport implements SSHTransport { constructor(private readonly config: SSHConnectionConfig) {} @@ -264,9 +188,7 @@ export class OpenSSHTransport implements SSHTransport { assert(Number.isInteger(localPort) && localPort > 0 && localPort < 65536, "bad localPort"); await this.acquireConnection({ maxWaitMs: 0 }); - // Only after adoption failed: whatever master is still there no longer works. - await closeOpenSSHReverseForward(this.config); - const controlPath = reverseForwardControlPath(this.config); + const controlPath = path.join(os.tmpdir(), `xum-fwd-${randomBytes(8).toString("hex")}`); const args: string[] = []; if (this.config.port) args.push("-p", this.config.port.toString()); if (this.config.identityFile) args.push("-i", this.config.identityFile); diff --git a/src/node/runtime/transports/index.ts b/src/node/runtime/transports/index.ts index 2bb700849ac..d983f4b0806 100644 --- a/src/node/runtime/transports/index.ts +++ b/src/node/runtime/transports/index.ts @@ -1,10 +1,5 @@ -import { ssh2ConnectionPool } from "../SSH2ConnectionPool"; import type { SSHConnectionConfig } from "../sshConnectionPool"; -import { - OpenSSHTransport, - closeOpenSSHReverseForward, - retireOpenSSHMasters, -} from "./OpenSSHTransport"; +import { OpenSSHTransport } from "./OpenSSHTransport"; import { SSH2Transport } from "./SSH2Transport"; import type { SSHTransport } from "./SSHTransport"; @@ -15,22 +10,3 @@ export { ReverseForwardRefusedError } from "./SSHTransport"; export function createSSHTransport(config: SSHConnectionConfig, useSSH2: boolean): SSHTransport { return useSSH2 ? new SSH2Transport(config) : new OpenSSHTransport(config); } - -const retiredHostListeners = new Set<(host: string) => void>(); - -/** - * Connections the pools do not hold (a reverse forward opens its own) close through these when - * their host is retired. Returns the unsubscribe. - */ -export function onSshHostRetired(listener: (host: string) => void): () => void { - retiredHostListeners.add(listener); - return () => retiredHostListeners.delete(listener); -} - -/** Retire the host's connections on both transports (credentials or gateway changed). */ -export async function retireSshConnections(config: SSHConnectionConfig): Promise { - ssh2ConnectionPool.retireConnection(config); - for (const listener of retiredHostListeners) listener(config.host); - // The listeners close forwards this process owns; one adopted after a restart has no handle. - await Promise.all([retireOpenSSHMasters(config), closeOpenSSHReverseForward(config)]); -} diff --git a/src/node/services/bashAiProxy/bashAiProxyService.test.ts b/src/node/services/bashAiProxy/bashAiProxyService.test.ts index 84f628084cc..5bb0e27e4d4 100644 --- a/src/node/services/bashAiProxy/bashAiProxyService.test.ts +++ b/src/node/services/bashAiProxy/bashAiProxyService.test.ts @@ -11,11 +11,7 @@ import type { AiSdkUsageLike } from "@/common/utils/tokens/usageHelpers"; import { log } from "@/node/services/log"; import type { ProviderConfigRaw } from "@/node/utils/providerRequirements"; -import { - ReverseForwardRefusedError, - retireSshConnections, - type ReverseForward, -} from "@/node/runtime/transports"; +import { ReverseForwardRefusedError, type ReverseForward } from "@/node/runtime/transports"; import { BashAiProxyService } from "./bashAiProxyService"; import type { ForwardTarget } from "./reverseForwards"; @@ -716,26 +712,6 @@ describe("BashAiProxyService", () => { await res.text(); }); - test("retiring a host's ssh connections closes its forward, and the next turn opens one", async () => { - // Daytona retires a sandbox alias when its account or gateway changes: the forward's own - // connection would still end in the old sandbox. - const retired = fakeSshHost("sb-1.xum--daytona|22|"); - const other = fakeSshHost("sb-2.xum--daytona|22|"); - sshTargets.set("ws-1", retired.target); - sshTargets.set("ws-2", other.target); - await proxy.envFor("ws-1", "ssh", []); - await proxy.envFor("ws-2", "ssh", []); - - await retireSshConnections({ host: "sb-1.xum--daytona" }); - - expect(retired.closes()).toBe(1); - expect(other.closes()).toBe(0); - await proxy.envFor("ws-1", "ssh", []); - expect(retired.opened).toHaveLength(2); - await proxy.envFor("ws-2", "ssh", []); - expect(other.opened).toHaveLength(1); - }); - test("a forward that outlived a restart is adopted, not replaced", async () => { const host = fakeSshHost("ssh-host-4", { survivesClose: true }); sshTargets.set("ssh-ws", host.target); diff --git a/src/node/services/bashAiProxy/bashAiProxyService.ts b/src/node/services/bashAiProxy/bashAiProxyService.ts index 4ca79286fa4..81937946174 100644 --- a/src/node/services/bashAiProxy/bashAiProxyService.ts +++ b/src/node/services/bashAiProxy/bashAiProxyService.ts @@ -33,7 +33,6 @@ import { import type { RuntimeMode } from "@/common/types/runtime"; import type { ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator"; import type { AiSdkUsageLike } from "@/common/utils/tokens/usageHelpers"; -import { onSshHostRetired } from "@/node/runtime/transports"; import { log } from "@/node/services/log"; import { resolveProviderCredentials, @@ -264,7 +263,6 @@ export class BashAiProxyService { private stopped = false; private readonly state: ProxyStateStore; private readonly forwards: ReverseForwardManager; - private readonly stopRetireListener: () => void; /** The forward last saved per SSH host, to skip redundant writes. */ private readonly savedForwards = new Map(); private restoring: Promise | undefined; @@ -284,8 +282,6 @@ export class BashAiProxyService { savedRemotePort: (hostKey) => this.savedForwards.get(hostKey)?.remotePort, onEstablished: () => undefined, // envFor() records the workspace that uses it }); - // Daytona retires a sandbox alias when its account or gateway changes. - this.stopRetireListener = onSshHostRetired((host) => this.forwards.closeHost(host)); } /** @@ -442,7 +438,6 @@ export class BashAiProxyService { async stop(): Promise { this.stopped = true; - this.stopRetireListener(); const forwardsClosed = this.forwards.closeAll(); const server = this.server; this.server = undefined; diff --git a/src/node/services/bashAiProxy/reverseForwards.ts b/src/node/services/bashAiProxy/reverseForwards.ts index d5a51b03ea0..63d65330cd8 100644 --- a/src/node/services/bashAiProxy/reverseForwards.ts +++ b/src/node/services/bashAiProxy/reverseForwards.ts @@ -26,17 +26,6 @@ export interface ForwardTarget { remoteHealth(remotePort: number, nonce: string): Promise; } -const HOST_KEY_SEPARATOR = "|"; - -/** ForwardTarget.hostKey of an SSH host; the host comes first so closeHost() can find it. */ -export function sshForwardHostKey(config: { - host: string; - port?: number; - identityFile?: string; -}): string { - return [config.host, config.port ?? 22, config.identityFile ?? ""].join(HOST_KEY_SEPARATOR); -} - export interface ReverseForwardManagerOptions { /** Seeds the remote port candidates (the Xum root dir). */ seed: string; @@ -128,23 +117,12 @@ export class ReverseForwardManager { await Promise.allSettled(this.cancelAll()); } - /** - * Closes the forwards to `host` (its ssh connections were retired), as closeOwned() does for - * every host: a forward rides its own connection, which would still reach the old machine. - */ - closeHost(host: string): void { - for (const [hostKey, state] of this.hosts) { - if (hostKey.split(HOST_KEY_SEPARATOR)[0] !== host) continue; - this.cancel(state); - this.hosts.delete(hostKey); - } - } - /** Closes owned forwards and cancels setups in flight; returns those setups. */ private cancelAll(): Array> { const pending: Array> = []; for (const state of this.hosts.values()) { - this.cancel(state); + state.cancelled = true; + state.owned?.close(); if (state.establishing) pending.push(state.establishing); } // A setup in flight keeps its detached state; the next ensure() starts a fresh one. @@ -152,11 +130,6 @@ export class ReverseForwardManager { return pending; } - private cancel(state: HostState): void { - state.cancelled = true; - state.owned?.close(); - } - private stopped(state: HostState): boolean { return this.closed || state.cancelled === true; } diff --git a/src/node/services/bashAiProxy/sshForwardTarget.ts b/src/node/services/bashAiProxy/sshForwardTarget.ts index 547e87c3f09..30722842cfb 100644 --- a/src/node/services/bashAiProxy/sshForwardTarget.ts +++ b/src/node/services/bashAiProxy/sshForwardTarget.ts @@ -9,7 +9,7 @@ import { SSHRuntime } from "@/node/runtime/SSHRuntime"; import { ServerLockfile } from "@/node/services/serverLockfile"; import { execBuffered } from "@/node/utils/runtime/helpers"; -import { sshForwardHostKey, type ForwardTarget } from "./reverseForwards"; +import type { ForwardTarget } from "./reverseForwards"; import { BASH_AI_PROXY_HEALTH_PATH, isHealthNonce } from "./stableIdentity"; /** @@ -31,7 +31,7 @@ export function createSshForwardTarget(runtime: Runtime): ForwardTarget | undefi if (!(runtime instanceof SSHRuntime)) return undefined; const config = runtime.getConfig(); return { - hostKey: sshForwardHostKey(config), + hostKey: [config.host, config.port ?? 22, config.identityFile ?? ""].join("|"), // Managed boxes: restoring a forward at startup would start a stopped (billed) box. restoreAtStartup: !(runtime instanceof CoderSSHRuntime || runtime instanceof DaytonaSSHRuntime), openReverseForward: (remotePort, localPort) => diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index bdf1ca56094..2cda8984382 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -1,7 +1,5 @@ -import { toXumDaytonaHost } from "@/common/constants/daytona"; import type { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; -import { retireSshConnections } from "@/node/runtime/transports"; import { sleepWithAbort } from "@/node/utils/abort"; import { DaytonaAccount } from "./daytonaAccount"; import type { DaytonaClient } from "./daytonaClient"; @@ -18,8 +16,6 @@ export interface DaytonaServiceOptions { fetch?: typeof fetch; now?: () => number; sleep?: (ms: number) => Promise; - /** Defaults to retiring the pooled OpenSSH and ssh2 connections of the sandbox's alias. */ - retireSshConnections?: (sandboxId: string) => Promise; } /** @@ -45,9 +41,6 @@ export class DaytonaService { readiness: this.readiness, sshConfigWriter: options.sshConfigWriter ?? new DaytonaSshConfigWriter({ xumHome: options.rootDir }), - retireSshConnections: - options.retireSshConnections ?? - ((sandboxId) => retireSshConnections({ host: toXumDaytonaHost(sandboxId) })), now, }); this.sandboxes = new DaytonaSandboxes({ diff --git a/src/node/services/daytona/daytonaSsh.test.ts b/src/node/services/daytona/daytonaSsh.test.ts index 031cf0b0963..d529d97abf8 100644 --- a/src/node/services/daytona/daytonaSsh.test.ts +++ b/src/node/services/daytona/daytonaSsh.test.ts @@ -94,7 +94,7 @@ describe("DaytonaSsh", () => { } }); - /** A service whose first token mint waits until `releaseMint`; `includeFile` is its writer. */ + /** A service whose first token mint waits until `releaseMint`. */ function serviceWithHeldMint() { let mintStarted!: () => void; const started = new Promise((resolve) => (mintStarted = resolve)); @@ -111,10 +111,8 @@ describe("DaytonaSsh", () => { return plane.fetch(input, init); }) as typeof fetch; const written: string[] = []; - const includeFile = new Map(); - const store = (input: { sandboxId: string; token: string }) => { + const store = (input: { token: string }) => { written.push(input.token); - includeFile.set(input.sandboxId, input.token); return Promise.resolve(); }; const heldService = new DaytonaService({ @@ -128,15 +126,11 @@ describe("DaytonaSsh", () => { restoreGatewayPins: () => Promise.resolve(false), updateToken: store, repointGateway: () => Promise.resolve(), - removeSandbox: (sandboxId: string) => { - includeFile.delete(sandboxId); - return Promise.resolve(); - }, - + removeSandbox: () => Promise.resolve(), readTokens: () => Promise.resolve([]), }, }); - return { service: heldService, started, releaseMint, written, includeFile }; + return { service: heldService, started, releaseMint, written }; } it("refuses a settings change while a token is minted, and takes it afterwards", async () => { @@ -161,12 +155,6 @@ describe("DaytonaSsh", () => { /** A service whose ssh setup writes are recorded as `token@host keys`. */ function serviceRecordingWrites(fetchImpl: typeof fetch = plane.fetch) { const writes: string[] = []; - /** Sandboxes whose pooled ssh connections were retired. */ - const retired: string[] = []; - /** Include block removals, gateway repoints and retirements, in order. */ - const order: string[] = []; - /** Per retirement: whether the sandbox's next ssh access would set it up again. */ - const staleAtRetire: boolean[] = []; /** The include file: what ensureSshAccess reads back before it trusts its cache. */ const includeFile = new Map(); const record = (input: { @@ -190,25 +178,15 @@ describe("DaytonaSsh", () => { updateToken: record, removeSandbox: (sandboxId) => { includeFile.delete(sandboxId); - order.push(`remove ${sandboxId}`); - return Promise.resolve(); - }, - repointGateway: (gateway) => { - order.push(`repoint ${gateway.host} ${gateway.hostKeys.join(",")}`); return Promise.resolve(); }, + repointGateway: () => Promise.resolve(), readTokens: () => Promise.resolve([...includeFile].map(([sandboxId, token]) => ({ sandboxId, token }))), }, - retireSshConnections: (sandboxId) => { - retired.push(sandboxId); - order.push(`retire ${sandboxId}`); - staleAtRetire.push(recording.ssh.tokenNeedsRefresh(sandboxId)); - return Promise.resolve(); - }, }); - return { service: recording, writes, retired, order, staleAtRetire }; + return { service: recording, writes }; } it("refuses a settings change while it looks up the gateway for a token", async () => { @@ -244,7 +222,7 @@ describe("DaytonaSsh", () => { }); it("rewrites a sandbox's ssh setup once Daytona moves its gateway keys", async () => { - const { service: watching, writes, retired } = serviceRecordingWrites(); + const { service: watching, writes } = serviceRecordingWrites(); const configFetches = () => plane.calls.filter((call) => call === "GET /config").length; await watching.ssh.ensureSshAccess("sb-1"); @@ -261,8 +239,6 @@ describe("DaytonaSsh", () => { "tok2@ssh.test ssh-rsa CCCC,ssh-ed25519 BBBB", ]); expect(plane.revoked).toEqual(["tok1"]); - // A pooled connection to the old gateway must not carry later commands. - expect(retired).toEqual(["sb-1"]); // A refetch that finds the same gateway, keys reordered, rewrites nothing. plane.gatewayHostKeys = ["ssh-ed25519 BBBB", "ssh-rsa CCCC"]; @@ -270,7 +246,6 @@ describe("DaytonaSsh", () => { await watching.ssh.ensureSshAccess("sb-1"); expect(configFetches()).toBe(3); expect(writes).toHaveLength(2); - expect(retired).toEqual(["sb-1"]); }); it("shares one gateway refresh between concurrent lookups, so a stale reply cannot win", async () => { @@ -283,7 +258,7 @@ describe("DaytonaSsh", () => { held.push({ run: () => plane.fetch(input, init), resolve }); }); }) as typeof fetch; - const { service: watching, retired } = serviceRecordingWrites(fetchImpl); + const { service: watching } = serviceRecordingWrites(fetchImpl); await watching.ssh.ensureSshAccess("sb-1"); clock += 6 * 60_000; @@ -305,48 +280,6 @@ describe("DaytonaSsh", () => { ["ssh-ed25519 NEW"], ]); expect((await watching.ssh.getGatewayConfig()).sshGatewayHostKeys).toEqual(["ssh-ed25519 NEW"]); - expect(retired).toEqual(["sb-1"]); - }); - - it("installs a moved gateway before retiring the connections to the old one", async () => { - const { service: watching, order, staleAtRetire } = serviceRecordingWrites(); - await watching.ssh.ensureSshAccess("sb-1"); - - plane.gatewayHostKeys = ["ssh-ed25519 BBBB"]; - clock += 6 * 60_000; - await watching.ssh.getGatewayConfig(); - - // A connection opened while the old ones are retired reads the new files, and the next - // access sets the sandbox up again: nothing reaches the old gateway afterwards. - expect(order).toEqual(["repoint ssh.test ssh-ed25519 BBBB", "retire sb-1"]); - expect(staleAtRetire).toEqual([true]); - }); - - it("retires a released sandbox's ssh connections once its include block is gone", async () => { - const { service: releasing, order } = serviceRecordingWrites(); - await releasing.ssh.ensureSshAccess("sb-1"); - - await releasing.ssh.releaseSshAccess("sb-1"); - - // Pooled sessions and reverse forwards stay authenticated after the token is revoked; a - // retirement before the block is gone could reconnect from it. - expect(order).toEqual(["remove sb-1", "retire sb-1"]); - }); - - it("revokes a token minted across a release, keeping the access released", async () => { - const held = serviceWithHeldMint(); - - const access = held.service.ssh.ensureSshAccess("sb-1").catch((error: unknown) => error); - await held.started; - await held.service.ssh.releaseSshAccess("sb-1"); - held.releaseMint(); - - // The caller asked for access the release took away: it fails instead of minting again. - expect(String(await access)).toContain("released"); - expect(plane.revoked).toEqual(["tok1"]); - expect(held.includeFile.has("sb-1")).toBe(false); - expect(held.service.ssh.hasExpiringSshToken("sb-1")).toBe(false); - expect(plane.calls.filter((call) => call === "POST /sandbox/sb-1/ssh-access")).toHaveLength(1); }); it("removes the old account's aliases from the include file when the settings change", async () => { diff --git a/src/node/services/daytona/daytonaSsh.ts b/src/node/services/daytona/daytonaSsh.ts index 3c99610e433..341ad7af138 100644 --- a/src/node/services/daytona/daytonaSsh.ts +++ b/src/node/services/daytona/daytonaSsh.ts @@ -32,7 +32,6 @@ export interface DaytonaSshOptions { account: Pick; readiness: DaytonaReadiness; sshConfigWriter: DaytonaSshWriter; - retireSshConnections: (sandboxId: string) => Promise; now: () => number; } @@ -85,10 +84,7 @@ export class DaytonaSsh { private readonly tokenGenerations = new Map(); private lastTokenGeneration = 0; private readonly tokenRequests = new Map>(); - /** Per sandbox: bumped by every release of its SSH access, so a mint that spans one can tell. */ - private readonly accessReleases = new Map(); private readonly sshConfigWriter: DaytonaSshWriter; - private readonly retireSshConnections: DaytonaSshOptions["retireSshConnections"]; /** Sandboxes whose include block and `~/.ssh/config` block this process has written. */ private readonly sshConfigured = new Set(); /** Whether the include file was read once, seeding the tokens an earlier process left. */ @@ -99,7 +95,6 @@ export class DaytonaSsh { this.readiness = options.readiness; this.now = options.now; this.sshConfigWriter = options.sshConfigWriter; - this.retireSshConnections = options.retireSshConnections; } /** @@ -245,14 +240,10 @@ export class DaytonaSsh { /** * Daytona moved its gateway or rotated its host keys: every include block and the * known_hosts pins are stale. Each sandbox's next ssh access mints a token, which rewrites - * its whole ssh setup and revokes the old token. - * - * Install first, retire last: a command connecting while the old connections are retired - * then reads the new files and caches, so no connection to the old gateway outlives this. - * Retiring first left a window in which a turn reconnected from the stale setup. + * its whole ssh setup and revokes the old token. Pooled connections opened before keep + * their old gateway until they close; new ones read the new files. */ private async retireSshSetup(config: DaytonaGatewayConfig): Promise { - const sandboxIds = this.sandboxesWithSsh(); this.sshConfigured.clear(); this.readiness.invalidateAll(); for (const [sandboxId, access] of this.tokens) { @@ -266,30 +257,6 @@ export class DaytonaSsh { error: getErrorMessage(error), }); } - await this.retirePooledSsh(sandboxIds); - } - - private sandboxesWithSsh(): string[] { - return [...new Set([...this.tokens.keys(), ...this.sshConfigured])]; - } - - /** - * Pooled ssh connections (OpenSSH ControlMasters, ssh2 clients) outlive the include file - * they were opened with: without this, later commands keep reaching the old deployment. - */ - private async retirePooledSsh(sandboxIds: string[]): Promise { - await Promise.all( - sandboxIds.map(async (sandboxId) => { - try { - await this.retireSshConnections(sandboxId); - } catch (error) { - log.warn("Could not retire the pooled ssh connections of a Daytona sandbox", { - sandboxId, - error: getErrorMessage(error), - }); - } - }) - ); } /** @@ -355,16 +322,10 @@ export class DaytonaSsh { /** Revoke the token and remove the sandbox's include block. */ async releaseSshAccess(sandboxId: string): Promise { await this.readiness.waitForSandboxReadiness(sandboxId); - // A mint in flight sees the bump and drops its token instead of writing it back. - this.accessReleases.set(sandboxId, this.releaseCount(sandboxId) + 1); this.sshConfigured.delete(sandboxId); this.readiness.invalidateSandboxReady(sandboxId); await this.forgetSshToken(sandboxId); await this.sshConfigWriter.removeSandbox(sandboxId); - // A revoked token does not end sessions it opened: pooled connections and the bash AI - // proxy's reverse forward would keep reaching the sandbox. Retired only once the include - // block is gone, so nothing reconnects from it. - await this.retirePooledSsh([sandboxId]); } tokenNeedsRefresh(sandboxId: string): boolean { @@ -404,16 +365,10 @@ export class DaytonaSsh { previous: DaytonaSshAccess | undefined, options: DaytonaSshTokenOptions ): Promise { - const release = this.releaseCount(sandboxId); - const released = () => this.releaseCount(sandboxId) !== release; const { client } = await this.account.getClient(); const access = await client.createSshAccess(sandboxId, DAYTONA_SSH_TOKEN_TTL_MINUTES); rememberDaytonaToken(access.token); - // A mint that spans a release of the sandbox's access is never cached or kept in the - // include file: the caller's access was taken away. - if (released()) return this.discardReleasedMint(client, sandboxId, access, false); await this.announceNewToken(client, sandboxId, access, options); - if (released()) return this.discardReleasedMint(client, sandboxId, access, true); this.cacheNewToken(sandboxId, access); if (previous && previous.token !== access.token) { await this.revokeQuietly(client, sandboxId, previous.token); @@ -421,10 +376,6 @@ export class DaytonaSsh { return access; } - private releaseCount(sandboxId: string): number { - return this.accessReleases.get(sandboxId) ?? 0; - } - private async announceNewToken( client: DaytonaClient, sandboxId: string, @@ -440,19 +391,6 @@ export class DaytonaSsh { } } - private async discardReleasedMint( - client: DaytonaClient, - sandboxId: string, - access: DaytonaSshAccess, - wroteIncludeFile: boolean - ): Promise { - await this.revokeQuietly(client, sandboxId, access.token); - if (wroteIncludeFile) await this.sshConfigWriter.removeSandbox(sandboxId); - throw new Error( - `Xum released its SSH access to Daytona sandbox "${sandboxId}" while a token was minted` - ); - } - /** Revoke and forget the cached token (workspace deleted or sandbox detached). */ async forgetSshToken(sandboxId: string): Promise { const cached = this.tokens.get(sandboxId); From 01eda079f6525a5aa2ed203f104f25c76c8b2c2a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:51:58 +0000 Subject: [PATCH 213/231] refactor(ssh): drop the cross-process lock on managed ssh config writes Xum assumes one process per data folder; the in-process lock still keeps the Coder and Daytona blocks from overwriting each other. --- src/node/runtime/muxSshConfigWriter.test.ts | 43 --------------------- src/node/runtime/muxSshConfigWriter.ts | 22 +++-------- 2 files changed, 5 insertions(+), 60 deletions(-) diff --git a/src/node/runtime/muxSshConfigWriter.test.ts b/src/node/runtime/muxSshConfigWriter.test.ts index bfa8acf30f4..94be8ca35cd 100644 --- a/src/node/runtime/muxSshConfigWriter.test.ts +++ b/src/node/runtime/muxSshConfigWriter.test.ts @@ -8,7 +8,6 @@ import { MUX_CODER_SSH_BLOCK_END, MUX_CODER_SSH_BLOCK_START, } from "@/constants/coder"; -import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import { ensureManagedSSHConfigBlock, ensureMuxCoderSSHConfigFile } from "./muxSshConfigWriter"; function renderExpectedMuxBlock(coderBinaryPath: string): string { @@ -71,48 +70,6 @@ describe("ensureMuxCoderSSHConfigFile", () => { expect(content).toContain("Host work"); }); - it("waits for another backend's file lock on the resolved config before writing", async () => { - const sshConfigPath = await makeSSHConfigPath(); - const targetPath = path.join(path.dirname(sshConfigPath), "real-config"); - await writeSSHConfig(targetPath, "Host work\n User me\n"); - await fs.symlink(targetPath, sshConfigPath); - const block = (label: string) => ({ - markers: { start: `# >>> ${label} >>>`, end: `# <<< ${label} <<<`, label }, - block: [`# >>> ${label} >>>`, `Host *.${label}`, `# <<< ${label} <<<`].join("\n"), - sshConfigPath, - }); - const lockPath = `${targetPath}.xum.lock`; - - // Held by this process, the file lock looks to the writers like another backend mid-write. - const release = await acquireCrossProcessLock({ - lockPath, - acquireTimeoutMs: 1_000, - staleMs: 60_000, - timeoutMessage: "test holder", - }); - let settled = false; - const running = Promise.all([ - ensureManagedSSHConfigBlock(block("first")), - ensureManagedSSHConfigBlock(block("second")), - ]).finally(() => { - settled = true; - }); - try { - await new Promise((resolve) => setTimeout(resolve, 300)); - expect(settled).toBe(false); - expect(await readSSHConfig(targetPath)).toBe("Host work\n User me\n"); - } finally { - await release(); - } - await running; - - const content = await readSSHConfig(targetPath); - expect(content).toContain("Host *.first"); - expect(content).toContain("Host *.second"); - expect(content).toContain("Host work"); - expect(await fs.readdir(path.dirname(targetPath))).not.toContain(path.basename(lockPath)); - }); - it("creates block from an empty config", async () => { const sshConfigPath = await makeSSHConfigPath(); diff --git a/src/node/runtime/muxSshConfigWriter.ts b/src/node/runtime/muxSshConfigWriter.ts index 44431d33877..2a68b3f62c7 100644 --- a/src/node/runtime/muxSshConfigWriter.ts +++ b/src/node/runtime/muxSshConfigWriter.ts @@ -8,7 +8,6 @@ import { MUX_CODER_SSH_BLOCK_START, } from "@/constants/coder"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; -import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; interface EnsureMuxCoderSSHConfigFileOptions { coderBinaryPath: string; @@ -283,22 +282,11 @@ export async function ensureManagedSSHConfigBlock(opts: { await fs.mkdir(sshConfigDir, { recursive: true, mode: 0o700 }); - // Coder and Daytona setups can run at once, in this backend or another one sharing the config - // (even under a different Xum root): without both locks two writers read the old file and the - // last rename drops the other's block. The file lock sits next to the resolved config. - await sshConfigFileLocks.withLock(sshConfigPath, async () => { - const release = await acquireCrossProcessLock({ - lockPath: `${sshConfigPath}.xum.lock`, - acquireTimeoutMs: 30_000, - staleMs: 60_000, - timeoutMessage: "Another Xum process is updating the ssh config.", - }); - try { - await writeManagedBlock(sshConfigPath, markers, opts.block, opts.placement ?? "end"); - } finally { - await release(); - } - }); + // Coder and Daytona setups can run at once: without the lock two writers read the old file + // and the last rename drops the other's block. + await sshConfigFileLocks.withLock(sshConfigPath, () => + writeManagedBlock(sshConfigPath, markers, opts.block, opts.placement ?? "end") + ); } async function writeManagedBlock( From e23885d2b0e133d14259bf8e42ba6f948297da53 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:54:50 +0000 Subject: [PATCH 214/231] refactor(daytona): redact ssh output chunk by chunk, and turn off prompting auth The streaming redactor held back any tail that could start a token (with a quiet-period timer for PTYs) to catch a token split across chunks. ssh writes each message in one write, so redact each chunk instead. The Daytona gateway authenticates with "none" (checked against ssh.app.daytona.io), so the host block turns off password and keyboard-interactive auth: no prompt names the token. --- src/node/runtime/daytonaSshConfig.test.ts | 5 + src/node/runtime/daytonaSshConfig.ts | 4 + .../runtime/daytonaTokenRedaction.test.ts | 84 +--------- src/node/runtime/daytonaTokenRedaction.ts | 147 ++---------------- 4 files changed, 24 insertions(+), 216 deletions(-) diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index 93b0f04281c..62702c6fb42 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -68,6 +68,8 @@ describe("DaytonaSshConfigWriter", () => { " ForwardX11 no", " PermitLocalCommand no", " Tunnel no", + " PasswordAuthentication no", + " KbdInteractiveAuthentication no", " HostName ssh.daytona.test", " Port 22", " User tokenA", @@ -409,6 +411,7 @@ describe("DaytonaSshConfigWriter", () => { " ForwardAgent yes", " ForwardX11 yes", " PermitLocalCommand yes", + " PasswordAuthentication yes", " ProxyCommand nc -X connect -x gateway.example.com:3128 %h %p", " User me", "", @@ -424,6 +427,7 @@ describe("DaytonaSshConfigWriter", () => { expect(daytona).toContain("\nforwardagent no\n"); expect(daytona).toContain("\nforwardx11 no\n"); expect(daytona).toContain("\npermitlocalcommand no\n"); + expect(daytona).toContain("\npasswordauthentication no\n"); expect(daytona).not.toMatch(/\nproxy(command|jump) /); const other = resolveFromUserConfig("other.example.com"); @@ -432,6 +436,7 @@ describe("DaytonaSshConfigWriter", () => { expect(other).toContain("\nuserknownhostsfile /dev/null\n"); expect(other).toContain("\nforwardagent yes\n"); expect(other).toContain("\nforwardx11 yes\n"); + expect(other).toContain("\npasswordauthentication yes\n"); expect(other).toContain("\nproxycommand nc -X connect -x gateway.example.com:3128 %h %p\n"); } ); diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index e5b0d06fea6..9b3de38f540 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -179,6 +179,8 @@ export function renderDaytonaHostBlock(input: { // ignores a later ProxyJump too. KnownHostsCommand is left alone: OpenSSH before 8.5 rejects it // unless an `IgnoreUnknown` covers it, and ours would replace the user's own `IgnoreUnknown` // (first value wins). `Tunnel no` keeps a user's `Tunnel` from giving the sandbox a tun device. + // The gateway authenticates with "none" (the token is the user name), so password and + // keyboard-interactive auth are off: their prompts would print the token. // Forwards are not first-value-wins (see hasInheritedRemoteForward), and those an editor asks // for on its command line are left alone. return [ @@ -188,6 +190,8 @@ export function renderDaytonaHostBlock(input: { " ForwardX11 no", " PermitLocalCommand no", " Tunnel no", + " PasswordAuthentication no", + " KbdInteractiveAuthentication no", ` HostName ${input.gateway.host}`, ` Port ${input.gateway.port}`, ` User ${input.token}`, diff --git a/src/node/runtime/daytonaTokenRedaction.test.ts b/src/node/runtime/daytonaTokenRedaction.test.ts index 9f96709efd1..405fdbdb8af 100644 --- a/src/node/runtime/daytonaTokenRedaction.test.ts +++ b/src/node/runtime/daytonaTokenRedaction.test.ts @@ -35,7 +35,6 @@ describe("redactDaytonaPty", () => { function setup() { const dataHandlers = new Set<(data: string) => void>(); - const exitHandlers = new Set<(event: { exitCode: number }) => void>(); let killed = false; const pty: PtyHandle = { write: () => undefined, @@ -47,20 +46,9 @@ describe("redactDaytonaPty", () => { dataHandlers.add(handler); return { dispose: () => dataHandlers.delete(handler) }; }, - onExit: (handler) => { - exitHandlers.add(handler); - return { dispose: () => exitHandlers.delete(handler) }; - }, - }; - const timers: Array<{ run: () => void; cancelled: boolean }> = []; - const schedule = (run: () => void) => { - const timer = { run, cancelled: false }; - timers.push(timer); - return () => { - timer.cancelled = true; - }; + onExit: () => ({ dispose: () => undefined }), }; - const redacted = redactDaytonaPty(pty, schedule); + const redacted = redactDaytonaPty(pty); const output: string[] = []; redacted.onData((data) => output.push(data)); return { @@ -69,13 +57,6 @@ describe("redactDaytonaPty", () => { emit: (data: string) => { for (const handler of dataHandlers) handler(data); }, - exit: () => { - for (const handler of exitHandlers) handler({ exitCode: 255 }); - }, - runPendingTimers: () => { - for (const timer of timers.splice(0)) if (!timer.cancelled) timer.run(); - }, - pendingTimers: () => timers.filter((timer) => !timer.cancelled).length, wasKilled: () => killed, }; } @@ -86,70 +67,11 @@ describe("redactDaytonaPty", () => { expect(pty.output.join("")).toBe(`${DAYTONA_TOKEN_PLACEHOLDER}@gateway: Permission denied\r\n`); }); - it("redacts a token split across chunks", () => { - const pty = setup(); - pty.emit(`login ${TOKEN.slice(0, 5)}`); - pty.emit(`${TOKEN.slice(5)}@gateway's password: `); - expect(pty.output.join("")).toBe(`login ${DAYTONA_TOKEN_PLACEHOLDER}@gateway's password: `); - }); - - it("redacts a token that ends a chunk and ends with its own first char", () => { - const overlapping = "q-overlap-token-q"; - rememberDaytonaToken(overlapping); - const pty = setup(); - pty.emit(`denied for ${overlapping}`); - pty.runPendingTimers(); - expect(pty.output.join("")).toBe(`denied for ${DAYTONA_TOKEN_PLACEHOLDER}`); - }); - - it("emits a held tail that never becomes a token after the bound", () => { - const pty = setup(); - pty.emit("$ p"); - expect(pty.output.join("")).toBe("$ "); - pty.runPendingTimers(); - expect(pty.output.join("")).toBe("$ p"); - }); - - it("masks the rest of a token whose short start was echoed after the quiet period", () => { - const pty = setup(); - pty.emit(`login ${TOKEN.slice(0, 2)}`); - pty.runPendingTimers(); - pty.emit(`${TOKEN.slice(2)}@gateway's password: `); - const screen = pty.output.join(""); - expect(screen).not.toContain(TOKEN); - expect(screen).toBe( - `login ${TOKEN.slice(0, 2)}${DAYTONA_TOKEN_PLACEHOLDER}@gateway's password: ` - ); - }); - - it("keeps a long possible token start held past the quiet period", () => { - const pty = setup(); - pty.emit(`login ${TOKEN.slice(0, 6)}`); - pty.runPendingTimers(); - expect(pty.output.join("")).toBe("login "); - pty.emit(`${TOKEN.slice(6)}@gateway: Permission denied\r\n`); - expect(pty.output.join("")).toBe( - `login ${DAYTONA_TOKEN_PLACEHOLDER}@gateway: Permission denied\r\n` - ); - }); - - it("flushes a held tail before reporting exit", () => { - const pty = setup(); - const events: string[] = []; - pty.redacted.onExit(() => events.push(`exit:${pty.output.join("")}`)); - pty.emit("bye p"); - pty.exit(); - expect(events).toEqual(["exit:bye p"]); - expect(pty.pendingTimers()).toBe(0); - }); - - it("passes other output through and cancels the timer on kill", () => { + it("passes other output through and kills the underlying pty", () => { const pty = setup(); pty.emit("hello world\r\n"); expect(pty.output).toEqual(["hello world\r\n"]); - pty.emit("p"); pty.redacted.kill(); expect(pty.wasKilled()).toBe(true); - expect(pty.pendingTimers()).toBe(0); }); }); diff --git a/src/node/runtime/daytonaTokenRedaction.ts b/src/node/runtime/daytonaTokenRedaction.ts index 72b1289b1cf..d07ad9bc832 100644 --- a/src/node/runtime/daytonaTokenRedaction.ts +++ b/src/node/runtime/daytonaTokenRedaction.ts @@ -49,162 +49,39 @@ export function redactDaytonaError(error: unknown): unknown { return error; } -/** How many trailing chars of `text` could be the start of a remembered token. */ -function tokenPrefixSuffixLength(text: string): number { - let held = 0; - for (const token of rememberedTokens.keys()) { - for (let length = Math.min(token.length - 1, text.length); length > held; length--) { - if (text.endsWith(token.slice(0, length))) { - held = length; - break; - } - } - } - return held; -} - -export interface DaytonaTextRedactor { - /** Redacted text that is safe to publish now; a possible token start stays held. */ - push(text: string): string; - /** Everything still held, redacted. */ - flush(): string; - /** The held text, only while the whole possible token start is shorter than the bound. */ - releaseShortTail(): string; - holding(): boolean; -} - -// An accidental match of a token's first chars this short is negligible, and a leaked prefix -// this short is useless, so it may echo; a longer possible token start waits for more text. -const RELEASABLE_TAIL_LENGTH = 4; - -function commonPrefixLength(a: string, b: string): number { - let length = 0; - while (length < a.length && a[length] === b[length]) length++; - return length; -} - /** - * Redact text that arrives in pieces. A token split across pieces is caught by keeping the tail - * that could start one: its held part waits for the next piece (or the end), and its released - * part (already published) is redacted again with what follows, so a token whose start was - * released is still masked from there on. The text is redacted before the tail is chosen, so a - * whole token whose last chars also start a token is not split. + * Redact a byte stream chunk by chunk. A token split across two chunks is not caught: ssh writes + * each message (`@host: Permission denied`) in one write. */ -export function createDaytonaTextRedactor(): DaytonaTextRedactor { - let released = ""; - let held = ""; - return { - push(text) { - const redacted = redactDaytonaTokens(released + held + text); - const published = commonPrefixLength(released, redacted); - const tailStart = redacted.length - tokenPrefixSuffixLength(redacted); - const cut = Math.max(published, tailStart); - released = redacted.slice(tailStart, cut); - held = redacted.slice(cut); - return redacted.slice(published, cut); - }, - flush() { - const rest = held; - released = ""; - held = ""; - return rest; - }, - releaseShortTail() { - if (released.length + held.length >= RELEASABLE_TAIL_LENGTH) return ""; - const rest = held; - released += held; - held = ""; - return rest; - }, - holding: () => held !== "", - }; -} - -/** Redact a byte stream with createDaytonaTextRedactor. */ export function createDaytonaRedactingStream(): Transform { const decoder = new StringDecoder("utf8"); - const redactor = createDaytonaTextRedactor(); const emit = (stream: Transform, text: string) => { - if (text) stream.push(Buffer.from(text, "utf8")); + if (text) stream.push(Buffer.from(redactDaytonaTokens(text), "utf8")); }; return new Transform({ transform(chunk: Buffer, _encoding, callback) { - emit(this, redactor.push(decoder.write(chunk))); + emit(this, decoder.write(chunk)); callback(); }, flush(callback) { - emit(this, redactor.push(decoder.end()) + redactor.flush()); + emit(this, decoder.end()); callback(); }, }); } -// A PTY gives no signal that more output is coming, so a short held tail is published after -// this quiet period. Otherwise a typed char that happens to start a token would not echo until -// the next output. -const PTY_HOLD_MS = 50; - -type CancelTimer = () => void; - -function scheduleHoldFlush(run: () => void): CancelTimer { - const timer = setTimeout(run, PTY_HOLD_MS); - timer.unref?.(); - return () => clearTimeout(timer); -} - /** - * Redact a PTY's output: without BatchMode OpenSSH can print `@gateway: Permission - * denied` or a password prompt naming the token into the terminal, which clients replay. + * Redact a PTY's output chunk by chunk: OpenSSH can print `@gateway: Permission denied` + * into the terminal, which clients replay. The host block turns off password and + * keyboard-interactive auth, so no prompt names the token. */ -export function redactDaytonaPty( - pty: PtyHandle, - schedule: (run: () => void) => CancelTimer = scheduleHoldFlush -): PtyHandle { - const redactor = createDaytonaTextRedactor(); - const handlers = new Set<(data: string) => void>(); - let subscribed = false; - let cancelTimer: CancelTimer | undefined; - const stopTimer = () => { - cancelTimer?.(); - cancelTimer = undefined; - }; - const publish = (text: string) => { - if (!text) return; - for (const handler of handlers) handler(text); - }; - const flushHeld = () => { - stopTimer(); - publish(redactor.flush()); - }; - const releaseShortTail = () => { - cancelTimer = undefined; - publish(redactor.releaseShortTail()); - }; - const onRawData = (data: string) => { - stopTimer(); - publish(redactor.push(data)); - if (redactor.holding()) cancelTimer = schedule(releaseShortTail); - }; +export function redactDaytonaPty(pty: PtyHandle): PtyHandle { return { write: (data) => pty.write(data), resize: (cols, rows) => pty.resize(cols, rows), - kill: () => { - stopTimer(); - pty.kill(); - }, - onData(handler) { - handlers.add(handler); - if (!subscribed) { - subscribed = true; - pty.onData(onRawData); - } - return { dispose: () => handlers.delete(handler) }; - }, - onExit: (handler) => - pty.onExit((event) => { - flushHeld(); - handler(event); - }), + kill: () => pty.kill(), + onData: (handler) => pty.onData((data) => handler(redactDaytonaTokens(data))), + onExit: (handler) => pty.onExit(handler), }; } From 44309a09695d0908219cdef5ce7aec5cc33bb249 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:02:42 +0000 Subject: [PATCH 215/231] fix(daytona): share one /config refetch between ssh failures that race it Two commands failing together on a moved gateway shared one refetch only while the first one's slow connection retirement kept it in flight. The forced refetch now checks the gateway generation after the credentials read, so a failure that arrives after a concurrent refetch moved the gateway reuses it. The runtime redaction test writes OpenSSH's message in one write, as the chunk redactor expects. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 5 ++--- src/node/services/daytona/daytonaSsh.ts | 15 +++++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index b33d1047687..99cc7bc6414 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -836,11 +836,10 @@ describe("DaytonaSSHRuntime ssh token redaction", () => { transport: localShellTransport(rig.rootDir), }); - // OpenSSH's message, split across two writes inside the token. + // OpenSSH's message, in one write. const result = await runtimeHelpers.execBuffered( runtime, - `printf '%s' '${token.slice(0, 2)}' >&2; sleep 0.05; ` + - `printf '%s@127.0.0.1: Permission denied (publickey).\\n' '${token.slice(2)}' >&2; exit 255`, + `printf '%s@127.0.0.1: Permission denied (publickey).\\n' '${token}' >&2; exit 255`, { cwd: "/tmp", timeout: 10 } ); expect(result.stderr).not.toContain(token); diff --git a/src/node/services/daytona/daytonaSsh.ts b/src/node/services/daytona/daytonaSsh.ts index 341ad7af138..971d25b39fa 100644 --- a/src/node/services/daytona/daytonaSsh.ts +++ b/src/node/services/daytona/daytonaSsh.ts @@ -154,11 +154,18 @@ export class DaytonaSsh { } } - /** `force` refetches a cached `/config` that is still fresh (an in-flight fetch is shared). */ - async getGatewayConfig(options: { force?: boolean } = {}): Promise { + /** + * `unlessMovedSince` refetches a cached `/config` that is still fresh, unless the gateway moved + * since that generation (checked after the credentials read, so a failure that arrives as a + * concurrent one's refetch ends reuses it). An in-flight fetch is shared. + */ + async getGatewayConfig( + options: { unlessMovedSince?: number } = {} + ): Promise { const { client, credentials } = await this.account.getClient(); const cached = this.cachedGateway(credentials.apiUrl); - if (cached && !options.force && this.now() - cached.fetchedAt < DAYTONA_GATEWAY_CONFIG_TTL_MS) { + const force = options.unlessMovedSince === this.gatewayMoves; + if (cached && !force && this.now() - cached.fetchedAt < DAYTONA_GATEWAY_CONFIG_TTL_MS) { return cached.config; } return this.sharedGatewayRefresh(client, credentials.apiUrl, cached); @@ -214,7 +221,7 @@ export class DaytonaSsh { attemptGeneration: number ): Promise { if (this.gatewayMoves === attemptGeneration) { - const config = await this.getGatewayConfig({ force: true }); + const config = await this.getGatewayConfig({ unlessMovedSince: attemptGeneration }); if (this.gatewayMoves === attemptGeneration) return this.restoreGatewayPins(config); } await this.ensureSshAccess(sandboxId); From 7f3c6f05feeea12656d7aea0219f6796b5ea846b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:07:38 +0000 Subject: [PATCH 216/231] refactor(daytona): move the settings connection test into its own hook (CRAP) --- .../Sections/DaytonaSettingsForms.tsx | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx index e36aafaaf93..9886e5a9de7 100644 --- a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx @@ -18,13 +18,28 @@ function describeKey(config: DaytonaPublicConfig | null): string { return config.apiKeySource === "env" ? "Key set (env)" : "Key set (settings)"; } +function useDaytonaConnectionTest(api: ReturnType["api"]) { + const [test, setTest] = useState({ status: "idle" }); + const runTest = async () => { + if (!api) return; + setTest({ status: "testing" }); + try { + const outcome = await api.daytona.testConnection(); + setTest(outcome.ok ? { status: "ok" } : { status: "error", message: outcome.error }); + } catch (testError) { + setTest({ status: "error", message: getErrorMessage(testError) }); + } + }; + return { test, setTest, runTest }; +} + /** The saved public config (never the key itself) plus the save and test-connection calls. */ function useDaytonaAccess() { const { api } = useAPI(); const [config, setConfig] = useState(null); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); - const [test, setTest] = useState({ status: "idle" }); + const { test, setTest, runTest } = useDaytonaConnectionTest(api); useEffect(() => { if (!api) return; @@ -59,17 +74,6 @@ function useDaytonaAccess() { } }; - const runTest = async () => { - if (!api) return; - setTest({ status: "testing" }); - try { - const outcome = await api.daytona.testConnection(); - setTest(outcome.ok ? { status: "ok" } : { status: "error", message: outcome.error }); - } catch (testError) { - setTest({ status: "error", message: getErrorMessage(testError) }); - } - }; - return { api, config, saving, error, test, save, runTest }; } From a15df1335fbbaad9da4fc01505532cb5a9154ab2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:10:17 +0000 Subject: [PATCH 217/231] test(ssh): use an optional chain in the bundle upload test --- src/node/runtime/SSHRuntime.syncContract.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/runtime/SSHRuntime.syncContract.test.ts b/src/node/runtime/SSHRuntime.syncContract.test.ts index bc54d6c1dbd..231ec8b36ff 100644 --- a/src/node/runtime/SSHRuntime.syncContract.test.ts +++ b/src/node/runtime/SSHRuntime.syncContract.test.ts @@ -521,7 +521,7 @@ describe("SSHRuntime authoritative sync contract", () => { override async exec(command: string, options: ExecOptions): Promise { const stream = await super.exec(command, options); const git = spawned[0]; - if (git && git.exitCode === null) await once(git, "exit"); + if (git?.exitCode === null) await once(git, "exit"); await new Promise((resolve) => setImmediate(resolve)); return { ...stream, From c70e6e2624a9c9af9feaaec1db1f003b4f984cbf Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:06:49 +0000 Subject: [PATCH 218/231] refactor(daytona): drop the editor RemoteForward refusal An external editor's ssh reads the user's config, and a matching RemoteForward there now applies to Daytona aliases too; the docs say to scope it. --- docs/runtime/daytona.mdx | 2 +- src/node/runtime/daytonaSshConfig.test.ts | 39 ------------------ src/node/runtime/daytonaSshConfig.ts | 40 ------------------- src/node/runtime/runtimeHelpers.ts | 27 ------------- .../builtInSkillContent.generated.ts | 2 +- .../workspaceService.runtimeStatus.test.ts | 26 ------------ src/node/services/workspaceService.ts | 6 +-- 7 files changed, 3 insertions(+), 139 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index b3d50435728..37f8f70269b 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -73,7 +73,7 @@ Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and a Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts. -Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, and Xum keeps its block first in the file, so its settings win for sandboxes in those clients too: strict host key checking against the pinned keys, the token as the user name, no proxy or jump host, and no agent or X11 forwarding. Your other entries still apply to every other host. A `KnownHostsCommand` or `Match exec` line in your config still runs for sandboxes in your own clients, with the token as `%r`; scope such lines to the hosts that need them. OpenSSH adds up `RemoteForward` lines instead of keeping the first, so Xum refuses to open an editor on a sandbox while a `RemoteForward` in your SSH config applies to it; scope it to the hosts that need it. +Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, and Xum keeps its block first in the file, so its settings win for sandboxes in those clients too: strict host key checking against the pinned keys, the token as the user name, no proxy or jump host, and no agent or X11 forwarding. Your other entries still apply to every other host. A `KnownHostsCommand` or `Match exec` line in your config still runs for sandboxes in your own clients, with the token as `%r`; scope such lines to the hosts that need them. OpenSSH adds up `RemoteForward` lines instead of keeping the first, so a `RemoteForward` in a `Host *` entry also opens a tunnel from the sandbox to your machine in those clients; scope it to the hosts that need it. ## Notes diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index 62702c6fb42..f772bece952 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -10,7 +10,6 @@ import { } from "@/common/constants/daytona"; import { DaytonaSshConfigWriter, - hasInheritedRemoteForward, renderDaytonaKnownHosts, } from "./daytonaSshConfig"; import { resolveSSHConfig } from "./sshConfigParser"; @@ -452,44 +451,6 @@ describe("DaytonaSshConfigWriter", () => { ); }); - // OpenSSH adds up forwards instead of keeping the first one, so the block cannot turn a - // user's RemoteForward off; and ClearAllForwardings would also drop the editor's own tunnel. - it.skipIf(!hasOpenSsh)("reports a user RemoteForward that reaches the alias", async () => { - await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); - const alias = toXumDaytonaHost("sb-1"); - const check = () => hasInheritedRemoteForward(alias, { sshConfigPath }); - for (const forward of [ - "RemoteForward 6000 127.0.0.1:3000", - `RemoteForward ${path.join(tempDir, "remote.sock")} ${path.join(tempDir, "local.sock")}`, - ]) { - await fs.writeFile(sshConfigPath, `Host *\n ${forward}\n`, "utf8"); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - expect(await check()).toBe(true); - } - - await fs.writeFile( - sshConfigPath, - "Host work\n RemoteForward 6000 127.0.0.1:3000\nHost *\n User me\n", - "utf8" - ); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - expect(await check()).toBe(false); - }); - - it("fails closed, without the resolved config, when ssh cannot resolve the alias", async () => { - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - // A config ssh refuses to parse. - await fs.writeFile(sshConfigPath, "Host *\n NoSuchOption yes\n", "utf8"); - let refusal: unknown; - try { - await hasInheritedRemoteForward(toXumDaytonaHost("sb-1"), { sshConfigPath }); - } catch (error) { - refusal = error; - } - expect(refusal).toBeInstanceOf(Error); - expect(String(refusal)).not.toContain("tokenA"); - }); - it.skipIf(!hasOpenSsh)("gives the sandbox no tun device a user Tunnel asks for", async () => { await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); await fs.writeFile(sshConfigPath, "Host *\n Tunnel yes\n", "utf8"); diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 9b3de38f540..92aa4719666 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -1,4 +1,3 @@ -import { execFile } from "node:child_process"; import * as fs from "node:fs/promises"; import * as net from "node:net"; import * as path from "node:path"; @@ -181,8 +180,6 @@ export function renderDaytonaHostBlock(input: { // (first value wins). `Tunnel no` keeps a user's `Tunnel` from giving the sandbox a tun device. // The gateway authenticates with "none" (the token is the user name), so password and // keyboard-interactive auth are off: their prompts would print the token. - // Forwards are not first-value-wins (see hasInheritedRemoteForward), and those an editor asks - // for on its command line are left alone. return [ `Host ${toXumDaytonaHost(input.sandboxId)}`, " ProxyCommand none", @@ -201,43 +198,6 @@ export function renderDaytonaHostBlock(input: { ].join("\n"); } -const RESOLVE_ALIAS_TIMEOUT_MS = 10_000; - -/** - * Whether an editor's ssh would open a reverse forward into the sandbox for `alias`: OpenSSH - * adds up every RemoteForward that matches instead of keeping the first, so Xum's block cannot - * turn a user's `Host *` one off, and ClearAllForwardings would also drop the tunnel an editor - * opens on its command line. Resolved as the editor does (`ssh -G` with the user's and the - * system config). Throws when ssh cannot resolve the alias, so a caller fails closed. The output - * holds the token (as `user`): it is never logged or put in an error. - */ -export function hasInheritedRemoteForward( - alias: string, - /** Tests read a temp config instead of the user's (`-F` skips the system config too). */ - options: { sshConfigPath?: string } = {} -): Promise { - const args = options.sshConfigPath ? ["-G", "-F", options.sshConfigPath, alias] : ["-G", alias]; - return new Promise((resolve, reject) => { - execFile( - "ssh", - args, - { timeout: RESOLVE_ALIAS_TIMEOUT_MS, windowsHide: true, maxBuffer: 1024 * 1024 }, - (error, stdout) => { - if (error) { - const code = (error as NodeJS.ErrnoException).code ?? "unknown"; - reject( - new DaytonaSshConfigError( - `Could not check your SSH config for ${alias} (ssh -G failed: ${String(code)})` - ) - ); - return; - } - resolve(/^remoteforward\s/im.test(stdout)); - } - ); - }); -} - const INCLUDE_FILE_HEADER = "# Managed by Xum (Daytona sandboxes). Xum rewrites this file; do not edit it."; diff --git a/src/node/runtime/runtimeHelpers.ts b/src/node/runtime/runtimeHelpers.ts index 3689814898b..8b04278d4f0 100644 --- a/src/node/runtime/runtimeHelpers.ts +++ b/src/node/runtime/runtimeHelpers.ts @@ -7,8 +7,6 @@ import { type RuntimeConfig, } from "@/common/types/runtime"; import { Err, Ok, type Result } from "@/common/types/result"; -import { getErrorMessage } from "@/common/utils/errors"; -import { hasInheritedRemoteForward } from "./daytonaSshConfig"; import type { Runtime } from "./Runtime"; import { createRuntime } from "./runtimeFactory"; @@ -181,28 +179,3 @@ export async function ensureDaytonaSandboxReady( const ready = await createRuntimeForWorkspace(metadata).ensureReady({ externalSshClient: true }); return ready.ready ? Ok(undefined) : Err(ready.error); } - -/** - * An external editor reads the user's ssh config for a Daytona alias (Xum's own ssh does not), - * and a RemoteForward there would give the untrusted sandbox a tunnel to a local service: such - * an open is refused. Fails closed when ssh cannot resolve the alias. A no-op for every other - * runtime. - */ -export async function refuseDaytonaEditorRemoteForward( - runtimeConfig: WorkspaceMetadataForRuntime["runtimeConfig"] -): Promise> { - if (!isSSHRuntime(runtimeConfig) || !runtimeConfig.daytona) return Ok(undefined); - let forwarded: boolean; - try { - forwarded = await hasInheritedRemoteForward(runtimeConfig.host); - } catch (error) { - return Err(`${getErrorMessage(error)}; not opening an editor on the Daytona sandbox.`); - } - return forwarded - ? Err( - "A RemoteForward in your SSH config applies to Daytona sandboxes, so the editor would " + - "open a tunnel from the sandbox to this machine. Scope it to the hosts that need it " + - "(a narrower Host entry) and open the editor again." - ) - : Ok(undefined); -} diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 826d178d535..c74ab9d6aaa 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10110,7 +10110,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts.", "", - "Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, and Xum keeps its block first in the file, so its settings win for sandboxes in those clients too: strict host key checking against the pinned keys, the token as the user name, no proxy or jump host, and no agent or X11 forwarding. Your other entries still apply to every other host. A `KnownHostsCommand` or `Match exec` line in your config still runs for sandboxes in your own clients, with the token as `%r`; scope such lines to the hosts that need them. OpenSSH adds up `RemoteForward` lines instead of keeping the first, so Xum refuses to open an editor on a sandbox while a `RemoteForward` in your SSH config applies to it; scope it to the hosts that need it.", + "Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, and Xum keeps its block first in the file, so its settings win for sandboxes in those clients too: strict host key checking against the pinned keys, the token as the user name, no proxy or jump host, and no agent or X11 forwarding. Your other entries still apply to every other host. A `KnownHostsCommand` or `Match exec` line in your config still runs for sandboxes in your own clients, with the token as `%r`; scope such lines to the hosts that need them. OpenSSH adds up `RemoteForward` lines instead of keeping the first, so a `RemoteForward` in a `Host *` entry also opens a tunnel from the sandbox to your machine in those clients; scope it to the hosts that need it.", "", "## Notes", "", diff --git a/src/node/services/workspaceService.runtimeStatus.test.ts b/src/node/services/workspaceService.runtimeStatus.test.ts index 5b59d5a9514..96277d14668 100644 --- a/src/node/services/workspaceService.runtimeStatus.test.ts +++ b/src/node/services/workspaceService.runtimeStatus.test.ts @@ -5,7 +5,6 @@ import type { RuntimeConfig } from "@/common/types/runtime"; import { shellQuote } from "@/common/utils/shell"; import type { TerminalService } from "@/node/services/terminalService"; import type { EnsureReadyOptions, Runtime } from "@/node/runtime/Runtime"; -import * as daytonaSshConfig from "@/node/runtime/daytonaSshConfig"; import * as runtimeFactory from "@/node/runtime/runtimeFactory"; import type { DaytonaRuntimeService } from "@/node/runtime/DaytonaSSHRuntime"; import * as runtimeHelpers from "@/node/runtime/runtimeHelpers"; @@ -316,7 +315,6 @@ describe("WorkspaceService.recordExternalEditorOpen for a Daytona workspace", () let harness: WorkspaceServiceHarness; let projectPath: string; let createRuntime: { mockRestore: () => void }; - let forwardCheck: ReturnType>; /** The editor use leases held on the workspace while each readiness check ran. */ let leasesDuringReadiness: number[]; let readiness: { ready: true } | { ready: false; error: string }; @@ -337,13 +335,10 @@ describe("WorkspaceService.recordExternalEditorOpen for a Daytona workspace", () createRuntime = spyOn(runtimeHelpers, "createRuntimeForWorkspace").mockReturnValue({ ensureReady, } as unknown as Runtime); - // The user's real ssh config must not decide these tests. - forwardCheck = spyOn(daytonaSshConfig, "hasInheritedRemoteForward").mockResolvedValue(false); }); afterEach(async () => { createRuntime.mockRestore(); - forwardCheck.mockRestore(); await harness.cleanup(); }); @@ -424,27 +419,6 @@ describe("WorkspaceService.recordExternalEditorOpen for a Daytona workspace", () ).toEqual({ success: true, data: { remotePath: "/home/daytona/mux/project/dt" } }); }); - // The editor reads the user's ssh config, where a matching RemoteForward would give the - // sandbox a tunnel to a local service. - test("refuses the open when the user's ssh config forwards into the sandbox", async () => { - await addDaytonaWorkspace(); - const markerPath = path.join(harness.config.sessionsDir, "dt", "external-editor-opened"); - for (const check of [ - () => Promise.resolve(true), - () => Promise.reject(new daytonaSshConfig.DaytonaSshConfigError("ssh -G failed")), - ]) { - forwardCheck.mockImplementationOnce(check); - - const result = await harness.service.recordExternalEditorOpen("dt", "tok-1"); - - expect(result.success).toBe(false); - expect(forwardCheck).toHaveBeenLastCalledWith("sb-1.xum--daytona"); - expect(workspaceUseLeasesFor(harness.config).heldCount("dt", "editor")).toBe(0); - expect(await Bun.file(markerPath).exists()).toBe(false); - } - expect(leasesDuringReadiness).toEqual([1, 1]); - }); - test("refuses an archived workspace without starting its sandbox", async () => { await addDaytonaWorkspace("2026-01-02T00:00:00.000Z"); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 32aaf6ee31b..27223a79812 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -127,7 +127,6 @@ import { createRuntimeContextForWorkspace, createRuntimeForWorkspace, ensureDaytonaSandboxReady, - refuseDaytonaEditorRemoteForward, resolveWorkspaceExecutionPath, resolveWorkspaceRootPath, } from "@/node/runtime/runtimeHelpers"; @@ -12108,10 +12107,7 @@ export class WorkspaceService name: workspace.name ?? workspaceId, namedWorkspacePath: workspace.path, }); - if (!ready.success) return ready.error; - // After readiness, so the alias resolves with the managed block and a current token. - const forwarding = await refuseDaytonaEditorRemoteForward(workspace.runtimeConfig); - return forwarding.success ? undefined : forwarding.error; + return ready.success ? undefined : ready.error; } /** Body of recordExternalEditorOpenForLaunch after pending-recording admission. */ From 18d60a5d8f74a417539e40244fcf20fc612fd646 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:10:07 +0000 Subject: [PATCH 219/231] refactor(daytona): drop the account lease around Daytona operations A settings save now applies at once instead of being refused while a create, start, delete, ssh setup or sweep runs; it still revokes the old account's tokens. setConfig no longer throws a DaytonaConfigError, so the router's BAD_REQUEST mapping for it goes too. --- src/node/orpc/router.ts | 13 +-- src/node/orpc/routerDaytona.test.ts | 45 --------- src/node/runtime/DaytonaSSHRuntime.test.ts | 80 ---------------- src/node/runtime/DaytonaSSHRuntime.ts | 83 +++++++---------- .../services/daytona/daytona.testHarness.ts | 20 +--- src/node/services/daytona/daytonaAccount.ts | 44 --------- .../services/daytona/daytonaLifecycleHooks.ts | 27 +++--- .../services/daytona/daytonaReconciler.ts | 9 +- src/node/services/daytona/daytonaSandboxes.ts | 12 +-- src/node/services/daytona/daytonaSsh.test.ts | 91 ------------------- src/node/services/daytona/daytonaSsh.ts | 42 ++++----- 11 files changed, 76 insertions(+), 390 deletions(-) delete mode 100644 src/node/orpc/routerDaytona.test.ts diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 7607d1aefeb..844f31a83b5 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -19,7 +19,6 @@ import { EXPERIMENT_IDS } from "@/common/constants/experiments"; * providerService.ts and startDesktopFlowEffect in muxGatewayOauthService.ts). */ import { ORPCError, os, type ProcedureConfig } from "@orpc/server"; -import { DaytonaConfigError } from "@/node/config/daytonaCredentialsStore"; import { WorkspaceMutationInProgressError } from "@/node/services/workspaceUseLeases"; import { PerfCaptureRefusedError } from "@/node/services/perf/perfCaptureService"; import { PerfReportRefusedError } from "@/node/services/perf/perfReportService"; @@ -213,14 +212,6 @@ function rethrowDraftTooLarge(error: unknown): never { throw error; } -/** Transports mask plain errors as "Internal Server Error"; invalid settings need the reason. */ -function rethrowDaytonaConfigError(error: unknown): never { - if (error instanceof DaytonaConfigError) { - throw new ORPCError("BAD_REQUEST", { message: error.message }); - } - throw error; -} - /** A refused creation draft save needs corrected choices or a reload, never a retry. */ function rethrowCreationDraftRefusal(error: unknown): never { if (error instanceof CreationDraftRejectedError) { @@ -1813,9 +1804,7 @@ export const router = (authToken?: string) => { setConfig: t .input(schemas.daytona.setConfig.input) .output(schemas.daytona.setConfig.output) - .handler(async ({ context, input }) => - context.daytonaService.account.setConfig(input).catch(rethrowDaytonaConfigError) - ), + .handler(async ({ context, input }) => context.daytonaService.account.setConfig(input)), testConnection: t .input(schemas.daytona.testConnection.input) .output(schemas.daytona.testConnection.output) diff --git a/src/node/orpc/routerDaytona.test.ts b/src/node/orpc/routerDaytona.test.ts deleted file mode 100644 index 6a61f5a2615..00000000000 --- a/src/node/orpc/routerDaytona.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import * as fs from "fs/promises"; -import * as os from "os"; -import * as path from "path"; -import { createRouterClient } from "@orpc/server"; - -import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; -import { DAYTONA_BUSY_MESSAGE } from "@/node/services/daytona/daytonaAccount"; -import { DaytonaService } from "@/node/services/daytona/daytonaService"; -import type { ORPCContext } from "./context"; -import { router } from "./router"; - -describe("daytona procedures", () => { - let rootDir: string; - - beforeEach(async () => { - rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-router-daytona-")); - }); - - afterEach(async () => { - await fs.rm(rootDir, { recursive: true, force: true }); - }); - - test("a settings refusal reaches the client as a bad request with its reason", async () => { - const daytonaService = new DaytonaService({ - credentialsStore: new DaytonaCredentialsStore(rootDir, {}), - rootDir, - }); - const context = { daytonaService } as unknown as ORPCContext; - const client = createRouterClient(router(), { context }); - - let release!: () => void; - const lease = daytonaService.account.withAccountLease( - () => new Promise((resolve) => (release = resolve)) - ); - const error = await client.daytona.setConfig({ apiKey: "k" }).then( - () => null, - (rejection: Error) => rejection - ); - release(); - await lease; - expect(error).toMatchObject({ code: "BAD_REQUEST" }); - expect(error?.message).toContain(DAYTONA_BUSY_MESSAGE); - }); -}); diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 99cc7bc6414..d6126b935d9 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -4,7 +4,6 @@ import * as fs from "fs/promises"; import * as path from "path"; import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; import type { RuntimeConfig } from "@/common/types/runtime"; -import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; import { createDaytonaTestRig, localShellTransport, @@ -465,26 +464,6 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { } }); - it("refuses a settings change while it creates a sandbox, and takes one afterwards", async () => { - const newAccount = rig.addAccount("new-key"); - // The user saves another account's key while the create request is in flight. - let refusal: unknown; - rig.plane.onRequest.set("POST /sandbox", () => - rig.service.account.setConfig({ apiKey: "new-key" }).catch((error: unknown) => { - refusal = error; - }) - ); - - await makeRuntime({ sandboxId: "xum-kept", ...SMALL }).postCreateSetup(initParams()); - - expect(String(refusal)).toContain("Daytona is busy"); - expect(rig.plane.find("xum-kept")?.state).toBe("started"); - expect(newAccount.calls).toEqual([]); - expect((await rig.service.account.setConfig({ apiKey: "new-key" })).apiKeySource).toBe( - "settings" - ); - }); - it("never deletes a sandbox that took its name before the create request", async () => { // Another Xum install created this name between validateBeforePersist and the create. const other = rig.plane.addSandbox({ @@ -546,65 +525,6 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { sshDeleteSpy.mockRestore(); }); - it("refuses a settings change between its checks and the sandbox delete", async () => { - rig.plane.addSandbox({ id: "sb-clean" }); - const newAccount = rig.addAccount("new-key"); - let refusal: unknown; - sshDeleteSpy.mockImplementation(async () => { - await rig.service.account.setConfig({ apiKey: "new-key" }).catch((error: unknown) => { - refusal = error; - }); - return { success: true, deletedPath: "/path" }; - }); - - const result = await makeRuntime({ sandboxId: "sb-clean" }).deleteWorkspace( - "/home/me/project", - "feature", - false - ); - - expect(result.success).toBe(true); - expect(String(refusal)).toContain("Daytona is busy"); - expect(rig.plane.find("sb-clean")).toBeUndefined(); - expect(newAccount.calls).toEqual([]); - expect((await rig.service.account.setConfig({ apiKey: "new-key" })).apiKeySource).toBe( - "settings" - ); - }); - - it("starts a delete after a settings change being saved, in the new account only", async () => { - // The same name in both accounts: the delete must check and delete the new one's. - rig.plane.addSandbox({ id: "sb-1", name: "xum-same" }); - const newAccount = rig.addAccount("new-key"); - newAccount.addSandbox({ id: "sb-9", name: "xum-same" }); - let releaseUpdate = () => undefined as void; - const updateGate = new Promise((resolve) => (releaseUpdate = resolve)); - const updateSpy = spyOn(DaytonaCredentialsStore.prototype, "update").mockImplementation( - async function (this: DaytonaCredentialsStore, patch) { - await updateGate; - updateSpy.mockRestore(); - return this.update(patch); - } - ); - try { - const saved = rig.service.account.setConfig({ apiKey: "new-key" }); - const deleted = makeRuntime({ sandboxId: "xum-same" }).deleteWorkspace( - "/home/me/project", - "feature", - false - ); - releaseUpdate(); - await saved; - expect((await deleted).success).toBe(true); - } finally { - updateSpy.mockRestore(); - } - - expect(rig.plane.calls.filter((call) => call.includes("xum-same"))).toEqual([]); - expect(rig.plane.find("xum-same")).toBeDefined(); - expect(newAccount.find("xum-same")).toBeUndefined(); - }); - it("keeps the sandbox when a fork's forced removal cannot delete its checkout", async () => { rig.plane.addSandbox({ id: "sb-1" }); await rig.service.ssh.ensureSshAccess("sb-1"); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index cb28b4994b6..9731533ed1c 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -62,10 +62,7 @@ import { /** The DaytonaService parts this runtime uses (tests pass an in-memory fake). */ export interface DaytonaRuntimeService { - account: Pick< - DaytonaAccount, - "checkAvailability" | "withAccountLease" | "getDefaultTarget" | "findSnapshot" - >; + account: Pick; readiness: Pick< DaytonaReadiness, "isSandboxReady" | "markSandboxReady" | "invalidateSandboxReady" @@ -320,24 +317,22 @@ export class DaytonaSSHRuntime extends SSHRuntime { * checks share one control-plane request. A sandbox removed outside Xum is reported, never * silently recreated. */ - override ensureReady(options?: EnsureReadyOptions): Promise { - return this.daytonaService.account.withAccountLease(async () => { - const sandboxId = this.daytonaConfig.sandboxId; - if (!sandboxId) { - return { - ready: false, - error: "Daytona sandbox id not set", - errorType: "runtime_not_ready", - }; - } - const emitStatus = makeStatusEmitter(options?.statusSink); - // An external client reads the user's ssh config, which the cache does not cover: another - // tool may have removed the managed block since. Only a full check writes it again. - if (!options?.externalSshClient && this.daytonaService.readiness.isSandboxReady(sandboxId)) { - return this.ensureReadyFromCache(options, emitStatus); - } - return this.ensureReadyUncached(sandboxId, options, emitStatus); - }); + override async ensureReady(options?: EnsureReadyOptions): Promise { + const sandboxId = this.daytonaConfig.sandboxId; + if (!sandboxId) { + return { + ready: false, + error: "Daytona sandbox id not set", + errorType: "runtime_not_ready", + }; + } + const emitStatus = makeStatusEmitter(options?.statusSink); + // An external client reads the user's ssh config, which the cache does not cover: another + // tool may have removed the managed block since. Only a full check writes it again. + if (!options?.externalSshClient && this.daytonaService.readiness.isSandboxReady(sandboxId)) { + return this.ensureReadyFromCache(options, emitStatus); + } + return this.ensureReadyUncached(sandboxId, options, emitStatus); } private async ensureReadyUncached( @@ -424,21 +419,19 @@ export class DaytonaSSHRuntime extends SSHRuntime { * "Permission denied", so only a started sandbox gets a new token. Throws a clear error for * a sandbox deleted outside Xum. */ - private recoverForRetry( + private async recoverForRetry( error: unknown, attempt: SshAttempt, abortSignal: AbortSignal | undefined ): Promise { - return this.daytonaService.account.withAccountLease(async () => { - const sandboxId = this.daytonaConfig.sandboxId; - if (!sandboxId || !isRuntimeTransportError(error) || abortSignal?.aborted) return false; - const tokenRejected = isTokenRejected(error); - // A UI or background runtime never starts the sandbox, so only a refused token is worth a look. - if (!tokenRejected && !this.restartsStoppedSandbox) return false; - this.daytonaService.readiness.invalidateSandboxReady(sandboxId); - const state = await this.readStateAfterFailure(sandboxId); - return this.recoverInState(sandboxId, state, { error, tokenRejected, attempt }, abortSignal); - }); + const sandboxId = this.daytonaConfig.sandboxId; + if (!sandboxId || !isRuntimeTransportError(error) || abortSignal?.aborted) return false; + const tokenRejected = isTokenRejected(error); + // A UI or background runtime never starts the sandbox, so only a refused token is worth a look. + if (!tokenRejected && !this.restartsStoppedSandbox) return false; + this.daytonaService.readiness.invalidateSandboxReady(sandboxId); + const state = await this.readStateAfterFailure(sandboxId); + return this.recoverInState(sandboxId, state, { error, tokenRejected, attempt }, abortSignal); } private recoverInState( @@ -757,11 +750,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { * sync, worktree creation and init hook. If anything fails after the create request was * sent, the sandbox this call created is deleted again so it cannot linger and bill. */ - postCreateSetup(params: WorkspaceInitParams): Promise { - return this.daytonaService.account.withAccountLease(() => this.provisionSandbox(params)); - } - - private async provisionSandbox(params: WorkspaceInitParams): Promise { + async postCreateSetup(params: WorkspaceInitParams): Promise { const sandboxId = this.daytonaConfig.sandboxId; if (!sandboxId) { throw new Error("Daytona sandbox id is required (should be set by finalizeConfig)"); @@ -913,7 +902,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { * for Coder, a stopped or archived one is never started for them, so its work goes unchecked. * Force skips the checks, and deletes the sandbox without SSH. */ - override deleteWorkspace( + override async deleteWorkspace( projectPath: string, workspaceName: string, force: boolean, @@ -921,15 +910,13 @@ export class DaytonaSSHRuntime extends SSHRuntime { trusted?: boolean, options?: { keepBranch?: boolean } ): Promise { - return this.daytonaService.account.withAccountLease(async () => { - const sshDelete = () => - super.deleteWorkspace(projectPath, workspaceName, force, abortSignal, trusted, options); - return this.deleteWorkspaceAndSandbox( - this.getWorkspacePath(projectPath, workspaceName), - force, - sshDelete - ); - }); + const sshDelete = () => + super.deleteWorkspace(projectPath, workspaceName, force, abortSignal, trusted, options); + return this.deleteWorkspaceAndSandbox( + this.getWorkspacePath(projectPath, workspaceName), + force, + sshDelete + ); } private async deleteWorkspaceAndSandbox( diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index bb740d05462..3b083ce3079 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -368,10 +368,7 @@ export class FakeDaytonaSshConfigWriter implements Pick< export interface DaytonaTestRig { rootDir: string; - /** The account of the default API key. */ plane: FakeDaytonaControlPlane; - /** Another account: requests with this API key reach its own control plane. */ - addAccount: (apiKey: string) => FakeDaytonaControlPlane; writer: FakeDaytonaSshConfigWriter; service: DaytonaService; clock: { now: number }; @@ -388,11 +385,6 @@ export async function createDaytonaTestRig( const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-daytona-test-")); const clock = { now: Date.parse("2026-01-01T00:00:00Z") }; const plane = new FakeDaytonaControlPlane(() => clock.now); - const accounts = new Map(); - const planeFor = (init: RequestInit | undefined) => { - const auth = (init?.headers as Record | undefined)?.Authorization; - return accounts.get(auth?.replace(/^Bearer /, "") ?? "") ?? plane; - }; const writer = new FakeDaytonaSshConfigWriter(); const env: NodeJS.ProcessEnv = options.apiKey === null ? {} : { DAYTONA_API_KEY: options.apiKey ?? "test-key" }; @@ -400,8 +392,7 @@ export async function createDaytonaTestRig( credentialsStore: new DaytonaCredentialsStore(rootDir, env), rootDir, sshConfigWriter: writer, - fetch: ((input: string | URL | Request, init?: RequestInit) => - planeFor(init).fetch(input, init)) as typeof fetch, + fetch: plane.fetch, now: () => clock.now, sleep: (ms) => { clock.now += ms; @@ -410,17 +401,10 @@ export async function createDaytonaTestRig( }); // Sandboxes a test adds count as ones Xum created, as most tests need; a foreign one sets // its own labels. - const ownLabels = { [DAYTONA_LABEL_INSTANCE]: await service.sandboxes.getInstanceId() }; - plane.defaultLabels = ownLabels; + plane.defaultLabels = { [DAYTONA_LABEL_INSTANCE]: await service.sandboxes.getInstanceId() }; return { rootDir, plane, - addAccount: (apiKey) => { - const account = new FakeDaytonaControlPlane(() => clock.now); - account.defaultLabels = ownLabels; - accounts.set(apiKey, account); - return account; - }, writer, service, clock, diff --git a/src/node/services/daytona/daytonaAccount.ts b/src/node/services/daytona/daytonaAccount.ts index 7ddc1f84363..791888d8847 100644 --- a/src/node/services/daytona/daytonaAccount.ts +++ b/src/node/services/daytona/daytonaAccount.ts @@ -37,15 +37,6 @@ export function unsupportedDaytonaSandboxKind(item: { return undefined; } -export const DAYTONA_BUSY_MESSAGE = - "Daytona is busy creating, starting, deleting or checking sandboxes. Save these settings " + - "again when it is done."; - -/** Wait until `promise` settles; its outcome belongs to whoever started it. */ -async function settled(promise: Promise): Promise { - await Promise.allSettled([promise]); -} - export type DaytonaAvailability = { available: true } | { available: false; reason: string }; export interface DaytonaAccountOptions { @@ -74,10 +65,6 @@ export class DaytonaAccount { private readonly credentialsStore: DaytonaCredentialsStore; private readonly fetchImpl?: typeof fetch; private readonly onSettingsChange: DaytonaAccountOptions["onSettingsChange"]; - /** Operations running under withAccountLease; setConfig refuses while any runs. */ - private accountLeases = 0; - /** The settings change being saved; new leases wait for it. */ - private settingsChange?: Promise; constructor(options: DaytonaAccountOptions) { this.credentialsStore = options.credentialsStore; @@ -89,38 +76,7 @@ export class DaytonaAccount { return this.credentialsStore.getPublicConfig(); } - /** - * Run a Daytona operation that makes more than one control-plane call, or acts on what an - * earlier one returned (create and set up, delete after its checks, start and mint, archive, - * a reconcile sweep), against one account. setConfig refuses while any runs, so none spans a - * settings change; one that starts while a change is saved waits for it. Leases nest. - */ - async withAccountLease(fn: () => Promise): Promise { - // No await between this check and the claim (as in setConfig). - while (this.settingsChange) await settled(this.settingsChange); - this.accountLeases++; - try { - return await fn(); - } finally { - this.accountLeases--; - } - } - - /** Refused at once (DaytonaConfigError, shown in Settings) while a lease is held. */ async setConfig(patch: DaytonaCredentialsPatch): Promise { - while (this.settingsChange) await settled(this.settingsChange); - // No await between this check and the assignment: a lease cannot start in between. - if (this.accountLeases > 0) throw new DaytonaConfigError(DAYTONA_BUSY_MESSAGE); - const change = this.applyConfig(patch); - this.settingsChange = change; - try { - return await change; - } finally { - if (this.settingsChange === change) this.settingsChange = undefined; - } - } - - private async applyConfig(patch: DaytonaCredentialsPatch): Promise { const previousClient = await this.getClientIfConfigured(); await this.credentialsStore.update(patch); await this.onSettingsChange(previousClient); diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index 80730a3e8dd..d43b3174831 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -2,7 +2,6 @@ import { Err, Ok, type Result } from "@/common/types/result"; import { getDaytonaSandboxId, ownsDaytonaSandbox } from "@/common/types/runtime"; import { getErrorMessage } from "@/common/utils/errors"; import type { BeforeArchiveHook } from "@/node/services/workspaceLifecycleHooks"; -import type { DaytonaAccount } from "./daytonaAccount"; import type { DaytonaSandboxes } from "./daytonaSandboxes"; /** @@ -12,23 +11,21 @@ import type { DaytonaSandboxes } from "./daytonaSandboxes"; */ export interface DaytonaArchiveHookOptions { daytonaService: { - account: Pick; sandboxes: Pick; }; } export function createDaytonaArchiveHook(options: DaytonaArchiveHookOptions): BeforeArchiveHook { - return (args) => - options.daytonaService.account.withAccountLease(async (): Promise> => { - const sandboxId = ownsDaytonaSandbox(args.workspaceMetadata) - ? getDaytonaSandboxId(args.workspaceMetadata.runtimeConfig) - : undefined; - if (!sandboxId) return Ok(undefined); - try { - await options.daytonaService.sandboxes.deleteSandbox(sandboxId); - return Ok(undefined); - } catch (error) { - return Err(`Failed to delete Daytona sandbox "${sandboxId}": ${getErrorMessage(error)}`); - } - }); + return async (args): Promise> => { + const sandboxId = ownsDaytonaSandbox(args.workspaceMetadata) + ? getDaytonaSandboxId(args.workspaceMetadata.runtimeConfig) + : undefined; + if (!sandboxId) return Ok(undefined); + try { + await options.daytonaService.sandboxes.deleteSandbox(sandboxId); + return Ok(undefined); + } catch (error) { + return Err(`Failed to delete Daytona sandbox "${sandboxId}": ${getErrorMessage(error)}`); + } + }; } diff --git a/src/node/services/daytona/daytonaReconciler.ts b/src/node/services/daytona/daytonaReconciler.ts index 82ad4e54863..3c375e5c680 100644 --- a/src/node/services/daytona/daytonaReconciler.ts +++ b/src/node/services/daytona/daytonaReconciler.ts @@ -18,7 +18,7 @@ export interface DaytonaWorkspaceRef { export interface DaytonaReconcilerOptions { daytonaService: { - account: Pick; + account: Pick; sandboxes: Pick; ssh: Pick; }; @@ -107,12 +107,7 @@ export class DaytonaReconciler { this.timer = undefined; } - /** One account for the whole sweep: it refreshes sandboxes from what it listed. */ - sweep(): Promise { - return this.options.daytonaService.account.withAccountLease(() => this.sweepOnce()); - } - - private async sweepOnce(): Promise { + async sweep(): Promise { const service = this.options.daytonaService; // No credentials: no Daytona workspaces can be reached, and nothing to reconcile. if (!(await service.account.checkAvailability()).available) return; diff --git a/src/node/services/daytona/daytonaSandboxes.ts b/src/node/services/daytona/daytonaSandboxes.ts index e92142a5ea0..27c75dc1b04 100644 --- a/src/node/services/daytona/daytonaSandboxes.ts +++ b/src/node/services/daytona/daytonaSandboxes.ts @@ -94,7 +94,7 @@ function nextStartStep( } export interface DaytonaSandboxesOptions { - account: Pick; + account: Pick; ssh: Pick; readiness: DaytonaReadiness; /** Xum data root; holds the installation identity used for the `xum.instance` label. */ @@ -290,11 +290,9 @@ export class DaytonaSandboxes { * `onStarting`. Throws a DaytonaApiError (`not_found` when the sandbox is gone). */ ensureSandboxReady(sandboxId: string, options: DaytonaEnsureReadyOptions = {}): Promise { - return this.readiness.join(sandboxId, options, (onStarting) => - this.account.withAccountLease(async () => { - await this.startAndWait(sandboxId, this.now() + DAYTONA_START_TIMEOUT_MS, { onStarting }); - await this.ssh.ensureSshAccess(sandboxId); - }) - ); + return this.readiness.join(sandboxId, options, async (onStarting) => { + await this.startAndWait(sandboxId, this.now() + DAYTONA_START_TIMEOUT_MS, { onStarting }); + await this.ssh.ensureSshAccess(sandboxId); + }); } } diff --git a/src/node/services/daytona/daytonaSsh.test.ts b/src/node/services/daytona/daytonaSsh.test.ts index d529d97abf8..9dab5e11c49 100644 --- a/src/node/services/daytona/daytonaSsh.test.ts +++ b/src/node/services/daytona/daytonaSsh.test.ts @@ -7,7 +7,6 @@ import { XUM_DAYTONA_SSH_BLOCK_START } from "@/common/constants/daytona"; import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; import { log } from "@/node/services/log"; import { ScriptedSandboxControlPlane } from "./daytona.testHarness"; -import { DAYTONA_BUSY_MESSAGE } from "./daytonaAccount"; import { DaytonaService } from "./daytonaService"; describe("DaytonaSsh", () => { @@ -94,64 +93,6 @@ describe("DaytonaSsh", () => { } }); - /** A service whose first token mint waits until `releaseMint`. */ - function serviceWithHeldMint() { - let mintStarted!: () => void; - const started = new Promise((resolve) => (mintStarted = resolve)); - let releaseMint!: () => void; - const released = new Promise((resolve) => (releaseMint = resolve)); - let held = false; - const fetchHoldingFirstMint = (async (input: string | URL | Request, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - if (!held && init?.method === "POST" && url.includes("/ssh-access")) { - held = true; - mintStarted(); - await released; - } - return plane.fetch(input, init); - }) as typeof fetch; - const written: string[] = []; - const store = (input: { token: string }) => { - written.push(input.token); - return Promise.resolve(); - }; - const heldService = new DaytonaService({ - credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), - rootDir, - fetch: fetchHoldingFirstMint, - now: () => clock, - sshConfigWriter: { - writeSandbox: store, - ensureManagedBlock: () => Promise.resolve(), - restoreGatewayPins: () => Promise.resolve(false), - updateToken: store, - repointGateway: () => Promise.resolve(), - removeSandbox: () => Promise.resolve(), - readTokens: () => Promise.resolve([]), - }, - }); - return { service: heldService, started, releaseMint, written }; - } - - it("refuses a settings change while a token is minted, and takes it afterwards", async () => { - const held = serviceWithHeldMint(); - - const access = held.service.ssh.ensureSshAccess("sb-1"); - await held.started; - const refusal = await held.service.account.setConfig({ apiKey: "k" }).then( - () => undefined, - (error: unknown) => error - ); - expect(String(refusal)).toContain(DAYTONA_BUSY_MESSAGE); - held.releaseMint(); - await access; - - expect(held.written).toEqual(["tok1"]); - expect((await held.service.account.setConfig({ apiKey: "k" })).apiKeySource).toBe("settings"); - // The change revokes the old settings' token. - expect(plane.revoked).toEqual(["tok1"]); - }); - /** A service whose ssh setup writes are recorded as `token@host keys`. */ function serviceRecordingWrites(fetchImpl: typeof fetch = plane.fetch) { const writes: string[] = []; @@ -189,38 +130,6 @@ describe("DaytonaSsh", () => { return { service: recording, writes }; } - it("refuses a settings change while it looks up the gateway for a token", async () => { - let gatewayFetched!: () => void; - const fetched = new Promise((resolve) => (gatewayFetched = resolve)); - let releaseGateway!: () => void; - const released = new Promise((resolve) => (releaseGateway = resolve)); - let held = false; - // The first /config answer is delivered late, while the user saves new settings. - const fetchHoldingFirstConfig = (async (input: string | URL | Request, init?: RequestInit) => { - const response = await plane.fetch(input, init); - const url = input instanceof Request ? input.url : input.toString(); - if (!held && url.endsWith("/config")) { - held = true; - gatewayFetched(); - await released; - } - return response; - }) as typeof fetch; - const { service: racing, writes } = serviceRecordingWrites(fetchHoldingFirstConfig); - - const access = racing.ssh.ensureSshAccess("sb-1"); - await fetched; - const refusal = await racing.account.setConfig({ apiKey: "k" }).then( - () => undefined, - (error: unknown) => error - ); - expect(String(refusal)).toContain(DAYTONA_BUSY_MESSAGE); - releaseGateway(); - await access; - - expect(writes).toEqual(["tok1@ssh.test ssh-ed25519 AAAA"]); - }); - it("rewrites a sandbox's ssh setup once Daytona moves its gateway keys", async () => { const { service: watching, writes } = serviceRecordingWrites(); const configFetches = () => plane.calls.filter((call) => call === "GET /config").length; diff --git a/src/node/services/daytona/daytonaSsh.ts b/src/node/services/daytona/daytonaSsh.ts index 971d25b39fa..39742ddc341 100644 --- a/src/node/services/daytona/daytonaSsh.ts +++ b/src/node/services/daytona/daytonaSsh.ts @@ -29,7 +29,7 @@ export type DaytonaSshWriter = Pick< >; export interface DaytonaSshOptions { - account: Pick; + account: Pick; readiness: DaytonaReadiness; sshConfigWriter: DaytonaSshWriter; now: () => number; @@ -272,24 +272,22 @@ export class DaytonaSsh { * The `~/.ssh/config` block is checked every time: another tool may have removed it while a * cached token stays fresh, and an external editor needs it to resolve the alias. */ - ensureSshAccess(sandboxId: string): Promise { - return this.account.withAccountLease(async () => { - // Fails before a mint when the control plane cannot say where the gateway is. - await this.getGatewayConfig(); - // A cached token was written by the mint that produced it, so only a mint writes files. - await this.getSshToken(sandboxId, { - onNewToken: async ({ token }) => { - const gateway = toSshGateway(await this.getGatewayConfig()); - if (this.sshConfigured.has(sandboxId)) { - await this.sshConfigWriter.updateToken({ sandboxId, token, gateway }); - return; - } - await this.sshConfigWriter.writeSandbox({ sandboxId, token, gateway }); - }, - }); - await this.sshConfigWriter.ensureManagedBlock(); - this.sshConfigured.add(sandboxId); + async ensureSshAccess(sandboxId: string): Promise { + // Fails before a mint when the control plane cannot say where the gateway is. + await this.getGatewayConfig(); + // A cached token was written by the mint that produced it, so only a mint writes files. + await this.getSshToken(sandboxId, { + onNewToken: async ({ token }) => { + const gateway = toSshGateway(await this.getGatewayConfig()); + if (this.sshConfigured.has(sandboxId)) { + await this.sshConfigWriter.updateToken({ sandboxId, token, gateway }); + return; + } + await this.sshConfigWriter.writeSandbox({ sandboxId, token, gateway }); + }, }); + await this.sshConfigWriter.ensureManagedBlock(); + this.sshConfigured.add(sandboxId); } /** @@ -358,11 +356,9 @@ export class DaytonaSsh { const inFlight = this.tokenRequests.get(sandboxId); if (inFlight) return inFlight; - const request = this.account - .withAccountLease(() => this.mintSshToken(sandboxId, cached, options)) - .finally(() => { - this.tokenRequests.delete(sandboxId); - }); + const request = this.mintSshToken(sandboxId, cached, options).finally(() => { + this.tokenRequests.delete(sandboxId); + }); this.tokenRequests.set(sandboxId, request); return request; } From a1d7073fe1518c87cc4bce50ff96c636d51f0e7d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:16:38 +0000 Subject: [PATCH 220/231] refactor(daytona): one ssh repair path, and fetch the gateway config once Any ssh transport failure (not an abort) now gets one repair, shared by concurrent failures through readiness.join: a missing sandbox is reported; a stopped one is started by a runtime that may start it; a started one gets a refetched /config, every block and the pins repointed at it, and a fresh token. Then the command is retried once. Gone: the token and gateway generation counters, the host-key error gating, the 5-minute /config refetch, the shared /config refresh and the separate pin restore. A gateway move is now noticed on the first failing ssh command. --- docs/runtime/daytona.mdx | 2 +- src/node/runtime/DaytonaSSHRuntime.test.ts | 153 ++------------ src/node/runtime/DaytonaSSHRuntime.ts | 187 +++++------------- src/node/runtime/daytonaSshConfig.test.ts | 28 +-- src/node/runtime/daytonaSshConfig.ts | 31 +-- .../builtInSkillContent.generated.ts | 2 +- .../services/daytona/daytona.testHarness.ts | 25 --- src/node/services/daytona/daytonaSsh.test.ts | 100 ---------- src/node/services/daytona/daytonaSsh.ts | 183 +++-------------- 9 files changed, 97 insertions(+), 614 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index 37f8f70269b..a07acdafdd2 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -69,7 +69,7 @@ Xum does not stop or delete sandboxes that no workspace uses (for example one wh ## SSH setup -Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, in case that read failed at startup. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered. +Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, in case that read failed at startup. When an SSH command to a running sandbox fails (for example the gateway refuses a token revoked outside Xum, or Daytona moved its gateway or changed its host keys), Xum reads the gateway settings from Daytona again, rewrites the SSH settings, gets a new token, and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered. Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts. diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index d6126b935d9..77383d30f6c 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -765,13 +765,12 @@ describe("DaytonaSSHRuntime ssh token redaction", () => { expect(result.stderr).not.toContain(token); expect(result.stderr).toContain(`${DAYTONA_TOKEN_PLACEHOLDER}@127.0.0.1: Permission denied`); }); - it("keeps the token out of an error that gets no retry", async () => { + it("keeps the token out of the error of a failed retry", async () => { rig.plane.addSandbox({ id: "sb-tok" }); await rig.service.ssh.ensureSshAccess("sb-tok"); const token = rig.writer.tokens.get("sb-tok"); if (!token) throw new Error("expected a minted token"); let spawns = 0; - // A UI runtime never restarts the sandbox, so a dropped connection is not retried. const runtime = makeRuntime({ sandboxId: "sb-tok" }, rig, { transport: { ...localShellTransport(rig.rootDir), @@ -788,7 +787,7 @@ describe("DaytonaSSHRuntime ssh token redaction", () => { () => new Error("exec unexpectedly succeeded"), (rejection: unknown) => rejection ); - expect(spawns).toBe(1); + expect(spawns).toBe(2); expect(String(error)).not.toContain(token); expect(String(error)).toContain(DAYTONA_TOKEN_PLACEHOLDER); }); @@ -885,7 +884,7 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { expect(rig.plane.countCalls("POST /sandbox/sb-nap3/start")).toBe(1); }); - it("refetches a gateway that moved or rotated its keys within the config cache, then retries once", async () => { + it("refetches a gateway that moved, sharing one repair between concurrent failures", async () => { rig.plane.addSandbox({ id: "sb-moved" }); const local = localShellTransport(rig.rootDir); let attempts = 0; @@ -901,76 +900,26 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { : Promise.reject(new RuntimeError("Host key verification failed.", "network")); }, }; - const turn = () => makeRuntime({ sandboxId: "sb-moved" }, rig, { transport }); - expect(await turn().ensureReady()).toEqual({ ready: true }); - const configFetches = () => rig.plane.countCalls("GET /config"); - - const moves = [ - { ...rig.plane.gateway, host: "ssh2.daytona.test", port: 2222 }, - { ...rig.plane.gateway, hostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIRotated"] }, - ]; - for (const gateway of moves) { - // Within the five-minute cache: nothing refetches /config on its own. - rig.plane.gateway = gateway; - const [fetches, tries, resets] = [configFetches(), attempts, healthResets]; - const runtime = turn(); - expect(await runtime.ensureReady()).toEqual({ ready: true }); - // Two commands fail together; they share one refetch. - const streams = await Promise.all( - [1, 2].map(() => runtime.exec("echo ok", { cwd: "/tmp", timeout: 10 })) - ); - for (const stream of streams) { - expect(await new Response(stream.stdout).text()).toBe("ok\n"); - } - expect(attempts - tries).toBe(4); - expect(configFetches()).toBe(fetches + 1); - expect(rig.writer.repoints.at(-1)).toEqual(gateway); - expect(healthResets).toBeGreaterThan(resets); - } - - // The gateway did not change: the failure is something else, so no retry. - const before = { - fetches: configFetches(), - tries: attempts, - repoints: rig.writer.repoints.length, + const runtime = makeRuntime({ sandboxId: "sb-moved" }, rig, { transport }); + expect(await runtime.ensureReady()).toEqual({ ready: true }); + const configFetches = rig.plane.countCalls("GET /config"); + const gateway = { + host: "ssh2.daytona.test", + port: 2222, + hostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIRotated"], }; - const unchanged = turn(); - expect(await unchanged.ensureReady()).toEqual({ ready: true }); - rig.writer.gateway = { ...rig.plane.gateway, host: "unreachable.test" }; - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(unchanged.exec("echo ok", { cwd: "/tmp", timeout: 10 })).rejects.toThrow( - "Host key verification failed" - ); - expect(attempts - before.tries).toBe(1); - expect(configFetches()).toBe(before.fetches + 1); - expect(rig.writer.repoints).toHaveLength(before.repoints); - }); + rig.plane.gateway = gateway; - it("puts back the gateway's pins after a host key failure when the gateway did not move", async () => { - rig.plane.addSandbox({ id: "sb-pins" }); - const local = localShellTransport(rig.rootDir); - let attempts = 0; - // OpenSSH (StrictHostKeyChecking yes) refuses the gateway without its pinned key. - const transport: SSHTransport = { - ...local, - spawnRemoteProcess: (command, options) => { - attempts++; - return JSON.stringify(rig.writer.pins) === JSON.stringify(rig.plane.gateway) - ? local.spawnRemoteProcess(command, options) - : Promise.reject(new RuntimeError("Host key verification failed.", "network")); - }, - }; - const turn = makeRuntime({ sandboxId: "sb-pins" }, rig, { transport }); - expect(await turn.ensureReady()).toEqual({ ready: true }); - rig.writer.deletePins(); - - const stream = await turn.exec("echo ok", { cwd: "/tmp", timeout: 10 }); - - expect(await new Response(stream.stdout).text()).toBe("ok\n"); - expect(attempts).toBe(2); - // The keys /config advertises are pinned, never the one a server offers. - expect(rig.writer.pins).toEqual(rig.plane.gateway); - expect(rig.writer.repoints).toHaveLength(0); + const streams = await Promise.all( + [1, 2].map(() => runtime.exec("echo ok", { cwd: "/tmp", timeout: 10 })) + ); + for (const stream of streams) { + expect(await new Response(stream.stdout).text()).toBe("ok\n"); + } + expect(attempts).toBe(4); + expect(rig.plane.countCalls("GET /config")).toBe(configFetches + 1); + expect(rig.writer.repoints).toEqual([gateway]); + expect(healthResets).toBe(2); }); /** The gateway answers a stopped or deleted sandbox with the same refusal as a bad token. */ @@ -990,23 +939,6 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { }; } - it("restarts a stopped sandbox that refuses ssh, keeping its token", async () => { - const sandbox = rig.plane.addSandbox({ id: "sb-nap2" }); - const transport = refusingTransportWhile("sb-nap2", () => sandbox.state !== "started"); - const turn = makeRuntime({ sandboxId: "sb-nap2" }, rig, { transport }); - expect(await turn.ensureReady()).toEqual({ ready: true }); - const token = rig.writer.tokens.get("sb-nap2"); - // Stopped within the ready cache: the next command goes straight to ssh. - sandbox.state = "stopped"; - - const stream = await turn.exec("echo ok", { cwd: "/tmp", timeout: 10 }); - expect(await new Response(stream.stdout).text()).toBe("ok\n"); - expect(rig.plane.countCalls("POST /sandbox/sb-nap2/start")).toBe(1); - expect(rig.plane.countCalls("POST /sandbox/sb-nap2/ssh-access")).toBe(1); - expect(rig.writer.tokens.get("sb-nap2")).toBe(token); - expect(rig.plane.revokedTokens).toEqual([]); - }); - it("reports a sandbox that no longer exists instead of the refused token", async () => { rig.plane.addSandbox({ id: "sb-gone" }); await rig.service.ssh.ensureSshAccess("sb-gone"); @@ -1022,49 +954,6 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { expect(rig.plane.countCalls("POST /sandbox/sb-gone/ssh-access")).toBe(1); }); - it("never replaces a token that a concurrent command already replaced", async () => { - rig.plane.addSandbox({ id: "sb-race" }); - await rig.service.ssh.ensureSshAccess("sb-race"); - const first = rig.writer.tokens.get("sb-race"); - if (!first) throw new Error("expected a minted token"); - const local = localShellTransport(rig.rootDir); - const refuse = (user: string) => - Promise.reject( - new RuntimeError(`${user}@127.0.0.1: Permission denied (publickey).`, "network") - ); - // Command A: the gateway refuses the first token until the include file holds another. - const runtimeA = makeRuntime({ sandboxId: "sb-race" }, rig, { - transport: { - ...local, - spawnRemoteProcess: (command, options) => - rig.writer.tokens.get("sb-race") === first - ? refuse(first) - : local.spawnRemoteProcess(command, options), - }, - }); - // Command B ran on the first token too, but its refusal arrives once A has replaced it, with - // the token already redacted (as the OpenSSH pool reports a probe failure). - let attemptsB = 0; - const runtimeB = makeRuntime({ sandboxId: "sb-race" }, rig, { - transport: { - ...local, - spawnRemoteProcess: async (command, options) => { - if (attemptsB++ > 0) return local.spawnRemoteProcess(command, options); - const streamA = await runtimeA.exec("true", { cwd: "/tmp", timeout: 10 }); - expect(await streamA.exitCode).toBe(0); - return refuse(DAYTONA_TOKEN_PLACEHOLDER); - }, - }, - }); - - const stream = await runtimeB.exec("echo ok", { cwd: "/tmp", timeout: 10 }); - - expect(await new Response(stream.stdout).text()).toBe("ok\n"); - // Only A's refused token was revoked; the one A minted stays live. - expect(rig.plane.revokedTokens).toEqual([first]); - expect(rig.plane.countCalls("POST /sandbox/sb-race/ssh-access")).toBe(2); - }); - it("replaces a token the gateway refused and retries the command once", async () => { rig.plane.addSandbox({ id: "sb-rev" }); await rig.service.ssh.ensureSshAccess("sb-rev"); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 9731533ed1c..90790c3b532 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -24,7 +24,7 @@ import type { WorkspaceInitParams, WorkspaceInitResult, } from "./Runtime"; -import { isPermanentSSHFailure, isRuntimeTransportError, RuntimeError } from "./Runtime"; +import { isRuntimeTransportError, RuntimeError } from "./Runtime"; import type { SpawnResult } from "./RemoteRuntime"; import { SSHRuntime, type SSHRuntimeConfig } from "./SSHRuntime"; import type { PtyHandle, PtySessionParams, SSHTransport } from "./transports"; @@ -65,15 +65,12 @@ export interface DaytonaRuntimeService { account: Pick; readiness: Pick< DaytonaReadiness, - "isSandboxReady" | "markSandboxReady" | "invalidateSandboxReady" + "isSandboxReady" | "markSandboxReady" | "invalidateSandboxReady" | "join" >; ssh: Pick< DaytonaSsh, | "ensureSshAccess" - | "replaceRejectedSshToken" - | "sshTokenGeneration" - | "gatewayGeneration" - | "refreshGatewayAfterSshFailure" + | "repairSshAccess" | "loadPersistedTokens" | "getGatewayConfig" | "releaseSshAccess" @@ -153,41 +150,13 @@ function isBadRequest(error: unknown): boolean { return error instanceof DaytonaApiError && error.kind === "bad_request"; } -/** - * Captured before an ssh command: a recovery acts only on what changed since. A refusal - * replaces only the token this command used; a gateway another failure already followed is - * not refetched again. - */ -interface SshAttempt { - tokenGeneration: number; - gatewayGeneration: number; -} - type DeleteResult = | { success: true; deletedPath: string } | { success: false; error: string; leftoverPaths?: string[] }; type StatusEmitter = (phase: RuntimeStatusEvent["phase"], detail?: string) => void; -const HOST_KEY_FAILURE = /Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/; - -function errorChainText(error: unknown): string { - const parts: string[] = []; - for (let current = error, depth = 0; current instanceof Error && depth < 4; depth++) { - parts.push(current.message); - current = current.cause; - } - return parts.join("\n"); -} - -/** The gateway refused the token (the ssh user name), e.g. one revoked outside Xum. */ -function isTokenRejected(error: unknown): boolean { - return ( - isRuntimeTransportError(error) && - isPermanentSSHFailure(error) && - !HOST_KEY_FAILURE.test(errorChainText(error)) - ); -} +class SandboxMissingError extends Error {} function sandboxMissingMessage(sandboxId: string): string { return ( @@ -354,7 +323,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { emitStatus: StatusEmitter ): Promise { // Set before the probe: ensureReady is an explicit action, so a sandbox that stopped - // within the cache is started again (recoverForRetry) instead of failing until it expires. + // within the cache is started again (repairForRetry) instead of failing until it expires. this.restartsStoppedSandbox = true; const repoFailure = await this.checkRepo(options, emitStatus); if (repoFailure) return repoFailure; @@ -363,10 +332,8 @@ export class DaytonaSSHRuntime extends SSHRuntime { /** * Every ssh command to the sandbox goes through here: its stderr and errors are redacted - * (the token is the ssh user name). A refused token is replaced, and a connection failure on - * a turn's runtime whose sandbox stopped within the ready cache starts the sandbox again (or, - * when it still runs, follows a gateway Daytona moved); either way the command is retried - * once. + * (the token is the ssh user name). A transport failure is repaired (repairForRetry) and the + * command retried once. */ protected override async spawnRemoteProcess( fullCommand: string, @@ -374,11 +341,13 @@ export class DaytonaSSHRuntime extends SSHRuntime { ): Promise { let result: SpawnResult; const sandboxId = this.daytonaConfig.sandboxId; - const attempt = await this.prepareSshAttempt(sandboxId); + // ssh may send a token an earlier process left in the include file: the redactor must + // know it before ssh can quote it. Not every command runs after ensureReady. + if (sandboxId) await this.daytonaService.ssh.loadPersistedTokens(); try { result = await super.spawnRemoteProcess(fullCommand, options); } catch (error) { - if (!(await this.recoverForRetry(error, attempt, options.abortSignal))) { + if (!(await this.repairForRetry(error, options.abortSignal))) { throw redactDaytonaError(error); } try { @@ -401,127 +370,65 @@ export class DaytonaSSHRuntime extends SSHRuntime { }; } - /** What a recovery from this command's failure must compare against (see SshAttempt). */ - private async prepareSshAttempt(sandboxId: string | undefined): Promise { - if (!sandboxId) return { tokenGeneration: 0, gatewayGeneration: 0 }; - // ssh may send a token an earlier process left in the include file: the redactor must - // know it before ssh can quote it. Not every command runs after ensureReady. - await this.daytonaService.ssh.loadPersistedTokens(); - return { - tokenGeneration: this.daytonaService.ssh.sshTokenGeneration(sandboxId), - gatewayGeneration: this.daytonaService.ssh.gatewayGeneration(), - }; - } - /** - * True when the failure was fixed (a new token, or a restarted sandbox) and a retry may pass. - * The sandbox state decides, not the error text: a stopped or deleted sandbox also answers - * "Permission denied", so only a started sandbox gets a new token. Throws a clear error for - * a sandbox deleted outside Xum. + * True when the failure was repaired and a retry may pass. Concurrent failures on one + * sandbox share a single repair. Throws a clear error for a sandbox deleted outside Xum. */ - private async recoverForRetry( + private async repairForRetry( error: unknown, - attempt: SshAttempt, abortSignal: AbortSignal | undefined ): Promise { const sandboxId = this.daytonaConfig.sandboxId; if (!sandboxId || !isRuntimeTransportError(error) || abortSignal?.aborted) return false; - const tokenRejected = isTokenRejected(error); - // A UI or background runtime never starts the sandbox, so only a refused token is worth a look. - if (!tokenRejected && !this.restartsStoppedSandbox) return false; this.daytonaService.readiness.invalidateSandboxReady(sandboxId); - const state = await this.readStateAfterFailure(sandboxId); - return this.recoverInState(sandboxId, state, { error, tokenRejected, attempt }, abortSignal); - } - - private recoverInState( - sandboxId: string, - state: DaytonaSandboxState | undefined, - failure: { error: unknown; tokenRejected: boolean; attempt: SshAttempt }, - abortSignal: AbortSignal | undefined - ): Promise { - const { error, tokenRejected, attempt } = failure; - if (state === "missing") { - throw new RuntimeError( - sandboxMissingMessage(sandboxId), - "network", - redactDaytonaError(error) - ); - } - if (state === "started") { - // Only a runtime that may restart the sandbox gets here without a refused token. - return tokenRejected - ? this.replaceRejectedToken(sandboxId, attempt.tokenGeneration) - : this.followMovedGateway(sandboxId, attempt.gatewayGeneration); - } - if (state === undefined || !this.restartsStoppedSandbox) return Promise.resolve(false); - return this.restartSandbox(sandboxId, abortSignal); - } - - private async replaceRejectedToken(sandboxId: string, tokenGeneration: number): Promise { - log.info("The Daytona gateway refused the ssh token; replacing it and retrying once", { - sandboxId, - }); try { - await this.daytonaService.ssh.replaceRejectedSshToken(sandboxId, tokenGeneration); - } catch (replaceError) { - log.warn("Could not replace the refused Daytona ssh token", { + await this.daytonaService.readiness.join(sandboxId, { signal: abortSignal }, () => + this.repairSandbox(sandboxId) + ); + } catch (repairError) { + if (repairError instanceof SandboxMissingError) { + throw new RuntimeError( + sandboxMissingMessage(sandboxId), + "network", + redactDaytonaError(error) + ); + } + log.warn("Could not repair ssh access to the Daytona sandbox", { sandboxId, - error: getErrorMessage(redactDaytonaError(replaceError)), + error: getErrorMessage(redactDaytonaError(repairError)), }); return false; } + // A failure against the old token or gateway may have marked the alias as failing for good. this.sshTransport.resetConnectionHealth?.(); return true; } - private async followMovedGateway(sandboxId: string, gatewayGeneration: number): Promise { - let moved: boolean; - try { - moved = await this.daytonaService.ssh.refreshGatewayAfterSshFailure( - sandboxId, - gatewayGeneration - ); - } catch (refreshError) { - log.warn("Could not refetch the Daytona gateway after an ssh failure", { + /** + * The sandbox state decides, not the error text: a stopped or deleted sandbox also answers + * "Permission denied". A started one gets a fresh token and gateway; a stopped one is started + * only by a runtime that may start it. Throws when nothing was repaired. + */ + private async repairSandbox(sandboxId: string): Promise { + const { state } = await this.daytonaService.sandboxes.getSandboxState(sandboxId); + if (state === "missing") throw new SandboxMissingError(); + if (state === "started") { + log.info("An ssh command to the Daytona sandbox failed; replacing its token and retrying", { sandboxId, - error: getErrorMessage(redactDaytonaError(refreshError)), }); - return false; + await this.daytonaService.ssh.repairSshAccess(sandboxId); + return; } - if (!moved) return false; - log.info("The Daytona gateway moved; retrying the command once", { sandboxId }); - // A host key failure against the old gateway may have marked the alias as failing for good. - this.sshTransport.resetConnectionHealth?.(); - return true; - } - - private async restartSandbox( - sandboxId: string, - abortSignal: AbortSignal | undefined - ): Promise { + if (!this.restartsStoppedSandbox) throw new Error(`the sandbox is ${state}`); log.info("Daytona sandbox is not running; starting it and retrying the command once", { sandboxId, }); - const failure = await this.bringSandboxUp(sandboxId, () => undefined, abortSignal); - if (failure && !failure.ready) { - log.warn("Could not restart the Daytona sandbox", { sandboxId, error: failure.error }); - return false; - } - return true; - } - - /** Undefined when the state cannot be read (then nothing is retried). */ - private async readStateAfterFailure(sandboxId: string): Promise { - try { - return (await this.daytonaService.sandboxes.getSandboxState(sandboxId)).state; - } catch (stateError) { - log.warn("Could not read Daytona sandbox state after an ssh failure", { - sandboxId, - error: getErrorMessage(stateError), - }); - return undefined; - } + // Not ensureSandboxReady: it would join this repair and wait for itself. + await this.daytonaService.sandboxes.startAndWait( + sandboxId, + Date.now() + DAYTONA_START_TIMEOUT_MS + ); + await this.daytonaService.ssh.ensureSshAccess(sandboxId); } /** diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index f772bece952..3b62da36d7b 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -8,10 +8,7 @@ import { XUM_DAYTONA_SSH_BLOCK_START, toXumDaytonaHost, } from "@/common/constants/daytona"; -import { - DaytonaSshConfigWriter, - renderDaytonaKnownHosts, -} from "./daytonaSshConfig"; +import { DaytonaSshConfigWriter, renderDaytonaKnownHosts } from "./daytonaSshConfig"; import { resolveSSHConfig } from "./sshConfigParser"; import { appendOpenSSHHostKeyPolicyArgs } from "./sshConnectionPool"; @@ -120,29 +117,6 @@ describe("DaytonaSshConfigWriter", () => { expect(await fs.readFile(sshConfigPath, "utf8")).toBe(`${written}Host work\n User me\n`); }); - it("puts back the gateway's pins after known_hosts was deleted or changed, else writes nothing", async () => { - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - const pinned = await fs.readFile(writer.paths.knownHostsPath, "utf8"); - const { ino } = await fs.stat(writer.paths.knownHostsPath); - const include = await fs.readFile(writer.paths.includePath, "utf8"); - - expect(await writer.restoreGatewayPins(GATEWAY)).toBe(false); - expect((await fs.stat(writer.paths.knownHostsPath)).ino).toBe(ino); - - for (const damaged of [ - undefined, - "", - `${pinned}ssh.daytona.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIInjected\n`, - ]) { - if (damaged === undefined) await fs.rm(writer.paths.knownHostsPath); - else await fs.writeFile(writer.paths.knownHostsPath, damaged, "utf8"); - - expect(await writer.restoreGatewayPins(GATEWAY)).toBe(true); - expect(await fs.readFile(writer.paths.knownHostsPath, "utf8")).toBe(pinned); - } - expect(await fs.readFile(writer.paths.includePath, "utf8")).toBe(include); - }); - it("repoints every sandbox at a new gateway in the write that replaces its host keys", async () => { await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); await writer.writeSandbox({ sandboxId: "sb-2", token: "tokenB", gateway: GATEWAY }); diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 92aa4719666..c4a72f3f5cf 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -293,7 +293,10 @@ export class DaytonaSshConfigWriter { }); } - /** Daytona moved its gateway: repoint every sandbox's block (keeping its token) and pins. */ + /** + * After an ssh failure (Daytona may have moved its gateway, or known_hosts was edited): write + * the pins again and repoint every sandbox's block, keeping its token. + */ async repointGateway(gateway: DaytonaSshGateway): Promise { await this.installGateway(gateway, () => undefined); } @@ -319,23 +322,6 @@ export class DaytonaSshConfigWriter { }); } - /** - * known_hosts was deleted or edited while Xum runs: an unchanged gateway would otherwise fail - * every ssh command until a restart. Rewrites the pins (from `/config`, never a key a server - * offers) when they differ. True when it wrote. - */ - async restoreGatewayPins(gateway: DaytonaSshGateway): Promise { - const knownHosts = renderDaytonaKnownHosts(gateway); - this.assertPathsReadLiterallyBySsh(); - let wrote = false; - await this.withIncludeFileLock(async () => { - if ((await this.readKnownHosts()) === knownHosts) return; - await this.writeKnownHosts(knownHosts); - wrote = true; - }); - return wrote; - } - private async writeKnownHosts(content: string): Promise { await writeFileAtomic(this.paths.knownHostsPath, content, { encoding: "utf-8", mode: 0o600 }); } @@ -358,15 +344,6 @@ export class DaytonaSshConfigWriter { }); } - private async readKnownHosts(): Promise { - try { - return await fs.readFile(this.paths.knownHostsPath, "utf-8"); - } catch (error) { - if (isErrnoWithCode(error, "ENOENT")) return ""; - throw error; - } - } - /** The sandbox ids and tokens the include file holds (written by this or an earlier process). */ async readTokens(): Promise { return tokensIn(await this.readBlocks()); diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index c74ab9d6aaa..02fae16f3c3 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10106,7 +10106,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "## SSH setup", "", - "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, in case that read failed at startup. When the gateway refuses a token (for example one revoked outside Xum), Xum gets a new one and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered.", + "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, in case that read failed at startup. When an SSH command to a running sandbox fails (for example the gateway refuses a token revoked outside Xum, or Daytona moved its gateway or changed its host keys), Xum reads the gateway settings from Daytona again, rewrites the SSH settings, gets a new token, and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered.", "", "Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts.", "", diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 3b083ce3079..59572423630 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -291,7 +291,6 @@ export class FakeDaytonaSshConfigWriter implements Pick< | "repointGateway" | "removeSandbox" | "readTokens" - | "restoreGatewayPins" > { /** Current token per sandbox alias, as the include file would hold it. */ readonly tokens = new Map(); @@ -303,14 +302,6 @@ export class FakeDaytonaSshConfigWriter implements Pick< /** The gateway the include file and known_hosts point at, as last written or repointed. */ gateway?: DaytonaSshGateway; readonly repoints: DaytonaSshGateway[] = []; - /** The gateway known_hosts pins. */ - pins?: DaytonaSshGateway; - - /** Acts as if daytona_known_hosts were deleted. */ - deletePins(): void { - this.pins = undefined; - } - writeSandbox(input: { sandboxId: string; token: string; @@ -319,7 +310,6 @@ export class FakeDaytonaSshConfigWriter implements Pick< if (this.writeError) return Promise.reject(this.writeError); this.fullWrites.push(input.sandboxId); this.gateway = input.gateway; - this.pins = input.gateway; this.tokens.set(input.sandboxId, input.token); return Promise.resolve(); } @@ -345,16 +335,9 @@ export class FakeDaytonaSshConfigWriter implements Pick< repointGateway(gateway: DaytonaSshGateway): Promise { this.repoints.push(gateway); this.gateway = gateway; - this.pins = gateway; return Promise.resolve(); } - restoreGatewayPins(gateway: DaytonaSshGateway): Promise { - if (JSON.stringify(this.pins) === JSON.stringify(gateway)) return Promise.resolve(false); - this.pins = gateway; - return Promise.resolve(true); - } - removeSandbox(sandboxId: string): Promise { this.removed.push(sandboxId); this.tokens.delete(sandboxId); @@ -443,12 +426,4 @@ export class ScriptedSandboxControlPlane extends FakeDaytonaControlPlane { get revoked(): string[] { return this.revokedTokens; } - - get gatewayHostKeys(): string[] { - return this.gateway.hostKeys; - } - - set gatewayHostKeys(hostKeys: string[]) { - this.gateway = { ...this.gateway, hostKeys }; - } } diff --git a/src/node/services/daytona/daytonaSsh.test.ts b/src/node/services/daytona/daytonaSsh.test.ts index 9dab5e11c49..27eb4028415 100644 --- a/src/node/services/daytona/daytonaSsh.test.ts +++ b/src/node/services/daytona/daytonaSsh.test.ts @@ -93,104 +93,6 @@ describe("DaytonaSsh", () => { } }); - /** A service whose ssh setup writes are recorded as `token@host keys`. */ - function serviceRecordingWrites(fetchImpl: typeof fetch = plane.fetch) { - const writes: string[] = []; - /** The include file: what ensureSshAccess reads back before it trusts its cache. */ - const includeFile = new Map(); - const record = (input: { - sandboxId: string; - token: string; - gateway: { host: string; hostKeys: string[] }; - }) => { - includeFile.set(input.sandboxId, input.token); - writes.push(`${input.token}@${input.gateway.host} ${input.gateway.hostKeys.join(",")}`); - return Promise.resolve(); - }; - const recording = new DaytonaService({ - credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), - rootDir, - fetch: fetchImpl, - now: () => clock, - sshConfigWriter: { - writeSandbox: record, - ensureManagedBlock: () => Promise.resolve(), - restoreGatewayPins: () => Promise.resolve(false), - updateToken: record, - removeSandbox: (sandboxId) => { - includeFile.delete(sandboxId); - return Promise.resolve(); - }, - repointGateway: () => Promise.resolve(), - - readTokens: () => - Promise.resolve([...includeFile].map(([sandboxId, token]) => ({ sandboxId, token }))), - }, - }); - return { service: recording, writes }; - } - - it("rewrites a sandbox's ssh setup once Daytona moves its gateway keys", async () => { - const { service: watching, writes } = serviceRecordingWrites(); - const configFetches = () => plane.calls.filter((call) => call === "GET /config").length; - - await watching.ssh.ensureSshAccess("sb-1"); - clock += 4 * 60_000; - await watching.ssh.ensureSshAccess("sb-1"); - expect(configFetches()).toBe(1); - - // Rotated (and listed in another order) while Xum runs. - plane.gatewayHostKeys = ["ssh-rsa CCCC", "ssh-ed25519 BBBB"]; - clock += 2 * 60_000; - await watching.ssh.ensureSshAccess("sb-1"); - expect(writes).toEqual([ - "tok1@ssh.test ssh-ed25519 AAAA", - "tok2@ssh.test ssh-rsa CCCC,ssh-ed25519 BBBB", - ]); - expect(plane.revoked).toEqual(["tok1"]); - - // A refetch that finds the same gateway, keys reordered, rewrites nothing. - plane.gatewayHostKeys = ["ssh-ed25519 BBBB", "ssh-rsa CCCC"]; - clock += 6 * 60_000; - await watching.ssh.ensureSshAccess("sb-1"); - expect(configFetches()).toBe(3); - expect(writes).toHaveLength(2); - }); - - it("shares one gateway refresh between concurrent lookups, so a stale reply cannot win", async () => { - const held: Array<{ run: () => Promise; resolve: (response: Response) => void }> = []; - let holdConfig = false; - const fetchImpl = ((input: string | URL | Request, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - if (!holdConfig || !url.endsWith("/config")) return plane.fetch(input, init); - return new Promise((resolve) => { - held.push({ run: () => plane.fetch(input, init), resolve }); - }); - }) as typeof fetch; - const { service: watching } = serviceRecordingWrites(fetchImpl); - await watching.ssh.ensureSshAccess("sb-1"); - - clock += 6 * 60_000; - holdConfig = true; - const lookups = Promise.all([watching.ssh.getGatewayConfig(), watching.ssh.getGatewayConfig()]); - while (held.length === 0) await new Promise((resolve) => setTimeout(resolve, 1)); - await new Promise((resolve) => setTimeout(resolve, 20)); - // During a rollout the last request answers first with the new gateway; earlier ones late. - for (let i = held.length - 1; i >= 0; i--) { - plane.gatewayHostKeys = i === held.length - 1 ? ["ssh-ed25519 NEW"] : ["ssh-ed25519 AAAA"]; - held[i].resolve(await held[i].run()); - } - const results = await lookups; - holdConfig = false; - - expect(held).toHaveLength(1); - expect(results.map((config) => config.sshGatewayHostKeys)).toEqual([ - ["ssh-ed25519 NEW"], - ["ssh-ed25519 NEW"], - ]); - expect((await watching.ssh.getGatewayConfig()).sshGatewayHostKeys).toEqual(["ssh-ed25519 NEW"]); - }); - it("removes the old account's aliases from the include file when the settings change", async () => { const includeFile = new Map(); const store = (input: { sandboxId: string; token: string }) => { @@ -205,7 +107,6 @@ describe("DaytonaSsh", () => { sshConfigWriter: { writeSandbox: store, ensureManagedBlock: () => Promise.resolve(), - restoreGatewayPins: () => Promise.resolve(false), updateToken: store, removeSandbox: (sandboxId) => { includeFile.delete(sandboxId); @@ -233,7 +134,6 @@ describe("DaytonaSsh", () => { const writer = { writeSandbox: store, ensureManagedBlock: () => Promise.resolve(), - restoreGatewayPins: () => Promise.resolve(false), updateToken: store, removeSandbox: () => Promise.resolve(), diff --git a/src/node/services/daytona/daytonaSsh.ts b/src/node/services/daytona/daytonaSsh.ts index 39742ddc341..c96fd1eb33e 100644 --- a/src/node/services/daytona/daytonaSsh.ts +++ b/src/node/services/daytona/daytonaSsh.ts @@ -10,13 +10,6 @@ import type { DaytonaReadiness } from "./daytonaReadiness"; export const DAYTONA_SSH_TOKEN_TTL_MINUTES = 60; export const DAYTONA_SSH_TOKEN_REFRESH_MARGIN_MS = 15 * 60_000; -/** - * How long Daytona's `/config` (gateway endpoint and host keys) is trusted. Daytona can move - * its gateway or rotate its keys while Xum runs; a refetch that sees a change rewrites every - * sandbox's ssh setup on its next use. - */ -export const DAYTONA_GATEWAY_CONFIG_TTL_MS = 5 * 60_000; - export type DaytonaSshWriter = Pick< DaytonaSshConfigWriter, | "writeSandbox" @@ -25,7 +18,6 @@ export type DaytonaSshWriter = Pick< | "repointGateway" | "removeSandbox" | "readTokens" - | "restoreGatewayPins" >; export interface DaytonaSshOptions { @@ -52,12 +44,6 @@ function toSshGateway(config: DaytonaGatewayConfig) { }; } -/** What the ssh setup depends on; key order from Daytona is not significant. */ -function gatewayIdentity(config: DaytonaGatewayConfig): string { - const { host, port, hostKeys } = toSshGateway(config); - return JSON.stringify([host, port, [...hostKeys].sort()]); -} - /** * Xum's SSH access to sandboxes: the per-sandbox tokens (minted, refreshed, revoked), the * include file and `~/.ssh/config` block they are written to, and the gateway `/config` they @@ -68,21 +54,9 @@ export class DaytonaSsh { private readonly account: DaytonaSshOptions["account"]; private readonly readiness: DaytonaReadiness; private readonly now: () => number; - /** See gatewayGeneration. */ - private gatewayMoves = 0; - private gatewayCache?: { - apiUrl: string; - fetchedAt: number; - config: DaytonaGatewayConfig; - }; - private gatewayRequest?: { - apiUrl: string; - promise: Promise; - }; + /** Daytona's `/config` of these settings, fetched once; a repair fetches it again. */ + private gatewayCache?: { apiUrl: string; config: DaytonaGatewayConfig }; private readonly tokens = new Map(); - /** Bumped (unique per process) whenever a sandbox's cached token becomes another one. */ - private readonly tokenGenerations = new Map(); - private lastTokenGeneration = 0; private readonly tokenRequests = new Map>(); private readonly sshConfigWriter: DaytonaSshWriter; /** Sandboxes whose include block and `~/.ssh/config` block this process has written. */ @@ -118,7 +92,7 @@ export class DaytonaSsh { // An earlier run's tokens: holding them lets a replacement, a settings change or an // archive revoke them. The file has no expiry, so they are due for refresh at once. if (!this.persistedTokensLoaded && !this.tokens.has(sandboxId)) { - this.cacheNewToken(sandboxId, { token, expiresAt: new Date(0).toISOString() }); + this.tokens.set(sandboxId, { token, expiresAt: new Date(0).toISOString() }); } } this.persistedTokensLoaded = true; @@ -154,116 +128,32 @@ export class DaytonaSsh { } } - /** - * `unlessMovedSince` refetches a cached `/config` that is still fresh, unless the gateway moved - * since that generation (checked after the credentials read, so a failure that arrives as a - * concurrent one's refetch ends reuses it). An in-flight fetch is shared. - */ - async getGatewayConfig( - options: { unlessMovedSince?: number } = {} - ): Promise { + async getGatewayConfig(): Promise { const { client, credentials } = await this.account.getClient(); - const cached = this.cachedGateway(credentials.apiUrl); - const force = options.unlessMovedSince === this.gatewayMoves; - if (cached && !force && this.now() - cached.fetchedAt < DAYTONA_GATEWAY_CONFIG_TTL_MS) { - return cached.config; - } - return this.sharedGatewayRefresh(client, credentials.apiUrl, cached); - } - - /** - * Concurrent lookups share one refresh: a reply that started earlier and arrived last would - * otherwise publish the old gateway over the new one. - */ - private sharedGatewayRefresh( - client: DaytonaClient, - apiUrl: string, - cached: ReturnType - ): Promise { - const inFlight = this.gatewayRequest; - if (inFlight?.apiUrl === apiUrl) return inFlight.promise; - const promise = this.refreshGateway(client, apiUrl, cached).finally(() => { - if (this.gatewayRequest?.promise === promise) this.gatewayRequest = undefined; - }); - this.gatewayRequest = { apiUrl, promise }; - return promise; - } - - private async refreshGateway( - client: DaytonaClient, - apiUrl: string, - cached: ReturnType - ): Promise { + const cached = this.gatewayCache; + if (cached?.apiUrl === credentials.apiUrl) return cached.config; const config = await client.getConfig(); - const moved = cached && gatewayIdentity(cached.config) !== gatewayIdentity(config); - this.gatewayCache = { apiUrl, fetchedAt: this.now(), config }; - if (moved) { - await this.retireSshSetup(config); - this.gatewayMoves++; - } + this.gatewayCache = { apiUrl: credentials.apiUrl, config }; return config; } - /** Bumped once a moved gateway's ssh setup was rewritten; capture it before an ssh command. */ - gatewayGeneration(): number { - return this.gatewayMoves; - } - /** - * An ssh command to a started sandbox failed with neither a refused token nor a stop: Daytona - * may have moved its gateway or rotated its host keys within the `/config` cache. True when - * the gateway changed since `attemptGeneration` (refetching `/config` unless a concurrent - * failure already found the move) and the sandbox's ssh setup now points at the new one, or - * when the gateway stayed but its pins in known_hosts had to be put back. + * An ssh command to a started sandbox failed: the gateway may have refused its token (revoked + * outside Xum, or one an earlier run left), Daytona may have moved its gateway or rotated its + * host keys, or known_hosts was edited. Refetch `/config`, repoint every sandbox's block and + * the pins at it (pins come from `/config`, never from a key a server offered), and replace + * this sandbox's token, revoking the old one. Pooled connections opened before keep their old + * gateway until they close. */ - async refreshGatewayAfterSshFailure( - sandboxId: string, - attemptGeneration: number - ): Promise { - if (this.gatewayMoves === attemptGeneration) { - const config = await this.getGatewayConfig({ unlessMovedSince: attemptGeneration }); - if (this.gatewayMoves === attemptGeneration) return this.restoreGatewayPins(config); - } + async repairSshAccess(sandboxId: string): Promise { + const { client, credentials } = await this.account.getClient(); + const config = await client.getConfig(); + this.gatewayCache = { apiUrl: credentials.apiUrl, config }; + await this.sshConfigWriter.repointGateway(toSshGateway(config)); + const cached = this.tokens.get(sandboxId); + // Due at once, so ensureSshAccess mints, writes, and revokes the old one. + if (cached) this.tokens.set(sandboxId, { ...cached, expiresAt: new Date(0).toISOString() }); await this.ensureSshAccess(sandboxId); - return true; - } - - /** - * The pins come from the `/config` just fetched, never from a key a server offered. Only here, - * after a fresh fetch: a readiness check reads a cached `/config`. - */ - private async restoreGatewayPins(config: DaytonaGatewayConfig): Promise { - const restored = await this.sshConfigWriter.restoreGatewayPins(toSshGateway(config)); - if (restored) log.info("Daytona gateway pins were missing or changed; wrote them again"); - return restored; - } - - /** The cached `/config` of these settings, fresh or not. */ - private cachedGateway(apiUrl: string) { - const cached = this.gatewayCache; - return cached?.apiUrl === apiUrl ? cached : undefined; - } - - /** - * Daytona moved its gateway or rotated its host keys: every include block and the - * known_hosts pins are stale. Each sandbox's next ssh access mints a token, which rewrites - * its whole ssh setup and revokes the old token. Pooled connections opened before keep - * their old gateway until they close; new ones read the new files. - */ - private async retireSshSetup(config: DaytonaGatewayConfig): Promise { - this.sshConfigured.clear(); - this.readiness.invalidateAll(); - for (const [sandboxId, access] of this.tokens) { - this.tokens.set(sandboxId, { ...access, expiresAt: new Date(0).toISOString() }); - } - try { - await this.sshConfigWriter.repointGateway(toSshGateway(config)); - } catch (error) { - // Each sandbox's next ssh access rewrites its setup anyway. - log.warn("Could not repoint the Daytona ssh setup at the moved gateway", { - error: getErrorMessage(error), - }); - } } /** @@ -290,35 +180,6 @@ export class DaytonaSsh { this.sshConfigured.add(sandboxId); } - /** - * Which token the sandbox's ssh commands use now. Capture it before a command: if the gateway - * refuses that command's token, pass it to replaceRejectedSshToken. - */ - sshTokenGeneration(sandboxId: string): number { - return this.tokenGenerations.get(sandboxId) ?? 0; - } - - private cacheNewToken(sandboxId: string, access: DaytonaSshAccess): void { - this.tokens.set(sandboxId, access); - this.tokenGenerations.set(sandboxId, ++this.lastTokenGeneration); - } - - /** - * The gateway refused the token of a command that started at `attemptGeneration` (revoked - * outside Xum, or one an earlier process left): mint a new one and rewrite the include file. - * Nothing to do when the token changed since: a concurrent command already replaced it, and - * the current one must stay live. The generation, not the ssh error, decides: the OpenSSH - * pool redacts the token from its errors. - */ - async replaceRejectedSshToken(sandboxId: string, attemptGeneration: number): Promise { - if (this.sshTokenGeneration(sandboxId) !== attemptGeneration) return; - const cached = this.tokens.get(sandboxId); - this.readiness.invalidateSandboxReady(sandboxId); - // Due at once, so the next ensureSshAccess mints, writes, and revokes the refused one. - if (cached) this.tokens.set(sandboxId, { ...cached, expiresAt: new Date(0).toISOString() }); - await this.ensureSshAccess(sandboxId); - } - /** Whether this process holds a token for the sandbox that is close to expiry. */ hasExpiringSshToken(sandboxId: string): boolean { return this.tokens.has(sandboxId) && this.tokenNeedsRefresh(sandboxId); @@ -372,7 +233,7 @@ export class DaytonaSsh { const access = await client.createSshAccess(sandboxId, DAYTONA_SSH_TOKEN_TTL_MINUTES); rememberDaytonaToken(access.token); await this.announceNewToken(client, sandboxId, access, options); - this.cacheNewToken(sandboxId, access); + this.tokens.set(sandboxId, access); if (previous && previous.token !== access.token) { await this.revokeQuietly(client, sandboxId, previous.token); } From 328b7fe782ef597ae43446be3322e5058a5239a1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:58:46 +0000 Subject: [PATCH 221/231] fix(daytona): redact a token that a pipe or PTY splits across chunks Codex P1 on a1d7073fe1: chunk-by-chunk redaction let a token split by a read boundary through to logs, stderr and the terminal. Each piece is now redacted together with what the previous one held back, and only an end that could start a remembered token is held; a PTY shows a held end after 50 ms of quiet, so a prompt is never stuck. --- .../runtime/daytonaTokenRedaction.test.ts | 26 +++++++ src/node/runtime/daytonaTokenRedaction.ts | 70 ++++++++++++++++--- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/src/node/runtime/daytonaTokenRedaction.test.ts b/src/node/runtime/daytonaTokenRedaction.test.ts index 405fdbdb8af..13d9585d8f0 100644 --- a/src/node/runtime/daytonaTokenRedaction.test.ts +++ b/src/node/runtime/daytonaTokenRedaction.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import type { PtyHandle } from "./transports"; import { + createDaytonaRedactingStream, DAYTONA_TOKEN_PLACEHOLDER, redactDaytonaPty, redactDaytonaTokens, @@ -27,6 +28,21 @@ describe("Daytona token redaction", () => { `aged-out-token-k3v ${DAYTONA_TOKEN_PLACEHOLDER}` ); }); + + it("redacts a token that a stream splits across chunks", async () => { + rememberDaytonaToken("split-stream-token-q8r"); + const stream = createDaytonaRedactingStream(); + const out: Buffer[] = []; + stream.on("data", (chunk: Buffer) => out.push(chunk)); + const done = new Promise((resolve) => stream.on("end", resolve)); + stream.write("split-stream-"); + stream.end("token-q8r@gateway: Permission denied\r\n"); + await done; + + expect(Buffer.concat(out).toString()).toBe( + `${DAYTONA_TOKEN_PLACEHOLDER}@gateway: Permission denied\r\n` + ); + }); }); describe("redactDaytonaPty", () => { @@ -67,6 +83,16 @@ describe("redactDaytonaPty", () => { expect(pty.output.join("")).toBe(`${DAYTONA_TOKEN_PLACEHOLDER}@gateway: Permission denied\r\n`); }); + it("redacts a token split across chunks, and shows a held end that was not one", async () => { + const pty = setup(); + pty.emit(`denied for ${TOKEN.slice(0, 5)}`); + pty.emit(`${TOKEN.slice(5)}@gateway\r\n$ pty-`); + expect(pty.output.join("")).toBe(`denied for ${DAYTONA_TOKEN_PLACEHOLDER}@gateway\r\n$ `); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(pty.output.join("")).toEndWith("\r\n$ pty-"); + }); + it("passes other output through and kills the underlying pty", () => { const pty = setup(); pty.emit("hello world\r\n"); diff --git a/src/node/runtime/daytonaTokenRedaction.ts b/src/node/runtime/daytonaTokenRedaction.ts index d07ad9bc832..1b999b97837 100644 --- a/src/node/runtime/daytonaTokenRedaction.ts +++ b/src/node/runtime/daytonaTokenRedaction.ts @@ -49,38 +49,90 @@ export function redactDaytonaError(error: unknown): unknown { return error; } +/** Length of the longest end of `text` that is the start of a remembered token. */ +function tokenStartAtEnd(text: string): number { + let held = 0; + for (const token of rememberedTokens.keys()) { + for (let n = Math.min(token.length - 1, text.length); n > held; n--) { + if (text.endsWith(token.slice(0, n))) { + held = n; + break; + } + } + } + return held; +} + /** - * Redact a byte stream chunk by chunk. A token split across two chunks is not caught: ssh writes - * each message (`@host: Permission denied`) in one write. + * Pipes and PTYs can split one ssh message inside the token, so each piece is redacted with the + * text held from the previous one, and an end that could start a token waits for the next piece. */ +function createTokenRedactor() { + let held = ""; + return { + push(text: string): string { + const redacted = redactDaytonaTokens(held + text); + held = redacted.slice(redacted.length - tokenStartAtEnd(redacted)); + return redacted.slice(0, redacted.length - held.length); + }, + end(): string { + const rest = held; + held = ""; + return rest; + }, + holding: () => held.length > 0, + }; +} + +/** Redact a byte stream, including a token split across chunks. */ export function createDaytonaRedactingStream(): Transform { const decoder = new StringDecoder("utf8"); + const redactor = createTokenRedactor(); const emit = (stream: Transform, text: string) => { - if (text) stream.push(Buffer.from(redactDaytonaTokens(text), "utf8")); + if (text) stream.push(Buffer.from(text, "utf8")); }; return new Transform({ transform(chunk: Buffer, _encoding, callback) { - emit(this, decoder.write(chunk)); + emit(this, redactor.push(decoder.write(chunk))); callback(); }, flush(callback) { - emit(this, decoder.end()); + emit(this, redactor.push(decoder.end()) + redactor.end()); callback(); }, }); } +// A held PTY tail (possibly a prompt that only looks like a token start) is shown after this +// long without more output. +const PTY_HOLD_MS = 50; + /** - * Redact a PTY's output chunk by chunk: OpenSSH can print `@gateway: Permission denied` - * into the terminal, which clients replay. The host block turns off password and - * keyboard-interactive auth, so no prompt names the token. + * Redact a PTY's output: OpenSSH can print `@gateway: Permission denied` into the + * terminal, which clients replay. The host block turns off password and keyboard-interactive + * auth, so no prompt names the token. */ export function redactDaytonaPty(pty: PtyHandle): PtyHandle { return { write: (data) => pty.write(data), resize: (cols, rows) => pty.resize(cols, rows), kill: () => pty.kill(), - onData: (handler) => pty.onData((data) => handler(redactDaytonaTokens(data))), + onData: (handler) => { + const redactor = createTokenRedactor(); + let flushTimer: ReturnType | undefined; + const subscription = pty.onData((data) => { + clearTimeout(flushTimer); + const ready = redactor.push(data); + if (ready) handler(ready); + if (redactor.holding()) flushTimer = setTimeout(() => handler(redactor.end()), PTY_HOLD_MS); + }); + return { + dispose: () => { + clearTimeout(flushTimer); + subscription.dispose(); + }, + }; + }, onExit: (handler) => pty.onExit(handler), }; } From bd8c2a84536d47108115cfe24b5b31fed1e8579a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:09:42 +0000 Subject: [PATCH 222/231] fix(daytona): use only labelled sandboxes, and keep a setup on one account Codex P1s on a1d7073fe1, both reopened by the scope cuts: - startAndWait and the ssh repair of a started sandbox check this install's xum.instance label on the sandbox they already read, so Xum never starts or mints ssh access to a sandbox that lost its label or took a deleted one's name. - postCreateSetup runs with its Daytona client pinned (AsyncLocalStorage) to the account in effect at its first call: a settings save during the create no longer sends the readiness check and the cleanup to the new account, which leaked the sandbox in the old one. --- docs/runtime/daytona.mdx | 2 +- src/node/runtime/DaytonaSSHRuntime.test.ts | 33 +++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 17 +++++++--- .../builtInSkillContent.generated.ts | 2 +- .../services/daytona/daytona.testHarness.ts | 3 ++ src/node/services/daytona/daytonaAccount.ts | 27 ++++++++++++++- .../services/daytona/daytonaSandboxes.test.ts | 15 +++++++++ src/node/services/daytona/daytonaSandboxes.ts | 17 ++++++++++ 8 files changed, 109 insertions(+), 7 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index a07acdafdd2..331953e14a2 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -61,7 +61,7 @@ Xum uses only sandboxes it creates: a workspace cannot use an existing sandbox. | Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs | | Sandbox no longer exists | The workspace reports it; Xum does not recreate it | -Each sandbox belongs to the workspace that created it. Archiving or deleting that workspace deletes the sandbox, even while forks of it (sub-agent worktrees) still use it; **Settings > General > Remote box on archive** does not apply to Daytona. Unarchiving a workspace whose sandbox was deleted does not bring the sandbox back, so push or copy any work you need before you archive. An agent cannot archive the workspace that created a sandbox: it asks you to do it. Archiving or deleting a fork removes only its worktree and never stops or deletes the sandbox. Before any delete, Xum checks that the sandbox still carries this install's `xum.instance` label; a sandbox without it (for example one that took the name while Xum's create failed, or one whose label was removed) is kept, and Xum notes it in its log. +Each sandbox belongs to the workspace that created it. Archiving or deleting that workspace deletes the sandbox, even while forks of it (sub-agent worktrees) still use it; **Settings > General > Remote box on archive** does not apply to Daytona. Unarchiving a workspace whose sandbox was deleted does not bring the sandbox back, so push or copy any work you need before you archive. An agent cannot archive the workspace that created a sandbox: it asks you to do it. Archiving or deleting a fork removes only its worktree and never stops or deletes the sandbox. Before Xum starts a sandbox, opens SSH access to it, or deletes it, it checks that the sandbox still carries this install's `xum.instance` label. A sandbox without it (for example one whose label was removed, or one that took the name after the workspace's sandbox was deleted) is never used or deleted, and Xum notes it in its log. A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard. diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 77383d30f6c..cd37daef55c 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -355,6 +355,25 @@ describe("DaytonaSSHRuntime Windows and GPU sandboxes", () => { }); describe("DaytonaSSHRuntime.postCreateSetup", () => { + it("keeps a setup on the account it started with when the settings change during the create", async () => { + rig.plane.onRequest.set("POST /sandbox", () => + rig.service.account.setConfig({ apiKey: "key-b" }) + ); + + await makeRuntime({ sandboxId: "xum-switch", ...SMALL }).postCreateSetup(initParams()); + + const createAt = rig.plane.authorizedCalls.findIndex((call) => + call.startsWith("POST /sandbox ") + ); + // GET /config is public and sends no key. + const afterCreate = rig.plane.authorizedCalls + .slice(createAt) + .filter((c) => c.includes("Bearer")); + expect(afterCreate.length).toBeGreaterThan(1); + expect(afterCreate.filter((call) => !call.endsWith("Bearer test-key"))).toEqual([]); + expect((await rig.service.account.getClient()).credentials.apiKey).toBe("key-b"); + }); + it("creates the sandbox with preset resources, labels and lifecycle intervals", async () => { await makeRuntime({ sandboxId: "xum-new", ...SMALL }).postCreateSetup(initParams()); @@ -954,6 +973,20 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { expect(rig.plane.countCalls("POST /sandbox/sb-gone/ssh-access")).toBe(1); }); + it("mints no token for a started sandbox that lost this install's label", async () => { + const sandbox = rig.plane.addSandbox({ id: "sb-relabeled" }); + await rig.service.ssh.ensureSshAccess("sb-relabeled"); + const minted = rig.plane.countCalls("POST /sandbox/sb-relabeled/ssh-access"); + sandbox.labels = {}; + const runtime = makeRuntime({ sandboxId: "sb-relabeled" }, rig, { + transport: refusingTransportWhile("sb-relabeled", () => true), + }); + + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(runtime.exec("true", { cwd: "/tmp", timeout: 10 })).rejects.toThrow(); + expect(rig.plane.countCalls("POST /sandbox/sb-relabeled/ssh-access")).toBe(minted); + }); + it("replaces a token the gateway refused and retries the command once", async () => { rig.plane.addSandbox({ id: "sb-rev" }); await rig.service.ssh.ensureSshAccess("sb-rev"); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 90790c3b532..7308e353c27 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -62,7 +62,10 @@ import { /** The DaytonaService parts this runtime uses (tests pass an in-memory fake). */ export interface DaytonaRuntimeService { - account: Pick; + account: Pick< + DaytonaAccount, + "checkAvailability" | "getDefaultTarget" | "findSnapshot" | "withPinnedAccount" + >; readiness: Pick< DaytonaReadiness, "isSandboxReady" | "markSandboxReady" | "invalidateSandboxReady" | "join" @@ -78,6 +81,7 @@ export interface DaytonaRuntimeService { sandboxes: Pick< DaytonaSandboxes, | "getSandboxState" + | "assertOwned" | "buildLabels" | "createSandbox" | "resizeSandbox" @@ -410,9 +414,10 @@ export class DaytonaSSHRuntime extends SSHRuntime { * only by a runtime that may start it. Throws when nothing was repaired. */ private async repairSandbox(sandboxId: string): Promise { - const { state } = await this.daytonaService.sandboxes.getSandboxState(sandboxId); - if (state === "missing") throw new SandboxMissingError(); + const { state, sandbox } = await this.daytonaService.sandboxes.getSandboxState(sandboxId); + if (!sandbox) throw new SandboxMissingError(); if (state === "started") { + await this.daytonaService.sandboxes.assertOwned(sandbox, sandboxId); log.info("An ssh command to the Daytona sandbox failed; replacing its token and retrying", { sandboxId, }); @@ -657,7 +662,11 @@ export class DaytonaSSHRuntime extends SSHRuntime { * sync, worktree creation and init hook. If anything fails after the create request was * sent, the sandbox this call created is deleted again so it cannot linger and bill. */ - async postCreateSetup(params: WorkspaceInitParams): Promise { + postCreateSetup(params: WorkspaceInitParams): Promise { + return this.daytonaService.account.withPinnedAccount(() => this.setUpSandbox(params)); + } + + private async setUpSandbox(params: WorkspaceInitParams): Promise { const sandboxId = this.daytonaConfig.sandboxId; if (!sandboxId) { throw new Error("Daytona sandbox id is required (should be set by finalizeConfig)"); diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 02fae16f3c3..8273e49d379 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10098,7 +10098,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "| Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs |", "| Sandbox no longer exists | The workspace reports it; Xum does not recreate it |", "", - "Each sandbox belongs to the workspace that created it. Archiving or deleting that workspace deletes the sandbox, even while forks of it (sub-agent worktrees) still use it; **Settings > General > Remote box on archive** does not apply to Daytona. Unarchiving a workspace whose sandbox was deleted does not bring the sandbox back, so push or copy any work you need before you archive. An agent cannot archive the workspace that created a sandbox: it asks you to do it. Archiving or deleting a fork removes only its worktree and never stops or deletes the sandbox. Before any delete, Xum checks that the sandbox still carries this install's `xum.instance` label; a sandbox without it (for example one that took the name while Xum's create failed, or one whose label was removed) is kept, and Xum notes it in its log.", + "Each sandbox belongs to the workspace that created it. Archiving or deleting that workspace deletes the sandbox, even while forks of it (sub-agent worktrees) still use it; **Settings > General > Remote box on archive** does not apply to Daytona. Unarchiving a workspace whose sandbox was deleted does not bring the sandbox back, so push or copy any work you need before you archive. An agent cannot archive the workspace that created a sandbox: it asks you to do it. Archiving or deleting a fork removes only its worktree and never stops or deletes the sandbox. Before Xum starts a sandbox, opens SSH access to it, or deletes it, it checks that the sandbox still carries this install's `xum.instance` label. A sandbox without it (for example one whose label was removed, or one that took the name after the workspace's sandbox was deleted) is never used or deleted, and Xum notes it in its log.", "", "A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard.", "", diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 59572423630..3cf7cf03f65 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -41,6 +41,8 @@ export class FakeDaytonaControlPlane { readonly sandboxes = new Map(); /** `METHOD /path` for every request (query strings omitted). */ readonly calls: string[] = []; + /** `METHOD /path ` for every answered request. */ + readonly authorizedCalls: string[] = []; readonly createBodies: Array> = []; readonly revokedTokens: string[] = []; /** The organization's snapshots; `listedOnly` ones only come with the list, not by name. */ @@ -131,6 +133,7 @@ export class FakeDaytonaControlPlane { const fail = (status: number, message: string) => json(status, { statusCode: status, message }); if (this.failingRoutes.has(route)) return fail(500, "Internal error"); const auth = (init?.headers as Record | undefined)?.Authorization ?? ""; + this.authorizedCalls.push(`${route} ${auth}`); // Only the sandbox list checks it: that is the request a connection test makes. if (route === "GET /sandbox" && this.apiKey !== undefined && auth !== `Bearer ${this.apiKey}`) { return fail(401, "Invalid credentials"); diff --git a/src/node/services/daytona/daytonaAccount.ts b/src/node/services/daytona/daytonaAccount.ts index 791888d8847..7d8ae281a2b 100644 --- a/src/node/services/daytona/daytonaAccount.ts +++ b/src/node/services/daytona/daytonaAccount.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "async_hooks"; import type { DaytonaListSnapshotsResult, DaytonaPublicConfig, @@ -60,11 +61,19 @@ function toResultError(error: unknown): { return { ok: false, kind: "server", error: getErrorMessage(error) }; } +interface DaytonaAccountClient { + client: DaytonaClient; + credentials: DaytonaResolvedCredentials; +} + /** The Daytona settings, the API client they make, and the calls that need no sandbox. */ export class DaytonaAccount { private readonly credentialsStore: DaytonaCredentialsStore; private readonly fetchImpl?: typeof fetch; private readonly onSettingsChange: DaytonaAccountOptions["onSettingsChange"]; + private readonly pinnedClient = new AsyncLocalStorage<{ + client?: Promise; + }>(); constructor(options: DaytonaAccountOptions) { this.credentialsStore = options.credentialsStore; @@ -121,8 +130,24 @@ export class DaytonaAccount { } } + /** + * Runs `operation` with every getClient() inside it on the account in effect at its first + * call, so a settings save cannot split a setup across two accounts (a create in the old + * one, its readiness check and cleanup in the new one, leaking the sandbox). + */ + withPinnedAccount(operation: () => Promise): Promise { + return this.pinnedClient.run({}, operation); + } + /** Throws a `not_configured` DaytonaApiError when no API key is set. */ - async getClient(): Promise<{ client: DaytonaClient; credentials: DaytonaResolvedCredentials }> { + getClient(): Promise { + const pin = this.pinnedClient.getStore(); + if (!pin) return this.createClient(); + pin.client ??= this.createClient(); + return pin.client; + } + + private async createClient(): Promise { const credentials = await this.resolveCredentials(); if (!credentials) { throw new DaytonaApiError( diff --git a/src/node/services/daytona/daytonaSandboxes.test.ts b/src/node/services/daytona/daytonaSandboxes.test.ts index 1a403a6bce6..5c118cdfd67 100644 --- a/src/node/services/daytona/daytonaSandboxes.test.ts +++ b/src/node/services/daytona/daytonaSandboxes.test.ts @@ -101,6 +101,21 @@ describe("DaytonaSandboxes", () => { expect(labels[DAYTONA_LABEL_PROJECT]).toBe("my-project"); }); + it("refuses to start or open a sandbox without this install's label", async () => { + rig.plane.addSandbox({ id: "sb-stopped-foreign", state: "stopped", labels: {} }); + rig.plane.addSandbox({ + id: "sb-started-foreign", + labels: { [DAYTONA_LABEL_INSTANCE]: "another-install" }, + }); + + for (const id of ["sb-stopped-foreign", "sb-started-foreign"]) { + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(rig.service.sandboxes.ensureSandboxReady(id)).rejects.toThrow("label"); + expect(rig.plane.countCalls(`POST /sandbox/${id}/start`)).toBe(0); + expect(rig.plane.countCalls(`POST /sandbox/${id}/ssh-access`)).toBe(0); + } + }); + it("deletes only a sandbox with this install's label, and drops Xum's access to any", async () => { rig.plane.addSandbox({ id: "sb-foreign", diff --git a/src/node/services/daytona/daytonaSandboxes.ts b/src/node/services/daytona/daytonaSandboxes.ts index 27c75dc1b04..b1fd3a8e1da 100644 --- a/src/node/services/daytona/daytonaSandboxes.ts +++ b/src/node/services/daytona/daytonaSandboxes.ts @@ -176,6 +176,7 @@ export class DaytonaSandboxes { for (;;) { options.signal?.throwIfAborted(); const sandbox = await this.getSandboxForStart(client, sandboxId, options.signal); + await this.assertOwned(sandbox, sandboxId); const step = nextStartStep(sandboxId, sandbox, startRequested); if (step === "done") return sandbox; if (step === "start") { @@ -276,9 +277,25 @@ export class DaytonaSandboxes { if (isDaytonaNotFound(error)) return true; throw error; } + return this.carriesInstanceLabel(sandbox); + } + + private async carriesInstanceLabel(sandbox: DaytonaSandbox): Promise { return sandbox.labels?.[DAYTONA_LABEL_INSTANCE] === (await this.getInstanceId()); } + /** + * Refuse to start a sandbox or give Xum ssh access to it without this install's label: its + * label was removed, or another sandbox took the name after the workspace's was deleted. + */ + async assertOwned(sandbox: DaytonaSandbox, sandboxId: string): Promise { + if (await this.carriesInstanceLabel(sandbox)) return; + throw new DaytonaApiError( + "bad_request", + `Daytona sandbox ${sandboxId} does not carry this Xum install's label, so Xum will not use it` + ); + } + async refreshActivity(sandboxId: string): Promise { const { client } = await this.account.getClient(); await client.refreshActivity(sandboxId); From 1c26b3499dd04c27f08840c6752ac2abc22b8389 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:31:24 +0000 Subject: [PATCH 223/231] fix(daytona): quiet terminal ssh, and fail a setup the account changed under Codex P1s on bd8c2a8453: - A Daytona terminal's ssh runs with LogLevel=QUIET. OpenSSH names the user (the gateway token) only in its fatal "Permission denied" line, which QUIET also silences, and the sandbox shell cannot see the token. So the terminal stream carries no token, and the PTY redactor and its 50 ms hold timer, which a quiet gap could flush mid-token, are removed. - postCreateSetup fails when a settings save stored another API URL or key while it ran: it releases the ssh access it wrote after the save's cleanup and the failure path deletes the sandbox it created in the old account. Waiting for the setup instead would still leave the workspace in an account Xum no longer uses. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 71 ++++++++----------- src/node/runtime/DaytonaSSHRuntime.ts | 16 ++++- .../runtime/daytonaTokenRedaction.test.ts | 59 --------------- src/node/runtime/daytonaTokenRedaction.ts | 69 +++--------------- .../transports/OpenSSHTransport.test.ts | 19 +++-- .../runtime/transports/OpenSSHTransport.ts | 12 +++- src/node/services/daytona/daytonaAccount.ts | 13 ++++ 7 files changed, 92 insertions(+), 167 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index cd37daef55c..4b02d1d8dc9 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -213,43 +213,6 @@ describe("DaytonaSSHRuntime integrated terminals", () => { ).rejects.toThrow("no longer exists"); expect(opened).toBe(false); }); - - it("keep the token out of what the terminal prints", async () => { - rig.plane.addSandbox({ id: "sb-term-tok" }); - await rig.service.ssh.ensureSshAccess("sb-term-tok"); - const token = rig.writer.tokens.get("sb-term-tok"); - if (!token) throw new Error("expected a minted token"); - let emit: (data: string) => void = () => undefined; - const runtime = makeRuntime({ sandboxId: "sb-term-tok" }, rig, { - transport: { - ...localShellTransport(rig.rootDir), - createPtySession: () => - Promise.resolve({ - write: () => undefined, - resize: () => undefined, - kill: () => undefined, - onData: (handler: (data: string) => void) => { - emit = handler; - return { dispose: () => undefined }; - }, - onExit: () => ({ dispose: () => undefined }), - }), - }, - }); - - const pty = await runtime.createPtySession({ - workspacePath: "~/src/project/feature", - cols: 80, - rows: 24, - }); - const printed: string[] = []; - pty.onData((data) => printed.push(data)); - emit(`${token}@gateway: Permission denied (publickey).\r\n`); - - expect(printed.join("")).toBe( - `${DAYTONA_TOKEN_PLACEHOLDER}@gateway: Permission denied (publickey).\r\n` - ); - }); }); describe("DaytonaSSHRuntime.finalizeConfig and validateBeforePersist", () => { @@ -355,13 +318,20 @@ describe("DaytonaSSHRuntime Windows and GPU sandboxes", () => { }); describe("DaytonaSSHRuntime.postCreateSetup", () => { - it("keeps a setup on the account it started with when the settings change during the create", async () => { + // The save finishes (and drops the old account's ssh access) while the create is in flight. + it("fails a setup, and undoes it in the old account, when the settings change during the create", async () => { rig.plane.onRequest.set("POST /sandbox", () => rig.service.account.setConfig({ apiKey: "key-b" }) ); - await makeRuntime({ sandboxId: "xum-switch", ...SMALL }).postCreateSetup(initParams()); + const failure = await makeRuntime({ sandboxId: "xum-switch", ...SMALL }) + .postCreateSetup(initParams()) + .then( + () => undefined, + (error: unknown) => error + ); + expect(String(failure)).toContain("settings changed during setup"); const createAt = rig.plane.authorizedCalls.findIndex((call) => call.startsWith("POST /sandbox ") ); @@ -369,11 +339,32 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { const afterCreate = rig.plane.authorizedCalls .slice(createAt) .filter((c) => c.includes("Bearer")); - expect(afterCreate.length).toBeGreaterThan(1); expect(afterCreate.filter((call) => !call.endsWith("Bearer test-key"))).toEqual([]); + expect(rig.plane.find("xum-switch")).toBeUndefined(); + expect(rig.writer.tokens.get("xum-switch")).toBeUndefined(); expect((await rig.service.account.getClient()).credentials.apiKey).toBe("key-b"); }); + it("fails a fork's setup, and drops the ssh access it wrote, when the settings change during it", async () => { + rig.plane.addSandbox({ id: "sb-src" }); + rig.plane.onRequest.set("GET /sandbox/sb-src", () => { + rig.plane.onRequest.delete("GET /sandbox/sb-src"); + return rig.service.account.setConfig({ apiKey: "key-b" }); + }); + + const failure = await makeRuntime({ sandboxId: "sb-src", forked: true, ...SMALL }) + .postCreateSetup(initParams()) + .then( + () => undefined, + (error: unknown) => error + ); + + expect(String(failure)).toContain("settings changed during setup"); + expect(rig.plane.find("sb-src")).toBeDefined(); + expect(rig.writer.tokens.get("sb-src")).toBeUndefined(); + expect(rig.plane.revokedTokens).toHaveLength(1); + }); + it("creates the sandbox with preset resources, labels and lifecycle intervals", async () => { await makeRuntime({ sandboxId: "xum-new", ...SMALL }).postCreateSetup(initParams()); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 7308e353c27..0d24e8aca46 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -56,7 +56,6 @@ import { log } from "@/node/services/log"; import { redactChildStderr, redactDaytonaError, - redactDaytonaPty, redactDaytonaTokens, } from "./daytonaTokenRedaction"; @@ -64,7 +63,11 @@ import { export interface DaytonaRuntimeService { account: Pick< DaytonaAccount, - "checkAvailability" | "getDefaultTarget" | "findSnapshot" | "withPinnedAccount" + | "checkAvailability" + | "getDefaultTarget" + | "findSnapshot" + | "withPinnedAccount" + | "pinnedAccountChanged" >; readiness: Pick< DaytonaReadiness, @@ -444,7 +447,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { override async createPtySession(params: PtySessionParams): Promise { const ready = await this.ensureReady(); if (!ready.ready) throw new Error(ready.error); - return redactDaytonaPty(await super.createPtySession(params)); + return super.createPtySession(params); } /** Init output (sync, git push, hook) can quote ssh's stderr, so it is redacted too. */ @@ -682,6 +685,13 @@ export class DaytonaSSHRuntime extends SSHRuntime { } await this.connect(sandboxId, params); await this.prepareWorkspaceParentDirectory(params); + // After a settings save during the setup, the workspace would sit in an account Xum no + // longer uses, with ssh access the save's cleanup did not see: undo both, as for any + // failure. + if (await this.daytonaService.account.pinnedAccountChanged()) { + await this.daytonaService.ssh.releaseSshAccess(sandboxId); + throw new Error("the Daytona settings changed during setup; create the workspace again"); + } } catch (error) { const errorMsg = redactDaytonaTokens(getErrorMessage(error)); log.error("Daytona sandbox setup failed", { sandboxId, error: errorMsg }); diff --git a/src/node/runtime/daytonaTokenRedaction.test.ts b/src/node/runtime/daytonaTokenRedaction.test.ts index 13d9585d8f0..a3465e2533b 100644 --- a/src/node/runtime/daytonaTokenRedaction.test.ts +++ b/src/node/runtime/daytonaTokenRedaction.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from "bun:test"; -import type { PtyHandle } from "./transports"; import { createDaytonaRedactingStream, DAYTONA_TOKEN_PLACEHOLDER, - redactDaytonaPty, redactDaytonaTokens, rememberDaytonaToken, } from "./daytonaTokenRedaction"; @@ -44,60 +42,3 @@ describe("Daytona token redaction", () => { ); }); }); - -describe("redactDaytonaPty", () => { - const TOKEN = "pty-token-z7w"; - rememberDaytonaToken(TOKEN); - - function setup() { - const dataHandlers = new Set<(data: string) => void>(); - let killed = false; - const pty: PtyHandle = { - write: () => undefined, - resize: () => undefined, - kill: () => { - killed = true; - }, - onData: (handler) => { - dataHandlers.add(handler); - return { dispose: () => dataHandlers.delete(handler) }; - }, - onExit: () => ({ dispose: () => undefined }), - }; - const redacted = redactDaytonaPty(pty); - const output: string[] = []; - redacted.onData((data) => output.push(data)); - return { - redacted, - output, - emit: (data: string) => { - for (const handler of dataHandlers) handler(data); - }, - wasKilled: () => killed, - }; - } - - it("redacts a token within one chunk", () => { - const pty = setup(); - pty.emit(`${TOKEN}@gateway: Permission denied\r\n`); - expect(pty.output.join("")).toBe(`${DAYTONA_TOKEN_PLACEHOLDER}@gateway: Permission denied\r\n`); - }); - - it("redacts a token split across chunks, and shows a held end that was not one", async () => { - const pty = setup(); - pty.emit(`denied for ${TOKEN.slice(0, 5)}`); - pty.emit(`${TOKEN.slice(5)}@gateway\r\n$ pty-`); - expect(pty.output.join("")).toBe(`denied for ${DAYTONA_TOKEN_PLACEHOLDER}@gateway\r\n$ `); - - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(pty.output.join("")).toEndWith("\r\n$ pty-"); - }); - - it("passes other output through and kills the underlying pty", () => { - const pty = setup(); - pty.emit("hello world\r\n"); - expect(pty.output).toEqual(["hello world\r\n"]); - pty.redacted.kill(); - expect(pty.wasKilled()).toBe(true); - }); -}); diff --git a/src/node/runtime/daytonaTokenRedaction.ts b/src/node/runtime/daytonaTokenRedaction.ts index 1b999b97837..b02da2936c7 100644 --- a/src/node/runtime/daytonaTokenRedaction.ts +++ b/src/node/runtime/daytonaTokenRedaction.ts @@ -4,7 +4,6 @@ import { Transform } from "stream"; import { isXumDaytonaHost } from "@/common/constants/daytona"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; -import type { PtyHandle } from "./transports"; /** * Daytona's SSH gateway takes the access token as the ssh user name, so OpenSSH messages such @@ -64,79 +63,33 @@ function tokenStartAtEnd(text: string): number { } /** - * Pipes and PTYs can split one ssh message inside the token, so each piece is redacted with the - * text held from the previous one, and an end that could start a token waits for the next piece. + * Redact a byte stream. A pipe can split one ssh message inside the token, so each chunk is + * redacted with the text held from the previous one, and an end that could start a token + * waits for the next chunk or the end of the stream. */ -function createTokenRedactor() { - let held = ""; - return { - push(text: string): string { - const redacted = redactDaytonaTokens(held + text); - held = redacted.slice(redacted.length - tokenStartAtEnd(redacted)); - return redacted.slice(0, redacted.length - held.length); - }, - end(): string { - const rest = held; - held = ""; - return rest; - }, - holding: () => held.length > 0, - }; -} - -/** Redact a byte stream, including a token split across chunks. */ export function createDaytonaRedactingStream(): Transform { const decoder = new StringDecoder("utf8"); - const redactor = createTokenRedactor(); + let held = ""; + const release = (text: string): string => { + const redacted = redactDaytonaTokens(held + text); + held = redacted.slice(redacted.length - tokenStartAtEnd(redacted)); + return redacted.slice(0, redacted.length - held.length); + }; const emit = (stream: Transform, text: string) => { if (text) stream.push(Buffer.from(text, "utf8")); }; return new Transform({ transform(chunk: Buffer, _encoding, callback) { - emit(this, redactor.push(decoder.write(chunk))); + emit(this, release(decoder.write(chunk))); callback(); }, flush(callback) { - emit(this, redactor.push(decoder.end()) + redactor.end()); + emit(this, release(decoder.end()) + held); callback(); }, }); } -// A held PTY tail (possibly a prompt that only looks like a token start) is shown after this -// long without more output. -const PTY_HOLD_MS = 50; - -/** - * Redact a PTY's output: OpenSSH can print `@gateway: Permission denied` into the - * terminal, which clients replay. The host block turns off password and keyboard-interactive - * auth, so no prompt names the token. - */ -export function redactDaytonaPty(pty: PtyHandle): PtyHandle { - return { - write: (data) => pty.write(data), - resize: (cols, rows) => pty.resize(cols, rows), - kill: () => pty.kill(), - onData: (handler) => { - const redactor = createTokenRedactor(); - let flushTimer: ReturnType | undefined; - const subscription = pty.onData((data) => { - clearTimeout(flushTimer); - const ready = redactor.push(data); - if (ready) handler(ready); - if (redactor.holding()) flushTimer = setTimeout(() => handler(redactor.end()), PTY_HOLD_MS); - }); - return { - dispose: () => { - clearTimeout(flushTimer); - subscription.dispose(); - }, - }; - }, - onExit: (handler) => pty.onExit(handler), - }; -} - /** redactChildStderr for an ssh process to a Daytona alias; other hosts are left alone. */ export function redactDaytonaHostStderr(child: ChildProcess, host: string): void { if (isXumDaytonaHost(host)) redactChildStderr(child); diff --git a/src/node/runtime/transports/OpenSSHTransport.test.ts b/src/node/runtime/transports/OpenSSHTransport.test.ts index 3d84534de1c..156b4d87b4d 100644 --- a/src/node/runtime/transports/OpenSSHTransport.test.ts +++ b/src/node/runtime/transports/OpenSSHTransport.test.ts @@ -52,21 +52,32 @@ describe("OpenSSHTransport connection acquisition failures", () => { }); describe("OpenSSHTransport terminals on a Daytona alias", () => { - it("read only Xum's include file, as every other ssh call to the alias does", async () => { + async function terminalArgs(host: string): Promise { spyOn(sshConnectionPool, "acquireConnection").mockResolvedValue(undefined); const spawnPty = spyOn(ptySpawn, "spawnPtyProcess").mockReturnValue({} as IPty); - const host = toXumDaytonaHost("sb-1"); - await new OpenSSHTransport({ host }).createPtySession({ workspacePath: "~/src/project/feature", cols: 80, rows: 24, }); + return spawnPty.mock.calls[0][0].args; + } + + const logLevels = (args: string[]) => args.filter((arg) => arg.startsWith("LogLevel=")); - const args = spawnPty.mock.calls[0][0].args; + it("read only Xum's include file, as every other ssh call to the alias does", async () => { + const host = toXumDaytonaHost("sb-1"); + const args = await terminalArgs(host); const configFile = args.indexOf("-F"); expect(args[configFile + 1]).toBe(getDaytonaSshConfigPath()); expect(configFile).toBeGreaterThan(-1); expect(configFile).toBeLessThan(args.indexOf(host)); }); + + // The token is the ssh user name, and ssh prints `@: Permission denied` at FATAL. + it("print no ssh messages, so a refused token never reaches the terminal", async () => { + expect(logLevels(await terminalArgs(toXumDaytonaHost("sb-1")))).toEqual(["LogLevel=QUIET"]); + mock.restore(); + expect(logLevels(await terminalArgs("example.test"))).toEqual(["LogLevel=FATAL"]); + }); }); diff --git a/src/node/runtime/transports/OpenSSHTransport.ts b/src/node/runtime/transports/OpenSSHTransport.ts index 16672a6ea86..489347245e5 100644 --- a/src/node/runtime/transports/OpenSSHTransport.ts +++ b/src/node/runtime/transports/OpenSSHTransport.ts @@ -15,6 +15,7 @@ import { } from "../sshConnectionPool"; import type { SpawnResult } from "../RemoteRuntime"; import { RuntimeError } from "../Runtime"; +import { isXumDaytonaHost } from "@/common/constants/daytona"; import { assert } from "@/common/utils/assert"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; @@ -146,7 +147,12 @@ export class OpenSSHTransport implements SSHTransport { async createPtySession(params: PtySessionParams): Promise { await this.acquireConnection({ maxWaitMs: 0 }); - const args: string[] = [...this.buildBaseSSHArgs()]; + // Daytona's gateway takes the access token as the ssh user name, and OpenSSH's fatal + // `@: Permission denied` would print it into the terminal, which clients + // replay. QUIET also silences fatal messages, so a Daytona terminal shows no ssh messages + // (ensureReady reports connection problems before it opens). + const logLevel = isXumDaytonaHost(this.config.host) ? "QUIET" : "FATAL"; + const args: string[] = [...this.buildBaseSSHArgs(logLevel)]; args.push("-o", "ControlMaster=no"); args.push("-o", "ConnectTimeout=15"); args.push("-o", "ServerAliveInterval=5"); @@ -272,7 +278,7 @@ export class OpenSSHTransport implements SSHTransport { return { closed, close }; } - private buildBaseSSHArgs(): string[] { + private buildBaseSSHArgs(logLevel: "FATAL" | "QUIET" = "FATAL"): string[] { const args: string[] = []; if (this.config.port) { @@ -283,7 +289,7 @@ export class OpenSSHTransport implements SSHTransport { args.push("-i", this.config.identityFile); } - args.push("-o", "LogLevel=FATAL"); + args.push("-o", `LogLevel=${logLevel}`); return args; } } diff --git a/src/node/services/daytona/daytonaAccount.ts b/src/node/services/daytona/daytonaAccount.ts index 7d8ae281a2b..b6922bbc3d9 100644 --- a/src/node/services/daytona/daytonaAccount.ts +++ b/src/node/services/daytona/daytonaAccount.ts @@ -139,6 +139,19 @@ export class DaytonaAccount { return this.pinnedClient.run({}, operation); } + /** + * Whether a settings save stored another account (API URL or key) after the pinned + * operation took its own. That save has already dropped the old account's ssh access, so + * what the operation made since then would outlive it. + */ + async pinnedAccountChanged(): Promise { + const pinned = this.pinnedClient.getStore()?.client; + if (!pinned) return false; + const { credentials } = await pinned; + const current = await this.resolveCredentials(); + return current?.apiUrl !== credentials.apiUrl || current.apiKey !== credentials.apiKey; + } + /** Throws a `not_configured` DaytonaApiError when no API key is set. */ getClient(): Promise { const pin = this.pinnedClient.getStore(); From 07c785d9e46fe0933e4f5fed14317b5107b267ff Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:48:17 +0000 Subject: [PATCH 224/231] fix(daytona): drop a token whose mint spans a settings save A settings save snapshots and revokes only the tokens already cached. A mint still running at that point finished afterwards, wrote its alias and cached the old account's token, so an editor or a later ssh could reach the old account until the token expired. A mint now checks whether a save ran since it started; if so it removes its alias, revokes the token with the old key and fails its caller. --- src/node/services/daytona/daytonaSsh.test.ts | 92 ++++++++++++-------- src/node/services/daytona/daytonaSsh.ts | 45 +++++++--- 2 files changed, 91 insertions(+), 46 deletions(-) diff --git a/src/node/services/daytona/daytonaSsh.test.ts b/src/node/services/daytona/daytonaSsh.test.ts index 27eb4028415..7faa19266bf 100644 --- a/src/node/services/daytona/daytonaSsh.test.ts +++ b/src/node/services/daytona/daytonaSsh.test.ts @@ -8,6 +8,7 @@ import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; import { log } from "@/node/services/log"; import { ScriptedSandboxControlPlane } from "./daytona.testHarness"; import { DaytonaService } from "./daytonaService"; +import type { DaytonaSshWriter } from "./daytonaSsh"; describe("DaytonaSsh", () => { let rootDir: string; @@ -16,10 +17,14 @@ describe("DaytonaSsh", () => { let service: DaytonaService; const sleeps: number[] = []; - function makeService(env: NodeJS.ProcessEnv = { DAYTONA_API_KEY: "k" }): DaytonaService { + function makeService( + env: NodeJS.ProcessEnv = { DAYTONA_API_KEY: "k" }, + sshConfigWriter?: DaytonaSshWriter + ): DaytonaService { return new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(rootDir, env), rootDir, + sshConfigWriter, fetch: plane.fetch, now: () => clock, sleep: (ms) => { @@ -30,6 +35,26 @@ describe("DaytonaSsh", () => { }); } + /** An include file kept as a map from sandbox id to token. */ + function mapWriter(includeFile: Map): DaytonaSshWriter { + const store = (input: { sandboxId: string; token: string }) => { + includeFile.set(input.sandboxId, input.token); + return Promise.resolve(); + }; + return { + writeSandbox: store, + ensureManagedBlock: () => Promise.resolve(), + updateToken: store, + removeSandbox: (sandboxId) => { + includeFile.delete(sandboxId); + return Promise.resolve(); + }, + readTokens: () => + Promise.resolve([...includeFile].map(([sandboxId, token]) => ({ sandboxId, token }))), + repointGateway: () => Promise.resolve(), + }; + } + beforeEach(async () => { rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-daytona-service-")); clock = Date.parse("2026-01-01T00:00:00Z"); @@ -95,27 +120,7 @@ describe("DaytonaSsh", () => { it("removes the old account's aliases from the include file when the settings change", async () => { const includeFile = new Map(); - const store = (input: { sandboxId: string; token: string }) => { - includeFile.set(input.sandboxId, input.token); - return Promise.resolve(); - }; - const changing = new DaytonaService({ - credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), - rootDir, - fetch: plane.fetch, - now: () => clock, - sshConfigWriter: { - writeSandbox: store, - ensureManagedBlock: () => Promise.resolve(), - updateToken: store, - removeSandbox: (sandboxId) => { - includeFile.delete(sandboxId); - return Promise.resolve(); - }, - readTokens: () => Promise.resolve([]), - repointGateway: () => Promise.resolve(), - }, - }); + const changing = makeService(undefined, mapWriter(includeFile)); await changing.ssh.ensureSshAccess("sb-1"); expect(includeFile.has("sb-1")).toBe(true); @@ -124,23 +129,38 @@ describe("DaytonaSsh", () => { expect(includeFile.has("sb-1")).toBe(false); }); + it("drops a token whose mint was still running when the settings changed", async () => { + const includeFile = new Map(); + const changing = makeService(undefined, mapWriter(includeFile)); + const mintRoute = "POST /sandbox/sb-1/ssh-access"; + let mintStarted!: () => void; + const started = new Promise((resolve) => (mintStarted = resolve)); + let finishMint!: () => void; + plane.onRequest.set(mintRoute, () => { + mintStarted(); + return new Promise((resolve) => (finishMint = resolve)); + }); + const access = changing.ssh.ensureSshAccess("sb-1"); + await started; + + await changing.account.setConfig({ apiKey: "k2" }); + plane.onRequest.delete(mintRoute); + finishMint(); + + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(access).rejects.toThrow("settings changed"); + // The token the old key minted is revoked with that key, and no alias holds it. + expect(includeFile.has("sb-1")).toBe(false); + expect(plane.revoked).toEqual(["tok1"]); + expect(plane.authorizedCalls).toContain("DELETE /sandbox/sb-1/ssh-access Bearer k"); + await changing.ssh.ensureSshAccess("sb-1"); + expect(includeFile.get("sb-1")).toBe("tok2"); + }); + it("revokes the tokens an earlier process left in the include file once it replaces them", async () => { // The include file, shared by every Xum process (a restart is a new service). const includeFile = new Map([["sb-1", "earlier-process-token"]]); - const store = (input: { sandboxId: string; token: string }) => { - includeFile.set(input.sandboxId, input.token); - return Promise.resolve(); - }; - const writer = { - writeSandbox: store, - ensureManagedBlock: () => Promise.resolve(), - updateToken: store, - removeSandbox: () => Promise.resolve(), - - readTokens: () => - Promise.resolve([...includeFile].map(([sandboxId, token]) => ({ sandboxId, token }))), - repointGateway: () => Promise.resolve(), - }; + const writer = mapWriter(includeFile); const startProcess = async () => { const started = new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), diff --git a/src/node/services/daytona/daytonaSsh.ts b/src/node/services/daytona/daytonaSsh.ts index c96fd1eb33e..7eae7c6da69 100644 --- a/src/node/services/daytona/daytonaSsh.ts +++ b/src/node/services/daytona/daytonaSsh.ts @@ -63,6 +63,8 @@ export class DaytonaSsh { private readonly sshConfigured = new Set(); /** Whether the include file was read once, seeding the tokens an earlier process left. */ private persistedTokensLoaded = false; + /** Counts forgetAccount calls: a mint that spans one holds a token the call never saw. */ + private accountChanges = 0; constructor(options: DaytonaSshOptions) { this.account = options.account; @@ -105,20 +107,12 @@ export class DaytonaSsh { * the caches. Connections already open may reach the old account until they end. */ async forgetAccount(previousClient: DaytonaClient | undefined): Promise { + this.accountChanges++; const tokens = [...this.tokens]; this.tokens.clear(); this.sshConfigured.clear(); this.gatewayCache = undefined; - for (const [sandboxId] of tokens) { - try { - await this.sshConfigWriter.removeSandbox(sandboxId); - } catch (error) { - log.warn("Failed to remove an old-account Daytona alias from the include file", { - sandboxId, - error: getErrorMessage(error), - }); - } - } + for (const [sandboxId] of tokens) await this.removeOldAccountAlias(sandboxId); if (previousClient) { await Promise.all( tokens.map(([sandboxId, access]) => @@ -229,10 +223,15 @@ export class DaytonaSsh { previous: DaytonaSshAccess | undefined, options: DaytonaSshTokenOptions ): Promise { + const accountChanges = this.accountChanges; const { client } = await this.account.getClient(); const access = await client.createSshAccess(sandboxId, DAYTONA_SSH_TOKEN_TTL_MINUTES); rememberDaytonaToken(access.token); await this.announceNewToken(client, sandboxId, access, options); + // No await between this check and the set: forgetAccount sees the token, or this sees it. + if (this.accountChanges !== accountChanges) { + await this.dropOldAccountToken(client, sandboxId, access.token); + } this.tokens.set(sandboxId, access); if (previous && previous.token !== access.token) { await this.revokeQuietly(client, sandboxId, previous.token); @@ -255,6 +254,32 @@ export class DaytonaSsh { } } + /** + * A settings save ran forgetAccount during this mint: the token belongs to the account the + * save left, so remove its alias and revoke it as forgetAccount would have, and fail the + * caller. + */ + private async dropOldAccountToken( + client: DaytonaClient, + sandboxId: string, + token: string + ): Promise { + await this.removeOldAccountAlias(sandboxId); + await this.revokeQuietly(client, sandboxId, token); + throw new Error(`The Daytona settings changed while Xum got ssh access to ${sandboxId}`); + } + + private async removeOldAccountAlias(sandboxId: string): Promise { + try { + await this.sshConfigWriter.removeSandbox(sandboxId); + } catch (error) { + log.warn("Failed to remove an old-account Daytona alias from the include file", { + sandboxId, + error: getErrorMessage(error), + }); + } + } + /** Revoke and forget the cached token (workspace deleted or sandbox detached). */ async forgetSshToken(sandboxId: string): Promise { const cached = this.tokens.get(sandboxId); From dcc2630ee152adc36f6d4b7c641d60dac70b2f02 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:41:51 +0000 Subject: [PATCH 225/231] fix(daytona): delete the sandbox id the ownership check saw The workspace config holds the sandbox name, which another sandbox can take between the label check and the delete. Delete by the id the check read, and send no delete for a sandbox that is already gone. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 8 ++++++- .../services/daytona/daytonaSandboxes.test.ts | 20 ++++++++++++++++ src/node/services/daytona/daytonaSandboxes.ts | 23 +++++++++++-------- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index 4b02d1d8dc9..bef7b5e2c35 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -449,7 +449,12 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { ["xum-persistent", true], ] as const) { rig.writer.writeError = new Error("No space left on device"); - rig.plane.failingRoutes.add(`DELETE /sandbox/${sandboxId}`); + // The delete goes to the id of the sandbox the setup created, not to its name. + rig.plane.onRequest.set(`GET /sandbox/${sandboxId}`, () => { + const created = rig.plane.find(sandboxId); + if (created) rig.plane.failingRoutes.add(`DELETE /sandbox/${created.id}`); + return Promise.resolve(); + }); const daytona = { sandboxId, ...SMALL }; // eslint-disable-next-line @typescript-eslint/await-thenable @@ -459,6 +464,7 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { expect(rig.plane.find(sandboxId)).toBeDefined(); // The persisted workspace still names the sandbox; removing it deletes the sandbox. + rig.plane.onRequest.clear(); rig.plane.failingRoutes.clear(); if (!force) rig.writer.writeError = undefined; const removal = await makeRuntime(daytona).deleteWorkspace( diff --git a/src/node/services/daytona/daytonaSandboxes.test.ts b/src/node/services/daytona/daytonaSandboxes.test.ts index 5c118cdfd67..ed43c67433b 100644 --- a/src/node/services/daytona/daytonaSandboxes.test.ts +++ b/src/node/services/daytona/daytonaSandboxes.test.ts @@ -136,4 +136,24 @@ describe("DaytonaSandboxes", () => { await rig.service.sandboxes.deleteSandbox("sb-own"); expect(rig.plane.find("sb-own")).toBeUndefined(); }); + + it("never deletes a sandbox that took the name after the ownership check", async () => { + rig.plane.addSandbox({ id: "id-old", name: "ws-1" }); + await rig.service.ssh.ensureSshAccess("ws-1"); + // The revoke runs between the check and the delete: the sandbox goes, another takes its name. + rig.plane.onRequest.set("DELETE /sandbox/ws-1/ssh-access", () => { + rig.plane.sandboxes.delete("id-old"); + rig.plane.addSandbox({ id: "id-new", name: "ws-1", labels: {} }); + return Promise.resolve(); + }); + + await rig.service.sandboxes.deleteSandbox("ws-1"); + expect(rig.plane.find("id-new")).toBeDefined(); + expect(rig.plane.countCalls("DELETE /sandbox/ws-1")).toBe(0); + }); + + it("sends no delete for a sandbox that is already gone", async () => { + await rig.service.sandboxes.deleteSandbox("ws-gone"); + expect(rig.plane.countCalls("DELETE /sandbox/ws-gone")).toBe(0); + }); }); diff --git a/src/node/services/daytona/daytonaSandboxes.ts b/src/node/services/daytona/daytonaSandboxes.ts index b1fd3a8e1da..6936980c5a7 100644 --- a/src/node/services/daytona/daytonaSandboxes.ts +++ b/src/node/services/daytona/daytonaSandboxes.ts @@ -250,34 +250,37 @@ export class DaytonaSandboxes { * sandbox that is already gone counts as deleted. * * A workspace's config is not proof that Xum created the sandbox it names, so one without this - * install's label is kept (only Xum's access to it goes) and logged. + * install's label is kept (only Xum's access to it goes) and logged. The config holds the + * sandbox's name, which another sandbox can take once this one is gone, so the delete goes to + * the id the label check saw, and a sandbox already gone gets no delete. */ async deleteSandbox(sandboxId: string): Promise { const { client } = await this.account.getClient(); - const owned = await this.isOwned(client, sandboxId); + const sandbox = await this.findSandbox(client, sandboxId); // Revoke first: Daytona refuses to revoke a token of a sandbox that no longer exists. await this.ssh.releaseSshAccess(sandboxId); - if (!owned) { + if (!sandbox) return; + if (!(await this.carriesInstanceLabel(sandbox))) { log.warn("Keeping a Daytona sandbox without this install's label", { sandboxId }); return; } try { - await client.deleteSandbox(sandboxId); + await client.deleteSandbox(sandbox.id); } catch (error) { if (!isDaytonaNotFound(error)) throw error; } } - /** A sandbox that is already gone counts as owned: its delete is a no-op. */ - private async isOwned(client: DaytonaClient, sandboxId: string): Promise { - let sandbox: DaytonaSandbox; + private async findSandbox( + client: DaytonaClient, + sandboxId: string + ): Promise { try { - sandbox = await client.getSandbox(sandboxId); + return await client.getSandbox(sandboxId); } catch (error) { - if (isDaytonaNotFound(error)) return true; + if (isDaytonaNotFound(error)) return undefined; throw error; } - return this.carriesInstanceLabel(sandbox); } private async carriesInstanceLabel(sandbox: DaytonaSandbox): Promise { From 270e373356b71cf766d1750e0d50fcccd135b506 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:48:28 +0000 Subject: [PATCH 226/231] fix(daytona): open no bash AI proxy forward to a sandbox Daytona's ssh gateway refuses every remote port forward (OpenSSH -R with any bind address, ssh2 forwardIn; checked live). Trying anyway made a bash command wait up to 10 s every 5 minutes while Xum tried 4 ports. A Daytona runtime now gets no forward target, as Docker does. --- docs/config/providers.mdx | 2 +- src/node/services/bashAiProxy/sshForwardTarget.test.ts | 6 +++--- src/node/services/bashAiProxy/sshForwardTarget.ts | 6 ++++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/config/providers.mdx b/docs/config/providers.mdx index 0ea4ec14b26..62f9d567258 100644 --- a/docs/config/providers.mdx +++ b/docs/config/providers.mdx @@ -91,7 +91,7 @@ Scripts that the agent runs in the bash tool (test harnesses, `make bug-bash`, S - Xum sends the calls with the provider keys from this page. The command never sees the real key. - Local and Worktree commands reach Xum directly. SSH and Coder commands reach it through a reverse SSH forward (`ssh -R`) that Xum opens to each host, on the host's `127.0.0.1`. The first turn on a host can wait up to 10 seconds for it. The forward uses its own SSH connection without the forwards in your `~/.ssh/config`. A host that refuses forwarding (`AllowTcpForwarding no`) gets no variables, and Xum tries it again after 5 minutes. After a connection failure Xum tries again after 30 seconds. A host that asks for a password or a key passphrase gets no variables: the forward does not prompt, so load the key into `ssh-agent`. - With `XUM_ALLOW_MULTIPLE_INSTANCES=1`, or while a `xum server` of another process uses the same Xum home, SSH and Coder commands get no variables: a host keeps one forwarded port per Xum home, and two backends would each pick their own. Xum does not detect a desktop app started with `XUM_NO_API_SERVER=1` next to `xum server`. -- Docker and devcontainer commands keep their environment: they have no route to Xum. +- Docker, devcontainer and Daytona commands keep their environment: they have no route to Xum (Daytona's SSH gateway refuses `ssh -R`). - Bash commands in `xum run` and `xum workflow` sessions do not get the variables. - Commands in untrusted projects, and every command while `XUM_DISABLE_PROJECT_AUTOMATION=1` is set, get no variables: Xum blanks provider keys there, so repo code cannot spend through the proxy. Revoking a project's trust also refuses calls from its commands that are still running. - A provider without a Xum API key (for example OpenAI with Codex OAuth only) gets no variables. A provider that your [project secrets](/config/project-secrets) configure keeps the secret values. This includes `OPENAI_ORG_ID` and `OPENAI_PROJECT_ID`. diff --git a/src/node/services/bashAiProxy/sshForwardTarget.test.ts b/src/node/services/bashAiProxy/sshForwardTarget.test.ts index c06e7c0f123..70a00a798ff 100644 --- a/src/node/services/bashAiProxy/sshForwardTarget.test.ts +++ b/src/node/services/bashAiProxy/sshForwardTarget.test.ts @@ -11,7 +11,7 @@ import { createDaytonaTestRig } from "@/node/services/daytona/daytona.testHarnes import { createSshForwardTarget, isXumRootShared } from "./sshForwardTarget"; describe("createSshForwardTarget", () => { - test("never restores a forward at startup for a Daytona sandbox (it would start a billed box)", async () => { + test("gives a Daytona sandbox no forward (its gateway refuses remote port forwarding)", async () => { const rig = await createDaytonaTestRig(); try { const plain = { host: "box", srcBaseDir: "~/src" }; @@ -22,8 +22,8 @@ describe("createSshForwardTarget", () => { const daytonaTarget = createSshForwardTarget( new DaytonaSSHRuntime(daytona, createSSHTransport(daytona, false), rig.service) ); - expect(sshTarget?.restoreAtStartup).toBe(true); - expect(daytonaTarget?.restoreAtStartup).toBe(false); + expect(sshTarget).toBeDefined(); + expect(daytonaTarget).toBeUndefined(); } finally { await rig.cleanup(); } diff --git a/src/node/services/bashAiProxy/sshForwardTarget.ts b/src/node/services/bashAiProxy/sshForwardTarget.ts index 30722842cfb..80f4ae5b5a3 100644 --- a/src/node/services/bashAiProxy/sshForwardTarget.ts +++ b/src/node/services/bashAiProxy/sshForwardTarget.ts @@ -29,11 +29,13 @@ export function remoteHealthScript(remotePort: number, nonce: string): string { export function createSshForwardTarget(runtime: Runtime): ForwardTarget | undefined { // MultiProjectRuntime, Docker and devcontainer are not SSH runtimes: no forward. if (!(runtime instanceof SSHRuntime)) return undefined; + // Daytona's ssh gateway refuses remote port forwarding (checked live with OpenSSH and ssh2), so + // each try would only hold up the command that waits for it. + if (runtime instanceof DaytonaSSHRuntime) return undefined; const config = runtime.getConfig(); return { hostKey: [config.host, config.port ?? 22, config.identityFile ?? ""].join("|"), - // Managed boxes: restoring a forward at startup would start a stopped (billed) box. - restoreAtStartup: !(runtime instanceof CoderSSHRuntime || runtime instanceof DaytonaSSHRuntime), + restoreAtStartup: !(runtime instanceof CoderSSHRuntime), openReverseForward: (remotePort, localPort) => runtime.openReverseForward(remotePort, localPort), remoteHealth: async (remotePort, nonce) => { From bdcf0f0dac11ad5f2a99edc37f7fca4a0d582f0f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:04:28 +0000 Subject: [PATCH 227/231] fix(daytona): stop, resize and start the sandbox by its checked id The workspace config holds the sandbox name, which another sandbox can take. The resize fallback now stops, resizes and restarts the id that the create returned, and startAndWait starts the id of the sandbox whose label it checked. Also regenerates the built-in docs for the providers page change in 270e373356. --- src/node/runtime/DaytonaSSHRuntime.test.ts | 25 +++++++++++++++++++ src/node/runtime/DaytonaSSHRuntime.ts | 5 ++-- .../builtInSkillContent.generated.ts | 2 +- .../services/daytona/daytonaSandboxes.test.ts | 16 ++++++++++++ src/node/services/daytona/daytonaSandboxes.ts | 3 ++- 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index bef7b5e2c35..dbdc1b81b1a 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -419,6 +419,31 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { } ); + it("never stops or resizes a sandbox that took the name during the resize", async () => { + Object.assign(rig.plane, { + rejectCreateResources: true, + snapshotResources: { cpu: 1, memory: 1, disk: 3 }, + }); + // At the stop the created sandbox goes, and an unlabeled one takes its name. + const swap = () => { + const created = rig.plane.find("xum-swap"); + if (created && created.id !== "id-other") { + rig.plane.sandboxes.delete(created.id); + rig.plane.addSandbox({ id: "id-other", name: "xum-swap", labels: {}, disk: 50 }); + } + return Promise.resolve(); + }; + rig.plane.onRequest.set("POST /sandbox/xum-swap/stop", swap); + rig.plane.onRequest.set("POST /sandbox/sb-1/stop", swap); + + const daytona = { sandboxId: "xum-swap", snapshot: "fixed-snap", cpu: 4, memoryGiB: 8 }; + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect( + makeRuntime({ ...daytona, diskGiB: 20 }).postCreateSetup(initParams()) + ).rejects.toThrow("setup failed"); + expect(rig.plane.find("id-other")).toMatchObject({ state: "started", cpu: 2, disk: 50 }); + }); + it("deletes the sandbox it created when setup fails afterwards", async () => { rig.writer.writeError = new Error("No space left on device"); const params = initParams(); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index 0d24e8aca46..a5f03870739 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -768,8 +768,9 @@ export class DaytonaSSHRuntime extends SSHRuntime { `creating with the snapshot's size and resizing to ${preset.label}...` ); } - await this.daytonaService.sandboxes.createSandbox(base); - await this.resizeToPreset(sandboxId, preset); + const created = await this.daytonaService.sandboxes.createSandbox(base); + // By id: another sandbox can take the name while this one stops and resizes. + await this.resizeToPreset(created.id, preset); } private async resolveTarget(): Promise<{ target?: string }> { diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 8273e49d379..406d94aa94c 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -5385,7 +5385,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "- Xum sends the calls with the provider keys from this page. The command never sees the real key.", "- Local and Worktree commands reach Xum directly. SSH and Coder commands reach it through a reverse SSH forward (`ssh -R`) that Xum opens to each host, on the host's `127.0.0.1`. The first turn on a host can wait up to 10 seconds for it. The forward uses its own SSH connection without the forwards in your `~/.ssh/config`. A host that refuses forwarding (`AllowTcpForwarding no`) gets no variables, and Xum tries it again after 5 minutes. After a connection failure Xum tries again after 30 seconds. A host that asks for a password or a key passphrase gets no variables: the forward does not prompt, so load the key into `ssh-agent`.", "- With `XUM_ALLOW_MULTIPLE_INSTANCES=1`, or while a `xum server` of another process uses the same Xum home, SSH and Coder commands get no variables: a host keeps one forwarded port per Xum home, and two backends would each pick their own. Xum does not detect a desktop app started with `XUM_NO_API_SERVER=1` next to `xum server`.", - "- Docker and devcontainer commands keep their environment: they have no route to Xum.", + "- Docker, devcontainer and Daytona commands keep their environment: they have no route to Xum (Daytona's SSH gateway refuses `ssh -R`).", "- Bash commands in `xum run` and `xum workflow` sessions do not get the variables.", "- Commands in untrusted projects, and every command while `XUM_DISABLE_PROJECT_AUTOMATION=1` is set, get no variables: Xum blanks provider keys there, so repo code cannot spend through the proxy. Revoking a project's trust also refuses calls from its commands that are still running.", "- A provider without a Xum API key (for example OpenAI with Codex OAuth only) gets no variables. A provider that your [project secrets](/config/project-secrets) configure keeps the secret values. This includes `OPENAI_ORG_ID` and `OPENAI_PROJECT_ID`.", diff --git a/src/node/services/daytona/daytonaSandboxes.test.ts b/src/node/services/daytona/daytonaSandboxes.test.ts index ed43c67433b..854e5321552 100644 --- a/src/node/services/daytona/daytonaSandboxes.test.ts +++ b/src/node/services/daytona/daytonaSandboxes.test.ts @@ -152,6 +152,22 @@ describe("DaytonaSandboxes", () => { expect(rig.plane.countCalls("DELETE /sandbox/ws-1")).toBe(0); }); + it("never starts a sandbox that took the name after the label check", async () => { + rig.plane.addSandbox({ id: "id-a", name: "ws-s", state: "stopped" }); + const swap = () => { + if (rig.plane.sandboxes.delete("id-a")) { + rig.plane.addSandbox({ id: "id-b", name: "ws-s", state: "stopped", labels: {} }); + } + return Promise.resolve(); + }; + rig.plane.onRequest.set("POST /sandbox/ws-s/start", swap); + rig.plane.onRequest.set("POST /sandbox/id-a/start", swap); + + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(rig.service.sandboxes.ensureSandboxReady("ws-s")).rejects.toThrow(); + expect(rig.plane.find("id-b")?.state).toBe("stopped"); + }); + it("sends no delete for a sandbox that is already gone", async () => { await rig.service.sandboxes.deleteSandbox("ws-gone"); expect(rig.plane.countCalls("DELETE /sandbox/ws-gone")).toBe(0); diff --git a/src/node/services/daytona/daytonaSandboxes.ts b/src/node/services/daytona/daytonaSandboxes.ts index 6936980c5a7..b1ed184a183 100644 --- a/src/node/services/daytona/daytonaSandboxes.ts +++ b/src/node/services/daytona/daytonaSandboxes.ts @@ -181,7 +181,8 @@ export class DaytonaSandboxes { if (step === "done") return sandbox; if (step === "start") { options.onStarting?.(); - await client.startSandbox(sandboxId, options.signal); + // The id the label check saw: another sandbox can take the name in the meantime. + await client.startSandbox(sandbox.id, options.signal); startRequested = true; } const remaining = deadline - this.now(); From ebe478135b228f61cec60c447c30c1c764e3206a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:20:23 +0000 Subject: [PATCH 228/231] fix(daytona): keep your own ssh clients quiet for sandbox aliases OpenSSH names the user, which is the gateway token, in its fatal Permission denied line, and an editor's or a native terminal's ssh output passes no Xum redactor. The managed ~/.ssh/config block now sets LogLevel QUIET for sandbox aliases. Xum's own ssh reads only the include file, so its messages (redacted) stay. --- docs/runtime/daytona.mdx | 2 +- src/node/runtime/daytonaSshConfig.test.ts | 15 +++++++++++++++ src/node/runtime/daytonaSshConfig.ts | 6 +++++- .../agentSkills/builtInSkillContent.generated.ts | 2 +- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index 331953e14a2..5502c22fdb3 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -73,7 +73,7 @@ Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and a Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts. -Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, and Xum keeps its block first in the file, so its settings win for sandboxes in those clients too: strict host key checking against the pinned keys, the token as the user name, no proxy or jump host, and no agent or X11 forwarding. Your other entries still apply to every other host. A `KnownHostsCommand` or `Match exec` line in your config still runs for sandboxes in your own clients, with the token as `%r`; scope such lines to the hosts that need them. OpenSSH adds up `RemoteForward` lines instead of keeping the first, so a `RemoteForward` in a `Host *` entry also opens a tunnel from the sandbox to your machine in those clients; scope it to the hosts that need it. +Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, and Xum keeps its block first in the file, so its settings win for sandboxes in those clients too: strict host key checking against the pinned keys, the token as the user name, no proxy or jump host, no agent or X11 forwarding, and `LogLevel QUIET`, because an ssh error message would print the token. Your other entries still apply to every other host. A `KnownHostsCommand` or `Match exec` line in your config still runs for sandboxes in your own clients, with the token as `%r`; scope such lines to the hosts that need them. OpenSSH adds up `RemoteForward` lines instead of keeping the first, so a `RemoteForward` in a `Host *` entry also opens a tunnel from the sandbox to your machine in those clients; scope it to the hosts that need it. ## Notes diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index 3b62da36d7b..ab597f4d335 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -47,6 +47,7 @@ describe("DaytonaSshConfigWriter", () => { [ XUM_DAYTONA_SSH_BLOCK_START, "Host *.xum--daytona", + " LogLevel QUIET", ` Include ${writer.paths.includePath}`, "Host *", XUM_DAYTONA_SSH_BLOCK_END, @@ -312,6 +313,20 @@ describe("DaytonaSshConfigWriter", () => { expect(other.stdout.toString()).not.toContain("user tokenA"); }); + // OpenSSH names the user (the token) in its fatal `user@host: Permission denied` line, and + // an editor's or a native terminal's ssh output reaches no Xum redactor. + it.skipIf(!hasOpenSsh)("keeps your own ssh clients quiet for a Daytona alias", async () => { + await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); + await fs.writeFile(sshConfigPath, "Host *\n LogLevel VERBOSE\n", "utf8"); + await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + + const resolve = (host: string) => + spawnSync("ssh", ["-G", "-F", sshConfigPath, host]).stdout.toString(); + // `ssh -G` prints QUIET as SILENT. + expect(resolve(toXumDaytonaHost("sb-1"))).toMatch(/^loglevel (QUIET|SILENT)$/m); + expect(resolve("unrelated.example.com")).toMatch(/^loglevel VERBOSE$/m); + }); + // Any user directive with %r would get the token (the ssh user name), and `-o` cannot // override a Match exec, so ssh reads only Xum's include file for a Daytona alias. it.skipIf(!hasOpenSsh)("runs no user ssh config directive for a Daytona alias", async () => { diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index c4a72f3f5cf..125026403f7 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -123,12 +123,16 @@ function quoteConfigPath(filePath: string): string { * option, so the alias's hardening beats any user entry such as a `Host *` that turns off host * key checking or forwards the agent. The Include is scoped to `Host *.xum--daytona` so it * reaches no other host, and the closing `Host *` puts the user's top-level lines that follow - * back in scope for every host, as they were before the block. + * back in scope for every host, as they were before the block. `LogLevel QUIET` is here, not in + * the include file: ssh's fatal `user@host: Permission denied` names the token, and an editor's + * or a native terminal's ssh output passes no Xum redactor, while Xum's own ssh keeps its + * messages (its stderr is redacted). */ export function renderDaytonaManagedBlock(includePath: string): string { return [ XUM_DAYTONA_SSH_BLOCK_START, `Host *.${XUM_DAYTONA_HOST_SUFFIX}`, + " LogLevel QUIET", ` Include ${quoteConfigPath(includePath)}`, "Host *", XUM_DAYTONA_SSH_BLOCK_END, diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 406d94aa94c..f8c34052996 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10110,7 +10110,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts.", "", - "Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, and Xum keeps its block first in the file, so its settings win for sandboxes in those clients too: strict host key checking against the pinned keys, the token as the user name, no proxy or jump host, and no agent or X11 forwarding. Your other entries still apply to every other host. A `KnownHostsCommand` or `Match exec` line in your config still runs for sandboxes in your own clients, with the token as `%r`; scope such lines to the hosts that need them. OpenSSH adds up `RemoteForward` lines instead of keeping the first, so a `RemoteForward` in a `Host *` entry also opens a tunnel from the sandbox to your machine in those clients; scope it to the hosts that need it.", + "Xum's own SSH connections to sandboxes read only `daytona.conf` (OpenSSH runs with `-F`), so nothing in your `~/.ssh/config` or in `/etc/ssh/ssh_config` applies to them: a `Host *` entry's `User`, `ProxyCommand`, `KnownHostsCommand`, forwards or `Match exec` never run for a sandbox, and neither does a system-wide proxy or jump host. The managed block in `~/.ssh/config` is for your own SSH clients, such as an editor's Remote-SSH, which do read your config. OpenSSH uses the first value it finds for each setting, and Xum keeps its block first in the file, so its settings win for sandboxes in those clients too: strict host key checking against the pinned keys, the token as the user name, no proxy or jump host, no agent or X11 forwarding, and `LogLevel QUIET`, because an ssh error message would print the token. Your other entries still apply to every other host. A `KnownHostsCommand` or `Match exec` line in your config still runs for sandboxes in your own clients, with the token as `%r`; scope such lines to the hosts that need them. OpenSSH adds up `RemoteForward` lines instead of keeping the first, so a `RemoteForward` in a `Host *` entry also opens a tunnel from the sandbox to your machine in those clients; scope it to the hosts that need it.", "", "## Notes", "", From 69803bc98d55c2f1edb912e76af5acd87aa20d3c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:46:40 +0000 Subject: [PATCH 229/231] fix(daytona): record the sandbox id and send every request to it The workspace config held only the sandbox name, which another sandbox can take once the workspace's own is gone. The create now records Daytona's id as daytona.sandboxId (the name moves to daytona.sandboxName, which the ssh alias keeps using), and every control-plane request about a workspace's sandbox goes to that id: state, start, stop, resize, delete, activity, and ssh token mint and revoke. A sandbox without a recorded id is not Xum's to reach; the delete looks one up by name only to clean up a create whose answer never came, and only with this install's label. A workspace removed during the create records nothing, and its new sandbox is deleted again. By id, Daytona keeps answering for a deleted sandbox (deleting, then destroyed), so those states count as gone (found by the live suite). Cuts the reconciler's token refresh. ensureReady and the transport repair already mint a fresh token when one is needed, and the reconciler's mint ran outside the readiness join, so a release could not wait for it (both open Codex P1s on ebe478135b). --- docs/runtime/daytona.mdx | 4 +- .../RuntimeBadge/RuntimeBadge.test.ts | 2 +- .../components/RuntimeBadge/RuntimeBadge.tsx | 2 +- src/browser/stores/GitStatusStore.test.ts | 2 +- src/browser/utils/openInEditor.test.ts | 2 +- src/common/constants/daytona.ts | 6 +- src/common/orpc/schemas/daytona.ts | 12 +- src/common/orpc/schemas/runtime.test.ts | 4 +- src/common/types/runtime.ts | 8 +- src/node/runtime/DaytonaSSHRuntime.test.ts | 138 +++++------ src/node/runtime/DaytonaSSHRuntime.ts | 214 +++++++++--------- src/node/runtime/SSH2ConnectionPool.test.ts | 4 +- src/node/runtime/daytonaSshConfig.test.ts | 66 +++--- src/node/runtime/daytonaSshConfig.ts | 32 +-- src/node/runtime/runtimeFactory.test.ts | 2 +- src/node/runtime/runtimeFactory.ts | 6 +- src/node/runtime/sshConfigParser.test.ts | 4 +- .../builtInSkillContent.generated.ts | 4 +- .../bashAiProxy/sshForwardTarget.test.ts | 2 +- .../services/daytona/daytona.testHarness.ts | 35 ++- .../services/daytona/daytonaAccount.test.ts | 1 + .../daytona/daytonaLifecycleHooks.test.ts | 8 +- .../services/daytona/daytonaLifecycleHooks.ts | 12 +- src/node/services/daytona/daytonaReadiness.ts | 26 +-- .../daytona/daytonaReconciler.test.ts | 45 ++-- .../services/daytona/daytonaReconciler.ts | 68 +++--- .../daytona/daytonaSandboxIds.test.ts | 45 ++++ .../services/daytona/daytonaSandboxIds.ts | 82 +++++++ .../services/daytona/daytonaSandboxes.test.ts | 51 +++-- src/node/services/daytona/daytonaSandboxes.ts | 194 +++++++++++----- src/node/services/daytona/daytonaService.ts | 5 + src/node/services/daytona/daytonaSsh.test.ts | 30 ++- src/node/services/daytona/daytonaSsh.ts | 120 +++++----- src/node/services/serviceContainer.ts | 2 + src/node/services/terminalService.test.ts | 2 +- src/node/services/workspaceExternalEditors.ts | 5 +- .../services/workspaceService.archive.test.ts | 8 +- .../workspaceService.multiProject.test.ts | 2 +- .../services/workspaceService.remove.test.ts | 2 +- .../workspaceService.runtimeStatus.test.ts | 16 +- src/node/services/workspaceService.ts | 24 +- tests/runtime/daytonaRuntime.test.ts | 12 +- 42 files changed, 785 insertions(+), 524 deletions(-) create mode 100644 src/node/services/daytona/daytonaSandboxIds.test.ts create mode 100644 src/node/services/daytona/daytonaSandboxIds.ts diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index 5502c22fdb3..e20a136c706 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -61,7 +61,7 @@ Xum uses only sandboxes it creates: a workspace cannot use an existing sandbox. | Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs | | Sandbox no longer exists | The workspace reports it; Xum does not recreate it | -Each sandbox belongs to the workspace that created it. Archiving or deleting that workspace deletes the sandbox, even while forks of it (sub-agent worktrees) still use it; **Settings > General > Remote box on archive** does not apply to Daytona. Unarchiving a workspace whose sandbox was deleted does not bring the sandbox back, so push or copy any work you need before you archive. An agent cannot archive the workspace that created a sandbox: it asks you to do it. Archiving or deleting a fork removes only its worktree and never stops or deletes the sandbox. Before Xum starts a sandbox, opens SSH access to it, or deletes it, it checks that the sandbox still carries this install's `xum.instance` label. A sandbox without it (for example one whose label was removed, or one that took the name after the workspace's sandbox was deleted) is never used or deleted, and Xum notes it in its log. +Each sandbox belongs to the workspace that created it. Archiving or deleting that workspace deletes the sandbox, even while forks of it (sub-agent worktrees) still use it; **Settings > General > Remote box on archive** does not apply to Daytona. Unarchiving a workspace whose sandbox was deleted does not bring the sandbox back, so push or copy any work you need before you archive. An agent cannot archive the workspace that created a sandbox: it asks you to do it. Archiving or deleting a fork removes only its worktree and never stops or deletes the sandbox. Xum records the id of each sandbox it creates in the workspace's settings, and every Daytona request about the sandbox uses that id, so a sandbox that later gets the same name is never touched. Before Xum starts a sandbox, opens SSH access to it, or deletes it, it also checks that the sandbox carries this install's `xum.instance` label. A sandbox without it (for example one whose label was removed) is never used or deleted, and Xum notes it in its log. A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard. @@ -69,7 +69,7 @@ Xum does not stop or delete sandboxes that no workspace uses (for example one wh ## SSH setup -Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, in case that read failed at startup. When an SSH command to a running sandbox fails (for example the gateway refuses a token revoked outside Xum, or Daytona moved its gateway or changed its host keys), Xum reads the gateway settings from Daytona again, rewrites the SSH settings, gets a new token, and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered. +Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, in case that read failed at startup. When an SSH command to a running sandbox fails (for example the gateway refuses a token revoked outside Xum, or Daytona moved its gateway or changed its host keys), Xum reads the gateway settings from Daytona again, rewrites the SSH settings, gets a new token, and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered. Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts. diff --git a/src/browser/components/RuntimeBadge/RuntimeBadge.test.ts b/src/browser/components/RuntimeBadge/RuntimeBadge.test.ts index 75c91943b36..f65adea6281 100644 --- a/src/browser/components/RuntimeBadge/RuntimeBadge.test.ts +++ b/src/browser/components/RuntimeBadge/RuntimeBadge.test.ts @@ -25,7 +25,7 @@ describe("getSshRuntimeInfo", () => { type: "ssh", host: "sb-1.xum--daytona", srcBaseDir: "~/src", - daytona: { sandboxId: "sb-1" }, + daytona: { sandboxName: "sb-1" }, }); expect(created.type).toBe("daytona"); expect(created.label).toContain("sb-1"); diff --git a/src/browser/components/RuntimeBadge/RuntimeBadge.tsx b/src/browser/components/RuntimeBadge/RuntimeBadge.tsx index 9857154c9ce..e32df2c00f7 100644 --- a/src/browser/components/RuntimeBadge/RuntimeBadge.tsx +++ b/src/browser/components/RuntimeBadge/RuntimeBadge.tsx @@ -85,7 +85,7 @@ export function getSshRuntimeInfo(runtimeConfig: Extract { type: "ssh", host: "xum-daytona-sb-1", srcBaseDir: "/home/daytona/xum", - daytona: { sandboxId: "sb-1" }, + daytona: { sandboxName: "sb-1" }, }; // The default srcBaseDir (~/mux) is persisted unresolved: the sandbox may not exist yet when diff --git a/src/common/constants/daytona.ts b/src/common/constants/daytona.ts index 4b829470b67..d632158b5a3 100644 --- a/src/common/constants/daytona.ts +++ b/src/common/constants/daytona.ts @@ -6,14 +6,14 @@ export const DAYTONA_DEFAULT_API_URL = "https://app.daytona.io/api"; /** - * Host suffix for Daytona sandboxes (`.xum--daytona`). The `--` keeps Xum's managed + * Host suffix for Daytona sandboxes (`.xum--daytona`). The `--` keeps Xum's managed * ssh config block from colliding with user-managed entries, as the Coder suffix does. */ export const XUM_DAYTONA_HOST_SUFFIX = "xum--daytona"; /** Build the SSH alias for a Daytona sandbox (e.g. `abc123.xum--daytona`). */ -export function toXumDaytonaHost(sandboxId: string): string { - return `${sandboxId}.${XUM_DAYTONA_HOST_SUFFIX}`; +export function toXumDaytonaHost(sandboxName: string): string { + return `${sandboxName}.${XUM_DAYTONA_HOST_SUFFIX}`; } /** Whether an ssh host (optionally `user@host`) is a Daytona sandbox alias Xum manages. */ diff --git a/src/common/orpc/schemas/daytona.ts b/src/common/orpc/schemas/daytona.ts index 5d248e1a864..4022ac01ede 100644 --- a/src/common/orpc/schemas/daytona.ts +++ b/src/common/orpc/schemas/daytona.ts @@ -2,8 +2,16 @@ import { z } from "zod"; // Daytona sandbox config - attached to the SSH runtime when Daytona is the SSH backend. export const DaytonaSandboxConfigSchema = z.object({ - /** Set by Xum when it creates the sandbox (finalizeConfig picks its name). */ - sandboxId: z.string().optional().meta({ description: "Daytona sandbox id" }), + /** The name Xum gives the sandbox it creates (finalizeConfig picks it); also its ssh alias. */ + sandboxName: z.string().optional().meta({ description: "Daytona sandbox name" }), + /** + * Daytona's id of the sandbox, recorded once Xum created it. Control-plane requests go to the + * id: another sandbox can take the name once this one is gone, never the id. + */ + sandboxId: z + .string() + .optional() + .meta({ description: "Daytona sandbox id (recorded after creation)" }), snapshot: z .string() .optional() diff --git a/src/common/orpc/schemas/runtime.test.ts b/src/common/orpc/schemas/runtime.test.ts index 6a4d60f9a2f..36712d21ed6 100644 --- a/src/common/orpc/schemas/runtime.test.ts +++ b/src/common/orpc/schemas/runtime.test.ts @@ -88,7 +88,7 @@ describe("RuntimeConfigSchema ssh backends", () => { test("keeps the daytona sub-config through a persist round-trip", () => { const daytona = { - sandboxId: "sb-1", + sandboxName: "sb-1", snapshot: "node-22", cpu: 2, memoryGiB: 4, @@ -103,7 +103,7 @@ describe("RuntimeConfigSchema ssh backends", () => { const result = RuntimeConfigSchema.safeParse({ ...ssh, coder: { workspaceName: "ws" }, - daytona: { sandboxId: "sb-1" }, + daytona: { sandboxName: "sb-1" }, }); expect(result.success).toBe(false); }); diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts index bc909f149ab..87c44f34de4 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -276,9 +276,9 @@ export function isSSHRuntime( } /** The Daytona sandbox a runtime config points at, if any. */ -export function getDaytonaSandboxId(config: RuntimeConfig | undefined): string | undefined { - const sandboxId = isSSHRuntime(config) ? config.daytona?.sandboxId?.trim() : undefined; - return sandboxId === "" ? undefined : sandboxId; +export function getDaytonaSandboxName(config: RuntimeConfig | undefined): string | undefined { + const sandboxName = isSSHRuntime(config) ? config.daytona?.sandboxName?.trim() : undefined; + return sandboxName === "" ? undefined : sandboxName; } /** @@ -295,7 +295,7 @@ export function ownsDaytonaSandbox(workspace: { workspace.taskIsolation !== "none" && isSSHRuntime(config) && config.daytona?.forked !== true && - getDaytonaSandboxId(config) !== undefined + getDaytonaSandboxName(config) !== undefined ); } diff --git a/src/node/runtime/DaytonaSSHRuntime.test.ts b/src/node/runtime/DaytonaSSHRuntime.test.ts index dbdc1b81b1a..3590e613b08 100644 --- a/src/node/runtime/DaytonaSSHRuntime.test.ts +++ b/src/node/runtime/DaytonaSSHRuntime.test.ts @@ -83,28 +83,28 @@ const SMALL = { cpu: 2, memoryGiB: 4, diskGiB: 10 }; describe("DaytonaSSHRuntime.ensureReady", () => { it("trusts a ready result for 60 s across runtime instances, then checks again", async () => { rig.plane.addSandbox({ id: "sb-ready" }); - expect(await makeRuntime({ sandboxId: "sb-ready" }).ensureReady()).toEqual({ ready: true }); + expect(await makeRuntime({ sandboxName: "sb-ready" }).ensureReady()).toEqual({ ready: true }); const getsAfterFirst = rig.plane.countCalls("GET /sandbox/sb-ready"); // A stream start builds a fresh runtime; it must not hit the control plane again. - expect(await makeRuntime({ sandboxId: "sb-ready" }).ensureReady()).toEqual({ ready: true }); + expect(await makeRuntime({ sandboxName: "sb-ready" }).ensureReady()).toEqual({ ready: true }); expect(rig.plane.countCalls("GET /sandbox/sb-ready")).toBe(getsAfterFirst); rig.clock.now += DAYTONA_READY_CACHE_MS; - await makeRuntime({ sandboxId: "sb-ready" }).ensureReady(); + await makeRuntime({ sandboxName: "sb-ready" }).ensureReady(); expect(rig.plane.countCalls("GET /sandbox/sb-ready")).toBeGreaterThan(getsAfterFirst); }); it("puts back the ~/.ssh/config block for an external ssh client within the ready cache", async () => { rig.plane.addSandbox({ id: "sb-ready" }); - expect(await makeRuntime({ sandboxId: "sb-ready" }).ensureReady()).toEqual({ ready: true }); + expect(await makeRuntime({ sandboxName: "sb-ready" }).ensureReady()).toEqual({ ready: true }); rig.writer.managedBlockPresent = false; // Xum's own commands read only daytona.conf: a cached check leaves the block alone. - expect(await makeRuntime({ sandboxId: "sb-ready" }).ensureReady()).toEqual({ ready: true }); + expect(await makeRuntime({ sandboxName: "sb-ready" }).ensureReady()).toEqual({ ready: true }); expect(rig.writer.managedBlockPresent).toBe(false); - const forEditor = await makeRuntime({ sandboxId: "sb-ready" }).ensureReady({ + const forEditor = await makeRuntime({ sandboxName: "sb-ready" }).ensureReady({ externalSshClient: true, }); expect(forEditor).toEqual({ ready: true }); @@ -115,7 +115,7 @@ describe("DaytonaSSHRuntime.ensureReady", () => { execSpy.mockRestore(); rig.plane.addSandbox({ id: "sb-shared" }); const runtimeFor = (workspaceName: string) => - makeRuntime({ sandboxId: "sb-shared" }, rig, { + makeRuntime({ sandboxName: "sb-shared" }, rig, { transport: localShellTransport(rig.rootDir), projectPath: "/home/me/project", workspaceName, @@ -135,7 +135,7 @@ describe("DaytonaSSHRuntime.ensureReady", () => { rig.plane.addSandbox({ id: "sb-stopped", state: "stopped" }); const events: RuntimeStatusEvent[] = []; - const result = await makeRuntime({ sandboxId: "sb-stopped" }).ensureReady({ + const result = await makeRuntime({ sandboxName: "sb-stopped" }).ensureReady({ statusSink: (event) => events.push(event), }); @@ -151,7 +151,7 @@ describe("DaytonaSSHRuntime.ensureReady", () => { const phases: string[][] = [[], []]; const results = await Promise.all( phases.map((seen) => - makeRuntime({ sandboxId: "sb-archived" }).ensureReady({ + makeRuntime({ sandboxName: "sb-archived" }).ensureReady({ statusSink: (event) => seen.push(event.phase), }) ) @@ -164,7 +164,7 @@ describe("DaytonaSSHRuntime.ensureReady", () => { }); it("reports a sandbox removed outside Xum as runtime_not_ready and never recreates it", async () => { - const result = await makeRuntime({ sandboxId: "sb-gone" }).ensureReady(); + const result = await makeRuntime({ sandboxName: "sb-gone" }).ensureReady(); expect(result.ready).toBe(false); if (!result.ready) { @@ -179,7 +179,7 @@ describe("DaytonaSSHRuntime integrated terminals", () => { it("start a stopped sandbox before the terminal's ssh session opens", async () => { const sandbox = rig.plane.addSandbox({ id: "sb-term", state: "stopped" }); let stateWhenOpened: string | undefined; - const runtime = makeRuntime({ sandboxId: "sb-term" }, rig, { + const runtime = makeRuntime({ sandboxName: "sb-term" }, rig, { transport: { ...localShellTransport(rig.rootDir), createPtySession: () => { @@ -197,7 +197,7 @@ describe("DaytonaSSHRuntime integrated terminals", () => { it("report a sandbox that cannot be brought up instead of opening ssh", async () => { let opened = false; - const runtime = makeRuntime({ sandboxId: "sb-gone" }, rig, { + const runtime = makeRuntime({ sandboxName: "sb-gone" }, rig, { transport: { ...localShellTransport(rig.rootDir), createPtySession: () => { @@ -221,9 +221,9 @@ describe("DaytonaSSHRuntime.finalizeConfig and validateBeforePersist", () => { const result = await runtime.finalizeConfig("Feature_X", sshDaytonaConfig({})); expect(result.success).toBe(true); if (!result.success || result.data.type !== "ssh") throw new Error("expected ssh config"); - const sandboxId = result.data.daytona?.sandboxId ?? ""; - expect(sandboxId).toMatch(/^xum-feature-x-[0-9a-f]{6}$/); - expect(result.data.host).toBe(`${sandboxId}.xum--daytona`); + const sandboxName = result.data.daytona?.sandboxName ?? ""; + expect(sandboxName).toMatch(/^xum-feature-x-[0-9a-f]{6}$/); + expect(result.data.host).toBe(`${sandboxName}.xum--daytona`); expect(result.data.daytona).toMatchObject({ ...SMALL }); expect(await runtime.validateBeforePersist("Feature_X", result.data)).toEqual({ success: true, @@ -236,10 +236,10 @@ describe("DaytonaSSHRuntime.finalizeConfig and validateBeforePersist", () => { const runtime = makeRuntime({}); const result = await runtime.finalizeConfig( "b", - sshDaytonaConfig({ sandboxId: "sb-mine", forked: true }) + sshDaytonaConfig({ sandboxName: "sb-mine", forked: true }) ); if (!result.success || result.data.type !== "ssh") throw new Error("expected ssh config"); - expect(result.data.daytona?.sandboxId).toMatch(/^xum-b-[0-9a-f]{6}$/); + expect(result.data.daytona?.sandboxName).toMatch(/^xum-b-[0-9a-f]{6}$/); expect(result.data.daytona?.forked).toBeUndefined(); }); @@ -255,11 +255,11 @@ describe("DaytonaSSHRuntime.finalizeConfig and validateBeforePersist", () => { const runtime = makeRuntime({}); const odd = await runtime.finalizeConfig("b", sshDaytonaConfig({ cpu: 3 })); expect(odd.success).toBe(false); - const handEdited = sshDaytonaConfig({ sandboxId: "xum-b-1", cpu: 64 }); + const handEdited = sshDaytonaConfig({ sandboxName: "xum-b-1", cpu: 64 }); expect((await runtime.validateBeforePersist("b", handEdited)).success).toBe(false); rig.plane.addSandbox({ id: "sb-9", name: "xum-b-taken" }); - const taken = sshDaytonaConfig({ sandboxId: "xum-b-taken", ...SMALL }); + const taken = sshDaytonaConfig({ sandboxName: "xum-b-taken", ...SMALL }); expect((await runtime.validateBeforePersist("b", taken)).success).toBe(false); }); }); @@ -324,7 +324,7 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { rig.service.account.setConfig({ apiKey: "key-b" }) ); - const failure = await makeRuntime({ sandboxId: "xum-switch", ...SMALL }) + const failure = await makeRuntime({ sandboxName: "xum-switch", ...SMALL }) .postCreateSetup(initParams()) .then( () => undefined, @@ -352,7 +352,7 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { return rig.service.account.setConfig({ apiKey: "key-b" }); }); - const failure = await makeRuntime({ sandboxId: "sb-src", forked: true, ...SMALL }) + const failure = await makeRuntime({ sandboxName: "sb-src", forked: true, ...SMALL }) .postCreateSetup(initParams()) .then( () => undefined, @@ -366,7 +366,7 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { }); it("creates the sandbox with preset resources, labels and lifecycle intervals", async () => { - await makeRuntime({ sandboxId: "xum-new", ...SMALL }).postCreateSetup(initParams()); + await makeRuntime({ sandboxName: "xum-new", ...SMALL }).postCreateSetup(initParams()); expect(rig.plane.createBodies).toHaveLength(1); expect(rig.plane.createBodies[0]).toMatchObject({ @@ -401,7 +401,7 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { Object.assign(rig.plane, { rejectCreateResources: true, snapshotResources, ...omit }); await makeRuntime({ - sandboxId: "xum-fixed", + sandboxName: "xum-fixed", snapshot: "fixed-snap", cpu: 4, memoryGiB: 8, @@ -429,14 +429,16 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { const created = rig.plane.find("xum-swap"); if (created && created.id !== "id-other") { rig.plane.sandboxes.delete(created.id); - rig.plane.addSandbox({ id: "id-other", name: "xum-swap", labels: {}, disk: 50 }); + rig.plane.addSandbox( + { id: "id-other", name: "xum-swap", labels: {}, disk: 50 }, + { recorded: false } + ); } return Promise.resolve(); }; - rig.plane.onRequest.set("POST /sandbox/xum-swap/stop", swap); rig.plane.onRequest.set("POST /sandbox/sb-1/stop", swap); - const daytona = { sandboxId: "xum-swap", snapshot: "fixed-snap", cpu: 4, memoryGiB: 8 }; + const daytona = { sandboxName: "xum-swap", snapshot: "fixed-snap", cpu: 4, memoryGiB: 8 }; // eslint-disable-next-line @typescript-eslint/await-thenable await expect( makeRuntime({ ...daytona, diskGiB: 20 }).postCreateSetup(initParams()) @@ -448,7 +450,7 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { rig.writer.writeError = new Error("No space left on device"); const params = initParams(); - const failure = await makeRuntime({ sandboxId: "xum-doomed", ...SMALL }) + const failure = await makeRuntime({ sandboxName: "xum-doomed", ...SMALL }) .postCreateSetup(params) .then( () => undefined, @@ -469,27 +471,27 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { }); try { // A setup error that persists blocks the removal's checks, so only a forced one goes. - for (const [sandboxId, force] of [ + for (const [sandboxName, force] of [ ["xum-transient", false], ["xum-persistent", true], ] as const) { rig.writer.writeError = new Error("No space left on device"); - // The delete goes to the id of the sandbox the setup created, not to its name. - rig.plane.onRequest.set(`GET /sandbox/${sandboxId}`, () => { - const created = rig.plane.find(sandboxId); - if (created) rig.plane.failingRoutes.add(`DELETE /sandbox/${created.id}`); - return Promise.resolve(); + // The setup's delete of the sandbox it created fails. + const record = spyOn(rig.plane.recordedIds, "record").mockImplementation((name, id) => { + rig.plane.failingRoutes.add(`DELETE /sandbox/${id}`); + rig.plane.recordedIds.ids.set(name, id); + return Promise.resolve(true); }); - const daytona = { sandboxId, ...SMALL }; + const daytona = { sandboxName, ...SMALL }; // eslint-disable-next-line @typescript-eslint/await-thenable await expect(makeRuntime(daytona).postCreateSetup(initParams())).rejects.toThrow( "setup failed" ); - expect(rig.plane.find(sandboxId)).toBeDefined(); + expect(rig.plane.find(sandboxName)).toBeDefined(); // The persisted workspace still names the sandbox; removing it deletes the sandbox. - rig.plane.onRequest.clear(); + record.mockRestore(); rig.plane.failingRoutes.clear(); if (!force) rig.writer.writeError = undefined; const removal = await makeRuntime(daytona).deleteWorkspace( @@ -498,7 +500,7 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { force ); expect(removal.success).toBe(true); - expect(rig.plane.find(sandboxId)).toBeUndefined(); + expect(rig.plane.find(sandboxName)).toBeUndefined(); } } finally { sshDelete.mockRestore(); @@ -513,7 +515,7 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { labels: { "xum.instance": "another-install", "xum.workspaceId": "ws-other" }, }); - const failure = await makeRuntime({ sandboxId: "xum-raced", ...SMALL }) + const failure = await makeRuntime({ sandboxName: "xum-raced", ...SMALL }) .postCreateSetup(initParams()) .then( () => undefined, @@ -526,8 +528,8 @@ describe("DaytonaSSHRuntime.postCreateSetup", () => { }); it("after a create of unknown outcome, deletes the sandbox only when it has its labels", async () => { - const setUp = (sandboxId: string) => - makeRuntime({ sandboxId, ...SMALL }) + const setUp = (sandboxName: string) => + makeRuntime({ sandboxName, ...SMALL }) .postCreateSetup(initParams()) .then( () => undefined, @@ -571,7 +573,7 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { await rig.service.ssh.ensureSshAccess("sb-1"); sshDeleteSpy.mockResolvedValue({ success: false, error: "ssh: host unreachable" }); - const forced = await makeRuntime({ sandboxId: "sb-1", forked: true }, rig, { + const forced = await makeRuntime({ sandboxName: "sb-1", forked: true }, rig, { workspaceId: "ws-fork", }).deleteWorkspace("/home/me/project", "ws-fork", true); @@ -585,7 +587,7 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { rig.plane.addSandbox({ id: "sb-dirty" }); sshDeleteSpy.mockResolvedValue({ success: false, error: "uncommitted changes" }); - const result = await makeRuntime({ sandboxId: "sb-dirty" }).deleteWorkspace( + const result = await makeRuntime({ sandboxName: "sb-dirty" }).deleteWorkspace( "/home/me/project", "feature", false @@ -599,7 +601,7 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { rig.plane.addSandbox({ id: "sb-clean" }); await rig.service.ssh.ensureSshAccess("sb-clean"); - const result = await makeRuntime({ sandboxId: "sb-clean" }).deleteWorkspace( + const result = await makeRuntime({ sandboxName: "sb-clean" }).deleteWorkspace( "/home/me/project", "feature", false @@ -614,25 +616,25 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { it("never starts a stopped or archived sandbox for removal checks, as for Coder", async () => { for (const state of ["stopped", "archived"]) { - const sandboxId = `sb-${state}`; - rig.plane.addSandbox({ id: sandboxId, state }); + const sandboxName = `sb-${state}`; + rig.plane.addSandbox({ id: sandboxName, state }); - const fork = await makeRuntime({ sandboxId, forked: true }).deleteWorkspace( + const fork = await makeRuntime({ sandboxName, forked: true }).deleteWorkspace( "/home/me/project", "feature-fork", false ); expect(fork.success).toBe(true); - expect(rig.plane.find(sandboxId)?.state).toBe(state); + expect(rig.plane.find(sandboxName)?.state).toBe(state); - const owner = await makeRuntime({ sandboxId }).deleteWorkspace( + const owner = await makeRuntime({ sandboxName }).deleteWorkspace( "/home/me/project", "feature", false ); expect(owner.success).toBe(true); - expect(rig.plane.find(sandboxId)).toBeUndefined(); - expect(rig.plane.countCalls(`POST /sandbox/${sandboxId}/start`)).toBe(0); + expect(rig.plane.find(sandboxName)).toBeUndefined(); + expect(rig.plane.countCalls(`POST /sandbox/${sandboxName}/start`)).toBe(0); } expect(sshDeleteSpy).not.toHaveBeenCalled(); }); @@ -644,7 +646,7 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { workspacePath: "/fork", }); try { - const sourceDaytona = { sandboxId: "sb-parent", ...SMALL }; + const sourceDaytona = { sandboxName: "sb-parent", ...SMALL }; const fork = await makeRuntime(sourceDaytona).forkWorkspace({ projectPath: "/home/me/project", sourceWorkspaceName: "feature", @@ -685,8 +687,8 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { stateWhenForked = sandbox.state; return Promise.resolve({ success: true, workspacePath: "/fork" }); }); - const forkFrom = (sandboxId: string) => - makeRuntime({ sandboxId, ...SMALL }).forkWorkspace({ + const forkFrom = (sandboxName: string) => + makeRuntime({ sandboxName, ...SMALL }).forkWorkspace({ projectPath: "/home/me/project", sourceWorkspaceName: "feature", newWorkspaceName: "feature-fork", @@ -712,7 +714,7 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { workspacePath: "/fork", }); try { - const sourceDaytona = { sandboxId: "sb-parent", ...SMALL }; + const sourceDaytona = { sandboxName: "sb-parent", ...SMALL }; const fork = await makeRuntime(sourceDaytona).forkWorkspace({ projectPath: "/home/me/project", sourceWorkspaceName: "feature", @@ -745,7 +747,7 @@ describe("DaytonaSSHRuntime.deleteWorkspace", () => { rig.plane.addSandbox({ id: "sb-stuck" }); rig.plane.failingRoutes.add("DELETE /sandbox/sb-stuck"); - const result = await makeRuntime({ sandboxId: "sb-stuck" }).deleteWorkspace( + const result = await makeRuntime({ sandboxName: "sb-stuck" }).deleteWorkspace( "/home/me/project", "feature", true @@ -760,7 +762,7 @@ describe("DaytonaSSHRuntime delete checks on the unresolved ~ workspace path", ( execSpy.mockRestore(); const home = path.join(rig.rootDir, "sandbox-home"); rig.plane.addSandbox({ id: "sb-dirty" }); - const runtime = makeRuntime({ sandboxId: "sb-dirty" }, rig, { + const runtime = makeRuntime({ sandboxName: "sb-dirty" }, rig, { transport: localShellTransport(home), }); // Deferred access never resolves `~` at creation, so this is the path deletion sees. @@ -793,7 +795,7 @@ describe("DaytonaSSHRuntime ssh token redaction", () => { await rig.service.ssh.ensureSshAccess("sb-tok"); const token = rig.writer.tokens.get("sb-tok"); if (!token) throw new Error("expected a minted token"); - const runtime = makeRuntime({ sandboxId: "sb-tok" }, rig, { + const runtime = makeRuntime({ sandboxName: "sb-tok" }, rig, { transport: localShellTransport(rig.rootDir), }); @@ -812,7 +814,7 @@ describe("DaytonaSSHRuntime ssh token redaction", () => { const token = rig.writer.tokens.get("sb-tok"); if (!token) throw new Error("expected a minted token"); let spawns = 0; - const runtime = makeRuntime({ sandboxId: "sb-tok" }, rig, { + const runtime = makeRuntime({ sandboxName: "sb-tok" }, rig, { transport: { ...localShellTransport(rig.rootDir), spawnRemoteProcess: () => { @@ -847,7 +849,7 @@ describe("DaytonaSSHRuntime ssh token redaction", () => { let readsWhenSpawned: number | undefined; // A UI runtime: its commands never run ensureReady first. - const runtime = makeRuntime({ sandboxId: "sb-old" }, rig, { + const runtime = makeRuntime({ sandboxName: "sb-old" }, rig, { transport: { ...localShellTransport(rig.rootDir), spawnRemoteProcess: () => { @@ -880,12 +882,12 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { ? local.spawnRemoteProcess(command, options) : Promise.reject(new RuntimeError("Connection closed by 127.0.0.1 port 22", "network")), }; - const turn = makeRuntime({ sandboxId: "sb-nap" }, rig, { transport }); + const turn = makeRuntime({ sandboxName: "sb-nap" }, rig, { transport }); expect(await turn.ensureReady()).toEqual({ ready: true }); sandbox.state = "stopped"; // A UI or background runtime never starts the (billed) sandbox. - const background = makeRuntime({ sandboxId: "sb-nap" }, rig, { transport }); + const background = makeRuntime({ sandboxName: "sb-nap" }, rig, { transport }); // eslint-disable-next-line @typescript-eslint/await-thenable await expect(background.exec("echo ok", { cwd: "/tmp", timeout: 10 })).rejects.toThrow( "Connection closed" @@ -910,7 +912,7 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { : Promise.reject(new RuntimeError("Connection closed by 127.0.0.1 port 22", "network")), }; const runtimeFor = () => - makeRuntime({ sandboxId: "sb-nap3" }, rig, { + makeRuntime({ sandboxName: "sb-nap3" }, rig, { transport, projectPath: "/home/me/project", workspaceName: "ws-a", @@ -941,7 +943,7 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { : Promise.reject(new RuntimeError("Host key verification failed.", "network")); }, }; - const runtime = makeRuntime({ sandboxId: "sb-moved" }, rig, { transport }); + const runtime = makeRuntime({ sandboxName: "sb-moved" }, rig, { transport }); expect(await runtime.ensureReady()).toEqual({ ready: true }); const configFetches = rig.plane.countCalls("GET /config"); const gateway = { @@ -964,7 +966,7 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { }); /** The gateway answers a stopped or deleted sandbox with the same refusal as a bad token. */ - function refusingTransportWhile(sandboxId: string, refuses: () => boolean): SSHTransport { + function refusingTransportWhile(sandboxName: string, refuses: () => boolean): SSHTransport { const local = localShellTransport(rig.rootDir); return { ...local, @@ -972,7 +974,7 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { refuses() ? Promise.reject( new RuntimeError( - `${rig.writer.tokens.get(sandboxId) ?? ""}@127.0.0.1: Permission denied (publickey).`, + `${rig.writer.tokens.get(sandboxName) ?? ""}@127.0.0.1: Permission denied (publickey).`, "network" ) ) @@ -984,7 +986,7 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { rig.plane.addSandbox({ id: "sb-gone" }); await rig.service.ssh.ensureSshAccess("sb-gone"); rig.plane.sandboxes.delete("sb-gone"); - const runtime = makeRuntime({ sandboxId: "sb-gone" }, rig, { + const runtime = makeRuntime({ sandboxName: "sb-gone" }, rig, { transport: refusingTransportWhile("sb-gone", () => true), }); @@ -1000,7 +1002,7 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { await rig.service.ssh.ensureSshAccess("sb-relabeled"); const minted = rig.plane.countCalls("POST /sandbox/sb-relabeled/ssh-access"); sandbox.labels = {}; - const runtime = makeRuntime({ sandboxId: "sb-relabeled" }, rig, { + const runtime = makeRuntime({ sandboxName: "sb-relabeled" }, rig, { transport: refusingTransportWhile("sb-relabeled", () => true), }); @@ -1030,7 +1032,7 @@ describe("DaytonaSSHRuntime reconnect after a stop", () => { : local.spawnRemoteProcess(command, options), }; // Not a turn's runtime: a new token starts nothing, so any runtime replaces it. - const runtime = makeRuntime({ sandboxId: "sb-rev" }, rig, { transport }); + const runtime = makeRuntime({ sandboxName: "sb-rev" }, rig, { transport }); const stream = await runtime.exec("echo ok", { cwd: "/tmp", timeout: 10 }); expect(await new Response(stream.stdout).text()).toBe("ok\n"); diff --git a/src/node/runtime/DaytonaSSHRuntime.ts b/src/node/runtime/DaytonaSSHRuntime.ts index a5f03870739..a6f2536fbbc 100644 --- a/src/node/runtime/DaytonaSSHRuntime.ts +++ b/src/node/runtime/DaytonaSSHRuntime.ts @@ -4,9 +4,10 @@ * workspace metadata is persisted, so progress streams to the init log), and ensureReady starts * a stopped or archived sandbox on demand. * - * Sandbox identity: Daytona accepts a sandbox name wherever it takes an id, so for a sandbox - * Xum creates, finalizeConfig picks a unique name and stores it as `daytona.sandboxId`. The - * host alias (`.xum--daytona`) is then known before persist, like Coder's. + * Sandbox identity: finalizeConfig picks a unique name for the sandbox Xum creates and stores + * it as `daytona.sandboxName`, so the host alias (`.xum--daytona`) is known before + * persist, like Coder's. The create records Daytona's id as `daytona.sandboxId`, and every + * control-plane request goes to that id (DaytonaSandboxIds). */ import { randomBytes } from "crypto"; @@ -35,7 +36,7 @@ import { toXumDaytonaHost, type DaytonaSizePreset, } from "@/common/constants/daytona"; -import type { DaytonaSandboxConfig, DaytonaSandboxState } from "@/common/orpc/schemas/daytona"; +import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime } from "@/common/types/runtime"; import type { Result } from "@/common/types/result"; @@ -84,6 +85,7 @@ export interface DaytonaRuntimeService { sandboxes: Pick< DaytonaSandboxes, | "getSandboxState" + | "isNameTaken" | "assertOwned" | "buildLabels" | "createSandbox" @@ -165,9 +167,9 @@ type StatusEmitter = (phase: RuntimeStatusEvent["phase"], detail?: string) => vo class SandboxMissingError extends Error {} -function sandboxMissingMessage(sandboxId: string): string { +function sandboxMissingMessage(sandboxName: string): string { return ( - `Daytona sandbox "${sandboxId}" no longer exists. Archiving or removing the workspace ` + + `Daytona sandbox "${sandboxName}" no longer exists. Archiving or removing the workspace ` + `that created it deletes it, and Xum does not recreate it; delete this workspace.` ); } @@ -238,10 +240,10 @@ export class DaytonaSSHRuntime extends SSHRuntime { daytonaService: DaytonaRuntimeService, options?: DaytonaSSHRuntimeOptions ) { - const sandboxId = config.daytona.sandboxId; + const sandboxName = config.daytona.sandboxName; super( { - host: sandboxId ? toXumDaytonaHost(sandboxId) : config.host, + host: sandboxName ? toXumDaytonaHost(sandboxName) : config.host, srcBaseDir: config.srcBaseDir, bgOutputDir: config.bgOutputDir, identityFile: config.identityFile, @@ -256,31 +258,31 @@ export class DaytonaSSHRuntime extends SSHRuntime { this.workspaceId = options?.workspaceId; } - private sandboxMissing(sandboxId: string): EnsureReadyResult { + private sandboxMissing(sandboxName: string): EnsureReadyResult { return { ready: false, - error: sandboxMissingMessage(sandboxId), + error: sandboxMissingMessage(sandboxName), errorType: "runtime_not_ready", }; } /** Keep cleanup hooks from starting a stopped (billed) sandbox through SSH. */ async isRunningWithoutStart(_abortSignal: AbortSignal): Promise { - const sandboxId = this.daytonaConfig.sandboxId; - return sandboxId ? this.isSandboxStarted(sandboxId) : false; + const sandboxName = this.daytonaConfig.sandboxName; + return sandboxName ? this.isSandboxStarted(sandboxName) : false; } - private async isSandboxStarted(sandboxId: string): Promise { - return (await this.readSandboxState(sandboxId)) === "started"; + private async isSandboxStarted(sandboxName: string): Promise { + return (await this.readSandboxState(sandboxName)) === "started"; } /** Undefined when the state cannot be read: callers treat that as not running. */ - private async readSandboxState(sandboxId: string): Promise { + private async readSandboxState(sandboxName: string): Promise { try { - return (await this.daytonaService.sandboxes.getSandboxState(sandboxId)).state; + return (await this.daytonaService.sandboxes.getSandboxState(sandboxName)).state; } catch (error) { log.warn("Could not read Daytona sandbox state; treating it as not running", { - sandboxId, + sandboxName, error: getErrorMessage(error), }); return undefined; @@ -294,32 +296,32 @@ export class DaytonaSSHRuntime extends SSHRuntime { * silently recreated. */ override async ensureReady(options?: EnsureReadyOptions): Promise { - const sandboxId = this.daytonaConfig.sandboxId; - if (!sandboxId) { + const sandboxName = this.daytonaConfig.sandboxName; + if (!sandboxName) { return { ready: false, - error: "Daytona sandbox id not set", + error: "Daytona sandbox name not set", errorType: "runtime_not_ready", }; } const emitStatus = makeStatusEmitter(options?.statusSink); // An external client reads the user's ssh config, which the cache does not cover: another // tool may have removed the managed block since. Only a full check writes it again. - if (!options?.externalSshClient && this.daytonaService.readiness.isSandboxReady(sandboxId)) { + if (!options?.externalSshClient && this.daytonaService.readiness.isSandboxReady(sandboxName)) { return this.ensureReadyFromCache(options, emitStatus); } - return this.ensureReadyUncached(sandboxId, options, emitStatus); + return this.ensureReadyUncached(sandboxName, options, emitStatus); } private async ensureReadyUncached( - sandboxId: string, + sandboxName: string, options: EnsureReadyOptions | undefined, emitStatus: StatusEmitter ): Promise { emitStatus("checking"); - const failure = await this.bringSandboxUp(sandboxId, emitStatus, options?.signal); + const failure = await this.bringSandboxUp(sandboxName, emitStatus, options?.signal); if (failure) return failure; - const result = await this.markReadyIfRepoPresent(sandboxId, options, emitStatus); + const result = await this.markReadyIfRepoPresent(sandboxName, options, emitStatus); if (result.ready) this.restartsStoppedSandbox = true; return result; } @@ -347,10 +349,10 @@ export class DaytonaSSHRuntime extends SSHRuntime { options: ExecOptions & { deadlineMs?: number } ): Promise { let result: SpawnResult; - const sandboxId = this.daytonaConfig.sandboxId; + const sandboxName = this.daytonaConfig.sandboxName; // ssh may send a token an earlier process left in the include file: the redactor must // know it before ssh can quote it. Not every command runs after ensureReady. - if (sandboxId) await this.daytonaService.ssh.loadPersistedTokens(); + if (sandboxName) await this.daytonaService.ssh.loadPersistedTokens(); try { result = await super.spawnRemoteProcess(fullCommand, options); } catch (error) { @@ -369,8 +371,8 @@ export class DaytonaSSHRuntime extends SSHRuntime { ...result, onExit: (exitCode, stderr) => { // The ready cache must not hide a sandbox that stopped: the next ensureReady checks. - if (sandboxId && this.isTransportFailureExit(exitCode, stderr)) { - this.daytonaService.readiness.invalidateSandboxReady(sandboxId); + if (sandboxName && this.isTransportFailureExit(exitCode, stderr)) { + this.daytonaService.readiness.invalidateSandboxReady(sandboxName); } onExit?.(exitCode, stderr); }, @@ -385,23 +387,23 @@ export class DaytonaSSHRuntime extends SSHRuntime { error: unknown, abortSignal: AbortSignal | undefined ): Promise { - const sandboxId = this.daytonaConfig.sandboxId; - if (!sandboxId || !isRuntimeTransportError(error) || abortSignal?.aborted) return false; - this.daytonaService.readiness.invalidateSandboxReady(sandboxId); + const sandboxName = this.daytonaConfig.sandboxName; + if (!sandboxName || !isRuntimeTransportError(error) || abortSignal?.aborted) return false; + this.daytonaService.readiness.invalidateSandboxReady(sandboxName); try { - await this.daytonaService.readiness.join(sandboxId, { signal: abortSignal }, () => - this.repairSandbox(sandboxId) + await this.daytonaService.readiness.join(sandboxName, { signal: abortSignal }, () => + this.repairSandbox(sandboxName) ); } catch (repairError) { if (repairError instanceof SandboxMissingError) { throw new RuntimeError( - sandboxMissingMessage(sandboxId), + sandboxMissingMessage(sandboxName), "network", redactDaytonaError(error) ); } log.warn("Could not repair ssh access to the Daytona sandbox", { - sandboxId, + sandboxName, error: getErrorMessage(redactDaytonaError(repairError)), }); return false; @@ -416,27 +418,27 @@ export class DaytonaSSHRuntime extends SSHRuntime { * "Permission denied". A started one gets a fresh token and gateway; a stopped one is started * only by a runtime that may start it. Throws when nothing was repaired. */ - private async repairSandbox(sandboxId: string): Promise { - const { state, sandbox } = await this.daytonaService.sandboxes.getSandboxState(sandboxId); + private async repairSandbox(sandboxName: string): Promise { + const { state, sandbox } = await this.daytonaService.sandboxes.getSandboxState(sandboxName); if (!sandbox) throw new SandboxMissingError(); if (state === "started") { - await this.daytonaService.sandboxes.assertOwned(sandbox, sandboxId); + await this.daytonaService.sandboxes.assertOwned(sandbox, sandboxName); log.info("An ssh command to the Daytona sandbox failed; replacing its token and retrying", { - sandboxId, + sandboxName, }); - await this.daytonaService.ssh.repairSshAccess(sandboxId); + await this.daytonaService.ssh.repairSshAccess(sandboxName); return; } if (!this.restartsStoppedSandbox) throw new Error(`the sandbox is ${state}`); log.info("Daytona sandbox is not running; starting it and retrying the command once", { - sandboxId, + sandboxName, }); // Not ensureSandboxReady: it would join this repair and wait for itself. await this.daytonaService.sandboxes.startAndWait( - sandboxId, + sandboxName, Date.now() + DAYTONA_START_TIMEOUT_MS ); - await this.daytonaService.ssh.ensureSshAccess(sandboxId); + await this.daytonaService.ssh.ensureSshAccess(sandboxName); } /** @@ -465,30 +467,30 @@ export class DaytonaSSHRuntime extends SSHRuntime { /** Start the sandbox if needed and refresh ssh access; a failure result, or undefined. */ private async bringSandboxUp( - sandboxId: string, + sandboxName: string, emitStatus: StatusEmitter, signal: AbortSignal | undefined ): Promise { try { - await this.daytonaService.sandboxes.ensureSandboxReady(sandboxId, { + await this.daytonaService.sandboxes.ensureSandboxReady(sandboxName, { onStarting: () => emitStatus("starting", "Starting sandbox..."), signal, }); return undefined; } catch (error) { emitStatus("error"); - return this.ensureReadyFailure(sandboxId, error, signal); + return this.ensureReadyFailure(sandboxName, error, signal); } } private async markReadyIfRepoPresent( - sandboxId: string, + sandboxName: string, options: EnsureReadyOptions | undefined, emitStatus: StatusEmitter ): Promise { const repoFailure = await this.checkRepo(options, emitStatus); if (repoFailure) return repoFailure; - this.daytonaService.readiness.markSandboxReady(sandboxId); + this.daytonaService.readiness.markSandboxReady(sandboxName); emitStatus("ready"); return { ready: true }; } @@ -509,7 +511,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { } private ensureReadyFailure( - sandboxId: string, + sandboxName: string, error: unknown, signal: AbortSignal | undefined ): EnsureReadyResult { @@ -517,7 +519,7 @@ export class DaytonaSSHRuntime extends SSHRuntime { return { ready: false, error: "Aborted", errorType: "runtime_start_failed" }; } if (error instanceof DaytonaApiError) { - if (error.kind === "not_found") return this.sandboxMissing(sandboxId); + if (error.kind === "not_found") return this.sandboxMissing(sandboxName); if (error.kind === "not_configured" || error.kind === "unauthorized") { return { ready: false, error: error.message, errorType: "runtime_not_ready" }; } @@ -550,14 +552,15 @@ export class DaytonaSSHRuntime extends SSHRuntime { if (!preset) { return Err(invalidSizeError()); } - const sandboxId = buildDaytonaSandboxName(finalBranchName); + const sandboxName = buildDaytonaSandboxName(finalBranchName); return Ok({ ...config, - host: toXumDaytonaHost(sandboxId), + host: toXumDaytonaHost(sandboxName), daytona: { ...daytona, - sandboxId, + sandboxName, // A creation request never reuses a sandbox, whatever a hand-edited config says. + sandboxId: undefined, forked: undefined, cpu: preset.cpu, memoryGiB: preset.memoryGiB, @@ -582,20 +585,20 @@ export class DaytonaSSHRuntime extends SSHRuntime { return Err(availability.reason); } const daytona = config.daytona; - const sandboxId = daytona.sandboxId; - if (!sandboxId) { - return Err("Daytona sandbox id is required (should be set by finalizeConfig)"); + const sandboxName = daytona.sandboxName; + if (!sandboxName) { + return Err("Daytona sandbox name is required (should be set by finalizeConfig)"); } - return this.checkNewSandbox(sandboxId, daytona); + return this.checkNewSandbox(sandboxName, daytona); } /** A sandbox Xum creates: a preset size, a free name, and a snapshot that is not Windows. */ private async checkNewSandbox( - sandboxId: string, + sandboxName: string, daytona: DaytonaSandboxConfig ): Promise> { if (!findDaytonaSizePreset(daytona)) return Err(invalidSizeError()); - const nameFree = await this.checkNameFree(sandboxId); + const nameFree = await this.checkNameFree(sandboxName); if (!nameFree.success) return nameFree; return daytona.snapshot ? this.checkSnapshotClass(daytona.snapshot) @@ -617,16 +620,14 @@ export class DaytonaSSHRuntime extends SSHRuntime { return snapshot ? this.checkSnapshotClass(snapshot, { missingOk: true }) : Ok(undefined); } - private async checkNameFree(sandboxId: string): Promise> { - let state: DaytonaSandboxState; + private async checkNameFree(sandboxName: string): Promise> { + let taken: boolean; try { - state = (await this.daytonaService.sandboxes.getSandboxState(sandboxId)).state; + taken = await this.daytonaService.sandboxes.isNameTaken(sandboxName); } catch (error) { return Err(`Could not reach Daytona: ${getErrorMessage(error)}`); } - return state === "missing" - ? Ok(undefined) - : Err(`A Daytona sandbox named "${sandboxId}" already exists`); + return taken ? Err(`A Daytona sandbox named "${sandboxName}" already exists`) : Ok(undefined); } /** Refused before any sandbox is created: a Windows snapshot makes a billed box setup fails on. */ @@ -670,65 +671,65 @@ export class DaytonaSSHRuntime extends SSHRuntime { } private async setUpSandbox(params: WorkspaceInitParams): Promise { - const sandboxId = this.daytonaConfig.sandboxId; - if (!sandboxId) { - throw new Error("Daytona sandbox id is required (should be set by finalizeConfig)"); + const sandboxName = this.daytonaConfig.sandboxName; + if (!sandboxName) { + throw new Error("Daytona sandbox name is required (should be set by finalizeConfig)"); } // A fork reuses its source's sandbox: create nothing (and so never delete it on failure). const creates = this.daytonaConfig.forked !== true; try { if (creates) { - params.initLogger.logStep(`Creating Daytona sandbox "${sandboxId}"...`); - await this.createSandbox(sandboxId, params); + params.initLogger.logStep(`Creating Daytona sandbox "${sandboxName}"...`); + await this.createSandbox(sandboxName, params); params.initLogger.logStep("Daytona sandbox created"); } - await this.connect(sandboxId, params); + await this.connect(sandboxName, params); await this.prepareWorkspaceParentDirectory(params); // After a settings save during the setup, the workspace would sit in an account Xum no // longer uses, with ssh access the save's cleanup did not see: undo both, as for any // failure. if (await this.daytonaService.account.pinnedAccountChanged()) { - await this.daytonaService.ssh.releaseSshAccess(sandboxId); + await this.daytonaService.ssh.releaseSshAccess(sandboxName); throw new Error("the Daytona settings changed during setup; create the workspace again"); } } catch (error) { const errorMsg = redactDaytonaTokens(getErrorMessage(error)); - log.error("Daytona sandbox setup failed", { sandboxId, error: errorMsg }); + log.error("Daytona sandbox setup failed", { sandboxName, error: errorMsg }); params.initLogger.logStderr(`Daytona sandbox setup failed: ${errorMsg}`); // A failed create may still have made the sandbox (a timeout, a 5xx); deleteSandbox keeps // one without this install's label. - if (creates) await this.deleteCreatedSandbox(sandboxId, params.initLogger); + if (creates) await this.deleteCreatedSandbox(sandboxName, params.initLogger); throw new Error(`Daytona sandbox setup failed: ${errorMsg}`); } } /** Wait until the sandbox runs and write ssh access. */ - private async connect(sandboxId: string, params: WorkspaceInitParams): Promise { + private async connect(sandboxName: string, params: WorkspaceInitParams): Promise { const { initLogger, abortSignal } = params; initLogger.logStep("Waiting for the sandbox to start and configuring SSH..."); - await this.daytonaService.sandboxes.ensureSandboxReady(sandboxId, { + await this.daytonaService.sandboxes.ensureSandboxReady(sandboxName, { onStarting: () => initLogger.logStep("Starting sandbox..."), signal: abortSignal, }); } private async deleteCreatedSandbox( - sandboxId: string, + sandboxName: string, initLogger: WorkspaceInitParams["initLogger"] ): Promise { try { - initLogger.logStep(`Deleting the sandbox "${sandboxId}" this setup created...`); - await this.daytonaService.sandboxes.deleteSandbox(sandboxId); + initLogger.logStep(`Deleting the sandbox "${sandboxName}" this setup created...`); + await this.daytonaService.sandboxes.deleteSandbox(sandboxName); } catch (error) { // The persisted workspace still names the sandbox; removing the workspace deletes it. const message = getErrorMessage(error); log.error("Failed to delete a Daytona sandbox after setup failed", { - sandboxId, + sandboxName, error: message, }); initLogger.logStderr( - `Failed to delete Daytona sandbox "${sandboxId}": ${message}. The sandbox is kept; ` + + `Failed to delete Daytona sandbox "${sandboxName}": ${message}. The sandbox is kept; ` + "removing this workspace deletes it." ); } @@ -738,14 +739,14 @@ export class DaytonaSSHRuntime extends SSHRuntime { * Create with the preset resources. Daytona rejects resources (HTTP 400) for snapshots with * fixed resources: then create with the snapshot's own size and resize to the preset. */ - private async createSandbox(sandboxId: string, params: WorkspaceInitParams): Promise { + private async createSandbox(sandboxName: string, params: WorkspaceInitParams): Promise { const preset = resolveSizePreset(this.daytonaConfig); if (!preset) throw new Error(invalidSizeError()); const base = { - name: sandboxId, + name: sandboxName, ...(this.daytonaConfig.snapshot ? { snapshot: this.daytonaConfig.snapshot } : {}), labels: await this.daytonaService.sandboxes.buildLabels({ - workspaceId: this.workspaceId ?? sandboxId, + workspaceId: this.workspaceId ?? sandboxName, projectPath: params.projectPath, }), ...(await this.resolveTarget()), @@ -768,9 +769,8 @@ export class DaytonaSSHRuntime extends SSHRuntime { `creating with the snapshot's size and resizing to ${preset.label}...` ); } - const created = await this.daytonaService.sandboxes.createSandbox(base); - // By id: another sandbox can take the name while this one stops and resizes. - await this.resizeToPreset(created.id, preset); + await this.daytonaService.sandboxes.createSandbox(base); + await this.resizeToPreset(sandboxName, preset); } private async resolveTarget(): Promise<{ target?: string }> { @@ -783,9 +783,9 @@ export class DaytonaSSHRuntime extends SSHRuntime { * A running sandbox can only grow cpu and memory; shrinking them or growing the disk needs a * stopped one. Disk never shrinks, so a larger snapshot disk is kept. */ - private async resizeToPreset(sandboxId: string, preset: DaytonaSizePreset): Promise { + private async resizeToPreset(sandboxName: string, preset: DaytonaSizePreset): Promise { const deadline = () => Date.now() + DAYTONA_START_TIMEOUT_MS; - const sandbox = await this.daytonaService.sandboxes.startAndWait(sandboxId, deadline()); + const sandbox = await this.daytonaService.sandboxes.startAndWait(sandboxName, deadline()); const disk = growsDisk(sandbox, preset); const request = { cpu: preset.cpu, @@ -793,12 +793,12 @@ export class DaytonaSSHRuntime extends SSHRuntime { ...(disk ? { disk: preset.diskGiB } : {}), }; if (disk || shrinksCompute(sandbox, preset)) { - await this.daytonaService.sandboxes.stopAndWait(sandboxId, deadline()); - await this.daytonaService.sandboxes.resizeSandbox(sandboxId, request); + await this.daytonaService.sandboxes.stopAndWait(sandboxName, deadline()); + await this.daytonaService.sandboxes.resizeSandbox(sandboxName, request); } else if (changesCompute(sandbox, preset)) { - await this.daytonaService.sandboxes.resizeSandbox(sandboxId, request); + await this.daytonaService.sandboxes.resizeSandbox(sandboxName, request); } - await this.daytonaService.sandboxes.startAndWait(sandboxId, deadline()); + await this.daytonaService.sandboxes.startAndWait(sandboxName, deadline()); } /** @@ -851,21 +851,21 @@ export class DaytonaSSHRuntime extends SSHRuntime { force: boolean, sshDelete: () => Promise ): Promise { - const sandboxId = this.daytonaConfig.sandboxId; - if (!sandboxId) { - log.warn("Daytona sandbox id not set, falling back to SSH-only deletion"); + const sandboxName = this.daytonaConfig.sandboxName; + if (!sandboxName) { + log.warn("Daytona sandbox name not set, falling back to SSH-only deletion"); return sshDelete(); } - const reachable = reachableForCleanup(await this.readSandboxState(sandboxId)); + const reachable = reachableForCleanup(await this.readSandboxState(sandboxName)); if (this.daytonaConfig.forked === true) { return reachable - ? this.deleteForkCheckout(sandboxId, deletedPath, force, sshDelete) + ? this.deleteForkCheckout(sandboxName, deletedPath, force, sshDelete) : { success: true, deletedPath }; } - if (force || !reachable) return this.deleteOwnedSandbox(sandboxId, deletedPath); + if (force || !reachable) return this.deleteOwnedSandbox(sandboxName, deletedPath); const sshResult = await sshDelete(); return sshResult.success - ? this.deleteOwnedSandbox(sandboxId, deletedPath, "SSH delete succeeded, but ") + ? this.deleteOwnedSandbox(sandboxName, deletedPath, "SSH delete succeeded, but ") : sshResult; } @@ -874,35 +874,35 @@ export class DaytonaSSHRuntime extends SSHRuntime { * created it. A forced removal goes on when the checkout cannot be deleted. */ private async deleteForkCheckout( - sandboxId: string, + sandboxName: string, deletedPath: string, force: boolean, sshDelete: () => Promise ): Promise { const result = await sshDelete(); if (result.success || !force) return result; - log.warn("Left a fork's checkout on its Daytona sandbox", { sandboxId, deletedPath }); + log.warn("Left a fork's checkout on its Daytona sandbox", { sandboxName, deletedPath }); return { success: true, deletedPath }; } private async deleteOwnedSandbox( - sandboxId: string, + sandboxName: string, deletedPath: string, errorPrefix?: string ): Promise { try { - await this.daytonaService.sandboxes.deleteSandbox(sandboxId); + await this.daytonaService.sandboxes.deleteSandbox(sandboxName); return { success: true, deletedPath }; } catch (error) { const message = getErrorMessage(error); - log.error("Failed to delete Daytona sandbox", { sandboxId, error: message }); + log.error("Failed to delete Daytona sandbox", { sandboxName, error: message }); return { success: false, error: errorPrefix ? `${errorPrefix}failed to delete Daytona sandbox: ${message}` : `Failed to delete Daytona sandbox: ${message}`, // A forced removal goes on without it: remove shows this as a warning to act on. - leftoverPaths: [`Daytona sandbox "${sandboxId}"`], + leftoverPaths: [`Daytona sandbox "${sandboxName}"`], }; } } diff --git a/src/node/runtime/SSH2ConnectionPool.test.ts b/src/node/runtime/SSH2ConnectionPool.test.ts index 0ff73dd5585..fa22b7bee83 100644 --- a/src/node/runtime/SSH2ConnectionPool.test.ts +++ b/src/node/runtime/SSH2ConnectionPool.test.ts @@ -226,7 +226,7 @@ describe.skipIf(process.platform === "win32")( }; await writer.writeSandbox({ - sandboxId: "sb-1", + sandboxName: "sb-1", token: "tokenA", gateway: { ...gateway, hostKeys: [publicKeyLine(newPrivateKey())] }, }); @@ -265,7 +265,7 @@ describe.skipIf(process.platform === "win32")( ]) { await withDaytonaHome(userConfig, async (writer) => { await writer.writeSandbox({ - sandboxId: "sb-1", + sandboxName: "sb-1", token: "tokenA", gateway: { host: "127.0.0.1", diff --git a/src/node/runtime/daytonaSshConfig.test.ts b/src/node/runtime/daytonaSshConfig.test.ts index ab597f4d335..56446946472 100644 --- a/src/node/runtime/daytonaSshConfig.test.ts +++ b/src/node/runtime/daytonaSshConfig.test.ts @@ -41,7 +41,7 @@ describe("DaytonaSshConfigWriter", () => { await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); await fs.writeFile(sshConfigPath, "Host work\n User me\n", "utf8"); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway: GATEWAY }); expect(await fs.readFile(sshConfigPath, "utf8")).toBe( [ @@ -84,12 +84,12 @@ describe("DaytonaSshConfigWriter", () => { }); it("refreshes a token by rewriting only the include file, keeping other sandboxes", async () => { - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - await writer.writeSandbox({ sandboxId: "sb-2", token: "tokenB", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-2", token: "tokenB", gateway: GATEWAY }); const sshConfigBefore = await fs.readFile(sshConfigPath, "utf8"); await fs.rm(writer.paths.knownHostsPath); - await writer.updateToken({ sandboxId: "sb-1", token: "tokenA2", gateway: GATEWAY }); + await writer.updateToken({ sandboxName: "sb-1", token: "tokenA2", gateway: GATEWAY }); const include = await fs.readFile(writer.paths.includePath, "utf8"); expect(include).toContain("User tokenA2"); @@ -105,7 +105,7 @@ describe("DaytonaSshConfigWriter", () => { }); it("puts back a removed managed block, and leaves an unchanged config untouched", async () => { - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway: GATEWAY }); const written = await fs.readFile(sshConfigPath, "utf8"); const { ino } = await fs.stat(sshConfigPath); @@ -119,8 +119,8 @@ describe("DaytonaSshConfigWriter", () => { }); it("repoints every sandbox at a new gateway in the write that replaces its host keys", async () => { - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - await writer.writeSandbox({ sandboxId: "sb-2", token: "tokenB", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-2", token: "tokenB", gateway: GATEWAY }); const moved = { host: "ssh2.daytona.test", port: 2222, @@ -128,7 +128,7 @@ describe("DaytonaSshConfigWriter", () => { }; // sb-1 is refreshed first; sb-2 still has a turn on the shared known_hosts file. - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA2", gateway: moved }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA2", gateway: moved }); const include = await fs.readFile(writer.paths.includePath, "utf8"); expect(include).not.toContain("ssh.daytona.test"); @@ -141,8 +141,8 @@ describe("DaytonaSshConfigWriter", () => { ].join("\n") ); expect(await writer.readTokens()).toEqual([ - { sandboxId: "sb-1", token: "tokenA2" }, - { sandboxId: "sb-2", token: "tokenB" }, + { sandboxName: "sb-1", token: "tokenA2" }, + { sandboxName: "sb-2", token: "tokenB" }, ]); // Moved back without a token refresh: every block follows, tokens kept. @@ -156,20 +156,20 @@ describe("DaytonaSshConfigWriter", () => { const later = new DaytonaSshConfigWriter({ xumHome, sshConfigPath }); expect(await later.readTokens()).toEqual([]); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); - await writer.writeSandbox({ sandboxId: "sb-2", token: "tokenB", gateway: GATEWAY }); - await writer.updateToken({ sandboxId: "sb-1", token: "tokenA2", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-2", token: "tokenB", gateway: GATEWAY }); + await writer.updateToken({ sandboxName: "sb-1", token: "tokenA2", gateway: GATEWAY }); expect(await later.readTokens()).toEqual([ - { sandboxId: "sb-1", token: "tokenA2" }, - { sandboxId: "sb-2", token: "tokenB" }, + { sandboxName: "sb-1", token: "tokenA2" }, + { sandboxName: "sb-2", token: "tokenB" }, ]); }); it("refuses control-plane values that would inject ssh directives", async () => { for (const input of [ - { sandboxId: "sb-1", token: "tok\n ProxyCommand touch /tmp/pwned" }, - { sandboxId: "sb-1\nHost *", token: "tok" }, + { sandboxName: "sb-1", token: "tok\n ProxyCommand touch /tmp/pwned" }, + { sandboxName: "sb-1\nHost *", token: "tok" }, ]) { // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime await expect(writer.writeSandbox({ ...input, gateway: GATEWAY })).rejects.toThrow( @@ -195,7 +195,7 @@ describe("DaytonaSshConfigWriter", () => { xumHome: path.join(tempDir, root), sshConfigPath, }); - const input = { sandboxId: "sb-1", token: "tok", gateway: GATEWAY }; + const input = { sandboxName: "sb-1", token: "tok", gateway: GATEWAY }; for (const update of [ () => tokenWriter.writeSandbox(input), () => tokenWriter.updateToken(input), @@ -214,13 +214,13 @@ describe("DaytonaSshConfigWriter", () => { xumHome: path.join(tempDir, "José Smith", ".xum"), sshConfigPath, }); - await spacedWriter.writeSandbox({ sandboxId: "sb-1", token: "tok", gateway: GATEWAY }); - expect(await spacedWriter.readTokens()).toEqual([{ sandboxId: "sb-1", token: "tok" }]); + await spacedWriter.writeSandbox({ sandboxName: "sb-1", token: "tok", gateway: GATEWAY }); + expect(await spacedWriter.readTokens()).toEqual([{ sandboxName: "sb-1", token: "tok" }]); }); it("writes a self-hosted IPv6 gateway as OpenSSH resolves it, refusing zone ids", async () => { const gateway = { ...GATEWAY, host: "2001:db8::10", port: 2222 }; - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway }); expect(await fs.readFile(writer.paths.knownHostsPath, "utf8")).toBe( [ @@ -242,7 +242,7 @@ describe("DaytonaSshConfigWriter", () => { // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime await expect( writer.writeSandbox({ - sandboxId: "sb-2", + sandboxName: "sb-2", token: "t", gateway: { ...gateway, host: "fe80::1%eth0" }, }) @@ -253,7 +253,7 @@ describe("DaytonaSshConfigWriter", () => { // HostKeyAlias as is, both to look up the pinned key and (ssh2) to match known_hosts lines. it("writes an absolute gateway name with its trailing dot, pinned under that name", async () => { const gateway = { ...GATEWAY, host: "gateway.example.com.", port: 2222 }; - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway }); const knownHosts = await fs.readFile(writer.paths.knownHostsPath, "utf8"); expect(knownHosts).toBe( @@ -285,7 +285,7 @@ describe("DaytonaSshConfigWriter", () => { for (const host of ["gateway.example.com..", "gateway..example.com", ".example.com", "."]) { // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime await expect( - writer.writeSandbox({ sandboxId: "sb-2", token: "t", gateway: { ...gateway, host } }) + writer.writeSandbox({ sandboxName: "sb-2", token: "t", gateway: { ...gateway, host } }) ).rejects.toThrow(/Refusing to write an invalid Daytona gateway host/); } }); @@ -304,7 +304,7 @@ describe("DaytonaSshConfigWriter", () => { it.skipIf(!hasOpenSsh)("resolves the alias through the managed ssh config block", async () => { await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); await fs.writeFile(sshConfigPath, "Host other\n User someone\n", "utf8"); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway: GATEWAY }); const alias = spawnSync("ssh", ["-G", "-F", sshConfigPath, toXumDaytonaHost("sb-1")]); expect(alias.stdout.toString()).toMatch(/^user tokenA$/m); @@ -318,7 +318,7 @@ describe("DaytonaSshConfigWriter", () => { it.skipIf(!hasOpenSsh)("keeps your own ssh clients quiet for a Daytona alias", async () => { await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); await fs.writeFile(sshConfigPath, "Host *\n LogLevel VERBOSE\n", "utf8"); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway: GATEWAY }); const resolve = (host: string) => spawnSync("ssh", ["-G", "-F", sshConfigPath, host]).stdout.toString(); @@ -359,7 +359,7 @@ describe("DaytonaSshConfigWriter", () => { ].join("\n"), "utf8" ); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway: GATEWAY }); const daytona = resolve(toXumDaytonaHost("sb-1")); expect(daytona).toMatch(/^user tokenA$/m); @@ -406,7 +406,7 @@ describe("DaytonaSshConfigWriter", () => { ].join("\n"), "utf8" ); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway: GATEWAY }); const daytona = resolveFromUserConfig(toXumDaytonaHost("sb-1")); expect(daytona).toMatch(/^user tokenA$/m); @@ -432,7 +432,7 @@ describe("DaytonaSshConfigWriter", () => { it.skipIf(!hasOpenSsh)("ignores a ProxyJump the user sets for every host", async () => { await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); await fs.writeFile(sshConfigPath, "Host *\n ProxyJump jump.example.com\n", "utf8"); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway: GATEWAY }); expect(resolveFromUserConfig(toXumDaytonaHost("sb-1"))).not.toMatch(/\nproxy(command|jump) /); expect(resolveFromUserConfig("other.example.com")).toContain( @@ -443,7 +443,7 @@ describe("DaytonaSshConfigWriter", () => { it.skipIf(!hasOpenSsh)("gives the sandbox no tun device a user Tunnel asks for", async () => { await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); await fs.writeFile(sshConfigPath, "Host *\n Tunnel yes\n", "utf8"); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway: GATEWAY }); expect(resolveFromUserConfig(toXumDaytonaHost("sb-1"))).toContain("\ntunnel false\n"); expect(resolveFromUserConfig("other.example.com")).toContain("\ntunnel point-to-point\n"); @@ -465,14 +465,14 @@ describe("DaytonaSshConfigWriter", () => { "utf8" ); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway: GATEWAY }); const content = await fs.readFile(sshConfigPath, "utf8"); expect(content.startsWith(`${XUM_DAYTONA_SSH_BLOCK_START}\n`)).toBe(true); expect(content.endsWith(`${XUM_DAYTONA_SSH_BLOCK_END}\n${userConfig}`)).toBe(true); expect(content.split(XUM_DAYTONA_SSH_BLOCK_START)).toHaveLength(2); // Already on top: rewriting it changes nothing. - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway: GATEWAY }); expect(await fs.readFile(sshConfigPath, "utf8")).toBe(content); }); }); @@ -483,7 +483,7 @@ describe("DaytonaSshConfigWriter", () => { it.skipIf(!hasOpenSsh)("cannot turn off strict checking for Xum's ssh calls", async () => { await fs.mkdir(path.dirname(sshConfigPath), { recursive: true }); await fs.writeFile(sshConfigPath, WEAKENING_CONFIG, "utf8"); - await writer.writeSandbox({ sandboxId: "sb-1", token: "tokenA", gateway: GATEWAY }); + await writer.writeSandbox({ sandboxName: "sb-1", token: "tokenA", gateway: GATEWAY }); const args = ["-G", "-F", sshConfigPath]; appendOpenSSHHostKeyPolicyArgs(args, toXumDaytonaHost("sb-1")); diff --git a/src/node/runtime/daytonaSshConfig.ts b/src/node/runtime/daytonaSshConfig.ts index 125026403f7..3db39955367 100644 --- a/src/node/runtime/daytonaSshConfig.ts +++ b/src/node/runtime/daytonaSshConfig.ts @@ -68,7 +68,7 @@ export class DaytonaSshConfigError extends Error { // Everything rendered into ssh config is validated first: a newline or quote in a control-plane // value would let it inject arbitrary ssh directives (ProxyCommand runs local commands). -const SANDBOX_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const SANDBOX_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; const TOKEN_PATTERN = /^[A-Za-z0-9._~+/=-]+$/; // Dot-separated labels (no empty one), optionally absolute: one trailing dot. const HOSTNAME_LABEL = "[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?"; @@ -168,12 +168,12 @@ export function renderDaytonaKnownHosts(gateway: DaytonaSshGateway): string { } export function renderDaytonaHostBlock(input: { - sandboxId: string; + sandboxName: string; token: string; gateway: DaytonaSshGateway; knownHostsPath: string; }): string { - assertValid(input.sandboxId, SANDBOX_ID_PATTERN, "sandbox id"); + assertValid(input.sandboxName, SANDBOX_NAME_PATTERN, "sandbox name"); assertValid(input.token, TOKEN_PATTERN, "SSH token"); validateGateway(input.gateway); // The sandbox and the network path to the gateway are untrusted, and an editor's ssh reads @@ -185,7 +185,7 @@ export function renderDaytonaHostBlock(input: { // The gateway authenticates with "none" (the token is the user name), so password and // keyboard-interactive auth are off: their prompts would print the token. return [ - `Host ${toXumDaytonaHost(input.sandboxId)}`, + `Host ${toXumDaytonaHost(input.sandboxName)}`, " ProxyCommand none", " ForwardAgent no", " ForwardX11 no", @@ -232,7 +232,7 @@ function renderIncludeFile(blocks: Map): string { } export interface SandboxToken { - sandboxId: string; + sandboxName: string; token: string; } @@ -242,7 +242,7 @@ function tokensIn(blocks: Map): SandboxToken[] { for (const [alias, block] of blocks) { const token = /^\s*User\s+(\S+)\s*$/m.exec(block)?.[1]; if (alias.endsWith(suffix) && token) { - tokens.push({ sandboxId: alias.slice(0, -suffix.length), token }); + tokens.push({ sandboxName: alias.slice(0, -suffix.length), token }); } } return tokens; @@ -269,12 +269,12 @@ export class DaytonaSshConfigWriter { /** First write for a sandbox: known_hosts, its include block, and the managed ssh block. */ async writeSandbox(input: { - sandboxId: string; + sandboxName: string; token: string; gateway: DaytonaSshGateway; }): Promise { await this.installGateway(input.gateway, (blocks) => - blocks.set(toXumDaytonaHost(input.sandboxId), this.renderBlock(input)) + blocks.set(toXumDaytonaHost(input.sandboxName), this.renderBlock(input)) ); await this.ensureManagedBlock(); } @@ -318,7 +318,7 @@ export class DaytonaSshConfigWriter { await this.withIncludeFileLock(async () => { const blocks = await this.readBlocks(); for (const other of tokensIn(blocks)) { - blocks.set(toXumDaytonaHost(other.sandboxId), this.renderBlock({ ...other, gateway })); + blocks.set(toXumDaytonaHost(other.sandboxName), this.renderBlock({ ...other, gateway })); } edit(blocks); await this.writeKnownHosts(knownHosts); @@ -332,15 +332,15 @@ export class DaytonaSshConfigWriter { /** Token refresh: rewrites only the include file, never `~/.ssh/config` or known_hosts. */ async updateToken(input: { - sandboxId: string; + sandboxName: string; token: string; gateway: DaytonaSshGateway; }): Promise { await this.upsertBlock(input); } - async removeSandbox(sandboxId: string): Promise { - const alias = toXumDaytonaHost(sandboxId); + async removeSandbox(sandboxName: string): Promise { + const alias = toXumDaytonaHost(sandboxName); await this.withIncludeFileLock(async () => { const blocks = await this.readBlocks(); if (!blocks.delete(alias)) return; @@ -348,23 +348,23 @@ export class DaytonaSshConfigWriter { }); } - /** The sandbox ids and tokens the include file holds (written by this or an earlier process). */ + /** The sandbox names and tokens the include file holds (written by this or an earlier process). */ async readTokens(): Promise { return tokensIn(await this.readBlocks()); } - private renderBlock(input: { sandboxId: string; token: string; gateway: DaytonaSshGateway }) { + private renderBlock(input: { sandboxName: string; token: string; gateway: DaytonaSshGateway }) { return renderDaytonaHostBlock({ ...input, knownHostsPath: this.paths.knownHostsPath }); } private upsertBlock(input: { - sandboxId: string; + sandboxName: string; token: string; gateway: DaytonaSshGateway; }): Promise { this.assertPathsReadLiterallyBySsh(); const block = this.renderBlock(input); - const alias = toXumDaytonaHost(input.sandboxId); + const alias = toXumDaytonaHost(input.sandboxName); return this.withIncludeFileLock(async () => { const blocks = await this.readBlocks(); blocks.set(alias, block); diff --git a/src/node/runtime/runtimeFactory.test.ts b/src/node/runtime/runtimeFactory.test.ts index b0a030b7828..69ff962cbcc 100644 --- a/src/node/runtime/runtimeFactory.test.ts +++ b/src/node/runtime/runtimeFactory.test.ts @@ -216,7 +216,7 @@ describe("createRuntime - Daytona", () => { type: "ssh", host: "placeholder", srcBaseDir: "~/src", - daytona: { sandboxId: "sb-1" }, + daytona: { sandboxName: "sb-1" }, }; const runtime = createRuntime(config, { daytonaService: rig.service }); diff --git a/src/node/runtime/runtimeFactory.ts b/src/node/runtime/runtimeFactory.ts index 3b0ba053230..b5f13e022c5 100644 --- a/src/node/runtime/runtimeFactory.ts +++ b/src/node/runtime/runtimeFactory.ts @@ -314,9 +314,9 @@ export function createRuntime(config: RuntimeConfig, options?: CreateRuntimeOpti } function resolveSSHRuntimeHost(config: Extract): string { - const daytonaSandboxId = config.daytona?.sandboxId; - return daytonaSandboxId - ? toXumDaytonaHost(daytonaSandboxId) + const daytonaSandboxName = config.daytona?.sandboxName; + return daytonaSandboxName + ? toXumDaytonaHost(daytonaSandboxName) : resolveCoderSSHHost(config.host, config.coder?.workspaceName); } diff --git a/src/node/runtime/sshConfigParser.test.ts b/src/node/runtime/sshConfigParser.test.ts index 2939adcc57a..0f55c1021a3 100644 --- a/src/node/runtime/sshConfigParser.test.ts +++ b/src/node/runtime/sshConfigParser.test.ts @@ -132,7 +132,7 @@ describe("resolveSSHConfig", () => { sshConfigPath: configPath, }); await writer.writeSandbox({ - sandboxId: "sb-1", + sandboxName: "sb-1", token: "tokenA", gateway: { host: "ssh.daytona.test", port: 2222, hostKeys: ["ssh-ed25519 AAAA"] }, }); @@ -165,7 +165,7 @@ describe("resolveSSHConfig", () => { sshConfigPath: configPath, }); await writer.writeSandbox({ - sandboxId: "sb-1", + sandboxName: "sb-1", token: "tokenA", gateway: { host: "ssh.daytona.test", port: 22, hostKeys: ["ssh-ed25519 AAAA"] }, }); diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 7d2a54f34a9..2a012b76d97 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10139,7 +10139,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "| Opening a workspace | Background git and PR status checks do not start the sandbox; they wait until it runs |", "| Sandbox no longer exists | The workspace reports it; Xum does not recreate it |", "", - "Each sandbox belongs to the workspace that created it. Archiving or deleting that workspace deletes the sandbox, even while forks of it (sub-agent worktrees) still use it; **Settings > General > Remote box on archive** does not apply to Daytona. Unarchiving a workspace whose sandbox was deleted does not bring the sandbox back, so push or copy any work you need before you archive. An agent cannot archive the workspace that created a sandbox: it asks you to do it. Archiving or deleting a fork removes only its worktree and never stops or deletes the sandbox. Before Xum starts a sandbox, opens SSH access to it, or deletes it, it checks that the sandbox still carries this install's `xum.instance` label. A sandbox without it (for example one whose label was removed, or one that took the name after the workspace's sandbox was deleted) is never used or deleted, and Xum notes it in its log.", + "Each sandbox belongs to the workspace that created it. Archiving or deleting that workspace deletes the sandbox, even while forks of it (sub-agent worktrees) still use it; **Settings > General > Remote box on archive** does not apply to Daytona. Unarchiving a workspace whose sandbox was deleted does not bring the sandbox back, so push or copy any work you need before you archive. An agent cannot archive the workspace that created a sandbox: it asks you to do it. Archiving or deleting a fork removes only its worktree and never stops or deletes the sandbox. Xum records the id of each sandbox it creates in the workspace's settings, and every Daytona request about the sandbox uses that id, so a sandbox that later gets the same name is never touched. Before Xum starts a sandbox, opens SSH access to it, or deletes it, it also checks that the sandbox carries this install's `xum.instance` label. A sandbox without it (for example one whose label was removed) is never used or deleted, and Xum notes it in its log.", "", "A forced delete removes the workspace even when Daytona refuses to delete the sandbox; it then shows a warning that names the sandbox, which you delete in the Daytona dashboard.", "", @@ -10147,7 +10147,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "## SSH setup", "", - "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, in case that read failed at startup. When an SSH command to a running sandbox fails (for example the gateway refuses a token revoked outside Xum, or Daytona moved its gateway or changed its host keys), Xum reads the gateway settings from Daytona again, rewrites the SSH settings, gets a new token, and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered.", + "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, in case that read failed at startup. When an SSH command to a running sandbox fails (for example the gateway refuses a token revoked outside Xum, or Daytona moved its gateway or changed its host keys), Xum reads the gateway settings from Daytona again, rewrites the SSH settings, gets a new token, and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered.", "", "Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts.", "", diff --git a/src/node/services/bashAiProxy/sshForwardTarget.test.ts b/src/node/services/bashAiProxy/sshForwardTarget.test.ts index 70a00a798ff..f6c9f16bf3f 100644 --- a/src/node/services/bashAiProxy/sshForwardTarget.test.ts +++ b/src/node/services/bashAiProxy/sshForwardTarget.test.ts @@ -15,7 +15,7 @@ describe("createSshForwardTarget", () => { const rig = await createDaytonaTestRig(); try { const plain = { host: "box", srcBaseDir: "~/src" }; - const daytona = { ...plain, host: "sb-1.xum--daytona", daytona: { sandboxId: "sb-1" } }; + const daytona = { ...plain, host: "sb-1.xum--daytona", daytona: { sandboxName: "sb-1" } }; const sshTarget = createSshForwardTarget( new SSHRuntime(plain, createSSHTransport(plain, false)) ); diff --git a/src/node/services/daytona/daytona.testHarness.ts b/src/node/services/daytona/daytona.testHarness.ts index 3cf7cf03f65..daec3f4e32b 100644 --- a/src/node/services/daytona/daytona.testHarness.ts +++ b/src/node/services/daytona/daytona.testHarness.ts @@ -6,6 +6,7 @@ import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; import type { DaytonaSshConfigWriter, DaytonaSshGateway } from "@/node/runtime/daytonaSshConfig"; import type { SSHTransport } from "@/node/runtime/transports"; import { DAYTONA_LABEL_INSTANCE } from "./daytonaSandboxes"; +import { InMemoryDaytonaSandboxIds } from "./daytonaSandboxIds"; import { DaytonaService } from "./daytonaService"; /** @@ -86,6 +87,11 @@ export class FakeDaytonaControlPlane { apiKey?: string; /** A proxy that echoes the request URI and its Authorization header in a failed revoke. */ revokeEchoesRequest = false; + /** + * Xum's side, not Daytona's: the ids the workspace config records per sandbox name. A + * sandbox a test adds is recorded, as Xum records the sandboxes it creates. + */ + readonly recordedIds = new InMemoryDaytonaSandboxIds(); constructor(private readonly now: () => number) {} @@ -102,7 +108,10 @@ export class FakeDaytonaControlPlane { return hook().then(() => this.handle(route, url, init)); }) as typeof fetch; - addSandbox(partial: Partial & { id: string }): FakeSandbox { + addSandbox( + partial: Partial & { id: string }, + options: { recorded?: boolean } = {} + ): FakeSandbox { const sandbox: FakeSandbox = { name: partial.id, state: "started", @@ -114,6 +123,7 @@ export class FakeDaytonaControlPlane { ...partial, }; this.sandboxes.set(sandbox.id, sandbox); + if (options.recorded !== false) this.recordedIds.ids.set(sandbox.name, sandbox.id); return sandbox; } @@ -306,14 +316,14 @@ export class FakeDaytonaSshConfigWriter implements Pick< gateway?: DaytonaSshGateway; readonly repoints: DaytonaSshGateway[] = []; writeSandbox(input: { - sandboxId: string; + sandboxName: string; token: string; gateway: DaytonaSshGateway; }): Promise { if (this.writeError) return Promise.reject(this.writeError); - this.fullWrites.push(input.sandboxId); + this.fullWrites.push(input.sandboxName); this.gateway = input.gateway; - this.tokens.set(input.sandboxId, input.token); + this.tokens.set(input.sandboxName, input.token); return Promise.resolve(); } @@ -326,12 +336,12 @@ export class FakeDaytonaSshConfigWriter implements Pick< } updateToken(input: { - sandboxId: string; + sandboxName: string; token: string; gateway: DaytonaSshGateway; }): Promise { this.gateway = input.gateway; - this.tokens.set(input.sandboxId, input.token); + this.tokens.set(input.sandboxName, input.token); return Promise.resolve(); } @@ -341,14 +351,16 @@ export class FakeDaytonaSshConfigWriter implements Pick< return Promise.resolve(); } - removeSandbox(sandboxId: string): Promise { - this.removed.push(sandboxId); - this.tokens.delete(sandboxId); + removeSandbox(sandboxName: string): Promise { + this.removed.push(sandboxName); + this.tokens.delete(sandboxName); return Promise.resolve(); } - readTokens(): Promise> { - return Promise.resolve([...this.tokens].map(([sandboxId, token]) => ({ sandboxId, token }))); + readTokens(): Promise> { + return Promise.resolve( + [...this.tokens].map(([sandboxName, token]) => ({ sandboxName, token })) + ); } } @@ -376,6 +388,7 @@ export async function createDaytonaTestRig( options.apiKey === null ? {} : { DAYTONA_API_KEY: options.apiKey ?? "test-key" }; const service = new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(rootDir, env), + sandboxIds: plane.recordedIds, rootDir, sshConfigWriter: writer, fetch: plane.fetch, diff --git a/src/node/services/daytona/daytonaAccount.test.ts b/src/node/services/daytona/daytonaAccount.test.ts index 611900104d1..a3ee2dda0c7 100644 --- a/src/node/services/daytona/daytonaAccount.test.ts +++ b/src/node/services/daytona/daytonaAccount.test.ts @@ -16,6 +16,7 @@ describe("DaytonaAccount", () => { function makeService(env: NodeJS.ProcessEnv = { DAYTONA_API_KEY: "k" }): DaytonaService { return new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(rootDir, env), + sandboxIds: plane.recordedIds, rootDir, fetch: plane.fetch, now: () => clock, diff --git a/src/node/services/daytona/daytonaLifecycleHooks.test.ts b/src/node/services/daytona/daytonaLifecycleHooks.test.ts index 538532bd3ff..912001d54c6 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.test.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.test.ts @@ -35,7 +35,7 @@ describe("createDaytonaArchiveHook", () => { await rig.service.sandboxes.ensureSandboxReady("sb-own"); expect(rig.writer.tokens.has("sb-own")).toBe(true); - expect((await archive(metadata({ sandboxId: "sb-own" }))).success).toBe(true); + expect((await archive(metadata({ sandboxName: "sb-own" }))).success).toBe(true); expect(rig.plane.find("sb-own")).toBeUndefined(); expect(rig.writer.tokens.has("sb-own")).toBe(false); @@ -44,10 +44,10 @@ describe("createDaytonaArchiveHook", () => { it("leaves the sandbox alone when a fork or a shared-checkout task is archived", async () => { rig.plane.addSandbox({ id: "sb-parent" }); - const fork = metadata({ sandboxId: "sb-parent", forked: true }); + const fork = metadata({ sandboxName: "sb-parent", forked: true }); // An isolation:none task copies the parent's runtime config, sandbox ID included. const sharedCheckout = { - ...metadata({ sandboxId: "sb-parent" }), + ...metadata({ sandboxName: "sb-parent" }), taskIsolation: "none" as const, }; expect((await archive(fork)).success).toBe(true); @@ -58,6 +58,6 @@ describe("createDaytonaArchiveHook", () => { }); it("archives a workspace whose sandbox is already gone", async () => { - expect((await archive(metadata({ sandboxId: "sb-gone" }))).success).toBe(true); + expect((await archive(metadata({ sandboxName: "sb-gone" }))).success).toBe(true); }); }); diff --git a/src/node/services/daytona/daytonaLifecycleHooks.ts b/src/node/services/daytona/daytonaLifecycleHooks.ts index d43b3174831..bc2c091e897 100644 --- a/src/node/services/daytona/daytonaLifecycleHooks.ts +++ b/src/node/services/daytona/daytonaLifecycleHooks.ts @@ -1,5 +1,5 @@ import { Err, Ok, type Result } from "@/common/types/result"; -import { getDaytonaSandboxId, ownsDaytonaSandbox } from "@/common/types/runtime"; +import { getDaytonaSandboxName, ownsDaytonaSandbox } from "@/common/types/runtime"; import { getErrorMessage } from "@/common/utils/errors"; import type { BeforeArchiveHook } from "@/node/services/workspaceLifecycleHooks"; import type { DaytonaSandboxes } from "./daytonaSandboxes"; @@ -17,15 +17,15 @@ export interface DaytonaArchiveHookOptions { export function createDaytonaArchiveHook(options: DaytonaArchiveHookOptions): BeforeArchiveHook { return async (args): Promise> => { - const sandboxId = ownsDaytonaSandbox(args.workspaceMetadata) - ? getDaytonaSandboxId(args.workspaceMetadata.runtimeConfig) + const sandboxName = ownsDaytonaSandbox(args.workspaceMetadata) + ? getDaytonaSandboxName(args.workspaceMetadata.runtimeConfig) : undefined; - if (!sandboxId) return Ok(undefined); + if (!sandboxName) return Ok(undefined); try { - await options.daytonaService.sandboxes.deleteSandbox(sandboxId); + await options.daytonaService.sandboxes.deleteSandbox(sandboxName); return Ok(undefined); } catch (error) { - return Err(`Failed to delete Daytona sandbox "${sandboxId}": ${getErrorMessage(error)}`); + return Err(`Failed to delete Daytona sandbox "${sandboxName}": ${getErrorMessage(error)}`); } }; } diff --git a/src/node/services/daytona/daytonaReadiness.ts b/src/node/services/daytona/daytonaReadiness.ts index d275f52e711..8bbcee0bacb 100644 --- a/src/node/services/daytona/daytonaReadiness.ts +++ b/src/node/services/daytona/daytonaReadiness.ts @@ -27,18 +27,18 @@ export class DaytonaReadiness { constructor(private readonly now: () => number) {} /** Whether a readiness check for this sandbox passed within DAYTONA_READY_CACHE_MS. */ - isSandboxReady(sandboxId: string): boolean { - const at = this.readyAt.get(sandboxId); + isSandboxReady(sandboxName: string): boolean { + const at = this.readyAt.get(sandboxName); return at !== undefined && this.now() - at < DAYTONA_READY_CACHE_MS; } - markSandboxReady(sandboxId: string): void { - this.readyAt.set(sandboxId, this.now()); + markSandboxReady(sandboxName: string): void { + this.readyAt.set(sandboxName, this.now()); } /** An ssh connection failed: the sandbox may have stopped within the ready cache. */ - invalidateSandboxReady(sandboxId: string): void { - this.readyAt.delete(sandboxId); + invalidateSandboxReady(sandboxName: string): void { + this.readyAt.delete(sandboxName); } invalidateAll(): void { @@ -51,17 +51,17 @@ export class DaytonaReadiness { * `onStarting`, through the callback `run` gets. */ async join( - sandboxId: string, + sandboxName: string, options: DaytonaEnsureReadyOptions, run: (onStarting: () => void) => Promise ): Promise { - let request = this.readyRequests.get(sandboxId); + let request = this.readyRequests.get(sandboxName); if (!request) { // A cancelled caller hears no later start: nothing would report its end. request = run(() => { if (!options.signal?.aborted) options.onStarting?.(); - }).finally(() => this.readyRequests.delete(sandboxId)); - this.readyRequests.set(sandboxId, request); + }).finally(() => this.readyRequests.delete(sandboxName)); + this.readyRequests.set(sandboxName, request); } const raced = await raceWithAbortAndTimeout(request, { signal: options.signal }); if (raced.kind !== "ok") throw new Error("Aborted"); @@ -72,14 +72,14 @@ export class DaytonaReadiness { * otherwise land after a stop or a release and undo it. Bounded by the start timeout; its * failure belongs to its own callers, so it is only logged here. */ - async waitForSandboxReadiness(sandboxId: string): Promise { - const request = this.readyRequests.get(sandboxId); + async waitForSandboxReadiness(sandboxName: string): Promise { + const request = this.readyRequests.get(sandboxName); if (!request) return; try { await request; } catch (error) { log.warn("Daytona sandbox readiness failed while another operation waited for it", { - sandboxId, + sandboxName, error: getErrorMessage(error), }); } diff --git a/src/node/services/daytona/daytonaReconciler.test.ts b/src/node/services/daytona/daytonaReconciler.test.ts index ea413493650..4342f04bfcd 100644 --- a/src/node/services/daytona/daytonaReconciler.test.ts +++ b/src/node/services/daytona/daytonaReconciler.test.ts @@ -16,12 +16,16 @@ afterEach(async () => { await rig.cleanup(); }); -function daytonaConfig(sandboxId: string): RuntimeConfig { +/** A workspace on a sandbox Xum created: its config records the sandbox's id. */ +function daytonaConfig( + sandboxName: string, + sandboxId: string | undefined = sandboxName +): RuntimeConfig { return { type: "ssh", - host: `${sandboxId}.xum--daytona`, + host: `${sandboxName}.xum--daytona`, srcBaseDir: "~/src", - daytona: { sandboxId }, + daytona: { sandboxName, sandboxId }, }; } @@ -48,14 +52,13 @@ function makeReconciler( describe("DaytonaReconciler.sweep", () => { it("refreshes activity only for sandboxes of active workspaces", async () => { - // Xum stores the name of a sandbox it created; last-activity needs the real id. rig.plane.addSandbox({ id: "sb-1", name: "xum-busy-aaaaaa", labels: instanceLabels }); rig.plane.addSandbox({ id: "sb-2", name: "xum-idle-bbbbbb", labels: instanceLabels }); await makeReconciler( [ - { id: "ws-busy", runtimeConfig: daytonaConfig("xum-busy-aaaaaa") }, - { id: "ws-idle", runtimeConfig: daytonaConfig("xum-idle-bbbbbb") }, + { id: "ws-busy", runtimeConfig: daytonaConfig("xum-busy-aaaaaa", "sb-1") }, + { id: "ws-idle", runtimeConfig: daytonaConfig("xum-idle-bbbbbb", "sb-2") }, ], ["ws-busy"] ).sweep(); @@ -64,16 +67,27 @@ describe("DaytonaReconciler.sweep", () => { expect(rig.plane.countCalls("POST /sandbox/sb-2/last-activity")).toBe(0); }); + it("keeps alive the recorded sandbox, not a later one that took its name", async () => { + // The workspace's own sandbox is gone; another of this install's took the name. + rig.plane.addSandbox({ id: "sb-new", name: "xum-busy-aaaaaa", labels: instanceLabels }); + + await makeReconciler( + [{ id: "ws-busy", runtimeConfig: daytonaConfig("xum-busy-aaaaaa", "sb-old") }], + ["ws-busy"] + ).sweep(); + + expect(rig.plane.countCalls("POST /sandbox/sb-new/last-activity")).toBe(0); + }); + it("leaves an active workspace's sandbox alone once it lost this install's label", async () => { rig.plane.addSandbox({ id: "sb-1", name: "xum-foreign-aaaaaa", labels: {} }); await makeReconciler( - [{ id: "ws-busy", runtimeConfig: daytonaConfig("xum-foreign-aaaaaa") }], + [{ id: "ws-busy", runtimeConfig: daytonaConfig("xum-foreign-aaaaaa", "sb-1") }], ["ws-busy"] ).sweep(); expect(rig.plane.countCalls("POST /sandbox/sb-1/last-activity")).toBe(0); - expect(rig.plane.countCalls("POST /sandbox/sb-1/ssh-access")).toBe(0); }); it("moves on past a hung control-plane call", async () => { @@ -87,24 +101,13 @@ describe("DaytonaReconciler.sweep", () => { { id: "ws-next", runtimeConfig: daytonaConfig("sb-next") }, ], ["ws-hung", "ws-next"], - 20 + // Long enough that the calls that answer never time out on a loaded machine. + 250 ).sweep(); expect(rig.plane.countCalls("POST /sandbox/sb-next/last-activity")).toBe(1); }); - it("refreshes an active workspace's ssh token before it expires", async () => { - rig.plane.addSandbox({ id: "sb-1", labels: instanceLabels }); - await rig.service.ssh.ensureSshAccess("sb-1"); - const firstToken = rig.writer.tokens.get("sb-1"); - rig.clock.now += 50 * 60_000; - - await makeReconciler([{ id: "ws", runtimeConfig: daytonaConfig("sb-1") }], ["ws"]).sweep(); - - expect(rig.writer.tokens.get("sb-1")).not.toBe(firstToken); - expect(rig.plane.revokedTokens).toEqual([firstToken ?? ""]); - }); - it("keeps only started sandboxes alive and skips them all when the list fails", async () => { rig.plane.addSandbox({ id: "sb-stopped", state: "stopped", labels: instanceLabels }); rig.plane.addSandbox({ id: "sb-started", labels: instanceLabels }); diff --git a/src/node/services/daytona/daytonaReconciler.ts b/src/node/services/daytona/daytonaReconciler.ts index 3c375e5c680..645645e10b6 100644 --- a/src/node/services/daytona/daytonaReconciler.ts +++ b/src/node/services/daytona/daytonaReconciler.ts @@ -1,11 +1,10 @@ import type { Workspace } from "@/common/types/project"; -import { getDaytonaSandboxId, type RuntimeConfig } from "@/common/types/runtime"; +import { getDaytonaSandboxName, isSSHRuntime, type RuntimeConfig } from "@/common/types/runtime"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; import type { DaytonaSandbox } from "./daytonaClient"; import type { DaytonaAccount } from "./daytonaAccount"; import { mapDaytonaSandboxState, type DaytonaSandboxes } from "./daytonaSandboxes"; -import type { DaytonaSsh } from "./daytonaSsh"; export const DAYTONA_RECONCILE_INTERVAL_MS = 60_000; /** Each control-plane call gets this long; a hung call is logged and the sweep moves on. */ @@ -20,7 +19,6 @@ export interface DaytonaReconcilerOptions { daytonaService: { account: Pick; sandboxes: Pick; - ssh: Pick; }; listWorkspaces: () => DaytonaWorkspaceRef[]; /** @@ -48,10 +46,10 @@ export function listDaytonaWorkspaceRefs(config: { return refs; } -/** A workspace on a Daytona sandbox. */ +/** A workspace on a Daytona sandbox Xum created and recorded. */ export interface DaytonaSandboxRef { workspaceId: string; - runtimeConfig: RuntimeConfig; + sandboxName: string; sandboxId: string; } @@ -71,11 +69,11 @@ function withTimeout(promise: Promise, timeoutMs: number, what: string): P } /** - * One sweep a minute, safe to repeat, that keeps Daytona sandboxes in line with Xum: - * - keepalive: refresh activity for sandboxes whose workspace is streaming or running a - * background job, so Daytona's 30-minute auto-stop does not cut off long agent work; - * - token refresh: replace an active workspace's ssh token before it expires. - * Sandboxes no workspace uses are left to Daytona's auto-stop. + * One sweep a minute, safe to repeat, that refreshes activity for sandboxes whose workspace is + * streaming or running a background job, so Daytona's 30-minute auto-stop does not cut off long + * agent work. Sandboxes no workspace uses are left to Daytona's auto-stop. Tokens are not its + * business: ensureReady replaces an expiring one, and a command a stale token fails is repaired + * and retried, both under the sandbox's readiness request that a release waits for. * The first sweep runs one interval after start: startup never waits on the control plane. */ export class DaytonaReconciler { @@ -120,8 +118,8 @@ export class DaytonaReconciler { "listing Daytona sandboxes" ); } catch (error) { - // Without the list Xum cannot map stored names to ids or see states: keepalive waits - // for the next sweep instead of guessing. + // Without the list Xum cannot see labels or states: keepalive waits for the next sweep + // instead of guessing. log.warn("Daytona reconcile: could not list sandboxes", { error: getErrorMessage(error) }); return; } @@ -134,15 +132,27 @@ export class DaytonaReconciler { private collectReferences(): DaytonaSandboxRef[] { const refs: DaytonaSandboxRef[] = []; for (const { workspaceId, runtimeConfig } of this.options.listWorkspaces()) { - const sandboxId = getDaytonaSandboxId(runtimeConfig); - if (sandboxId && runtimeConfig) refs.push({ workspaceId, runtimeConfig, sandboxId }); + const sandboxName = getDaytonaSandboxName(runtimeConfig); + const sandboxId = isSSHRuntime(runtimeConfig) ? runtimeConfig.daytona?.sandboxId : undefined; + if (sandboxName && sandboxId) refs.push({ workspaceId, sandboxName, sandboxId }); } return refs; } private async keepAlive(ref: DaytonaSandboxRef, sandboxes: DaytonaSandbox[]): Promise { const sandbox = await this.findActiveSandbox(ref, sandboxes); - if (sandbox) await this.refreshActivityAndToken(ref.sandboxId, sandbox.id); + if (!sandbox) return; + try { + await this.call( + this.options.daytonaService.sandboxes.refreshActivity(sandbox.id), + "refreshing activity" + ); + } catch (error) { + log.warn("Daytona reconcile: activity refresh failed", { + sandboxName: ref.sandboxName, + error: getErrorMessage(error), + }); + } } /** The sandbox of a workspace that is active, if the sandbox is known and started. */ @@ -150,9 +160,9 @@ export class DaytonaReconciler { ref: DaytonaSandboxRef, sandboxes: DaytonaSandbox[] ): Promise { - // Xum stores a name for sandboxes it created; last-activity only takes the real id. Only the - // label-filtered list counts: a sandbox without this install's label is not Xum's to touch. - const sandbox = sandboxes.find((sb) => sb.id === ref.sandboxId || sb.name === ref.sandboxId); + // Only the label-filtered list counts: a sandbox without this install's label is not Xum's + // to touch. + const sandbox = sandboxes.find((sb) => sb.id === ref.sandboxId); // A stopped sandbox has nothing to keep alive, and checking its jobs over ssh would start it. if (!sandbox || !isStarted(sandbox)) return undefined; return (await this.isWorkspaceActive(ref.workspaceId)) ? sandbox : undefined; @@ -173,28 +183,6 @@ export class DaytonaReconciler { } } - /** `storedId` is what the workspace config holds (tokens use it), `realId` the sandbox id. */ - private async refreshActivityAndToken(storedId: string, realId: string): Promise { - const service = this.options.daytonaService; - try { - await this.call(service.sandboxes.refreshActivity(realId), "refreshing activity"); - } catch (error) { - log.warn("Daytona reconcile: activity refresh failed", { - sandboxId: storedId, - error: getErrorMessage(error), - }); - } - if (!service.ssh.hasExpiringSshToken(storedId)) return; - try { - await this.call(service.ssh.ensureSshAccess(storedId), "refreshing the ssh token"); - } catch (error) { - log.warn("Daytona reconcile: ssh token refresh failed", { - sandboxId: storedId, - error: getErrorMessage(error), - }); - } - } - private call(promise: Promise, what: string): Promise { return withTimeout( promise, diff --git a/src/node/services/daytona/daytonaSandboxIds.test.ts b/src/node/services/daytona/daytonaSandboxIds.test.ts new file mode 100644 index 00000000000..10884af463f --- /dev/null +++ b/src/node/services/daytona/daytonaSandboxIds.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "bun:test"; +import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; +import type { ProjectsConfig, Workspace } from "@/common/types/project"; +import { createConfigSandboxIds } from "./daytonaSandboxIds"; + +function daytonaRow(id: string, daytona: DaytonaSandboxConfig): Workspace { + return { + id, + path: `/src/${id}`, + runtimeConfig: { type: "ssh", host: "x.xum--daytona", srcBaseDir: "~/mux", daytona }, + }; +} + +/** A Config whose edits apply to one in-memory ProjectsConfig. */ +function memoryConfig(workspaces: Workspace[]) { + const current = { + projects: new Map([["/p", { workspaces }]]), + } as unknown as ProjectsConfig; + return { + loadConfigOrDefault: () => current, + editConfig: (fn: (config: ProjectsConfig) => ProjectsConfig) => { + fn(current); + return Promise.resolve(); + }, + }; +} + +describe("createConfigSandboxIds", () => { + it("records the id on the creating workspace, which a fork's copy never overrides", async () => { + const creator = daytonaRow("ws", { sandboxName: "xum-a" }); + const fork = daytonaRow("fork", { sandboxName: "xum-a", sandboxId: "stale", forked: true }); + const ids = createConfigSandboxIds(memoryConfig([fork, creator])); + + expect(ids.get("xum-a")).toBeUndefined(); + expect(await ids.record("xum-a", "id-1")).toBe(true); + expect(ids.get("xum-a")).toBe("id-1"); + expect(fork.runtimeConfig).toMatchObject({ daytona: { sandboxId: "stale" } }); + }); + + it("records nothing once no workspace names the sandbox", async () => { + const ids = createConfigSandboxIds(memoryConfig([daytonaRow("ws", { sandboxName: "xum-b" })])); + expect(await ids.record("xum-gone", "id-2")).toBe(false); + expect(ids.get("xum-gone")).toBeUndefined(); + }); +}); diff --git a/src/node/services/daytona/daytonaSandboxIds.ts b/src/node/services/daytona/daytonaSandboxIds.ts new file mode 100644 index 00000000000..10f2f4b11f8 --- /dev/null +++ b/src/node/services/daytona/daytonaSandboxIds.ts @@ -0,0 +1,82 @@ +import type { ProjectsConfig, Workspace } from "@/common/types/project"; +import type { Config } from "@/node/config"; +import { DaytonaApiError } from "./daytonaClient"; + +/** + * Daytona's ids of the sandboxes Xum created, as the workspace config records them + * (`daytona.sandboxId`, next to the `sandboxName` the ssh alias uses). Every control-plane + * request about a workspace's sandbox goes to this id: another sandbox can take the name once + * the workspace's is gone, never the id. + */ +export interface DaytonaSandboxIds { + /** The recorded id; undefined while the sandbox is not created (or its create never finished). */ + get(sandboxName: string): string | undefined; + /** + * Record a created sandbox's id on the workspace that names it. False when no workspace + * names it any more (it was removed during the create). + */ + record(sandboxName: string, sandboxId: string): Promise; +} + +function workspaceRows(config: ProjectsConfig): Workspace[] { + return [...config.projects.values()].flatMap((project) => project.workspaces); +} + +function daytonaOf(row: Workspace) { + return row.runtimeConfig?.type === "ssh" ? row.runtimeConfig.daytona : undefined; +} + +/** Reads and records ids in the workspace config (the app's DaytonaService uses this). */ +export function createConfigSandboxIds( + config: Pick +): DaytonaSandboxIds { + return { + get(sandboxName) { + // The creating workspace's record: a fork copies the whole config, record included. + return workspaceRows(config.loadConfigOrDefault()) + .map(daytonaOf) + .find((daytona) => daytona?.sandboxName === sandboxName && daytona.forked !== true) + ?.sandboxId; + }, + async record(sandboxName, sandboxId) { + let recorded = false; + await config.editConfig((current) => { + // editConfig may run this again after waiting for the file lock. + recorded = false; + for (const row of workspaceRows(current)) { + const daytona = daytonaOf(row); + if (daytona?.sandboxName !== sandboxName || daytona.forked === true) continue; + daytona.sandboxId = sandboxId; + recorded = true; + } + return current; + }); + return recorded; + }, + }; +} + +/** For tests and the live suite: ids kept in memory, as if every workspace still named them. */ +export class InMemoryDaytonaSandboxIds implements DaytonaSandboxIds { + readonly ids = new Map(); + + get(sandboxName: string): string | undefined { + return this.ids.get(sandboxName); + } + + record(sandboxName: string, sandboxId: string): Promise { + this.ids.set(sandboxName, sandboxId); + return Promise.resolve(true); + } +} + +/** The recorded id, or a `not_found` error: an unrecorded sandbox is not Xum's to reach. */ +export function requireSandboxId(ids: Pick, sandboxName: string): string { + const sandboxId = ids.get(sandboxName); + if (sandboxId) return sandboxId; + throw new DaytonaApiError( + "not_found", + `Daytona sandbox ${sandboxName} has no recorded id: Xum never finished creating it`, + 404 + ); +} diff --git a/src/node/services/daytona/daytonaSandboxes.test.ts b/src/node/services/daytona/daytonaSandboxes.test.ts index 854e5321552..618d8b779c7 100644 --- a/src/node/services/daytona/daytonaSandboxes.test.ts +++ b/src/node/services/daytona/daytonaSandboxes.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; import { DaytonaApiError } from "./daytonaClient"; import { createDaytonaTestRig, type DaytonaTestRig } from "./daytona.testHarness"; @@ -92,6 +92,7 @@ describe("DaytonaSandboxes", () => { }); const again = await new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(rig.rootDir, {}), + sandboxIds: rig.plane.recordedIds, rootDir: rig.rootDir, }).sandboxes.buildLabels({ workspaceId: "ws-2", @@ -137,39 +138,51 @@ describe("DaytonaSandboxes", () => { expect(rig.plane.find("sb-own")).toBeUndefined(); }); - it("never deletes a sandbox that took the name after the ownership check", async () => { + it("deletes the recorded sandbox, never a later one that took its name", async () => { rig.plane.addSandbox({ id: "id-old", name: "ws-1" }); - await rig.service.ssh.ensureSshAccess("ws-1"); - // The revoke runs between the check and the delete: the sandbox goes, another takes its name. - rig.plane.onRequest.set("DELETE /sandbox/ws-1/ssh-access", () => { - rig.plane.sandboxes.delete("id-old"); - rig.plane.addSandbox({ id: "id-new", name: "ws-1", labels: {} }); - return Promise.resolve(); - }); + rig.plane.sandboxes.delete("id-old"); + rig.plane.addSandbox({ id: "id-new", name: "ws-1" }, { recorded: false }); await rig.service.sandboxes.deleteSandbox("ws-1"); expect(rig.plane.find("id-new")).toBeDefined(); - expect(rig.plane.countCalls("DELETE /sandbox/ws-1")).toBe(0); + expect(rig.plane.calls.filter((call) => call.startsWith("DELETE /sandbox/id-new"))).toEqual([]); }); - it("never starts a sandbox that took the name after the label check", async () => { + it("starts and opens ssh access to the recorded sandbox only", async () => { rig.plane.addSandbox({ id: "id-a", name: "ws-s", state: "stopped" }); - const swap = () => { - if (rig.plane.sandboxes.delete("id-a")) { - rig.plane.addSandbox({ id: "id-b", name: "ws-s", state: "stopped", labels: {} }); - } - return Promise.resolve(); - }; - rig.plane.onRequest.set("POST /sandbox/ws-s/start", swap); - rig.plane.onRequest.set("POST /sandbox/id-a/start", swap); + rig.plane.sandboxes.delete("id-a"); + rig.plane.addSandbox({ id: "id-b", name: "ws-s", state: "stopped" }, { recorded: false }); // eslint-disable-next-line @typescript-eslint/await-thenable await expect(rig.service.sandboxes.ensureSandboxReady("ws-s")).rejects.toThrow(); expect(rig.plane.find("id-b")?.state).toBe("stopped"); + expect(rig.plane.calls.filter((call) => call.includes("/sandbox/id-b"))).toEqual([]); + }); + + it("deletes the sandbox it created when the workspace is gone before the id is recorded", async () => { + spyOn(rig.plane.recordedIds, "record").mockResolvedValue(false); + + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(rig.service.sandboxes.createSandbox({ name: "ws-gone" })).rejects.toThrow( + "removed during setup" + ); + expect(rig.plane.find("ws-gone")).toBeUndefined(); }); it("sends no delete for a sandbox that is already gone", async () => { await rig.service.sandboxes.deleteSandbox("ws-gone"); expect(rig.plane.countCalls("DELETE /sandbox/ws-gone")).toBe(0); }); + + it("treats a deleted sandbox that Daytona still reports by id as gone", async () => { + rig.plane.addSandbox({ id: "id-del", name: "ws-del", state: "deleting" }); + + expect((await rig.service.sandboxes.getSandboxState("ws-del")).state).toBe("missing"); + const error = await rig.service.sandboxes + .startAndWait("ws-del", rig.clock.now + 60_000) + .catch((caught: unknown) => caught); + expect(error instanceof DaytonaApiError && error.kind).toBe("not_found"); + await rig.service.sandboxes.deleteSandbox("ws-del"); + expect(rig.plane.countCalls("DELETE /sandbox/id-del")).toBe(0); + }); }); diff --git a/src/node/services/daytona/daytonaSandboxes.ts b/src/node/services/daytona/daytonaSandboxes.ts index b1ed184a183..3b4dee4220f 100644 --- a/src/node/services/daytona/daytonaSandboxes.ts +++ b/src/node/services/daytona/daytonaSandboxes.ts @@ -1,5 +1,6 @@ import * as path from "path"; import type { DaytonaSandboxState } from "@/common/orpc/schemas/daytona"; +import { getErrorMessage } from "@/common/utils/errors"; import { loadOrCreateInstallationId } from "@/node/config/installationIdentity"; import { log } from "@/node/services/log"; import type { DaytonaAccount } from "./daytonaAccount"; @@ -12,6 +13,7 @@ import { type DaytonaSandbox, } from "./daytonaClient"; import type { DaytonaEnsureReadyOptions, DaytonaReadiness } from "./daytonaReadiness"; +import { requireSandboxId, type DaytonaSandboxIds } from "./daytonaSandboxIds"; import type { DaytonaSsh } from "./daytonaSsh"; export const DAYTONA_LABEL_INSTANCE = "xum.instance"; @@ -53,6 +55,12 @@ const RAW_STATE_MAP: Record = { ...Object.fromEntries(DESTROYING_STATE_NAMES.map((state) => [state, "error" as const])), }; +const DESTROYING_STATES = new Set(DESTROYING_STATE_NAMES); + +function isDestroyedState(raw: string | undefined): boolean { + return raw !== undefined && DESTROYING_STATES.has(raw); +} + /** Collapse Daytona's ~20 sandbox states to the five Xum acts on; unknown values are errors. */ export function mapDaytonaSandboxState(raw: string | undefined): ObservedSandboxState { return (raw ? RAW_STATE_MAP[raw] : undefined) ?? "error"; @@ -61,18 +69,18 @@ export function mapDaytonaSandboxState(raw: string | undefined): ObservedSandbox // States from which `start` brings the sandbox back (stopped includes paused). const STARTABLE_STATES = new Set(["stopped", "archived"]); -function sandboxStateError(sandboxId: string, sandbox: DaytonaSandbox): DaytonaApiError { +function sandboxStateError(sandboxName: string, sandbox: DaytonaSandbox): DaytonaApiError { const reason = sandbox.errorReason ? `: ${sandbox.errorReason}` : ""; return new DaytonaApiError( "server", - `Daytona sandbox ${sandboxId} is in state ${sandbox.state ?? "unknown"}${reason}` + `Daytona sandbox ${sandboxName} is in state ${sandbox.state ?? "unknown"}${reason}` ); } -function startTimeoutError(sandboxId: string, sandbox: DaytonaSandbox): DaytonaApiError { +function startTimeoutError(sandboxName: string, sandbox: DaytonaSandbox): DaytonaApiError { return new DaytonaApiError( "timeout", - `Daytona sandbox ${sandboxId} did not start in time (last state: ${sandbox.state ?? "unknown"})` + `Daytona sandbox ${sandboxName} did not start in time (last state: ${sandbox.state ?? "unknown"})` ); } @@ -81,13 +89,13 @@ function startTimeoutError(sandboxId: string, sandbox: DaytonaSandbox): DaytonaA * stopped or archived, otherwise keep waiting. Error states throw. */ function nextStartStep( - sandboxId: string, + sandboxName: string, sandbox: DaytonaSandbox, startRequested: boolean ): "done" | "start" | "wait" { const state = mapDaytonaSandboxState(sandbox.state); if (state === "started") return "done"; - if (state === "error") throw sandboxStateError(sandboxId, sandbox); + if (state === "error") throw sandboxStateError(sandboxName, sandbox); // Start once: Daytona may report the old state for a poll or two after accepting it. if (!startRequested && STARTABLE_STATES.has(state)) return "start"; return "wait"; @@ -97,6 +105,7 @@ export interface DaytonaSandboxesOptions { account: Pick; ssh: Pick; readiness: DaytonaReadiness; + ids: DaytonaSandboxIds; /** Xum data root; holds the installation identity used for the `xum.instance` label. */ rootDir: string; now: () => number; @@ -109,11 +118,15 @@ export interface DaytonaStartAndWaitOptions { signal?: AbortSignal; } -/** The sandboxes themselves: their state, start and stop, create and delete, and labels. */ +/** + * The sandboxes themselves: their state, start and stop, create and delete, and labels. Callers + * name a workspace's sandbox by its `sandboxName`; requests go to the id recorded for it. + */ export class DaytonaSandboxes { private readonly account: DaytonaSandboxesOptions["account"]; private readonly ssh: DaytonaSandboxesOptions["ssh"]; private readonly readiness: DaytonaReadiness; + private readonly ids: DaytonaSandboxIds; private readonly rootDir: string; private readonly now: () => number; private readonly sleep: (ms: number) => Promise; @@ -122,6 +135,7 @@ export class DaytonaSandboxes { this.account = options.account; this.ssh = options.ssh; this.readiness = options.readiness; + this.ids = options.ids; this.rootDir = options.rootDir; this.now = options.now; this.sleep = options.sleep; @@ -149,15 +163,45 @@ export class DaytonaSandboxes { return client.listSandboxes({ [DAYTONA_LABEL_INSTANCE]: await this.getInstanceId() }); } + /** `missing` too for a sandbox with no recorded id: Xum never finished creating it. */ async getSandboxState( - sandboxId: string + sandboxName: string ): Promise<{ state: DaytonaSandboxState; sandbox?: DaytonaSandbox }> { + const sandboxId = this.ids.get(sandboxName); + if (!sandboxId) return { state: "missing" }; const { client } = await this.account.getClient(); + const sandbox = await this.lookUp(client, sandboxId); + return sandbox + ? { state: mapDaytonaSandboxState(sandbox.state), sandbox } + : { state: "missing" }; + } + + /** + * The sandbox with this id or name, or undefined once it is gone. By id, Daytona keeps + * answering for a deleted sandbox (state `deleting`, then `destroyed`), so those count as gone. + */ + private async lookUp( + client: DaytonaClient, + idOrName: string, + signal?: AbortSignal + ): Promise { try { - const sandbox = await client.getSandbox(sandboxId); - return { state: mapDaytonaSandboxState(sandbox.state), sandbox }; + const sandbox = await client.getSandbox(idOrName, signal); + return isDestroyedState(sandbox.state) ? undefined : sandbox; } catch (error) { - if (isDaytonaNotFound(error)) return { state: "missing" }; + if (isDaytonaNotFound(error)) return undefined; + throw error; + } + } + + /** Whether any sandbox has this name: a new sandbox needs a free one. */ + async isNameTaken(sandboxName: string): Promise { + const { client } = await this.account.getClient(); + try { + await client.getSandbox(sandboxName); + return true; + } catch (error) { + if (isDaytonaNotFound(error)) return false; throw error; } } @@ -167,79 +211,106 @@ export class DaytonaSandboxes { * stopped, paused or archived. Fails on error states, a missing sandbox, or the deadline. */ async startAndWait( - sandboxId: string, + sandboxName: string, deadline: number, options: DaytonaStartAndWaitOptions = {} ): Promise { + const sandboxId = requireSandboxId(this.ids, sandboxName); const { client } = await this.account.getClient(); let startRequested = false; for (;;) { options.signal?.throwIfAborted(); - const sandbox = await this.getSandboxForStart(client, sandboxId, options.signal); - await this.assertOwned(sandbox, sandboxId); - const step = nextStartStep(sandboxId, sandbox, startRequested); + const sandbox = await this.getSandboxForStart(client, sandboxName, sandboxId, options.signal); + await this.assertOwned(sandbox, sandboxName); + const step = nextStartStep(sandboxName, sandbox, startRequested); if (step === "done") return sandbox; if (step === "start") { options.onStarting?.(); - // The id the label check saw: another sandbox can take the name in the meantime. - await client.startSandbox(sandbox.id, options.signal); + await client.startSandbox(sandboxId, options.signal); startRequested = true; } const remaining = deadline - this.now(); - if (remaining <= 0) throw startTimeoutError(sandboxId, sandbox); + if (remaining <= 0) throw startTimeoutError(sandboxName, sandbox); await this.sleep(Math.min(DAYTONA_START_POLL_INTERVAL_MS, remaining)); } } private async getSandboxForStart( client: DaytonaClient, + sandboxName: string, sandboxId: string, signal?: AbortSignal ): Promise { + const sandbox = await this.lookUp(client, sandboxId, signal); + if (sandbox) return sandbox; + throw new DaytonaApiError( + "not_found", + `Daytona sandbox ${sandboxName} no longer exists (removed outside Xum?)`, + 404 + ); + } + + /** + * Create a sandbox named `request.name` and record its id on the workspace that names it. A + * workspace removed during the create records nothing: its new sandbox is deleted again. + */ + async createSandbox( + request: DaytonaCreateSandboxRequest & { name: string } + ): Promise { + const { client } = await this.account.getClient(); + const created = await client.createSandbox(request); + let recorded: boolean; try { - return await client.getSandbox(sandboxId, signal); + recorded = await this.ids.record(request.name, created.id); } catch (error) { - if (isDaytonaNotFound(error)) { - throw new DaytonaApiError( - "not_found", - `Daytona sandbox ${sandboxId} no longer exists (removed outside Xum?)`, - 404 - ); - } + await this.deleteUnrecorded(client, created.id, request.name); throw error; } + if (!recorded) { + await this.deleteUnrecorded(client, created.id, request.name); + throw new Error(`the workspace of Daytona sandbox ${request.name} was removed during setup`); + } + return created; } - async createSandbox(request: DaytonaCreateSandboxRequest): Promise { - return (await this.account.getClient()).client.createSandbox(request); + private async deleteUnrecorded( + client: DaytonaClient, + sandboxId: string, + sandboxName: string + ): Promise { + try { + await client.deleteSandbox(sandboxId); + } catch (error) { + log.error("Failed to delete a Daytona sandbox whose id Xum could not record", { + sandboxName, + error: getErrorMessage(error), + }); + } } - async resizeSandbox(sandboxId: string, request: DaytonaResizeRequest): Promise { + async resizeSandbox(sandboxName: string, request: DaytonaResizeRequest): Promise { + const sandboxId = requireSandboxId(this.ids, sandboxName); const { client } = await this.account.getClient(); - this.readiness.invalidateSandboxReady(sandboxId); + this.readiness.invalidateSandboxReady(sandboxName); await client.resizeSandbox(sandboxId, request); } - async stopSandbox(sandboxId: string): Promise { - const { client } = await this.account.getClient(); - this.readiness.invalidateSandboxReady(sandboxId); - await client.stopSandbox(sandboxId); - } - /** Stop and wait until Daytona reports the sandbox stopped (a resize of disk needs that). */ - async stopAndWait(sandboxId: string, deadline: number): Promise { - await this.stopSandbox(sandboxId); + async stopAndWait(sandboxName: string, deadline: number): Promise { + const sandboxId = requireSandboxId(this.ids, sandboxName); const { client } = await this.account.getClient(); + this.readiness.invalidateSandboxReady(sandboxName); + await client.stopSandbox(sandboxId); for (;;) { - const sandbox = await this.getSandboxForStart(client, sandboxId); + const sandbox = await this.getSandboxForStart(client, sandboxName, sandboxId); const state = mapDaytonaSandboxState(sandbox.state); if (state === "stopped") return; - if (state === "error") throw sandboxStateError(sandboxId, sandbox); + if (state === "error") throw sandboxStateError(sandboxName, sandbox); const remaining = deadline - this.now(); if (remaining <= 0) { throw new DaytonaApiError( "timeout", - `Daytona sandbox ${sandboxId} did not stop in time (last state: ${sandbox.state ?? "unknown"})` + `Daytona sandbox ${sandboxName} did not stop in time (last state: ${sandbox.state ?? "unknown"})` ); } await this.sleep(Math.min(DAYTONA_START_POLL_INTERVAL_MS, remaining)); @@ -251,18 +322,16 @@ export class DaytonaSandboxes { * sandbox that is already gone counts as deleted. * * A workspace's config is not proof that Xum created the sandbox it names, so one without this - * install's label is kept (only Xum's access to it goes) and logged. The config holds the - * sandbox's name, which another sandbox can take once this one is gone, so the delete goes to - * the id the label check saw, and a sandbox already gone gets no delete. + * install's label is kept (only Xum's access to it goes) and logged. */ - async deleteSandbox(sandboxId: string): Promise { + async deleteSandbox(sandboxName: string): Promise { const { client } = await this.account.getClient(); - const sandbox = await this.findSandbox(client, sandboxId); + const sandbox = await this.findSandbox(client, sandboxName); // Revoke first: Daytona refuses to revoke a token of a sandbox that no longer exists. - await this.ssh.releaseSshAccess(sandboxId); + await this.ssh.releaseSshAccess(sandboxName); if (!sandbox) return; if (!(await this.carriesInstanceLabel(sandbox))) { - log.warn("Keeping a Daytona sandbox without this install's label", { sandboxId }); + log.warn("Keeping a Daytona sandbox without this install's label", { sandboxName }); return; } try { @@ -272,16 +341,16 @@ export class DaytonaSandboxes { } } + /** + * The recorded id's sandbox. Without a record, the sandbox a create whose answer never came + * (a timeout, a 5xx) may have made: found by its fresh name, and deleted only with this + * install's label. A recorded sandbox that is gone is never looked up by name again. + */ private async findSandbox( client: DaytonaClient, - sandboxId: string + sandboxName: string ): Promise { - try { - return await client.getSandbox(sandboxId); - } catch (error) { - if (isDaytonaNotFound(error)) return undefined; - throw error; - } + return this.lookUp(client, this.ids.get(sandboxName) ?? sandboxName); } private async carriesInstanceLabel(sandbox: DaytonaSandbox): Promise { @@ -290,16 +359,17 @@ export class DaytonaSandboxes { /** * Refuse to start a sandbox or give Xum ssh access to it without this install's label: its - * label was removed, or another sandbox took the name after the workspace's was deleted. + * label was removed, or a hand-edited config names a sandbox Xum did not create. */ - async assertOwned(sandbox: DaytonaSandbox, sandboxId: string): Promise { + async assertOwned(sandbox: DaytonaSandbox, sandboxName: string): Promise { if (await this.carriesInstanceLabel(sandbox)) return; throw new DaytonaApiError( "bad_request", - `Daytona sandbox ${sandboxId} does not carry this Xum install's label, so Xum will not use it` + `Daytona sandbox ${sandboxName} does not carry this Xum install's label, so Xum will not use it` ); } + /** By id: the reconciler passes the id of a sandbox in this install's label-filtered list. */ async refreshActivity(sandboxId: string): Promise { const { client } = await this.account.getClient(); await client.refreshActivity(sandboxId); @@ -310,10 +380,10 @@ export class DaytonaSandboxes { * block). Concurrent callers for one sandbox share a single request; only the first hears * `onStarting`. Throws a DaytonaApiError (`not_found` when the sandbox is gone). */ - ensureSandboxReady(sandboxId: string, options: DaytonaEnsureReadyOptions = {}): Promise { - return this.readiness.join(sandboxId, options, async (onStarting) => { - await this.startAndWait(sandboxId, this.now() + DAYTONA_START_TIMEOUT_MS, { onStarting }); - await this.ssh.ensureSshAccess(sandboxId); + ensureSandboxReady(sandboxName: string, options: DaytonaEnsureReadyOptions = {}): Promise { + return this.readiness.join(sandboxName, options, async (onStarting) => { + await this.startAndWait(sandboxName, this.now() + DAYTONA_START_TIMEOUT_MS, { onStarting }); + await this.ssh.ensureSshAccess(sandboxName); }); } } diff --git a/src/node/services/daytona/daytonaService.ts b/src/node/services/daytona/daytonaService.ts index 2cda8984382..ee5dc13952c 100644 --- a/src/node/services/daytona/daytonaService.ts +++ b/src/node/services/daytona/daytonaService.ts @@ -5,10 +5,13 @@ import { DaytonaAccount } from "./daytonaAccount"; import type { DaytonaClient } from "./daytonaClient"; import { DaytonaReadiness } from "./daytonaReadiness"; import { DaytonaSandboxes } from "./daytonaSandboxes"; +import type { DaytonaSandboxIds } from "./daytonaSandboxIds"; import { DaytonaSsh, type DaytonaSshWriter } from "./daytonaSsh"; export interface DaytonaServiceOptions { credentialsStore: DaytonaCredentialsStore; + /** Where the ids of created sandboxes are recorded: the workspace config in the app. */ + sandboxIds: DaytonaSandboxIds; /** Xum data root; holds the installation identity used for the `xum.instance` label. */ rootDir: string; /** Defaults to a writer for the real `~/.ssh/config`; tests pass one aimed at a temp dir. */ @@ -39,6 +42,7 @@ export class DaytonaService { this.ssh = new DaytonaSsh({ account: this.account, readiness: this.readiness, + ids: options.sandboxIds, sshConfigWriter: options.sshConfigWriter ?? new DaytonaSshConfigWriter({ xumHome: options.rootDir }), now, @@ -47,6 +51,7 @@ export class DaytonaService { account: this.account, ssh: this.ssh, readiness: this.readiness, + ids: options.sandboxIds, rootDir: options.rootDir, now, sleep: options.sleep ?? sleepWithAbort, diff --git a/src/node/services/daytona/daytonaSsh.test.ts b/src/node/services/daytona/daytonaSsh.test.ts index 7faa19266bf..8a3f8bbf2d0 100644 --- a/src/node/services/daytona/daytonaSsh.test.ts +++ b/src/node/services/daytona/daytonaSsh.test.ts @@ -23,6 +23,7 @@ describe("DaytonaSsh", () => { ): DaytonaService { return new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(rootDir, env), + sandboxIds: plane.recordedIds, rootDir, sshConfigWriter, fetch: plane.fetch, @@ -35,22 +36,22 @@ describe("DaytonaSsh", () => { }); } - /** An include file kept as a map from sandbox id to token. */ + /** An include file kept as a map from sandbox name to token. */ function mapWriter(includeFile: Map): DaytonaSshWriter { - const store = (input: { sandboxId: string; token: string }) => { - includeFile.set(input.sandboxId, input.token); + const store = (input: { sandboxName: string; token: string }) => { + includeFile.set(input.sandboxName, input.token); return Promise.resolve(); }; return { writeSandbox: store, ensureManagedBlock: () => Promise.resolve(), updateToken: store, - removeSandbox: (sandboxId) => { - includeFile.delete(sandboxId); + removeSandbox: (sandboxName) => { + includeFile.delete(sandboxName); return Promise.resolve(); }, readTokens: () => - Promise.resolve([...includeFile].map(([sandboxId, token]) => ({ sandboxId, token }))), + Promise.resolve([...includeFile].map(([sandboxName, token]) => ({ sandboxName, token }))), repointGateway: () => Promise.resolve(), }; } @@ -86,6 +87,21 @@ describe("DaytonaSsh", () => { expect(plane.revoked).toEqual([first.token]); }); + it("mints and revokes tokens for the recorded id, never for a sandbox that took the name", async () => { + // Daytona resolves names too: a request by name would reach the sandbox holding it now. + const mine = plane.addSandbox({ id: "id-mine", name: "xum-a" }); + mine.name = "xum-a-renamed"; + plane.addSandbox({ id: "id-other", name: "xum-a" }, { recorded: false }); + + await service.ssh.ensureSshAccess("xum-a"); + await service.ssh.releaseSshAccess("xum-a"); + + expect(plane.calls.filter((call) => call.includes("ssh-access"))).toEqual([ + "POST /sandbox/id-mine/ssh-access", + "DELETE /sandbox/id-mine/ssh-access", + ]); + }); + it("shares one mint between concurrent token requests", async () => { const [a, b] = await Promise.all([ service.ssh.getSshToken("sb-1"), @@ -164,6 +180,7 @@ describe("DaytonaSsh", () => { const startProcess = async () => { const started = new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), + sandboxIds: plane.recordedIds, rootDir, fetch: plane.fetch, now: () => clock, @@ -188,6 +205,7 @@ describe("DaytonaSsh", () => { const sshConfigPath = path.join(rootDir, "home", ".ssh", "config"); service = new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(rootDir, { DAYTONA_API_KEY: "k" }), + sandboxIds: plane.recordedIds, rootDir, fetch: plane.fetch, now: () => clock, diff --git a/src/node/services/daytona/daytonaSsh.ts b/src/node/services/daytona/daytonaSsh.ts index 7eae7c6da69..b25f9be70ed 100644 --- a/src/node/services/daytona/daytonaSsh.ts +++ b/src/node/services/daytona/daytonaSsh.ts @@ -5,6 +5,7 @@ import { log } from "@/node/services/log"; import type { DaytonaAccount } from "./daytonaAccount"; import type { DaytonaClient, DaytonaGatewayConfig, DaytonaSshAccess } from "./daytonaClient"; import type { DaytonaReadiness } from "./daytonaReadiness"; +import { requireSandboxId, type DaytonaSandboxIds } from "./daytonaSandboxIds"; /** Tokens are minted for an hour and replaced once less than 15 minutes remain. */ export const DAYTONA_SSH_TOKEN_TTL_MINUTES = 60; @@ -23,6 +24,7 @@ export type DaytonaSshWriter = Pick< export interface DaytonaSshOptions { account: Pick; readiness: DaytonaReadiness; + ids: Pick; sshConfigWriter: DaytonaSshWriter; now: () => number; } @@ -48,11 +50,13 @@ function toSshGateway(config: DaytonaGatewayConfig) { * Xum's SSH access to sandboxes: the per-sandbox tokens (minted, refreshed, revoked), the * include file and `~/.ssh/config` block they are written to, and the gateway `/config` they * point at. One instance per Xum backend: a second one would mint (and revoke) tokens behind - * the first one's back. + * the first one's back. Keyed by sandbox name (the alias); tokens are minted and revoked for the + * recorded id. */ export class DaytonaSsh { private readonly account: DaytonaSshOptions["account"]; private readonly readiness: DaytonaReadiness; + private readonly ids: DaytonaSshOptions["ids"]; private readonly now: () => number; /** Daytona's `/config` of these settings, fetched once; a repair fetches it again. */ private gatewayCache?: { apiUrl: string; config: DaytonaGatewayConfig }; @@ -69,6 +73,7 @@ export class DaytonaSsh { constructor(options: DaytonaSshOptions) { this.account = options.account; this.readiness = options.readiness; + this.ids = options.ids; this.now = options.now; this.sshConfigWriter = options.sshConfigWriter; } @@ -80,7 +85,7 @@ export class DaytonaSsh { * reads it too but goes on when the read fails. A missing file holds nothing. */ async loadPersistedTokens(): Promise { - let entries: Array<{ sandboxId: string; token: string }>; + let entries: Array<{ sandboxName: string; token: string }>; try { entries = await this.sshConfigWriter.readTokens(); } catch (error) { @@ -89,12 +94,12 @@ export class DaytonaSsh { }); throw error; } - for (const { sandboxId, token } of entries) { + for (const { sandboxName, token } of entries) { rememberDaytonaToken(token); // An earlier run's tokens: holding them lets a replacement, a settings change or an // archive revoke them. The file has no expiry, so they are due for refresh at once. - if (!this.persistedTokensLoaded && !this.tokens.has(sandboxId)) { - this.tokens.set(sandboxId, { token, expiresAt: new Date(0).toISOString() }); + if (!this.persistedTokensLoaded && !this.tokens.has(sandboxName)) { + this.tokens.set(sandboxName, { token, expiresAt: new Date(0).toISOString() }); } } this.persistedTokensLoaded = true; @@ -112,11 +117,11 @@ export class DaytonaSsh { this.tokens.clear(); this.sshConfigured.clear(); this.gatewayCache = undefined; - for (const [sandboxId] of tokens) await this.removeOldAccountAlias(sandboxId); + for (const [sandboxName] of tokens) await this.removeOldAccountAlias(sandboxName); if (previousClient) { await Promise.all( - tokens.map(([sandboxId, access]) => - this.revokeQuietly(previousClient, sandboxId, access.token) + tokens.map(([sandboxName, access]) => + this.revokeQuietly(previousClient, sandboxName, access.token) ) ); } @@ -139,15 +144,15 @@ export class DaytonaSsh { * this sandbox's token, revoking the old one. Pooled connections opened before keep their old * gateway until they close. */ - async repairSshAccess(sandboxId: string): Promise { + async repairSshAccess(sandboxName: string): Promise { const { client, credentials } = await this.account.getClient(); const config = await client.getConfig(); this.gatewayCache = { apiUrl: credentials.apiUrl, config }; await this.sshConfigWriter.repointGateway(toSshGateway(config)); - const cached = this.tokens.get(sandboxId); + const cached = this.tokens.get(sandboxName); // Due at once, so ensureSshAccess mints, writes, and revokes the old one. - if (cached) this.tokens.set(sandboxId, { ...cached, expiresAt: new Date(0).toISOString() }); - await this.ensureSshAccess(sandboxId); + if (cached) this.tokens.set(sandboxName, { ...cached, expiresAt: new Date(0).toISOString() }); + await this.ensureSshAccess(sandboxName); } /** @@ -156,40 +161,35 @@ export class DaytonaSsh { * The `~/.ssh/config` block is checked every time: another tool may have removed it while a * cached token stays fresh, and an external editor needs it to resolve the alias. */ - async ensureSshAccess(sandboxId: string): Promise { + async ensureSshAccess(sandboxName: string): Promise { // Fails before a mint when the control plane cannot say where the gateway is. await this.getGatewayConfig(); // A cached token was written by the mint that produced it, so only a mint writes files. - await this.getSshToken(sandboxId, { + await this.getSshToken(sandboxName, { onNewToken: async ({ token }) => { const gateway = toSshGateway(await this.getGatewayConfig()); - if (this.sshConfigured.has(sandboxId)) { - await this.sshConfigWriter.updateToken({ sandboxId, token, gateway }); + if (this.sshConfigured.has(sandboxName)) { + await this.sshConfigWriter.updateToken({ sandboxName, token, gateway }); return; } - await this.sshConfigWriter.writeSandbox({ sandboxId, token, gateway }); + await this.sshConfigWriter.writeSandbox({ sandboxName, token, gateway }); }, }); await this.sshConfigWriter.ensureManagedBlock(); - this.sshConfigured.add(sandboxId); - } - - /** Whether this process holds a token for the sandbox that is close to expiry. */ - hasExpiringSshToken(sandboxId: string): boolean { - return this.tokens.has(sandboxId) && this.tokenNeedsRefresh(sandboxId); + this.sshConfigured.add(sandboxName); } /** Revoke the token and remove the sandbox's include block. */ - async releaseSshAccess(sandboxId: string): Promise { - await this.readiness.waitForSandboxReadiness(sandboxId); - this.sshConfigured.delete(sandboxId); - this.readiness.invalidateSandboxReady(sandboxId); - await this.forgetSshToken(sandboxId); - await this.sshConfigWriter.removeSandbox(sandboxId); + async releaseSshAccess(sandboxName: string): Promise { + await this.readiness.waitForSandboxReadiness(sandboxName); + this.sshConfigured.delete(sandboxName); + this.readiness.invalidateSandboxReady(sandboxName); + await this.forgetSshToken(sandboxName); + await this.sshConfigWriter.removeSandbox(sandboxName); } - tokenNeedsRefresh(sandboxId: string): boolean { - const cached = this.tokens.get(sandboxId); + private tokenNeedsRefresh(sandboxName: string): boolean { + const cached = this.tokens.get(sandboxName); if (!cached) return true; const expiresAt = Date.parse(cached.expiresAt); return ( @@ -203,45 +203,46 @@ export class DaytonaSsh { * after `onNewToken` resolves; a failed revoke is logged, since the token expires anyway. */ async getSshToken( - sandboxId: string, + sandboxName: string, options: DaytonaSshTokenOptions = {} ): Promise { - const cached = this.tokens.get(sandboxId); - if (cached && !this.tokenNeedsRefresh(sandboxId)) return cached; - const inFlight = this.tokenRequests.get(sandboxId); + const cached = this.tokens.get(sandboxName); + if (cached && !this.tokenNeedsRefresh(sandboxName)) return cached; + const inFlight = this.tokenRequests.get(sandboxName); if (inFlight) return inFlight; - const request = this.mintSshToken(sandboxId, cached, options).finally(() => { - this.tokenRequests.delete(sandboxId); + const request = this.mintSshToken(sandboxName, cached, options).finally(() => { + this.tokenRequests.delete(sandboxName); }); - this.tokenRequests.set(sandboxId, request); + this.tokenRequests.set(sandboxName, request); return request; } private async mintSshToken( - sandboxId: string, + sandboxName: string, previous: DaytonaSshAccess | undefined, options: DaytonaSshTokenOptions ): Promise { const accountChanges = this.accountChanges; + const sandboxId = requireSandboxId(this.ids, sandboxName); const { client } = await this.account.getClient(); const access = await client.createSshAccess(sandboxId, DAYTONA_SSH_TOKEN_TTL_MINUTES); rememberDaytonaToken(access.token); - await this.announceNewToken(client, sandboxId, access, options); + await this.announceNewToken(client, sandboxName, access, options); // No await between this check and the set: forgetAccount sees the token, or this sees it. if (this.accountChanges !== accountChanges) { - await this.dropOldAccountToken(client, sandboxId, access.token); + await this.dropOldAccountToken(client, sandboxName, access.token); } - this.tokens.set(sandboxId, access); + this.tokens.set(sandboxName, access); if (previous && previous.token !== access.token) { - await this.revokeQuietly(client, sandboxId, previous.token); + await this.revokeQuietly(client, sandboxName, previous.token); } return access; } private async announceNewToken( client: DaytonaClient, - sandboxId: string, + sandboxName: string, access: DaytonaSshAccess, options: DaytonaSshTokenOptions ): Promise { @@ -249,7 +250,7 @@ export class DaytonaSsh { await options.onNewToken?.(access); } catch (error) { // Nothing holds the new token any more: revoke it rather than leave it live. - await this.revokeQuietly(client, sandboxId, access.token); + await this.revokeQuietly(client, sandboxName, access.token); throw error; } } @@ -261,45 +262,48 @@ export class DaytonaSsh { */ private async dropOldAccountToken( client: DaytonaClient, - sandboxId: string, + sandboxName: string, token: string ): Promise { - await this.removeOldAccountAlias(sandboxId); - await this.revokeQuietly(client, sandboxId, token); - throw new Error(`The Daytona settings changed while Xum got ssh access to ${sandboxId}`); + await this.removeOldAccountAlias(sandboxName); + await this.revokeQuietly(client, sandboxName, token); + throw new Error(`The Daytona settings changed while Xum got ssh access to ${sandboxName}`); } - private async removeOldAccountAlias(sandboxId: string): Promise { + private async removeOldAccountAlias(sandboxName: string): Promise { try { - await this.sshConfigWriter.removeSandbox(sandboxId); + await this.sshConfigWriter.removeSandbox(sandboxName); } catch (error) { log.warn("Failed to remove an old-account Daytona alias from the include file", { - sandboxId, + sandboxName, error: getErrorMessage(error), }); } } /** Revoke and forget the cached token (workspace deleted or sandbox detached). */ - async forgetSshToken(sandboxId: string): Promise { - const cached = this.tokens.get(sandboxId); - this.tokens.delete(sandboxId); + async forgetSshToken(sandboxName: string): Promise { + const cached = this.tokens.get(sandboxName); + this.tokens.delete(sandboxName); if (!cached) return; const { client } = await this.account.getClient(); - await this.revokeQuietly(client, sandboxId, cached.token); + await this.revokeQuietly(client, sandboxName, cached.token); } + /** Without a recorded id there is no sandbox to ask: the token expires on its own. */ private async revokeQuietly( client: DaytonaClient, - sandboxId: string, + sandboxName: string, token: string ): Promise { + const sandboxId = this.ids.get(sandboxName); + if (!sandboxId) return; try { await client.revokeSshAccess(sandboxId, token); } catch (error) { // Never log the token itself. log.warn("Failed to revoke a Daytona SSH token; it expires on its own", { - sandboxId, + sandboxName, error: getErrorMessage(error), }); } diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 2f72790933a..3537afb49c2 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -60,6 +60,7 @@ import { createBackendCpuProfiler } from "@/node/services/perf/backendCpuProfile import { PerfCaptureService } from "@/node/services/perf/perfCaptureService"; import { AgentResourceLimitsService } from "@/node/services/agentResourceLimits/agentResourceLimitsService"; import { DaytonaCredentialsStore } from "@/node/config/daytonaCredentialsStore"; +import { createConfigSandboxIds } from "@/node/services/daytona/daytonaSandboxIds"; import { DaytonaService } from "@/node/services/daytona/daytonaService"; import { DaytonaReconciler, @@ -346,6 +347,7 @@ export class ServiceContainer { this.agentResourceLimits = new AgentResourceLimitsService(this.config); this.daytonaService = new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(this.config.rootDir), + sandboxIds: createConfigSandboxIds(this.config), rootDir: this.config.rootDir, }); this.perfCaptures = new PerfCaptureService({ diff --git a/src/node/services/terminalService.test.ts b/src/node/services/terminalService.test.ts index 497c30a9e0b..84e57e7e2da 100644 --- a/src/node/services/terminalService.test.ts +++ b/src/node/services/terminalService.test.ts @@ -2000,7 +2000,7 @@ describe("TerminalService.openNative", () => { type: "ssh", host: "sb-1.xum--daytona", srcBaseDir: "~/src", - daytona: { sandboxId: "sb-1" }, + daytona: { sandboxName: "sb-1" }, }, }, ]) diff --git a/src/node/services/workspaceExternalEditors.ts b/src/node/services/workspaceExternalEditors.ts index 747114f81e5..70032010db6 100644 --- a/src/node/services/workspaceExternalEditors.ts +++ b/src/node/services/workspaceExternalEditors.ts @@ -14,7 +14,10 @@ import { isSSHRuntime } from "@/common/types/runtime"; import { isWorkspaceArchived } from "@/common/utils/archive"; import { getErrorMessage } from "@/common/utils/errors"; import type { Config } from "@/node/config"; -import { createRuntimeForWorkspace, ensureDaytonaSandboxReady } from "@/node/runtime/runtimeHelpers"; +import { + createRuntimeForWorkspace, + ensureDaytonaSandboxReady, +} from "@/node/runtime/runtimeHelpers"; import type { HostFiles } from "@/node/services/hostFiles/hostFiles"; import { log } from "@/node/services/log"; import { findWorkspaceEntry } from "@/node/services/taskUtils"; diff --git a/src/node/services/workspaceService.archive.test.ts b/src/node/services/workspaceService.archive.test.ts index e63f7978156..ae306bdf7f9 100644 --- a/src/node/services/workspaceService.archive.test.ts +++ b/src/node/services/workspaceService.archive.test.ts @@ -630,9 +630,9 @@ describe("WorkspaceService archive lifecycle hooks", () => { }); test("archive() refuses a model-driven archive that deletes a Daytona sandbox, not a fork's", async () => { - const daytonaRuntime = (daytona: { sandboxId: string; forked?: boolean }) => ({ + const daytonaRuntime = (daytona: { sandboxName: string; forked?: boolean }) => ({ type: "ssh" as const, - host: `${daytona.sandboxId}.xum--daytona`, + host: `${daytona.sandboxName}.xum--daytona`, srcBaseDir: "~/src", daytona, }); @@ -651,13 +651,13 @@ describe("WorkspaceService archive lifecycle hooks", () => { }); await editEntry((entry) => { - entry.runtimeConfig = daytonaRuntime({ sandboxId: "xum-own" }); + entry.runtimeConfig = daytonaRuntime({ sandboxName: "xum-own" }); }); expect(await modelArchive()).toEqual(Err(DAYTONA_ARCHIVE_NEEDS_USER)); expect(hookRuns).toBe(0); await editEntry((entry) => { - entry.runtimeConfig = daytonaRuntime({ sandboxId: "xum-own", forked: true }); + entry.runtimeConfig = daytonaRuntime({ sandboxName: "xum-own", forked: true }); }); expect(await modelArchive()).toEqual(Ok({ kind: "archived" })); expect(hookRuns).toBe(1); diff --git a/src/node/services/workspaceService.multiProject.test.ts b/src/node/services/workspaceService.multiProject.test.ts index 44e3080b377..fe66e1c183d 100644 --- a/src/node/services/workspaceService.multiProject.test.ts +++ b/src/node/services/workspaceService.multiProject.test.ts @@ -1167,7 +1167,7 @@ describe("WorkspaceService multi-project lifecycle", () => { type: "ssh", host: "daytona://", srcBaseDir: "~/src", - daytona: { sandboxId: "xum-multi" }, + daytona: { sandboxName: "xum-multi" }, }, ], ])( diff --git a/src/node/services/workspaceService.remove.test.ts b/src/node/services/workspaceService.remove.test.ts index 12cfc6c9170..28103605ad2 100644 --- a/src/node/services/workspaceService.remove.test.ts +++ b/src/node/services/workspaceService.remove.test.ts @@ -747,7 +747,7 @@ describe("WorkspaceService remove shared-workspace guard", () => { type: "ssh" as const, host: "sb-1.xum--daytona", srcBaseDir: "/home/daytona/xum", - daytona: { sandboxId: "sb-1" }, + daytona: { sandboxName: "sb-1" }, }; async function createDaytonaHarness(borrowers: string[]): Promise { diff --git a/src/node/services/workspaceService.runtimeStatus.test.ts b/src/node/services/workspaceService.runtimeStatus.test.ts index 96277d14668..0fa8c9ce8e9 100644 --- a/src/node/services/workspaceService.runtimeStatus.test.ts +++ b/src/node/services/workspaceService.runtimeStatus.test.ts @@ -231,11 +231,11 @@ describe("WorkspaceService.getRuntimeStatuses for Daytona workspaces", () => { let harness: WorkspaceServiceHarness; let projectPath: string; let globalService: { mockRestore: () => void }; - const getSandboxState = mock((sandboxId: string) => - sandboxId === "sb-broken" + const getSandboxState = mock((sandboxName: string) => + sandboxName === "sb-broken" ? Promise.reject(new Error("throttled")) : Promise.resolve({ - state: sandboxId === "sb-1" ? ("started" as const) : ("stopped" as const), + state: sandboxName === "sb-1" ? ("started" as const) : ("stopped" as const), }) ); @@ -258,15 +258,15 @@ describe("WorkspaceService.getRuntimeStatuses for Daytona workspaces", () => { await harness.config.editConfig((config) => { config.projects.set(projectPath, { trusted: true, - workspaces: Object.entries(sandboxByWorkspace).map(([id, sandboxId]) => ({ + workspaces: Object.entries(sandboxByWorkspace).map(([id, sandboxName]) => ({ id, name: id, path: path.join(harness.rootDir, "checkouts", id), runtimeConfig: { type: "ssh", - host: `${sandboxId}.xum--daytona`, + host: `${sandboxName}.xum--daytona`, srcBaseDir: "/home/daytona/xum", - daytona: { sandboxId }, + daytona: { sandboxName }, }, createdAt: "2026-01-01T00:00:00.000Z", })), @@ -303,7 +303,7 @@ describe("WorkspaceService.getRuntimeStatuses for Daytona workspaces", () => { broken1: "unknown", broken2: "unknown", }); - expect(getSandboxState.mock.calls.map(([sandboxId]) => sandboxId).sort()).toEqual([ + expect(getSandboxState.mock.calls.map(([sandboxName]) => sandboxName).sort()).toEqual([ "sb-1", "sb-2", "sb-broken", @@ -355,7 +355,7 @@ describe("WorkspaceService.recordExternalEditorOpen for a Daytona workspace", () type: "ssh", host: "sb-1.xum--daytona", srcBaseDir: "/home/daytona/xum", - daytona: { sandboxId: "sb-1" }, + daytona: { sandboxName: "sb-1" }, }, createdAt: "2026-01-01T00:00:00.000Z", archivedAt, diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index d4a6528afa8..a3d121c6a1b 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -356,7 +356,7 @@ import type { import { hasSrcBaseDir, getSrcBaseDir, - getDaytonaSandboxId, + getDaytonaSandboxName, isSSHRuntime, ownsDaytonaSandbox, isDockerRuntime, @@ -1075,16 +1075,18 @@ async function probeDaytonaRuntimeStatuses( const workspacesBySandbox = new Map(); for (const workspaceId of workspaceIds) { const runtimeConfig = metadataById.get(workspaceId)?.runtimeConfig; - const sandboxId = isSSHRuntime(runtimeConfig) ? runtimeConfig.daytona?.sandboxId : undefined; - if (!sandboxId) continue; - const workspaces = workspacesBySandbox.get(sandboxId) ?? []; + const sandboxName = isSSHRuntime(runtimeConfig) + ? runtimeConfig.daytona?.sandboxName + : undefined; + if (!sandboxName) continue; + const workspaces = workspacesBySandbox.get(sandboxName) ?? []; workspaces.push(workspaceId); - workspacesBySandbox.set(sandboxId, workspaces); + workspacesBySandbox.set(sandboxName, workspaces); } const statuses: Record = {}; await Promise.all( - [...workspacesBySandbox].map(async ([sandboxId, workspaces]) => { - const status = await probeDaytonaRuntimeStatus(sandboxId); + [...workspacesBySandbox].map(async ([sandboxName, workspaces]) => { + const status = await probeDaytonaRuntimeStatus(sandboxName); for (const workspaceId of workspaces) statuses[workspaceId] = status; }) ); @@ -1092,15 +1094,15 @@ async function probeDaytonaRuntimeStatuses( } /** Passive Daytona probe: reads the sandbox state, never starts it. */ -async function probeDaytonaRuntimeStatus(sandboxId: string): Promise { +async function probeDaytonaRuntimeStatus(sandboxName: string): Promise { const service = getGlobalDaytonaService(); if (!service) return "unknown"; try { - const { state } = await service.sandboxes.getSandboxState(sandboxId); + const { state } = await service.sandboxes.getSandboxState(sandboxName); return DAYTONA_RUNTIME_STATUS[state]; } catch (error) { log.debug("Failed to read Daytona sandbox state for runtime status", { - sandboxId, + sandboxName, error: getErrorMessage(error), }); return "unknown"; @@ -1134,7 +1136,7 @@ const ARCHIVE_OWN_ACTIVITY_POLICY = { /** What archive refusals call the dedicated remote box, so Daytona users are not told "Coder". */ function dedicatedRemoteBoxLabel(metadata: WorkspaceMetadata | undefined): string { - return getDaytonaSandboxId(metadata?.runtimeConfig) ? "Daytona sandbox" : "Coder workspace"; + return getDaytonaSandboxName(metadata?.runtimeConfig) ? "Daytona sandbox" : "Coder workspace"; } /** A Coder workspace Xum created for this workspace (not an existing one it connected to). */ diff --git a/tests/runtime/daytonaRuntime.test.ts b/tests/runtime/daytonaRuntime.test.ts index 9175e7ec2f5..c2d9e5460dd 100644 --- a/tests/runtime/daytonaRuntime.test.ts +++ b/tests/runtime/daytonaRuntime.test.ts @@ -18,6 +18,7 @@ import type { InitLogger } from "@/node/runtime/Runtime"; import { DaytonaSshConfigWriter } from "@/node/runtime/daytonaSshConfig"; import { createSSHTransport } from "@/node/runtime/transports"; import { createDaytonaArchiveHook } from "@/node/services/daytona/daytonaLifecycleHooks"; +import { InMemoryDaytonaSandboxIds } from "@/node/services/daytona/daytonaSandboxIds"; import { DaytonaService } from "@/node/services/daytona/daytonaService"; import { execBuffered, readFileString, writeFileString } from "@/node/utils/runtime/helpers"; @@ -36,7 +37,7 @@ describeDaytona("Daytona runtime (real control plane)", () => { let originalHome: string | undefined; let service: DaytonaService; let projectPath: string; - let sandboxId: string | undefined; + let sandboxName: string | undefined; function makeRuntime(config: Extract, workspaceName: string) { if (!config.daytona) throw new Error("expected a daytona config"); @@ -56,6 +57,7 @@ describeDaytona("Daytona runtime (real control plane)", () => { await fs.mkdir(xumHome, { recursive: true }); service = new DaytonaService({ credentialsStore: new DaytonaCredentialsStore(xumHome), + sandboxIds: new InMemoryDaytonaSandboxIds(), rootDir: xumHome, sshConfigWriter: new DaytonaSshConfigWriter({ xumHome, @@ -73,7 +75,7 @@ describeDaytona("Daytona runtime (real control plane)", () => { }, 60_000); afterAll(async () => { - if (sandboxId) await service.sandboxes.deleteSandbox(sandboxId); + if (sandboxName) await service.sandboxes.deleteSandbox(sandboxName); process.env.HOME = originalHome; await fs.rm(tempRoot, { recursive: true, force: true }); }, 120_000); @@ -88,7 +90,7 @@ describeDaytona("Daytona runtime (real control plane)", () => { const creator = makeRuntime(draft, "feature"); const finalized = await creator.finalizeConfig("feature", draft); if (!finalized.success || finalized.data.type !== "ssh") throw new Error("finalize failed"); - sandboxId = finalized.data.daytona?.sandboxId; + sandboxName = finalized.data.daytona?.sandboxName; expect((await creator.validateBeforePersist("feature", finalized.data)).success).toBe(true); const runtime = makeRuntime(finalized.data, "feature"); @@ -138,7 +140,7 @@ describeDaytona("Daytona runtime (real control plane)", () => { const forkRuntime = makeRuntime(forkConfig, "feature-fork"); const deleted = await forkRuntime.deleteWorkspace(projectPath, "feature-fork", false); expect(deleted.success).toBe(true); - expect((await service.sandboxes.getSandboxState(sandboxId ?? "")).state).toBe("started"); + expect((await service.sandboxes.getSandboxState(sandboxName ?? "")).state).toBe("started"); // Archiving the workspace that created the sandbox deletes it. const archiveHook = createDaytonaArchiveHook({ daytonaService: service }); @@ -153,6 +155,6 @@ describeDaytona("Daytona runtime (real control plane)", () => { }, }); expect(archived.success).toBe(true); - expect((await service.sandboxes.getSandboxState(sandboxId ?? "")).state).toBe("missing"); + expect((await service.sandboxes.getSandboxState(sandboxName ?? "")).state).toBe("missing"); }, 900_000); }); From 8b8f113cf123f075b73d64073b3176b0ed03fe8d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:17:23 +0000 Subject: [PATCH 230/231] docs(daytona): say when Xum replaces the ssh token The reconciler no longer refreshes tokens in the background, so the token is replaced only when Xum brings the sandbox up (or an ssh command fails). Say so, and that the user's own ssh clients can be refused after the hour until Xum uses the sandbox again (UAT round 8 observation). --- docs/runtime/daytona.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx index e20a136c706..529f9bbba9f 100644 --- a/docs/runtime/daytona.mdx +++ b/docs/runtime/daytona.mdx @@ -69,7 +69,7 @@ Xum does not stop or delete sandboxes that no workspace uses (for example one wh ## SSH setup -Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, in case that read failed at startup. When an SSH command to a running sandbox fails (for example the gateway refuses a token revoked outside Xum, or Daytona moved its gateway or changed its host keys), Xum reads the gateway settings from Daytona again, rewrites the SSH settings, gets a new token, and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered. +Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and gets it for one hour. When Xum brings a sandbox up for a turn or a terminal, it replaces a token that has less than 15 minutes left. Xum does not replace tokens in the background, so your own SSH clients can be refused after the hour until Xum uses the sandbox again. Xum revokes the token when it replaces it, when you change the Daytona settings, and when it deletes the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, in case that read failed at startup. When an SSH command to a running sandbox fails (for example the gateway refuses a token revoked outside Xum, or Daytona moved its gateway or changed its host keys), Xum reads the gateway settings from Daytona again, rewrites the SSH settings, gets a new token, and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered. Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts. From f0fec1855359b228e3e5e549ce454a0ff01317da Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:26:09 +0000 Subject: [PATCH 231/231] chore: regenerate the built-in docs skill for the Daytona token wording --- src/node/services/agentSkills/builtInSkillContent.generated.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 2a012b76d97..959ae8da9d9 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -10147,7 +10147,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "## SSH setup", "", - "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and refreshes it before it expires. It revokes the token when it replaces it, when you change the Daytona settings, and when it deletes the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, in case that read failed at startup. When an SSH command to a running sandbox fails (for example the gateway refuses a token revoked outside Xum, or Daytona moved its gateway or changed its host keys), Xum reads the gateway settings from Daytona again, rewrites the SSH settings, gets a new token, and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered.", + "Xum writes the SSH settings for its sandboxes to `~/.xum/ssh/daytona.conf` and adds a managed block to the top of `~/.ssh/config` that includes it for hosts named `.xum--daytona`. The gateway's access token is the SSH user name; Xum keeps it in memory and gets it for one hour. When Xum brings a sandbox up for a turn or a terminal, it replaces a token that has less than 15 minutes left. Xum does not replace tokens in the background, so your own SSH clients can be refused after the hour until Xum uses the sandbox again. Xum revokes the token when it replaces it, when you change the Daytona settings, and when it deletes the sandbox. At startup, Xum takes over the tokens that an earlier run left in `daytona.conf`, so it can filter and revoke them too. Before each SSH command it reads the file again, in case that read failed at startup. When an SSH command to a running sandbox fails (for example the gateway refuses a token revoked outside Xum, or Daytona moved its gateway or changed its host keys), Xum reads the gateway settings from Daytona again, rewrites the SSH settings, gets a new token, and runs the command again once. Xum replaces any token it finds in SSH errors and logs with ``; output of commands that you or an agent run in a terminal is not filtered.", "", "Xum pins the gateway's host keys in `~/.xum/ssh/daytona_known_hosts` and refuses a gateway whose key does not match, with OpenSSH and with the built-in SSH2 transport alike. This holds for `xum server` too, which otherwise skips host key checks for SSH hosts.", "",