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: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@
!go.sum
!cmd/
!cmd/**
!internal/
!internal/terminalws/
!internal/terminalws/**
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Unify managed terminal clients on the multiplex `/api/terminal/ws` protocol, remove direct PTY routes, and share one framed Go transport across the CLI and SSH gateway.
- Connect Crabfleet lifecycle and terminal traffic to Crabbox through a Cloudflare service binding and deploy an identical route-scoped credential atomically across both coordinators.
- Make OpenClaw room trees recoverable with idempotent Crabbox creation and root-level admission freeze plus recursive stop.
- Add root-fenced OpenClaw service supervision for Crabbox room trees, including current state, bounded transcript evidence, targeted terminal nudges, audited stop requests, and canonical browser URLs.
Expand All @@ -18,7 +19,7 @@
- Reject runtime-adapter redirects with Cloudflare-compatible manual redirect handling instead of using unsupported Worker fetch semantics.
- Make create, run, and admin drawers real modal dialogs with keyboard focus containment and restoration.
- Move the public product hosts to safely converged Worker Custom Domains and fail deploys unless app and product endpoints are reachable.
- Bound Crabbox terminal output with negotiated acknowledgements and legacy-client compatibility.
- Bound Crabbox terminal output with negotiated acknowledgements on the multiplex terminal hub.
- 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.
- 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.
Expand All @@ -35,7 +36,7 @@
- Reject stale same-generation credential-policy registrations, preflight and atomically stage failed managed Sandbox claims, require the provision bearer for standalone stop after backend removal, and backfill D1-only terminal archives when R2 is enabled later.
- Proactively generation-wrap migrated legacy Sandbox credential policies under a live durable lease before cleanup, preserve live pre-token sessions, and use crash-safe cron retries that retain unattended session credentials.
- Bound every runtime-adapter response stream, revalidate desktop authorization after minting, make legacy local stops atomic with scheduled crash recovery, and redact credentials before opaque provider identifiers.
- Recover active credential policies after a post-registration crash, redact provider identities from structured adapter errors, and propagate terminal dimensions through direct bridge and runner PTY routes without rewriting opaque adapter URLs.
- Recover active credential policies after a post-registration crash, redact provider identities from structured adapter errors, and propagate terminal dimensions through configured bridge and runner PTY routes without rewriting opaque adapter URLs.
- Support an optional authoritative `GITHUB_REDIRECT_URI` deployment binding with strict HTTPS callback validation, canonical-origin login handoff, and callback host/path enforcement while retaining safe request-origin defaults.
- Replace native browser confirms and prompts with accessible Crabfleet dialogs for session cleanup, shutdown, and share-link fallback.
- Sharpen the app visual system with flatter controls, tighter surfaces, and restrained overlay elevation.
Expand Down
1 change: 1 addition & 0 deletions Dockerfile.ssh-gateway
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY internal/terminalws ./internal/terminalws
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/crabbox-ssh-gateway ./cmd/crabbox-ssh-gateway

FROM alpine:3.22
Expand Down
84 changes: 19 additions & 65 deletions cmd/crabbox-ssh-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
"strings"
"time"

"github.com/coder/websocket"
"github.com/openclaw/crabfleet/internal/terminalws"
"golang.org/x/crypto/ssh"
)

Expand Down Expand Up @@ -62,7 +62,7 @@ type interactiveSession struct {
Purpose string `json:"purpose"`
Summary string `json:"summary"`
Capabilities *sessionCapabilities `json:"capabilities"`
PtyAvailable *bool `json:"ptyAvailable"`
PtyAvailable bool `json:"ptyAvailable"`
LeaseID string `json:"leaseId"`
AttachURL string `json:"attachUrl"`
VNCURL string `json:"vncUrl"`
Expand Down Expand Up @@ -536,25 +536,7 @@ func attachable(session interactiveSession) bool {
}

func ptyAttachable(session interactiveSession) bool {
if session.PtyAvailable != nil {
return *session.PtyAvailable
}
if strings.HasPrefix(session.LeaseID, "sandbox:") || strings.HasPrefix(session.LeaseID, "cloudflare:") {
return true
}
return strings.HasPrefix(session.AttachURL, "/api/interactive-sessions/") || validWebSocketAttachURL(session.AttachURL)
}

func validWebSocketAttachURL(raw string) bool {
target, err := url.Parse(raw)
if err != nil || target.Host == "" || target.User != nil {
return false
}
if target.Scheme == "wss" {
return true
}
host := target.Hostname()
return target.Scheme == "ws" && (host == "localhost" || host == "127.0.0.1" || host == "::1")
return session.PtyAvailable
}

func printHelp(out io.Writer, user user) {
Expand Down Expand Up @@ -950,79 +932,51 @@ func (c *apiClient) updateSummary(ctx context.Context, fingerprint string, id st
}

func (c *apiClient) message(ctx context.Context, fingerprint string, id string, message string, enter bool, pty sessionPTY) error {
u, err := url.Parse(c.baseURL)
endpoint, err := terminalws.Endpoint(c.baseURL)
if err != nil {
return err
}
switch u.Scheme {
case "https":
u.Scheme = "wss"
default:
u.Scheme = "ws"
}
u.Path = "/api/ssh/interactive-sessions/" + url.PathEscape(id) + "/pty"
q := u.Query()
q.Set("fingerprint", fingerprint)
q.Set("cols", fmt.Sprint(pty.cols))
q.Set("rows", fmt.Sprint(pty.rows))
u.RawQuery = q.Encode()

headers := http.Header{}
headers.Set("Authorization", "Bearer "+c.token)
headers.Set("X-Crabfleet-SSH-Fingerprint", fingerprint)
ws, _, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{HTTPHeader: headers})
client, err := terminalws.Dial(ctx, endpoint, id, terminalws.Options{
Header: headers,
Cols: pty.cols,
Rows: pty.rows,
})
if err != nil {
return err
}
defer ws.Close(websocket.StatusNormalClosure, "")
defer client.Close()
payload := message
if enter {
payload += "\n"
}
return ws.Write(ctx, websocket.MessageBinary, []byte(payload))
return client.SendInput(ctx, []byte(payload))
}

func (c *apiClient) attach(ctx context.Context, fingerprint string, id string, terminal io.ReadWriter, pty sessionPTY) uint32 {
u, err := url.Parse(c.baseURL)
endpoint, err := terminalws.Endpoint(c.baseURL)
if err != nil {
fmt.Fprintf(terminal, "error: %v\n", err)
return 1
}
switch u.Scheme {
case "https":
u.Scheme = "wss"
default:
u.Scheme = "ws"
}
u.Path = "/api/ssh/interactive-sessions/" + url.PathEscape(id) + "/pty"
q := u.Query()
q.Set("fingerprint", fingerprint)
q.Set("cols", fmt.Sprint(pty.cols))
q.Set("rows", fmt.Sprint(pty.rows))
u.RawQuery = q.Encode()

headers := http.Header{}
headers.Set("Authorization", "Bearer "+c.token)
headers.Set("X-Crabfleet-SSH-Fingerprint", fingerprint)
ws, _, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{HTTPHeader: headers})
client, err := terminalws.Dial(ctx, endpoint, id, terminalws.Options{
Header: headers,
Cols: pty.cols,
Rows: pty.rows,
})
if err != nil {
fmt.Fprintf(terminal, "attach failed: %v\n", err)
return 1
}
defer ws.Close(websocket.StatusNormalClosure, "")
netConn := websocket.NetConn(ctx, ws, websocket.MessageBinary)
defer netConn.Close()

errCh := make(chan error, 2)
go func() {
_, err := io.Copy(netConn, terminal)
errCh <- err
}()
go func() {
_, err := io.Copy(terminal, netConn)
errCh <- err
}()
err = <-errCh
defer client.Close()
err = client.Attach(ctx, terminal)
if err != nil && !errors.Is(err, net.ErrClosed) && !strings.Contains(err.Error(), "closed") {
fmt.Fprintf(terminal, "\nattach closed: %v\n", err)
return 1
Expand Down
19 changes: 5 additions & 14 deletions cmd/crabbox-ssh-gateway/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,26 +121,17 @@ func TestTerminalCapabilityWithdrawalSuppressesAttach(t *testing.T) {
}

func TestCreateAutoAttachRequiresReadyResolvablePTY(t *testing.T) {
available := true
if attachable(interactiveSession{Status: "provisioning", PtyAvailable: &available}) {
if attachable(interactiveSession{Status: "provisioning", PtyAvailable: true}) {
t.Fatal("provisioning create must succeed without auto-attach")
}
available = false
if attachable(interactiveSession{Status: "ready", PtyAvailable: &available}) {
if attachable(interactiveSession{Status: "ready", PtyAvailable: false}) {
t.Fatal("ready session without a PTY route must not auto-attach")
}
available = true
if !attachable(interactiveSession{Status: "ready", PtyAvailable: &available}) {
if !attachable(interactiveSession{Status: "ready", PtyAvailable: true}) {
t.Fatal("ready session with a PTY route should auto-attach")
}
if !attachable(interactiveSession{
Status: "detached",
AttachURL: "/api/interactive-sessions/IS-1/pty",
}) {
t.Fatal("legacy API PTY routes should remain attachable")
}
if attachable(interactiveSession{Status: "ready", AttachURL: "ws://example.com/terminal"}) {
t.Fatal("insecure remote websocket should not auto-attach")
if attachable(interactiveSession{Status: "detached", AttachURL: "/api/terminal/ws"}) {
t.Fatal("attach URL must not override missing PTY availability")
}
}

Expand Down
50 changes: 12 additions & 38 deletions cmd/crabfleet/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
"time"

"github.com/alecthomas/kong"
"github.com/coder/websocket"
"github.com/openclaw/crabfleet/internal/terminalws"
)

const defaultAPIURL = "https://crabfleet.openclaw.ai"
Expand Down Expand Up @@ -165,7 +165,7 @@ type interactiveSession struct {
Purpose string `json:"purpose"`
Summary string `json:"summary"`
Capabilities *sessionCapabilities `json:"capabilities"`
PtyAvailable *bool `json:"ptyAvailable"`
PtyAvailable bool `json:"ptyAvailable"`
LeaseID string `json:"leaseId"`
AttachURL string `json:"attachUrl"`
VNCURL string `json:"vncUrl"`
Expand Down Expand Up @@ -798,39 +798,31 @@ func (c *apiClient) transcript(ctx context.Context, id string) (string, error) {
}

func (c *apiClient) message(ctx context.Context, id string, message string, enter bool) error {
path := "/api/ssh/interactive-sessions/" + url.PathEscape(id) + "/pty"
apiPath, authMode, err := c.authenticatedPath(path)
_, authMode, err := c.authenticatedPath("/api/terminal/ws")
if err != nil {
return err
}
u, err := url.Parse(c.baseURL)
endpoint, err := terminalws.Endpoint(c.baseURL)
if err != nil {
return err
}
switch u.Scheme {
case "https":
u.Scheme = "wss"
default:
u.Scheme = "ws"
}
u.Path = apiPath
q := u.Query()
q.Set("cols", "120")
q.Set("rows", "34")
u.RawQuery = q.Encode()

headers := http.Header{}
c.setAuthHeaders(headers, authMode)
ws, _, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{HTTPHeader: headers})
client, err := terminalws.Dial(ctx, endpoint, id, terminalws.Options{
Header: headers,
Cols: 120,
Rows: 34,
})
if err != nil {
return err
}
defer ws.Close(websocket.StatusNormalClosure, "")
defer client.Close()
payload := message
if enter {
payload += "\n"
}
return ws.Write(ctx, websocket.MessageBinary, []byte(payload))
return client.SendInput(ctx, []byte(payload))
}

func (c *apiClient) updateSummary(ctx context.Context, id string, summary string, purpose string) (interactiveSession, error) {
Expand Down Expand Up @@ -1193,25 +1185,7 @@ func terminalCapable(session interactiveSession) bool {
}

func ptyAttachable(session interactiveSession) bool {
if session.PtyAvailable != nil {
return *session.PtyAvailable
}
if strings.HasPrefix(session.LeaseID, "sandbox:") || strings.HasPrefix(session.LeaseID, "cloudflare:") {
return true
}
return strings.HasPrefix(session.AttachURL, "/api/interactive-sessions/") || validWebSocketAttachURL(session.AttachURL)
}

func validWebSocketAttachURL(raw string) bool {
target, err := url.Parse(raw)
if err != nil || target.Host == "" || target.User != nil {
return false
}
if target.Scheme == "wss" {
return true
}
host := target.Hostname()
return target.Scheme == "ws" && (host == "localhost" || host == "127.0.0.1" || host == "::1")
return session.PtyAvailable
}

func isTerminal(file *os.File) bool {
Expand Down
24 changes: 7 additions & 17 deletions cmd/crabfleet/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,38 +206,28 @@ func TestLegacyProviderCleanupWarningRequiresConfirmedLegacyStop(t *testing.T) {
}
}

func TestAttachableRequiresReadySessionWithAttachURL(t *testing.T) {
if !attachable(interactiveSession{Status: "ready", AttachURL: "/api/interactive-sessions/IS-1/pty"}) {
t.Fatal("ready session with sandbox attach URL should be attachable")
func TestAttachableRequiresAuthoritativePTYAvailability(t *testing.T) {
if !attachable(interactiveSession{Status: "ready", PtyAvailable: true}) {
t.Fatal("ready session with an available PTY should be attachable")
}
if attachable(interactiveSession{Status: "pending_adapter", LeaseID: "sandbox:test"}) {
t.Fatal("pending session should not be attachable")
}
if attachable(interactiveSession{Status: "ready", AttachURL: "https://example.com/console"}) {
t.Fatal("http console URL should not be SSH attachable")
}
if attachable(interactiveSession{Status: "ready", AttachURL: "ws://example.com/terminal"}) {
t.Fatal("insecure remote websocket should not be attachable")
}
if !attachable(interactiveSession{Status: "ready", AttachURL: "ws://127.0.0.1:9000/terminal"}) {
t.Fatal("loopback websocket should be attachable")
}
if !attachable(interactiveSession{Status: "ready", LeaseID: "sandbox:test"}) {
t.Fatal("sandbox lease should be attachable")
if attachable(interactiveSession{Status: "ready", AttachURL: "/api/terminal/ws"}) {
t.Fatal("attach URL must not override missing PTY availability")
}
if attachable(interactiveSession{
Status: "ready",
LeaseID: "sandbox:test",
AttachURL: "/api/interactive-sessions/IS-1/pty",
PtyAvailable: true,
Capabilities: &sessionCapabilities{Terminal: false},
}) {
t.Fatal("session with withdrawn terminal capability should not be attachable")
}
available := false
if attachable(interactiveSession{
Status: "ready",
LeaseID: "sandbox:test",
PtyAvailable: &available,
PtyAvailable: false,
}) {
t.Fatal("server PTY availability should be authoritative")
}
Expand Down
Loading