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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
- Bound Crabbox terminal output with negotiated acknowledgements and legacy-client compatibility.
- Enable the OpenClaw deployment's versioned Crabbox runtime adapter with a stable tenant namespace.
- Add comprehensive documentation for durable GitHub Actions sessions, including registration, runner and viewer relay, work-state heartbeats, Codex steering, resumption, completion, cancellation, authentication, archives, and troubleshooting.
- Keep GitHub Actions sessions out of legacy workspace-stop reconciliation and give them a dedicated cancel lifecycle.
- Name versioned provider-backed workspace lifecycle actions Delete across Fleet, the Go CLI, and SSH while retaining explicit Stop wording for legacy sessions and `stop` as a CLI compatibility alias; keep the provider stop wire action internal; and fail closed without adopting or deleting a pre-existing adapter workspace on an explicit ID conflict.
- Keep GitHub Actions sessions out of legacy workspace-stop reconciliation and let operators end their Crabfleet terminal session without claiming to cancel the underlying workflow run.
- Add durable steerable GitHub Actions sessions with service registration, scoped runner URLs, work-state heartbeats, Fleet metadata, and a SessionControlDO PTY relay.
- Add a tenant-namespaced versioned runtime lifecycle adapter with replayable idempotent create, monotonic workspace identities, CAS reconciliation, durable terminal finalization, confirmed provider release before failure/stop, presence-aware capability/expiry tracking, authenticated transient VNC redirects, and deployment-neutral configuration.
- Reconcile runtime lifecycles and every adapter's terminal archives on cron and direct access, preserve partial capability-object defaults while honoring authoritative lists and explicit terminal withdrawal, make PTY availability server-authoritative, preserve opaque signed terminal and desktop URLs byte-for-byte, retain adapter failure evidence through confirmed release and exact session-version archive finalization, preflight adapter credentials before session allocation, bind every external lifecycle to its immutable registered control plane, generation-fence managed and standalone Sandbox credential ownership across crashes and late requests, repair incomplete equal-count archives, run teardown only after an exact cleanup CAS, use unique concurrent archive attempts, and transactionally remove D1 archive pointers before best-effort R2 object cleanup.
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Crabfleet gives OpenClaw maintainers a fleet dashboard where every Codex crabbox
- **Ghostty WebAssembly** for the fullscreen attach grid and run log replay.
- **Cloudflare Sandbox containers** for standalone interactive Codex CLI workspaces with live PTY attach.
- **Runtime adapter descriptors** for Container and Crabbox selection, capability display, interactive lifecycle handoff, and guarded takeover.
- **Versioned lifecycle adapter** for idempotent external workspace creation, bounded status reconciliation, provider-backed stop, terminal attachment, and authenticated transient desktop connections.
- **Versioned lifecycle adapter** for idempotent external workspace creation, bounded status reconciliation, provider-backed deletion, terminal attachment, and authenticated transient desktop connections.
- **Provision endpoint** at `/api/provision/interactive` that can use the built-in Sandbox backend or retain a legacy create-only adapter or ClawFleet integration, with durable ownership and a bearer-authenticated standalone PTY route.
- **SessionControlDO relay** for one outbound GitHub Actions runner and multiple authenticated Ghostty viewers per action session.
- **R2 session archives** for crabbox event NDJSON, transcripts, and summaries.
Expand Down Expand Up @@ -300,8 +300,9 @@ go run ./cmd/crabbox-ssh-gateway
```

Unknown public keys get a short GitHub OAuth link through `ssh link@host`. Linked keys can
run `whoami`, `list`, `new`, and `attach SESSION_ID`; `new` creates an interactive Codex
session and attaches.
run `whoami`, `list`, `new`, `attach SESSION_ID`, and `delete SESSION_ID`; `new` creates an
interactive Codex session and attaches. Delete confirms runtime release for versioned lifecycle
adapters; legacy create-only and ClawFleet sessions stop locally and may need provider cleanup.

Production should expose the gateway at `crabd.sh` as a DNS-only `A` record.
Use `ssh link@crabd.sh` once to connect a GitHub-backed SSH key, then run
Expand All @@ -319,9 +320,12 @@ go run ./cmd/crabfleet login
go run ./cmd/crabfleet list
go run ./cmd/crabfleet new --repo openclaw/crabfleet "start on the release checklist"
go run ./cmd/crabfleet attach <session-id>
go run ./cmd/crabfleet delete <session-id>
go run ./cmd/crabfleet vnc --open <session-id>
```

