diff --git a/docs/config/providers.mdx b/docs/config/providers.mdx index cffdfb3c050..8009d194bf0 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/docs/docs.json b/docs/docs.json index d271bb3c6fc..5babb70f8dc 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -67,6 +67,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 48d1c5048f8..4e8988e14a3 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. diff --git a/docs/runtime/daytona.mdx b/docs/runtime/daytona.mdx new file mode 100644 index 00000000000..529f9bbba9f --- /dev/null +++ b/docs/runtime/daytona.mdx @@ -0,0 +1,82 @@ +--- +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 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, and refuses one before it creates a sandbox. + +## Set up access + +1. Open **Settings > Runtimes** and find the **Daytona** row. +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` 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. + + +## Sizes + +| 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. Pick a snapshot and a size. +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. + +Xum uses only sandboxes it creates: a workspace cannot use an existing sandbox. + +## 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 | Delete the sandbox at once, without a check for uncommitted work | +| 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. 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. + +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. + +## 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 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. + +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 + +- **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/browser/components/ChatPane/WorkspaceFooterBar.tsx b/src/browser/components/ChatPane/WorkspaceFooterBar.tsx index e8e1ceb9530..5a4b218ebb8 100644 --- a/src/browser/components/ChatPane/WorkspaceFooterBar.tsx +++ b/src/browser/components/ChatPane/WorkspaceFooterBar.tsx @@ -3,10 +3,10 @@ import { ArrowLeftRight, Github, MessageCircle } from "lucide-react"; import { cn } from "@/common/lib/utils"; import { stopKeyboardPropagation } from "@/browser/utils/events"; import { updateUserPreferences, useUserPreferences } from "@/browser/stores/AppConfigStore"; -import { isDevcontainerRuntime, type RuntimeConfig } from "@/common/types/runtime"; +import { isDevcontainerRuntime, isSSHRuntime, type RuntimeConfig } from "@/common/types/runtime"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { ProjectConfig } from "@/common/types/project"; -import { 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,7 +138,7 @@ function DriftModeToggle(props: { showLabel: boolean }) { ); } -type DevcontainerChip = ReturnType; +type DevcontainerChip = ReturnType; function WorkspaceBranchControls(props: { workspaceId: string; @@ -429,8 +429,10 @@ export const WorkspaceFooterBar: React.FC = (props) => const runtimeStatus = useRuntimeStatus(props.workspaceId); const devcontainerChip = isDevcontainerRuntime(props.runtimeConfig) - ? getDevcontainerStatusChip(runtimeStatus) - : null; + ? getRuntimeStatusChip(runtimeStatus, "Devcontainer") + : isSSHRuntime(props.runtimeConfig) && props.runtimeConfig.daytona != null + ? 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. const isNarrow = useNarrowViewport(); diff --git a/src/browser/components/RuntimeBadge/RuntimeBadge.test.ts b/src/browser/components/RuntimeBadge/RuntimeBadge.test.ts new file mode 100644 index 00000000000..f65adea6281 --- /dev/null +++ b/src/browser/components/RuntimeBadge/RuntimeBadge.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "bun:test"; +import { getSshRuntimeInfo } from "./RuntimeBadge"; + +describe("getSshRuntimeInfo", () => { + it("shows a Coder workspace by name, or by host before it has one", () => { + const named = getSshRuntimeInfo({ + type: "ssh", + host: "coder-host", + srcBaseDir: "~/src", + coder: { workspaceName: "my-ws" }, + }); + expect(named.type).toBe("coder"); + expect(named.label).toContain("my-ws"); + const unnamed = getSshRuntimeInfo({ + type: "ssh", + host: "coder-host", + srcBaseDir: "~/src", + coder: {}, + }); + expect(unnamed.label).toContain("coder-host"); + }); + + it("shows a Daytona sandbox by id, or by host before it has one", () => { + const created = getSshRuntimeInfo({ + type: "ssh", + host: "sb-1.xum--daytona", + srcBaseDir: "~/src", + daytona: { sandboxName: "sb-1" }, + }); + expect(created.type).toBe("daytona"); + expect(created.label).toContain("sb-1"); + const pending = getSshRuntimeInfo({ + type: "ssh", + host: "daytona-placeholder", + srcBaseDir: "~/src", + daytona: {}, + }); + expect(pending.type).toBe("daytona"); + expect(pending.label).toContain("daytona-placeholder"); + }); + + it("shows a plain SSH host without its user", () => { + const info = getSshRuntimeInfo({ type: "ssh", host: "me@box.example", srcBaseDir: "~/src" }); + expect(info.type).toBe("ssh"); + expect(info.label).toContain("box.example"); + expect(info.label).not.toContain("me@"); + }); +}); diff --git a/src/browser/components/RuntimeBadge/RuntimeBadge.tsx b/src/browser/components/RuntimeBadge/RuntimeBadge.tsx index 5f19b85eb0c..e32df2c00f7 100644 --- a/src/browser/components/RuntimeBadge/RuntimeBadge.tsx +++ b/src/browser/components/RuntimeBadge/RuntimeBadge.tsx @@ -70,20 +70,33 @@ function TooltipRow({ type RuntimeType = keyof typeof RUNTIME_BADGE_UI; +/** SSH runtimes split by their managed backend (Coder, Daytona) or plain host. */ +export 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.sandboxName ?? 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}`, - }; - } - 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/components/icons/RuntimeIcons/RuntimeIcons.tsx b/src/browser/components/icons/RuntimeIcons/RuntimeIcons.tsx index 2f0a0df39cf..e600b398bd2 100644 --- a/src/browser/components/icons/RuntimeIcons/RuntimeIcons.tsx +++ b/src/browser/components/icons/RuntimeIcons/RuntimeIcons.tsx @@ -92,6 +92,26 @@ export function CoderIcon({ size = 10, className }: IconProps) { ); } +/** Cloud icon for the Daytona-backed SSH runtime (a managed cloud sandbox) */ +export function DaytonaIcon({ size = 10, className }: IconProps) { + return ( + + + + ); +} + /** Container icon for Docker runtime */ export function DockerIcon({ size = 10, className }: IconProps) { return ( diff --git a/src/browser/features/ChatInput/CreationControls.tsx b/src/browser/features/ChatInput/CreationControls.tsx index 0e6adfc1fac..43be21eafac 100644 --- a/src/browser/features/ChatInput/CreationControls.tsx +++ b/src/browser/features/ChatInput/CreationControls.tsx @@ -1,12 +1,16 @@ import React, { useCallback, useEffect } from "react"; import * as SelectPrimitive from "@radix-ui/react-select"; import { + normalizeRuntimeEnablement, RUNTIME_MODE, type CoderWorkspaceConfig, type ParsedRuntime, type RuntimeEnablement, CODER_RUNTIME_PLACEHOLDER, + DAYTONA_RUNTIME_PLACEHOLDER, + isManagedSshPlaceholder, } from "@/common/types/runtime"; +import { DaytonaCreationForm } from "../Runtime/DaytonaControls"; import type { RuntimeAvailabilityMap, RuntimeAvailabilityState } from "./useCreationWorkspace"; import { resolveDevcontainerSelection, @@ -159,6 +163,7 @@ const RUNTIME_CHOICE_ORDER: RuntimeChoice[] = [ RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH, "coder", + "daytona", RUNTIME_MODE.DOCKER, RUNTIME_MODE.DEVCONTAINER, ]; @@ -168,6 +173,7 @@ const RUNTIME_FALLBACK_ORDER: RuntimeChoice[] = [ RUNTIME_MODE.LOCAL, RUNTIME_MODE.SSH, "coder", + "daytona", RUNTIME_MODE.DOCKER, RUNTIME_MODE.DEVCONTAINER, ]; @@ -204,6 +210,12 @@ const RUNTIME_CHOICE_ACCENTS: Record< iconClass: "text-[var(--color-runtime-ssh-text)]", accentColor: "var(--color-runtime-ssh)", }, + daytona: { + triggerClass: + "border-[var(--color-runtime-ssh)]/60 bg-[var(--color-runtime-ssh)]/15 text-[var(--color-runtime-ssh-text)]", + iconClass: "text-[var(--color-runtime-ssh-text)]", + accentColor: "var(--color-runtime-ssh)", + }, docker: { triggerClass: "border-[var(--color-runtime-docker)]/60 bg-[var(--color-runtime-docker)]/15 text-[var(--color-runtime-docker-text)]", @@ -264,7 +276,8 @@ const resolveRuntimeButtonState = ( }; } - // Coder is SSH under the hood; all other RuntimeChoice values are RuntimeMode identity. + // Coder is SSH under the hood; Daytona has its own availability entry; all other + // RuntimeChoice values are RuntimeMode identity. const availabilityKey = value === "coder" ? RUNTIME_MODE.SSH : value; const availability = availabilityMap?.[availabilityKey]; // Disable only if availability is explicitly known and unavailable. @@ -483,6 +496,64 @@ function SourceBranchPicker(props: { ); } +interface RuntimeChoiceContext { + selectedRuntime: ParsedRuntime; + sshHostFallback: string; + coderConfigFallback: CoderWorkspaceConfig; + runtimeAvailabilityState: RuntimeAvailabilityState; +} + +/** 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: () => ({ mode: "ssh", host: DAYTONA_RUNTIME_PLACEHOLDER, daytona: {} }), + [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: runtimeAvailabilityState, + }); + return { + mode: "devcontainer", + configPath: initialSelection.configPath, + shareCredentials: false, + }; + }, + [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) { usePerfRenderMarker("chat-input.creation-controls"); const { nameState, runtimeAvailabilityState } = props; @@ -490,10 +561,22 @@ function CreationControlsContent(props: CreationControlsProps) { // Extract mode from discriminated union for convenience const runtimeMode = props.selectedRuntime.mode; const { selectedRuntime, onSelectedRuntimeChange } = props; - // Coder is surfaced as a separate runtime option while keeping SSH as the config mode. + // Coder and Daytona are surfaced as separate runtime options while keeping SSH as the config mode. const isCoderSelected = selectedRuntime.mode === RUNTIME_MODE.SSH && selectedRuntime.coder != null; - const runtimeChoice: RuntimeChoice = isCoderSelected ? "coder" : runtimeMode; + const isDaytonaSelected = + selectedRuntime.mode === RUNTIME_MODE.SSH && selectedRuntime.daytona != null; + const runtimeChoice: RuntimeChoice = isDaytonaSelected + ? "daytona" + : isCoderSelected + ? "coder" + : runtimeMode; + const runtimeChoiceContext: RuntimeChoiceContext = { + selectedRuntime, + sshHostFallback: props.sshHostFallback, + coderConfigFallback: props.coderConfigFallback, + runtimeAvailabilityState, + }; const coderInfo = props.coderInfo ?? props.coderProps?.coderInfo ?? null; const coderAvailability = resolveCoderAvailability(coderInfo); const isCoderAvailable = coderAvailability.state === "available"; @@ -577,71 +660,25 @@ function CreationControlsContent(props: CreationControlsProps) { } // User request: auto-switch away from Settings-disabled runtimes. - if (firstEnabled === "coder") { - if (!props.coderProps || !isCoderAvailable) { - return; - } - onSelectedRuntimeChange({ - mode: "ssh", - host: CODER_RUNTIME_PLACEHOLDER, - coder: props.coderConfigFallback, - }); - return; - } - - switch (firstEnabled) { - case RUNTIME_MODE.SSH: { - const sshHost = - selectedRuntime.mode === RUNTIME_MODE.SSH && - selectedRuntime.host !== CODER_RUNTIME_PLACEHOLDER - ? selectedRuntime.host - : props.sshHostFallback; - onSelectedRuntimeChange({ - mode: "ssh", - host: sshHost, - }); - return; - } - case RUNTIME_MODE.DOCKER: - onSelectedRuntimeChange({ - mode: "docker", - image: selectedRuntime.mode === "docker" ? selectedRuntime.image : "", - }); - return; - case RUNTIME_MODE.DEVCONTAINER: { - const initialSelection = resolveDevcontainerSelection({ - selectedRuntime: { mode: "devcontainer", configPath: "" }, - availabilityState: runtimeAvailabilityState, - }); - onSelectedRuntimeChange({ - mode: "devcontainer", - configPath: - selectedRuntime.mode === "devcontainer" - ? selectedRuntime.configPath - : initialSelection.configPath, - shareCredentials: - selectedRuntime.mode === "devcontainer" ? selectedRuntime.shareCredentials : false, - }); - return; - } - case RUNTIME_MODE.LOCAL: - onSelectedRuntimeChange({ mode: "local" }); - return; - case RUNTIME_MODE.WORKTREE: - default: - onSelectedRuntimeChange({ mode: "worktree" }); - return; - } + // 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, + runtimeAvailabilityState, + }) + ); }, [ isDevcontainerMissing, isNonGitRepo, onSelectedRuntimeChange, - props.coderConfigFallback, props.coderProps, props.runtimeEnablement, props.sshHostFallback, - availabilityMap, + props.coderConfigFallback, runtimeAvailabilityState, + availabilityMap, runtimeChoice, selectedRuntime, isCoderAvailable, @@ -790,65 +827,10 @@ function CreationControlsContent(props: CreationControlsProps) { { - if (mode === "coder") { - if (!props.coderProps) { - return; - } - // Switch to SSH mode with the last known Coder config so prior selections restore. - onSelectedRuntimeChange({ - mode: "ssh", - host: CODER_RUNTIME_PLACEHOLDER, - coder: props.coderConfigFallback, - }); + if (mode === "coder" && !props.coderProps) { return; } - // Convert mode to ParsedRuntime with appropriate defaults - switch (mode) { - case RUNTIME_MODE.SSH: { - const sshHost = - selectedRuntime.mode === "ssh" && - selectedRuntime.host !== CODER_RUNTIME_PLACEHOLDER - ? selectedRuntime.host - : props.sshHostFallback; - onSelectedRuntimeChange({ - mode: "ssh", - host: sshHost, - }); - break; - } - case RUNTIME_MODE.DOCKER: - onSelectedRuntimeChange({ - mode: "docker", - image: selectedRuntime.mode === "docker" ? selectedRuntime.image : "", - }); - break; - case RUNTIME_MODE.DEVCONTAINER: { - // Use resolver to get initial config path (prefers first available config) - const initialSelection = resolveDevcontainerSelection({ - selectedRuntime: { mode: "devcontainer", configPath: "" }, - availabilityState: runtimeAvailabilityState, - }); - onSelectedRuntimeChange({ - mode: "devcontainer", - configPath: - selectedRuntime.mode === "devcontainer" - ? selectedRuntime.configPath - : initialSelection.configPath, - shareCredentials: - selectedRuntime.mode === "devcontainer" - ? selectedRuntime.shareCredentials - : false, - }); - break; - } - case RUNTIME_MODE.LOCAL: - onSelectedRuntimeChange({ mode: "local" }); - break; - case RUNTIME_MODE.WORKTREE: - default: - onSelectedRuntimeChange({ mode: "worktree" }); - break; - } + onSelectedRuntimeChange(buildRuntimeForChoice(mode, runtimeChoiceContext)); }} defaultMode={props.defaultRuntimeMode} disabled={props.disabled} @@ -882,6 +864,7 @@ function CreationControlsContent(props: CreationControlsProps) { {selectedRuntime.mode === "ssh" && !isCoderSelected && + !isDaytonaSelected && !props.coderProps?.enabled && // Also hide when Coder is still checking but has saved config (will enable after check) !(props.coderProps?.coderInfo === null && props.coderProps?.coderConfig) && ( @@ -1016,6 +999,25 @@ function CreationControlsContent(props: CreationControlsProps) { /> )} + {isDaytonaSelected && ( +
+ {availabilityMap?.daytona?.available === false && ( +

{availabilityMap.daytona.reason}

+ )} + + onSelectedRuntimeChange({ + mode: "ssh", + host: DAYTONA_RUNTIME_PLACEHOLDER, + daytona, + }) + } + disabled={props.disabled} + /> +
+ )} + {/* Coder Controls - shown when Coder runtime is selected */} {isCoderSelected && props.coderProps && (
@@ -1092,19 +1094,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/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index ddcc953559d..e59a491ac1b 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -211,7 +211,10 @@ interface UseCreationWorkspaceReturn { } /** Runtime availability status for each mode */ -export type RuntimeAvailabilityMap = Record; +// Daytona is `ssh` plus a sub-config, so its availability rides next to the mode-keyed entries. +export type RuntimeAvailabilityMap = Record & { + daytona?: RuntimeAvailabilityStatus; +}; export type RuntimeAvailabilityState = | { status: "loading" } diff --git a/src/browser/features/ChatInput/utils.ts b/src/browser/features/ChatInput/utils.ts index a70a1a66604..38134408987 100644 --- a/src/browser/features/ChatInput/utils.ts +++ b/src/browser/features/ChatInput/utils.ts @@ -18,6 +18,7 @@ import { isMcpPromptCommandKey, } from "@/common/utils/tools/mcpPromptCommandKey"; import type { ParsedRuntime } from "@/common/types/runtime"; +import type { CoderWorkspaceConfig } from "@/common/orpc/schemas/coder"; import { buildAgentSkillMetadata, dedupeAgentSkillRefs, @@ -447,6 +448,28 @@ 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; +} + export function validateCreationRuntime( runtime: ParsedRuntime, coderPresetCount: number @@ -456,26 +479,9 @@ export function validateCreationRuntime( } if (runtime.mode === "ssh") { - 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; - } - + // 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 new file mode 100644 index 00000000000..c8c0a4679d3 --- /dev/null +++ b/src/browser/features/Runtime/DaytonaControls.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { expect, within } from "storybook/test"; +import { APIProvider } from "@/browser/contexts/API"; +import { lightweightMeta, PIXEL_DISABLED } from "@/browser/stories/meta.js"; +import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; +import type { DaytonaPublicConfig, DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; +import { DaytonaCreationForm } from "./DaytonaControls"; + +const PHONE_VIEWPORT = { + phone390: { + name: "Phone 390", + styles: { width: "390px", height: "844px" }, + type: "mobile", + }, +}; + +const meta: Meta = { + ...lightweightMeta, + title: "App/Runtime/Daytona Controls", + 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 StatefulForm(props: { initialConfig: DaytonaSandboxConfig; access: DaytonaPublicConfig }) { + const [client] = useState(() => + createMockORPCClient({ + daytonaConfig: props.access, + daytonaSnapshotsResult: { + ok: true, + snapshots: [ + { name: "daytonaio/sandbox:0.4.3" }, + { name: "xum-node-22-with-a-long-snapshot-name" }, + ], + }, + }) + ); + const [config, setConfig] = useState(props.initialConfig); + return ( + + {/* The test-runner plays at desktop size: force the phone width here. */} +
+ +
+
+ ); +} + +/** New sandbox at phone width: the long preset label and snapshot must not overflow. */ +export const NewSandboxPhone: Story = { + globals: { viewport: { value: "phone390", isRotated: false } }, + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole("combobox", { name: "Daytona snapshot" }); + const form = canvas.getByTestId("daytona-sandbox-form"); + await expect(form.scrollWidth).toBeLessThanOrEqual(form.clientWidth); + }, +}; + +/** 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 } }, + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText(/Set a Daytona API key in Settings/); + await expect(canvas.queryByRole("combobox", { name: "Daytona size" })).toBeNull(); + }, +}; diff --git a/src/browser/features/Runtime/DaytonaControls.test.tsx b/src/browser/features/Runtime/DaytonaControls.test.tsx new file mode 100644 index 00000000000..89ce3cb8206 --- /dev/null +++ b/src/browser/features/Runtime/DaytonaControls.test.tsx @@ -0,0 +1,87 @@ +import "../../../../tests/ui/dom"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import * as ActualSelectPrimitiveModule from "@/browser/components/SelectPrimitive/SelectPrimitive"; +import { APIProvider } from "@/browser/contexts/API"; +import { createTestApiClient } from "@/browser/testUtils"; +import type { + DaytonaListSnapshotsResult, + DaytonaSandboxConfig, +} from "@/common/orpc/schemas/daytona"; +import { installDom } from "../../../../tests/ui/dom"; +import { createSelectPrimitiveDouble } from "../../../../tests/ui/selectPrimitiveDouble"; +import { DaytonaCreationForm } from "./DaytonaControls"; + +// Capture before installing the module mock; mock.restore() does not undo it. +const actualSelectPrimitiveModule = { ...ActualSelectPrimitiveModule }; +void mock.module("@/browser/components/SelectPrimitive/SelectPrimitive", () => + createSelectPrimitiveDouble() +); + +const LISTED: DaytonaListSnapshotsResult = { + ok: true, + snapshots: [{ name: "base" }, { name: "node" }], +}; + +describe("DaytonaCreationForm snapshot row", () => { + let cleanupDom: (() => void) | null = null; + + afterAll(async () => { + await mock.module( + "@/browser/components/SelectPrimitive/SelectPrimitive", + () => actualSelectPrimitiveModule + ); + }); + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + async function renderForm(config: DaytonaSandboxConfig, listed = LISTED) { + const onConfigChange = mock((_config: DaytonaSandboxConfig) => undefined); + const client = createTestApiClient({ + daytona: { + getConfig: () => Promise.resolve({ apiKeySet: true, apiKeySource: "settings" as const }), + listSnapshots: () => Promise.resolve(listed), + }, + }); + const view = render( + + + + ); + const select = await waitFor(() => view.getByTestId("daytona-snapshot-select")); + const open = () => fireEvent.pointerDown(select); + return { view, onConfigChange, open }; + } + + test("keeps a saved snapshot the list no longer has, and reports a failed list", async () => { + const { view, open } = await renderForm( + { snapshot: "gone" }, + { ok: false, kind: "server", error: "boom" } + ); + expect(view.getByTestId("daytona-snapshot-select").textContent).toBe("gone"); + await waitFor(() => expect(view.getByRole("alert").textContent).toContain("boom")); + open(); + expect(view.getByRole("button", { name: "gone" })).toBeTruthy(); + }); + + test("picking a snapshot sets it, and picking the default clears it", async () => { + const picked = await renderForm({ cpu: 2 }); + picked.open(); + fireEvent.click(picked.view.getByRole("button", { name: "base" })); + expect(picked.onConfigChange).toHaveBeenLastCalledWith({ cpu: 2, snapshot: "base" }); + cleanup(); + + const cleared = await renderForm({ snapshot: "base" }); + cleared.open(); + fireEvent.click(cleared.view.getByRole("button", { name: "Daytona default" })); + expect(cleared.onConfigChange).toHaveBeenLastCalledWith({ snapshot: undefined }); + }); +}); diff --git a/src/browser/features/Runtime/DaytonaControls.tsx b/src/browser/features/Runtime/DaytonaControls.tsx new file mode 100644 index 00000000000..a6712527b32 --- /dev/null +++ b/src/browser/features/Runtime/DaytonaControls.tsx @@ -0,0 +1,193 @@ +/** + * Daytona sandbox controls for the SSH-based Daytona runtime: create a sandbox from a snapshot + * at a size preset, on workspace creation. + */ +import { Loader2 } from "lucide-react"; +import { + DAYTONA_DEFAULT_SIZE_PRESET_ID, + DAYTONA_SIZE_PRESETS, + findDaytonaSizePreset, + type DaytonaSizePreset, + type DaytonaSizePresetId, +} from "@/common/constants/daytona"; +import type { DaytonaSandboxConfig } from "@/common/orpc/schemas/daytona"; +import { useDaytonaResources, type DaytonaResources } from "@/browser/hooks/useDaytonaResources"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/SelectPrimitive/SelectPrimitive"; + +// Radix Select reserves the empty string, so "use Daytona's default snapshot" needs a token. +const DEFAULT_SNAPSHOT_VALUE = "__daytona_default__"; + +export function getDaytonaSizePresetId(config: DaytonaSandboxConfig): DaytonaSizePresetId { + return findDaytonaSizePreset(config)?.id ?? DAYTONA_DEFAULT_SIZE_PRESET_ID; +} + +export function withDaytonaSizePreset( + config: DaytonaSandboxConfig, + presetId: string +): DaytonaSandboxConfig { + const preset = DAYTONA_SIZE_PRESETS.find((candidate) => candidate.id === presetId); + if (!preset) return config; + return { ...config, cpu: preset.cpu, memoryGiB: preset.memoryGiB, diskGiB: preset.diskGiB }; +} + +export function formatDaytonaSizePreset(preset: DaytonaSizePreset): string { + return `${preset.label} (${preset.cpu} vCPU, ${preset.memoryGiB} GiB, ${preset.diskGiB} GiB disk)`; +} + +/** Shown instead of the form when Daytona cannot be used yet. */ +function DaytonaAccessNotice(props: { resources: DaytonaResources }) { + if (props.resources.configError) { + return ( +

