Compare commits
1 Commits
main
...
fix/bridge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc07c4efa |
@ -8,6 +8,7 @@
|
|||||||
- Fixed `run --no-sync` timing summaries so they report `sync_skipped=true`.
|
- Fixed `run --no-sync` timing summaries so they report `sync_skipped=true`.
|
||||||
- Fixed native Windows command output so first-use PowerShell progress records do not leak CLIXML into run logs.
|
- Fixed native Windows command output so first-use PowerShell progress records do not leak CLIXML into run logs.
|
||||||
- Fixed Islo provider sync so `crabbox run --provider islo` uploads the local workspace, uses the correct `/workspace/<workdir>`, and falls back to chunked exec upload while the archive API returns server errors.
|
- Fixed Islo provider sync so `crabbox run --provider islo` uploads the local workspace, uses the correct `/workspace/<workdir>`, and falls back to chunked exec upload while the archive API returns server errors.
|
||||||
|
- Fixed Code and WebVNC bridge websocket auth so upgraded brokers receive short-lived bridge tickets in the `Authorization` header instead of logging them in URL query strings, while preserving query fallback for older brokers.
|
||||||
|
|
||||||
## 0.6.0 - 2026-05-07
|
## 0.6.0 - 2026-05-07
|
||||||
|
|
||||||
|
|||||||
@ -250,9 +250,14 @@ func connectCodeBridge(ctx context.Context, coord *CoordinatorClient, leaseID, h
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ws, _, err := websocket.Dial(ctx, webCodeAgentURL(coord.BaseURL, leaseID, ticket.Ticket), &websocket.DialOptions{
|
ws, resp, err := websocket.Dial(ctx, webCodeAgentURL(coord.BaseURL, leaseID), &websocket.DialOptions{
|
||||||
HTTPHeader: coord.webVNCAccessHeaders(),
|
HTTPHeader: bridgeTicketHeaders(coord, ticket.Ticket),
|
||||||
})
|
})
|
||||||
|
if retryBridgeTicketInQuery(resp, err) {
|
||||||
|
ws, _, err = websocket.Dial(ctx, webCodeAgentURLWithTicket(coord.BaseURL, leaseID, ticket.Ticket), &websocket.DialOptions{
|
||||||
|
HTTPHeader: coord.webVNCAccessHeaders(),
|
||||||
|
})
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -690,7 +695,7 @@ func availableLocalCodePort() string {
|
|||||||
return "8081"
|
return "8081"
|
||||||
}
|
}
|
||||||
|
|
||||||
func webCodeAgentURL(base, leaseID, ticket string) string {
|
func webCodeAgentURL(base, leaseID string) string {
|
||||||
u, err := url.Parse(base)
|
u, err := url.Parse(base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return base
|
return base
|
||||||
@ -701,6 +706,16 @@ func webCodeAgentURL(base, leaseID, ticket string) string {
|
|||||||
u.Scheme = "ws"
|
u.Scheme = "ws"
|
||||||
}
|
}
|
||||||
u.Path = strings.TrimRight(u.Path, "/") + "/v1/leases/" + url.PathEscape(leaseID) + "/code/agent"
|
u.Path = strings.TrimRight(u.Path, "/") + "/v1/leases/" + url.PathEscape(leaseID) + "/code/agent"
|
||||||
|
u.RawQuery = ""
|
||||||
|
u.Fragment = ""
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func webCodeAgentURLWithTicket(base, leaseID, ticket string) string {
|
||||||
|
u, err := url.Parse(webCodeAgentURL(base, leaseID))
|
||||||
|
if err != nil {
|
||||||
|
return base
|
||||||
|
}
|
||||||
values := url.Values{}
|
values := url.Values{}
|
||||||
values.Set("ticket", ticket)
|
values.Set("ticket", ticket)
|
||||||
u.RawQuery = values.Encode()
|
u.RawQuery = values.Encode()
|
||||||
|
|||||||
@ -1,20 +1,27 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"nhooyr.io/websocket"
|
"nhooyr.io/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWebCodeURLs(t *testing.T) {
|
func TestWebCodeURLs(t *testing.T) {
|
||||||
if got := webCodeAgentURL("https://crabbox.openclaw.ai", "cbx_abcdef123456", "code_abc"); got != "wss://crabbox.openclaw.ai/v1/leases/cbx_abcdef123456/code/agent?ticket=code_abc" {
|
if got := webCodeAgentURL("https://crabbox.openclaw.ai", "cbx_abcdef123456"); got != "wss://crabbox.openclaw.ai/v1/leases/cbx_abcdef123456/code/agent" {
|
||||||
t.Fatalf("agent URL=%q", got)
|
t.Fatalf("agent URL=%q", got)
|
||||||
}
|
}
|
||||||
|
if got := webCodeAgentURLWithTicket("https://crabbox.openclaw.ai", "cbx_abcdef123456", "code_abc"); got != "wss://crabbox.openclaw.ai/v1/leases/cbx_abcdef123456/code/agent?ticket=code_abc" {
|
||||||
|
t.Fatalf("agent fallback URL=%q", got)
|
||||||
|
}
|
||||||
if got := webCodePortalURL("https://crabbox.openclaw.ai/", "cbx_abcdef123456"); got != "https://crabbox.openclaw.ai/portal/leases/cbx_abcdef123456/code/" {
|
if got := webCodePortalURL("https://crabbox.openclaw.ai/", "cbx_abcdef123456"); got != "https://crabbox.openclaw.ai/portal/leases/cbx_abcdef123456/code/" {
|
||||||
t.Fatalf("portal URL=%q", got)
|
t.Fatalf("portal URL=%q", got)
|
||||||
}
|
}
|
||||||
@ -23,6 +30,58 @@ func TestWebCodeURLs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConnectCodeBridgeSendsTicketInAuthorizationHeader(t *testing.T) {
|
||||||
|
agentConnected := make(chan struct{})
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/leases/cbx_abcdef123456/code/ticket":
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("ticket method=%s", r.Method)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
||||||
|
t.Errorf("authorization=%q", got)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(coordinatorCodeTicket{
|
||||||
|
Ticket: "code_abcdef1234567890abcdef1234567890",
|
||||||
|
LeaseID: "cbx_abcdef123456",
|
||||||
|
})
|
||||||
|
case "/v1/leases/cbx_abcdef123456/code/agent":
|
||||||
|
if got := r.URL.Query().Get("ticket"); got != "" {
|
||||||
|
t.Errorf("query ticket=%q", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer code_abcdef1234567890abcdef1234567890" {
|
||||||
|
t.Errorf("bridge authorization=%q", got)
|
||||||
|
}
|
||||||
|
conn, err := websocket.Accept(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("websocket accept: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
close(agentConnected)
|
||||||
|
_, _, _ = conn.Read(context.Background())
|
||||||
|
_ = conn.Close(websocket.StatusNormalClosure, "test done")
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
coord := &CoordinatorClient{BaseURL: server.URL, Token: "test-token", Client: server.Client()}
|
||||||
|
bridge, err := connectCodeBridge(ctx, coord, "cbx_abcdef123456", "127.0.0.1", "8080")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer bridge.Close(websocket.StatusNormalClosure, "test done")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-agentConnected:
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal(ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMappedRemoteCodeFolderTracksCurrentSubdirectory(t *testing.T) {
|
func TestMappedRemoteCodeFolderTracksCurrentSubdirectory(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
subdir := filepath.Join(root, "worker", "src")
|
subdir := filepath.Join(root, "worker", "src")
|
||||||
|
|||||||
@ -398,9 +398,14 @@ func connectWebVNCBridge(ctx context.Context, coord *CoordinatorClient, leaseID,
|
|||||||
_ = tcp.Close()
|
_ = tcp.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ws, _, err := websocket.Dial(ctx, webVNCAgentURL(coord.BaseURL, leaseID, ticket.Ticket), &websocket.DialOptions{
|
ws, resp, err := websocket.Dial(ctx, webVNCAgentURL(coord.BaseURL, leaseID), &websocket.DialOptions{
|
||||||
HTTPHeader: coord.webVNCAccessHeaders(),
|
HTTPHeader: bridgeTicketHeaders(coord, ticket.Ticket),
|
||||||
})
|
})
|
||||||
|
if retryBridgeTicketInQuery(resp, err) {
|
||||||
|
ws, _, err = websocket.Dial(ctx, webVNCAgentURLWithTicket(coord.BaseURL, leaseID, ticket.Ticket), &websocket.DialOptions{
|
||||||
|
HTTPHeader: coord.webVNCAccessHeaders(),
|
||||||
|
})
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tcp.Close()
|
_ = tcp.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -506,7 +511,24 @@ func (c *CoordinatorClient) webVNCAccessHeaders() http.Header {
|
|||||||
return header
|
return header
|
||||||
}
|
}
|
||||||
|
|
||||||
func webVNCAgentURL(base, leaseID, ticket string) string {
|
func bridgeTicketHeaders(coord *CoordinatorClient, ticket string) http.Header {
|
||||||
|
headers := coord.webVNCAccessHeaders()
|
||||||
|
headers.Set("Authorization", "Bearer "+ticket)
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryBridgeTicketInQuery(resp *http.Response, err error) bool {
|
||||||
|
if err == nil || resp == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
return resp.StatusCode == http.StatusUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
func webVNCAgentURL(base, leaseID string) string {
|
||||||
u, err := url.Parse(base)
|
u, err := url.Parse(base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return base
|
return base
|
||||||
@ -517,6 +539,16 @@ func webVNCAgentURL(base, leaseID, ticket string) string {
|
|||||||
u.Scheme = "ws"
|
u.Scheme = "ws"
|
||||||
}
|
}
|
||||||
u.Path = strings.TrimRight(u.Path, "/") + "/v1/leases/" + url.PathEscape(leaseID) + "/webvnc/agent"
|
u.Path = strings.TrimRight(u.Path, "/") + "/v1/leases/" + url.PathEscape(leaseID) + "/webvnc/agent"
|
||||||
|
u.RawQuery = ""
|
||||||
|
u.Fragment = ""
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func webVNCAgentURLWithTicket(base, leaseID, ticket string) string {
|
||||||
|
u, err := url.Parse(webVNCAgentURL(base, leaseID))
|
||||||
|
if err != nil {
|
||||||
|
return base
|
||||||
|
}
|
||||||
values := url.Values{}
|
values := url.Values{}
|
||||||
values.Set("ticket", ticket)
|
values.Set("ticket", ticket)
|
||||||
u.RawQuery = values.Encode()
|
u.RawQuery = values.Encode()
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@ -18,9 +19,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestWebVNCURLs(t *testing.T) {
|
func TestWebVNCURLs(t *testing.T) {
|
||||||
if got := webVNCAgentURL("https://crabbox.openclaw.ai", "cbx_abcdef123456", "wvnc_abc"); got != "wss://crabbox.openclaw.ai/v1/leases/cbx_abcdef123456/webvnc/agent?ticket=wvnc_abc" {
|
if got := webVNCAgentURL("https://crabbox.openclaw.ai", "cbx_abcdef123456"); got != "wss://crabbox.openclaw.ai/v1/leases/cbx_abcdef123456/webvnc/agent" {
|
||||||
t.Fatalf("agent URL=%q", got)
|
t.Fatalf("agent URL=%q", got)
|
||||||
}
|
}
|
||||||
|
if got := webVNCAgentURLWithTicket("https://crabbox.openclaw.ai", "cbx_abcdef123456", "wvnc_abc"); got != "wss://crabbox.openclaw.ai/v1/leases/cbx_abcdef123456/webvnc/agent?ticket=wvnc_abc" {
|
||||||
|
t.Fatalf("agent fallback URL=%q", got)
|
||||||
|
}
|
||||||
if got := webVNCPortalURL("https://crabbox.openclaw.ai/", "cbx_abcdef123456", "", "secret value"); got != "https://crabbox.openclaw.ai/portal/leases/cbx_abcdef123456/vnc#password=secret+value" {
|
if got := webVNCPortalURL("https://crabbox.openclaw.ai/", "cbx_abcdef123456", "", "secret value"); got != "https://crabbox.openclaw.ai/portal/leases/cbx_abcdef123456/vnc#password=secret+value" {
|
||||||
t.Fatalf("portal URL=%q", got)
|
t.Fatalf("portal URL=%q", got)
|
||||||
}
|
}
|
||||||
@ -78,8 +82,11 @@ func TestConnectWebVNCBridgeRegistersAgentBeforeServe(t *testing.T) {
|
|||||||
LeaseID: "cbx_abcdef123456",
|
LeaseID: "cbx_abcdef123456",
|
||||||
})
|
})
|
||||||
case "/v1/leases/cbx_abcdef123456/webvnc/agent":
|
case "/v1/leases/cbx_abcdef123456/webvnc/agent":
|
||||||
if got := r.URL.Query().Get("ticket"); got != "wvnc_abcdef1234567890abcdef1234567890" {
|
if got := r.URL.Query().Get("ticket"); got != "" {
|
||||||
t.Errorf("ticket=%q", got)
|
t.Errorf("query ticket=%q", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer wvnc_abcdef1234567890abcdef1234567890" {
|
||||||
|
t.Errorf("bridge authorization=%q", got)
|
||||||
}
|
}
|
||||||
conn, err := websocket.Accept(w, r, nil)
|
conn, err := websocket.Accept(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -156,6 +163,22 @@ func TestRetryableWebVNCBridgeErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRetryBridgeTicketInQuery(t *testing.T) {
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusUnauthorized,
|
||||||
|
Body: io.NopCloser(strings.NewReader("old broker needs query ticket")),
|
||||||
|
}
|
||||||
|
if !retryBridgeTicketInQuery(resp, errors.New("websocket rejected")) {
|
||||||
|
t.Fatal("expected unauthorized websocket response to retry with query ticket")
|
||||||
|
}
|
||||||
|
if retryBridgeTicketInQuery(&http.Response{StatusCode: http.StatusForbidden}, errors.New("forbidden")) {
|
||||||
|
t.Fatal("forbidden response should not retry with query ticket")
|
||||||
|
}
|
||||||
|
if retryBridgeTicketInQuery(resp, nil) {
|
||||||
|
t.Fatal("successful dial should not retry with query ticket")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWebVNCDaemonArgsStripBackgroundFlags(t *testing.T) {
|
func TestWebVNCDaemonArgsStripBackgroundFlags(t *testing.T) {
|
||||||
got := strings.Join(stripWebVNCDaemonFlags([]string{
|
got := strings.Join(stripWebVNCDaemonFlags([]string{
|
||||||
"--provider",
|
"--provider",
|
||||||
|
|||||||
@ -1356,7 +1356,7 @@ export class FleetDurableObject implements DurableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async consumeWebVNCTicket(request: Request): Promise<WebVNCTicketRecord | undefined> {
|
private async consumeWebVNCTicket(request: Request): Promise<WebVNCTicketRecord | undefined> {
|
||||||
const value = new URL(request.url).searchParams.get("ticket") ?? "";
|
const value = bridgeTicketFromRequest(request);
|
||||||
if (!validWebVNCTicket(value)) {
|
if (!validWebVNCTicket(value)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -1385,7 +1385,7 @@ export class FleetDurableObject implements DurableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async consumeCodeTicket(request: Request): Promise<CodeTicketRecord | undefined> {
|
private async consumeCodeTicket(request: Request): Promise<CodeTicketRecord | undefined> {
|
||||||
const value = new URL(request.url).searchParams.get("ticket") ?? "";
|
const value = bridgeTicketFromRequest(request);
|
||||||
if (!validCodeTicket(value)) {
|
if (!validCodeTicket(value)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -2173,6 +2173,15 @@ function validCodeTicket(value: string | undefined): value is string {
|
|||||||
return typeof value === "string" && /^code_[a-f0-9]{32}$/.test(value);
|
return typeof value === "string" && /^code_[a-f0-9]{32}$/.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function bridgeTicketFromRequest(request: Request): string {
|
||||||
|
const auth = request.headers.get("authorization")?.trim() ?? "";
|
||||||
|
const match = /^Bearer\s+(.+)$/i.exec(auth);
|
||||||
|
if (match?.[1]) {
|
||||||
|
return match[1].trim();
|
||||||
|
}
|
||||||
|
return new URL(request.url).searchParams.get("ticket") ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
function validImageID(value: string | undefined): value is string {
|
function validImageID(value: string | undefined): value is string {
|
||||||
return typeof value === "string" && /^ami-[a-f0-9]{8,32}$/.test(value);
|
return typeof value === "string" && /^ami-[a-f0-9]{8,32}$/.test(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
FleetDurableObject,
|
FleetDurableObject,
|
||||||
|
bridgeTicketFromRequest,
|
||||||
codeForwardHeaders,
|
codeForwardHeaders,
|
||||||
codeResponseHeaders,
|
codeResponseHeaders,
|
||||||
flushPendingWebVNC,
|
flushPendingWebVNC,
|
||||||
@ -1318,6 +1319,21 @@ describe("fleet lease identity and idle", () => {
|
|||||||
expect(missingTicket.status).toBe(401);
|
expect(missingTicket.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts bridge tickets in authorization before falling back to query strings", () => {
|
||||||
|
expect(
|
||||||
|
bridgeTicketFromRequest(
|
||||||
|
request("GET", "/v1/leases/blue-lobster/code/agent?ticket=code_query", {
|
||||||
|
headers: { authorization: "Bearer code_header" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe("code_header");
|
||||||
|
expect(
|
||||||
|
bridgeTicketFromRequest(
|
||||||
|
request("GET", "/v1/leases/blue-lobster/code/agent?ticket=code_query"),
|
||||||
|
),
|
||||||
|
).toBe("code_query");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses a VS Code-compatible CSP for code proxy responses", () => {
|
it("uses a VS Code-compatible CSP for code proxy responses", () => {
|
||||||
const headers = codeResponseHeaders({
|
const headers = codeResponseHeaders({
|
||||||
"content-security-policy": "default-src 'none'; script-src 'self'",
|
"content-security-policy": "default-src 'none'; script-src 'self'",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user