`crabfleet stop <session-id>` remains a compatibility alias for `delete`.

### CLI Release

Tagged releases publish `crabfleet` with GoReleaser and dispatch the OpenClaw Homebrew tap updater:
Expand Down
52 changes: 52 additions & 0 deletions cmd/crabbox-ssh-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ type interactiveSession struct {
Repo string `json:"repo"`
Branch string `json:"branch"`
Runtime string `json:"runtime"`
Adapter string `json:"adapter"`
Status string `json:"status"`
Owner string `json:"owner"`
CreatedBy string `json:"createdBy"`
Expand All @@ -69,6 +70,28 @@ type interactiveSession struct {
LogArchive logArchive `json:"logArchive"`
}

func legacyProviderCleanupMayBeRequired(session interactiveSession) bool {
if session.Adapter == "runtime-v1" || session.Runtime == "github_actions" {
return false
}
switch session.Status {
case "stopping", "stopped", "expired":
return true
default:
return false
}
}

func lifecycleStopNote(session interactiveSession) string {
if session.Runtime == "github_actions" && session.Status == "stopped" {
return "GitHub Actions workflow run was not canceled and may continue on GitHub"
}
if legacyProviderCleanupMayBeRequired(session) {
return "provider deletion was not confirmed; legacy runtimes may require separate cleanup"
}
return ""
}

type sessionCapabilities struct {
Terminal bool `json:"terminal"`
}
Expand Down Expand Up @@ -399,6 +422,21 @@ func runCommand(ctx context.Context, out io.ReadWriter, perms *ssh.Permissions,
}
fmt.Fprintf(out, "session %s not found\n", terminalSafe(args[1]))
return 1
case "delete", "stop":
if len(args) != 2 {
fmt.Fprintln(out, "usage: delete SESSION_ID")
return 2
}
session, err := client.action(ctx, auth.fingerprint, args[1], "stop")
if err != nil {
fmt.Fprintf(out, "error: %v\n", err)
return 1
}
fmt.Fprintf(out, "session: %s\nstatus: %s\n", terminalSafe(session.ID), terminalSafe(session.Status))
if note := lifecycleStopNote(session); note != "" {
fmt.Fprintf(out, "note: %s\n", note)
}
return 0
case "logs":
if len(args) < 2 {
fmt.Fprintln(out, "usage: logs SESSION_ID")
Expand Down Expand Up @@ -527,6 +565,7 @@ func printHelp(out io.Writer, user user) {
fmt.Fprintln(out, " --runtime overrides the deployment default")
fmt.Fprintln(out, " attach SESSION_ID")
fmt.Fprintln(out, " vnc SESSION_ID")
fmt.Fprintln(out, " delete SESSION_ID")
fmt.Fprintln(out, " logs SESSION_ID")
fmt.Fprintln(out, " transcript SESSION_ID")
fmt.Fprintln(out, " message SESSION_ID [--no-enter] TEXT")
Expand Down Expand Up @@ -874,6 +913,19 @@ func (c *apiClient) createSession(ctx context.Context, fingerprint string, reque
return response.Session, err
}

func (c *apiClient) action(ctx context.Context, fingerprint string, id string, action string) (interactiveSession, error) {
var response sessionResponse
err := c.do(
ctx,
http.MethodPost,
"/api/ssh/interactive-sessions/"+url.PathEscape(id)+"/actions",
fingerprint,
map[string]string{"action": action},
&response,
)
return response.Session, err
}

func (c *apiClient) logs(ctx context.Context, fingerprint string, id string) (sessionLogResponse, error) {
var response sessionLogResponse
err := c.do(ctx, http.MethodGet, "/api/ssh/interactive-sessions/"+url.PathEscape(id)+"/logs", fingerprint, nil, &response)
Expand Down
98 changes: 98 additions & 0 deletions cmd/crabbox-ssh-gateway/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@ package main

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"

"golang.org/x/crypto/ssh"
)

func TestSplitCommandKeepsQuotedValues(t *testing.T) {
Expand Down Expand Up @@ -127,6 +133,98 @@ func TestCreateAutoAttachRequiresReadyResolvablePTY(t *testing.T) {
}
}

func TestDeleteCommandAndStopAliasUseWorkspaceStopAction(t *testing.T) {
var action string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/ssh/interactive-sessions/IS-7/actions" {
t.Errorf("request = %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusBadRequest)
return
}
if got := r.Header.Get("X-Crabfleet-SSH-Fingerprint"); got != "SHA256:test" {
t.Errorf("fingerprint = %q", got)
w.WriteHeader(http.StatusBadRequest)
return
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
w.WriteHeader(http.StatusBadRequest)
return
}
action = body["action"]
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"session":{"id":"IS-7","status":"stopping"}}`))
}))
defer server.Close()

client := &apiClient{baseURL: server.URL, token: "gateway-token", client: server.Client()}
permissions := &ssh.Permissions{Extensions: map[string]string{
"authorized": "true",
"fingerprint": "SHA256:test",
"login": "operator",
"role": "owner",
}}
for _, command := range []string{"delete IS-7", "stop IS-7"} {
action = ""
var output bytes.Buffer
if exit := runCommand(context.Background(), &output, permissions, client, command, sessionPTY{}); exit != 0 {
t.Fatalf("command=%q exit=%d output=%q", command, exit, output.String())
}
if action != "stop" {
t.Fatalf("command=%q action=%q, want stop", command, action)
}
if !strings.Contains(output.String(), "provider deletion was not confirmed") {
t.Fatalf("command=%q missing legacy cleanup warning: %q", command, output.String())
}
if got := output.String(); !strings.Contains(got, "session: IS-7\nstatus: stopping\n") {
t.Fatalf("command=%q output=%q", command, got)
}
}
for _, command := range []string{"delete", "delete IS-7 extra", "stop", "stop IS-7 extra"} {
action = ""
var output bytes.Buffer
if exit := runCommand(context.Background(), &output, permissions, client, command, sessionPTY{}); exit != 2 {
t.Fatalf("command=%q exit=%d output=%q", command, exit, output.String())
}
if action != "" {
t.Fatalf("command=%q unexpectedly submitted action=%q", command, action)
}
if got := output.String(); got != "usage: delete SESSION_ID\n" {
t.Fatalf("command=%q output=%q", command, got)
}
}
}

func TestHelpNamesDeleteAsCanonicalCommand(t *testing.T) {
var output bytes.Buffer
printHelp(&output, user{Login: "operator", Role: "owner"})
if got := output.String(); !strings.Contains(got, "delete SESSION_ID") || strings.Contains(got, "stop SESSION_ID") {
t.Fatalf("help = %q", got)
}
}

func TestLegacyProviderCleanupWarningRequiresConfirmedLegacyStop(t *testing.T) {
if !legacyProviderCleanupMayBeRequired(interactiveSession{Status: "stopped"}) {
t.Fatal("confirmed legacy stop should retain the cleanup warning")
}
for _, session := range []interactiveSession{
{Status: "failed"},
{Status: "stopped", Adapter: "runtime-v1"},
{Status: "stopped", Runtime: "github_actions"},
} {
if legacyProviderCleanupMayBeRequired(session) {
t.Fatalf("session %#v must not recommend provider cleanup", session)
}
}
if got := lifecycleStopNote(interactiveSession{Status: "stopped", Runtime: "github_actions"}); !strings.Contains(got, "not canceled") {
t.Fatalf("GitHub Actions note = %q", got)
}
if got := lifecycleStopNote(interactiveSession{Status: "failed"}); got != "" {
t.Fatalf("failed unowned workspace note = %q", got)
}
}

func TestPrintListShowsOwnersAndSessionTree(t *testing.T) {
var out bytes.Buffer
printList(&out, stateResponse{
Expand Down
34 changes: 30 additions & 4 deletions cmd/crabfleet/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ type cli struct {
New newCmd `cmd:"" help:"Create a repo-ready crabbox and attach."`
Attach attachCmd `cmd:"" help:"Attach to a crabbox terminal."`
Status statusCmd `cmd:"" help:"Show one crabbox lifecycle state."`
Stop stopCmd `cmd:"" help:"Stop a crabbox workspace."`
Delete deleteCmd `cmd:"" aliases:"stop" help:"End a crabbox session through its configured lifecycle."`
Doctor doctorCmd `cmd:"" help:"Check API, auth, and linked lifecycle access."`
Checkpoints checkpointsCmd `cmd:"" help:"List sandbox checkpoints."`
Checkpoint checkpointCmd `cmd:"" help:"Create a sandbox checkpoint."`
Expand Down Expand Up @@ -82,7 +82,7 @@ type statusCmd struct {
ID string `arg:"" help:"Crabbox session id."`
}

type stopCmd struct {
type deleteCmd struct {
ID string `arg:"" help:"Crabbox session id."`
}

Expand Down Expand Up @@ -157,6 +157,7 @@ type interactiveSession struct {
Repo string `json:"repo"`
Branch string `json:"branch"`
Runtime string `json:"runtime"`
Adapter string `json:"adapter"`
Status string `json:"status"`
Owner string `json:"owner"`
CreatedBy string `json:"createdBy"`
Expand All @@ -171,6 +172,28 @@ type interactiveSession struct {
LogArchive logArchive `json:"logArchive"`
}

func legacyProviderCleanupMayBeRequired(session interactiveSession) bool {
if session.Adapter == "runtime-v1" || session.Runtime == "github_actions" {
return false
}
switch session.Status {
case "stopping", "stopped", "expired":
return true
default:
return false
}
}

func lifecycleStopNote(session interactiveSession) string {
if session.Runtime == "github_actions" && session.Status == "stopped" {
return "GitHub Actions workflow run was not canceled and may continue on GitHub"
}
if legacyProviderCleanupMayBeRequired(session) {
return "provider deletion was not confirmed; legacy runtimes may require separate cleanup"
}
return ""
}

type sessionCapabilities struct {
Terminal bool `json:"terminal"`
}
Expand Down Expand Up @@ -436,18 +459,21 @@ func (cmd statusCmd) Run(app *cli, api *apiClient) error {
return nil
}

func (cmd stopCmd) Run(app *cli, api *apiClient) error {
func (cmd deleteCmd) Run(app *cli, api *apiClient) error {
session, err := api.action(context.Background(), cmd.ID, "stop")
if err != nil {
if app.NoInput || app.JSON {
return err
}
return runSSH(app, "stop", cmd.ID)
return runSSH(app, "delete", cmd.ID)
}
if app.JSON {
return json.NewEncoder(os.Stdout).Encode(session)
}
fmt.Fprintf(os.Stdout, "session: %s\nstatus: %s\n", session.ID, session.Status)
if note := lifecycleStopNote(session); note != "" {
fmt.Fprintf(os.Stdout, "note: %s\n", note)
}
return nil
}

Expand Down
Loading