+ Could not read Daytona settings: {props.resources.configError} +

+ ); + } + if (!props.resources.config) { + return ( +

+ + Checking Daytona access... +

+ ); + } + return ( +

+ Set a Daytona API key in Settings > Runtimes to create sandboxes. +

+ ); +} + +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 SnapshotRow(props: { + config: DaytonaSandboxConfig; + onConfigChange: (config: DaytonaSandboxConfig) => void; + resources: DaytonaResources; + disabled?: boolean; +}) { + const { snapshots, snapshotsError, loadingSnapshots } = props.resources; + const current = props.config.snapshot; + // Keep a saved snapshot selectable even when the list failed or no longer has it. + const options = current && !snapshots.includes(current) ? [current, ...snapshots] : snapshots; + return ( + <> +
+ + {loadingSnapshots ? ( + + ) : ( + + )} +
+ {snapshotsError && ( +

+ Could not list snapshots: {snapshotsError} +

+ )} + + ); +} + +function SizeRow(props: { + config: DaytonaSandboxConfig; + onConfigChange: (config: DaytonaSandboxConfig) => void; + disabled?: boolean; +}) { + return ( +
+ + +
+ ); +} + +/** + * The workspace-creation form. Mounted only while Daytona is the picked runtime, so the + * snapshot list (an account API call) loads only then. + */ +export function DaytonaCreationForm(props: { + config: DaytonaSandboxConfig; + onConfigChange: (config: DaytonaSandboxConfig) => void; + disabled?: boolean; +}) { + const resources = useDaytonaResources(); + const ready = resources.config?.apiKeySet === true; + + return ( +
+ {!ready ? ( + + ) : ( + <> + + + + )} +
+ ); +} 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..46c9475a345 --- /dev/null +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.stories.tsx @@ -0,0 +1,51 @@ +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 { DaytonaAccessForm } from "./DaytonaSettingsForms"; +import { SettingsSectionStory } from "./settingsStoryUtils"; + +const meta: Meta = { + ...lightweightMeta, + title: "Settings/Sections/Daytona Settings", + parameters: { + ...lightweightMeta.parameters, + // Interaction contract only: the Pixel snapshot budget has no headroom. + pixel: PIXEL_DISABLED, + }, +}; + +export default meta; +type Story = StoryObj; + +/** First setup: paste a key, save it, then test the connection. */ +export const SaveKeyAndTest: Story = { + render: () => ( + + createMockORPCClient({ + daytonaConfig: { apiKeySet: false, apiKeySource: null }, + daytonaTestConnectionResult: { ok: true }, + }) + } + > +
+ +
+
+ ), + 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)"); + // 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.test.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.test.tsx new file mode 100644 index 00000000000..603c1b9cb1b --- /dev/null +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.test.tsx @@ -0,0 +1,52 @@ +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 NO_KEY: DaytonaPublicConfig = { apiKeySet: false, apiKeySource: null }; + + test("saves a new key and clears the field", async () => { + const setConfig = mock((_input: { apiKey?: string }) => + Promise.resolve({ apiKeySet: true, apiKeySource: "settings" }) + ); + const client = createTestApiClient({ + daytona: { getConfig: () => Promise.resolve(NO_KEY), setConfig }, + }); + const view = render( + + + + ); + const key = await waitFor(() => { + const input = 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"); + await user.click(view.getByRole("button", { name: "Save" })); + await waitFor(() => expect(setConfig).toHaveBeenCalledTimes(1)); + expect(setConfig.mock.calls[0][0]).toEqual({ apiKey: "new-key" }); + await waitFor(() => expect(key.value).toBe("")); + expect(view.getByText("Key set (settings)")).toBeTruthy(); + }); +}); diff --git a/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx new file mode 100644 index 00000000000..9886e5a9de7 --- /dev/null +++ b/src/browser/features/Settings/Sections/DaytonaSettingsForms.tsx @@ -0,0 +1,215 @@ +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 type { DaytonaPublicConfig } from "@/common/orpc/schemas/daytona"; +import { getErrorMessage } from "@/common/utils/errors"; + +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)"; +} + +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, runTest } = useDaytonaConnectionTest(api); + + 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]); + + /** Resolves true when the backend accepted the key. */ + const save = async (apiKey: string): Promise => { + if (!api) return false; + setSaving(true); + setError(null); + setTest({ status: "idle" }); + try { + setConfig(await api.daytona.setConfig({ apiKey })); + return true; + } catch (saveError) { + setError(`Could not save Daytona settings: ${getErrorMessage(saveError)}`); + return false; + } finally { + setSaving(false); + } + }; + + return { api, config, saving, error, test, save, runTest }; +} + +function AccessMessages(props: { test: TestState; error: string | null }) { + return ( + <> + {props.test.status === "ok" && ( +

+ + Connected: the API key works. +

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

+ + {props.test.message} +

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

+ {props.error} +

+ )} + + ); +} + +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 + * own 0600 file, never in Secrets, which agent turns and terminals receive. + */ +export function DaytonaAccessForm() { + const access = useDaytonaAccess(); + const [apiKey, setApiKey] = useState(""); + const disabled = !access.config || access.saving || !access.api; + + const save = async (value: string) => { + if (await access.save(value)) setApiKey(""); + }; + + return ( +
+ + 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 + DAYTONA_TARGET there pick a self-hosted deployment and a default region. +

+
+ ); +} + +/** 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/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 5ce360ef773..174ad4b7c44 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -1150,10 +1150,10 @@ 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. + permanent. Archiving a Daytona chat always deletes the sandbox it created.