From e8fbbfb485f23f731b7bc769c83285a6797552ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Jun 2026 21:24:23 -0700 Subject: [PATCH] feat: add runtime profile selector --- CHANGELOG.md | 1 + README.md | 13 +++ cmd/crabbox-ssh-gateway/main.go | 2 + cmd/crabbox-ssh-gateway/main_test.go | 11 +++ cmd/crabfleet/main.go | 6 ++ cmd/crabfleet/main_test.go | 24 +++++- src/app/main.jsx | 33 +++++++- src/app/utils.js | 24 +++++- src/index.ts | 54 +++++++++---- src/runtime-profiles.ts | 114 +++++++++++++++++++++++++++ tests/app-utils.test.ts | 42 ++++++++++ tests/html-dialogs.test.ts | 4 +- tests/runtime-adapter.test.ts | 17 ++++ tests/runtime-profiles.test.ts | 82 +++++++++++++++++++ 14 files changed, 404 insertions(+), 23 deletions(-) create mode 100644 src/runtime-profiles.ts create mode 100644 tests/runtime-profiles.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d06b2c..96ebd31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add a deployment-configured runtime profile selector with generic labels, targets, capability previews, server-side allowlisting, and CLI/SSH profile overrides. - Strip upstream authorization credentials from authenticated trusted-proxy requests before app and terminal routing. - Add deployment-neutral trusted reverse-proxy identity with exact-origin and shared-secret proof, existing allowlist authorization, cross-origin mutation rejection, fail-closed assertions, cookie isolation, and downstream credential stripping. - Add a native macOS Crabfleet VNC control deck with generic RFB 3.3/3.7/3.8 connections, Metal rendering, six warm desktops, paced previews, fast focus transitions, saved profiles, and stabilized opt-in clipboard synchronization. diff --git a/README.md b/README.md index 300c617..19e918b 100644 --- a/README.md +++ b/README.md @@ -235,9 +235,22 @@ The Crabbox namespace cutover intentionally has no old-name compatibility. Exist - `CRABFLEET_PREFERRED_REPO` – Optional first/default enabled repo, default `openclaw/crabfleet` - `CRABFLEET_DEFAULT_RUNTIME` – Optional interactive runtime default, `container` or `crabbox`; defaults to `container` - `CRABFLEET_DEFAULT_PROFILE` – Optional opaque runtime-adapter profile, default `default` +- `CRABFLEET_RUNTIME_PROFILES_JSON` – Optional bounded JSON array of generic profile descriptors (`id`, `label`, optional `target`, and optional boolean `capabilities`) shown to authenticated users when creating Crabbox sessions; when configured, `CRABFLEET_DEFAULT_PROFILE` must name one entry - `CRABFLEET_DEV_LOGIN_ENABLED` – Explicit local-only development identity login gate; disabled unless exactly `true`, and still restricted to literal localhost requests - `OPENAI_API_KEY` – Required for built-in Cloudflare Sandbox Codex CLI sessions; injected by the Worker outbound path for Cloudflare Sandbox requests +For example, a deployment can expose two generic desktop profiles without +teaching Crabfleet about either provider: + +```dotenv +CRABFLEET_DEFAULT_RUNTIME="crabbox" +CRABFLEET_DEFAULT_PROFILE="linux-desktop" +CRABFLEET_RUNTIME_PROFILES_JSON='[{"id":"linux-desktop","label":"Linux","target":"linux","capabilities":{"terminal":true,"desktop":true,"vnc":true}},{"id":"macos-desktop","label":"macOS","target":"macos","capabilities":{"terminal":true,"desktop":true,"vnc":true}}]' +``` + +The configured lifecycle adapter remains responsible for mapping each opaque +profile ID to a provider and enforcing its real capabilities. + ### Verify Deployment The app Worker includes a once-per-minute cron trigger for bounded runtime lifecycle and terminal-archive reconciliation. Keep the `triggers.crons` entry when deriving deployment configuration; direct session, PTY, and VNC access also performs CAS-guarded targeted refreshes. diff --git a/cmd/crabbox-ssh-gateway/main.go b/cmd/crabbox-ssh-gateway/main.go index 569f2f4..28dd02a 100644 --- a/cmd/crabbox-ssh-gateway/main.go +++ b/cmd/crabbox-ssh-gateway/main.go @@ -108,6 +108,7 @@ type createSessionRequest struct { Repo string `json:"repo,omitempty"` Branch string `json:"branch,omitempty"` Runtime string `json:"runtime,omitempty"` + Profile string `json:"profile,omitempty"` Command string `json:"command,omitempty"` Prompt string `json:"prompt,omitempty"` ParentSessionID string `json:"parentSessionId,omitempty"` @@ -834,6 +835,7 @@ func parseCreate(args []string, client *apiClient, fingerprint string) createArg fs.StringVar(&req.Repo, "repo", "", "repo") fs.StringVar(&req.Branch, "branch", "main", "branch") fs.StringVar(&req.Runtime, "runtime", "", "runtime override; defaults to deployment") + fs.StringVar(&req.Profile, "profile", "", "runtime profile override; defaults to deployment") fs.StringVar(&req.Command, "command", "", "command") fs.StringVar(&req.ParentSessionID, "parent", "", "parent session") fs.StringVar(&req.RootSessionID, "root", "", "root session") diff --git a/cmd/crabbox-ssh-gateway/main_test.go b/cmd/crabbox-ssh-gateway/main_test.go index 064a7b2..b2c704d 100644 --- a/cmd/crabbox-ssh-gateway/main_test.go +++ b/cmd/crabbox-ssh-gateway/main_test.go @@ -98,6 +98,17 @@ func TestParseCreateLeavesRuntimeToDeploymentDefault(t *testing.T) { } } +func TestParseCreateAcceptsProfileOverride(t *testing.T) { + create := parseCreate( + []string{"--repo", "openclaw/crabfleet", "--profile", "desktop-a", "fix it"}, + nil, + "", + ) + if create.request.Profile != "desktop-a" { + t.Fatalf("profile = %q, want explicit override", create.request.Profile) + } +} + func TestTerminalCapabilityWithdrawalSuppressesAttach(t *testing.T) { if !terminalCapable(interactiveSession{}) { t.Fatal("legacy session without capabilities should remain attachable") diff --git a/cmd/crabfleet/main.go b/cmd/crabfleet/main.go index 389a2fa..bf21219 100644 --- a/cmd/crabfleet/main.go +++ b/cmd/crabfleet/main.go @@ -64,6 +64,7 @@ type newCmd struct { Repo string `help:"Repository to prepare, owner/repo."` Branch string `help:"Git branch to checkout." default:"main"` Runtime *string `help:"Runtime backend override; omit to use the deployment default." enum:"crabbox,container"` + Profile string `help:"Runtime profile override; omit to use the deployment default."` Command string `help:"Command to run after checkout." default:"codex --yolo"` Parent string `help:"Parent crabbox session id."` Root string `help:"Root crabbox session id."` @@ -202,6 +203,7 @@ type createSessionRequest struct { Repo string `json:"repo,omitempty"` Branch string `json:"branch,omitempty"` Runtime string `json:"runtime,omitempty"` + Profile string `json:"profile,omitempty"` Command string `json:"command,omitempty"` Prompt string `json:"prompt,omitempty"` ParentSessionID string `json:"parentSessionId,omitempty"` @@ -396,6 +398,7 @@ func (cmd newCmd) sessionRequest(app *cli) createSessionRequest { Repo: cmd.Repo, Branch: cmd.Branch, Runtime: runtime, + Profile: cmd.Profile, Command: cmd.Command, Prompt: prompt, ParentSessionID: parent, @@ -410,6 +413,9 @@ func (cmd newCmd) sshCreateArgs(req createSessionRequest) []string { if req.Runtime != "" { args = append(args, "--runtime", req.Runtime) } + if req.Profile != "" { + args = append(args, "--profile", req.Profile) + } if req.Repo != "" { args = append(args, "--repo", req.Repo) } diff --git a/cmd/crabfleet/main_test.go b/cmd/crabfleet/main_test.go index e9b7ba2..1e19b9f 100644 --- a/cmd/crabfleet/main_test.go +++ b/cmd/crabfleet/main_test.go @@ -45,7 +45,7 @@ func TestFirstLineSkipsBlankLines(t *testing.T) { } } -func TestNewRuntimeOverrideIsOptional(t *testing.T) { +func TestNewRuntimeAndProfileOverridesAreOptional(t *testing.T) { t.Setenv("CRABFLEET_ROOT_SESSION_ID", "") parse := func(args ...string) cli { var app cli @@ -69,6 +69,9 @@ func TestNewRuntimeOverrideIsOptional(t *testing.T) { if req.Runtime != "" { t.Fatalf("runtime = %q, want deployment default", req.Runtime) } + if req.Profile != "" { + t.Fatalf("profile = %q, want deployment default", req.Profile) + } encoded, err := json.Marshal(req) if err != nil { t.Fatal(err) @@ -76,6 +79,9 @@ func TestNewRuntimeOverrideIsOptional(t *testing.T) { if bytes.Contains(encoded, []byte(`"runtime"`)) { t.Fatalf("omitted runtime was serialized: %s", encoded) } + if bytes.Contains(encoded, []byte(`"profile"`)) { + t.Fatalf("omitted profile was serialized: %s", encoded) + } for _, arg := range cmd.sshCreateArgs(req) { if arg == "--runtime" { t.Fatal("SSH fallback forced a runtime override") @@ -97,6 +103,22 @@ func TestNewRuntimeOverrideIsOptional(t *testing.T) { if !found { t.Fatalf("explicit runtime missing from SSH fallback: %q", args) } + + cmd = parse("new", "--profile", "desktop-a").New + req = cmd.sessionRequest(&cli{}) + if req.Profile != "desktop-a" { + t.Fatalf("profile = %q, want explicit override", req.Profile) + } + args = cmd.sshCreateArgs(req) + found = false + for index := 0; index+1 < len(args); index++ { + if args[index] == "--profile" && args[index+1] == "desktop-a" { + found = true + } + } + if !found { + t.Fatalf("explicit profile missing from SSH fallback: %q", args) + } } func TestDeleteCommandUsesProviderStopAction(t *testing.T) { diff --git a/src/app/main.jsx b/src/app/main.jsx index 37627e3..5e3ee11 100644 --- a/src/app/main.jsx +++ b/src/app/main.jsx @@ -23,6 +23,7 @@ import { preferredRepos, runCapabilities, runtimeCapabilityLabel, + runtimeProfileOptionLabel, sessionLogsUrl, sessionItems, statusLabel, @@ -50,6 +51,7 @@ const defaultDeployment = { preferredRepo, defaultRuntime: "container", defaultProfile: "default", + runtimeProfiles: [], }; const loginReturnKey = "crabbox-login-return"; const skipAutoGithubLoginKey = "crabbox-skip-auto-github-login"; @@ -852,7 +854,11 @@ function App() { async function createInteractiveSession(form) { const data = new FormData(form); - const optimistic = optimisticInteractiveSession(data, state.user?.login); + const optimistic = optimisticInteractiveSession( + data, + state.user?.login, + state.deployment?.runtimeProfiles, + ); upsertInteractiveSession(optimistic); closeDrawer("interactive"); setFocusedSessionId(optimistic.id); @@ -1631,6 +1637,11 @@ function CardDrawer({ drawers, closeDrawer, createCard, state }) { function InteractiveDrawer({ drawers, closeDrawer, createInteractiveSession, state }) { const [busy, setBusy] = useState(false); + const defaultRuntime = state.deployment?.defaultRuntime || "container"; + const defaultProfile = state.deployment?.defaultProfile || "default"; + const runtimeProfiles = state.deployment?.runtimeProfiles || []; + const [runtime, setRuntime] = useState(defaultRuntime); + useEffect(() => setRuntime(defaultRuntime), [defaultRuntime]); return ( setRuntime(defaultRuntime)} onSubmit={async (event) => { event.preventDefault(); setBusy(true); @@ -1659,15 +1671,28 @@ function InteractiveDrawer({ drawers, closeDrawer, createInteractiveSession, sta - + {runtime === "crabbox" && runtimeProfiles.length > 0 ? ( + + ) : ( + + )}