diff --git a/internal/cli/bootstrap_test.go b/internal/cli/bootstrap_test.go index cdcb523..5663417 100644 --- a/internal/cli/bootstrap_test.go +++ b/internal/cli/bootstrap_test.go @@ -85,6 +85,25 @@ func TestCloudInitBrowserProfile(t *testing.T) { } } +func TestCloudInitCodeProfile(t *testing.T) { + cfg := baseConfig() + cfg.Code = true + got := cloudInit(cfg, "ssh-ed25519 test") + for _, want := range []string{ + "https://code-server.dev/install.sh", + "--method=standalone --prefix=/usr/local", + "/usr/local/bin/code-server --version >/dev/null", + "test -x /usr/local/bin/code-server", + } { + if !strings.Contains(got, want) { + t.Fatalf("cloudInit(code) missing %q", want) + } + } + if strings.Contains(cloudInit(baseConfig(), "ssh-ed25519 test"), "code-server") { + t.Fatal("cloudInit should not install code-server by default") + } +} + func TestCloudInitTailscaleProfile(t *testing.T) { cfg := baseConfig() cfg.SSHUser = "runner" diff --git a/internal/cli/code_test.go b/internal/cli/code_test.go new file mode 100644 index 0000000..db02feb --- /dev/null +++ b/internal/cli/code_test.go @@ -0,0 +1,46 @@ +package cli + +import ( + "strings" + "testing" +) + +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" { + t.Fatalf("agent URL=%q", got) + } + 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) + } +} + +func TestCodeUpstreamPathStripsPortalLeasePrefix(t *testing.T) { + tests := map[string]string{ + "/portal/leases/cbx_abcdef123456/code/": "/", + "/portal/leases/cbx_abcdef123456/code/static/main.js": "/static/main.js", + "/portal/leases/cbx_abcdef123456/code/?folder=/work/repo": "/?folder=/work/repo", + "/portal/leases/blue-lobster/code/vscode-remote-resource": "/vscode-remote-resource", + "/portal/leases/blue-lobster/vnc/viewer": "/portal/leases/blue-lobster/vnc/viewer", + "/portal/leases/blue-lobster/code/proxy/3000/?q=hello+you": "/proxy/3000/?q=hello+you", + } + for input, want := range tests { + if got := codeUpstreamPath(input); got != want { + t.Fatalf("codeUpstreamPath(%q)=%q want %q", input, got, want) + } + } +} + +func TestStartCodeServerCommand(t *testing.T) { + got := startCodeServerCommand("/work/crabbox/cbx_abcdef123456/repo") + for _, want := range []string{ + "/usr/local/bin/code-server", + "--auth none", + "--bind-addr 127.0.0.1:8080", + "VSCODE_PROXY_URI='./proxy/{{port}}'", + "/tmp/crabbox-code-server.log", + } { + if !strings.Contains(got, want) { + t.Fatalf("startCodeServerCommand missing %q:\n%s", want, got) + } + } +} diff --git a/worker/src/fleet.ts b/worker/src/fleet.ts index 4678f51..be9d786 100644 --- a/worker/src/fleet.ts +++ b/worker/src/fleet.ts @@ -1668,7 +1668,7 @@ function codeLeaseError(lease: LeaseRecord): string { if (!lease.code) { return "lease was not created with code=true"; } - if (lease.target !== "linux") { + if (lease.target && lease.target !== "linux") { return "code is currently available for Linux leases only"; } if (!lease.host) { diff --git a/worker/test/bootstrap.test.ts b/worker/test/bootstrap.test.ts index 3638b7a..5ff33c0 100644 --- a/worker/test/bootstrap.test.ts +++ b/worker/test/bootstrap.test.ts @@ -104,6 +104,16 @@ describe("cloud-init bootstrap", () => { expect(got).toContain('"$BROWSER" --version >/dev/null'); }); + it("adds code-server setup only when requested", () => { + const plain = cloudInit(config); + expect(plain).not.toContain("code-server"); + const got = cloudInit({ ...config, code: true }); + expect(got).toContain("https://code-server.dev/install.sh"); + expect(got).toContain("--method=standalone --prefix=/usr/local"); + expect(got).toContain("/usr/local/bin/code-server --version >/dev/null"); + expect(got).toContain("test -x /usr/local/bin/code-server"); + }); + it("adds Tailscale setup only when requested", () => { const plain = cloudInit(config); expect(plain).not.toContain("tailscale up"); diff --git a/worker/test/fleet.test.ts b/worker/test/fleet.test.ts index ed0e669..79c900d 100644 --- a/worker/test/fleet.test.ts +++ b/worker/test/fleet.test.ts @@ -473,6 +473,7 @@ describe("fleet lease identity and idle", () => { owner: "peter@example.com", org: "openclaw", desktop: true, + code: true, expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), }), ); @@ -499,9 +500,71 @@ describe("fleet lease identity and idle", () => { const body = await response.text(); expect(body).toContain("blue-lobster"); expect(body).toContain("/portal/leases/cbx_000000000001/vnc"); + expect(body).toContain("/portal/leases/cbx_000000000001/code/"); expect(body).not.toContain("amber-krill"); }); + it("serves code pages only for code leases and requires a bridge ticket", async () => { + const storage = new MemoryStorage(); + const fleet = testFleet(storage); + const headers = { + "x-crabbox-owner": "peter@example.com", + "x-crabbox-org": "openclaw", + }; + storage.seed( + "lease:cbx_000000000001", + testLease({ + id: "cbx_000000000001", + slug: "blue-lobster", + owner: "peter@example.com", + org: "openclaw", + code: true, + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }), + ); + storage.seed( + "lease:cbx_000000000002", + testLease({ + id: "cbx_000000000002", + slug: "plain-lobster", + owner: "peter@example.com", + org: "openclaw", + code: false, + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }), + ); + + const page = await fleet.fetch(request("GET", "/portal/leases/blue-lobster/code/", { headers })); + expect(page.status).toBe(200); + const pageBody = await page.text(); + expect(pageBody).toContain("crabbox code --id blue-lobster --open"); + + const plain = await fleet.fetch( + request("GET", "/portal/leases/plain-lobster/code/", { headers }), + ); + expect(plain.status).toBe(409); + + const ticket = await fleet.fetch( + request("POST", "/v1/leases/blue-lobster/code/ticket", { headers, body: {} }), + ); + expect(ticket.status).toBe(200); + const ticketBody = (await ticket.json()) as { ticket: string; leaseID: string }; + expect(ticketBody.ticket).toMatch(/^code_[a-f0-9]{32}$/); + expect(ticketBody.leaseID).toBe("cbx_000000000001"); + + const agent = await fleet.fetch( + request("GET", "/v1/leases/blue-lobster/code/agent", { headers }), + ); + expect(agent.status).toBe(426); + + const missingTicket = await fleet.fetch( + request("GET", "/v1/leases/blue-lobster/code/agent", { + headers: { upgrade: "websocket" }, + }), + ); + expect(missingTicket.status).toBe(401); + }); + it("serves WebVNC pages only for desktop leases and requires an agent upgrade", async () => { const storage = new MemoryStorage(); const fleet = testFleet(storage);