Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions cmd/crabbox-ssh-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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")
Expand Down
11 changes: 11 additions & 0 deletions cmd/crabbox-ssh-gateway/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions cmd/crabfleet/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."`
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
Expand Down
24 changes: 23 additions & 1 deletion cmd/crabfleet/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -69,13 +69,19 @@ 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)
}
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")
Expand All @@ -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) {
Expand Down
33 changes: 29 additions & 4 deletions src/app/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
preferredRepos,
runCapabilities,
runtimeCapabilityLabel,
runtimeProfileOptionLabel,
sessionLogsUrl,
sessionItems,
statusLabel,
Expand Down Expand Up @@ -50,6 +51,7 @@ const defaultDeployment = {
preferredRepo,
defaultRuntime: "container",
defaultProfile: "default",
runtimeProfiles: [],
};
const loginReturnKey = "crabbox-login-return";
const skipAutoGithubLoginKey = "crabbox-skip-auto-github-login";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
<Drawer
id="interactive-drawer"
Expand All @@ -1641,6 +1652,7 @@ function InteractiveDrawer({ drawers, closeDrawer, createInteractiveSession, sta
<form
class="form-grid"
aria-busy={busy ? "true" : "false"}
onReset={() => setRuntime(defaultRuntime)}
onSubmit={async (event) => {
event.preventDefault();
setBusy(true);
Expand All @@ -1659,15 +1671,28 @@ function InteractiveDrawer({ drawers, closeDrawer, createInteractiveSession, sta
<label>
Runtime
<select
key={state.deployment?.defaultRuntime || "container"}
name="runtime"
defaultValue={state.deployment?.defaultRuntime || "container"}
value={runtime}
onChange={(event) => setRuntime(event.currentTarget.value)}
>
<option value="container">Cloudflare Sandbox</option>
<option value="crabbox">Crabbox</option>
</select>
</label>
<input type="hidden" name="profile" value={state.deployment?.defaultProfile || "default"} />
{runtime === "crabbox" && runtimeProfiles.length > 0 ? (
<label>
Profile
<select key={defaultProfile} name="profile" defaultValue={defaultProfile}>
{runtimeProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{runtimeProfileOptionLabel(profile)}
</option>
))}
</select>
</label>
) : (
<input type="hidden" name="profile" value={defaultProfile} />
)}
<label>
Command
<input name="command" defaultValue="codex --yolo" placeholder="codex --yolo" />
Expand Down
24 changes: 23 additions & 1 deletion src/app/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,13 +334,17 @@ export function clipboardExtension(mediaType) {
);
}

export function optimisticInteractiveSession(data, owner) {
export function optimisticInteractiveSession(data, owner, runtimeProfiles = []) {
const now = Date.now();
const repo = String(data.get("repo") || preferredRepo);
const branch = String(data.get("branch") || "main");
const runtime = String(data.get("runtime") || "container");
const profile = String(data.get("profile") || "default");
const pendingRuntimeLabel = runtimeLabel(runtime);
const configuredCapabilities =
runtime === "crabbox"
? runtimeProfiles.find((candidate) => candidate.id === profile)?.capabilities
: undefined;
return {
id: `LOCAL-${now}`,
repo,
Expand All @@ -354,6 +358,7 @@ export function optimisticInteractiveSession(data, owner) {
desktop: runtime === "crabbox",
logs: true,
artifacts: false,
...configuredCapabilities,
},
command: interactiveCommand(data.get("command")),
prompt: String(data.get("prompt") || ""),
Expand Down Expand Up @@ -383,6 +388,23 @@ export function optimisticInteractiveSession(data, owner) {
};
}

export function runtimeProfileOptionLabel(profile) {
const label = String(profile?.label || profile?.id || "Profile");
const details = [];
if (profile?.target && String(profile.target).toLowerCase() !== label.toLowerCase()) {
details.push(String(profile.target));
}
const capabilities = [
["terminal", "terminal"],
["desktop", "desktop"],
["vnc", "VNC"],
]
.filter(([name]) => profile?.capabilities?.[name] === true)
.map(([, name]) => name);
if (capabilities.length > 0) details.push(capabilities.join(", "));
return details.length > 0 ? `${label} — ${details.join(" · ")}` : label;
}

export function interactiveCommand(value) {
return String(value || "codex --yolo")
.trim()
Expand Down
Loading