feat: add lease sharing

This commit is contained in:
Peter Steinberger 2026-05-07 13:39:07 +01:00
parent 62d5c1b3d5
commit 0d3a65dfc1
No known key found for this signature in database
16 changed files with 712 additions and 16 deletions

View File

@ -6,6 +6,7 @@
- Added mediated egress commands and browser wiring so Linux desktop leases can proxy selected app traffic through the operator machine via the coordinator bridge.
- Added WebVNC portal clipboard controls for sending local clipboard text into the remote session and copying remote clipboard text back to the local browser.
- Added lease sharing for individual users or the owning org, including `crabbox share`, `crabbox unshare`, API access checks, and a portal share control on lease detail pages.
### Fixed

View File

@ -81,7 +81,7 @@ For the full mental model, see [How Crabbox Works](docs/how-it-works.md). For th
- **Cost guardrails.** Per-lease and monthly spend caps. Live pricing from EC2 Spot history or Hetzner server-type prices, with static fallbacks. `crabbox usage` summarizes spend by user, org, provider, and type.
- **GitHub Actions hydration.** `crabbox actions hydrate` registers a leased box as an ephemeral Actions runner, so the repo's own workflow installs runtimes, services, and secrets. Crabbox does not parse Actions YAML.
- **Interactive desktop and browser leases.** `--browser` provisions Chrome or Chromium for headless automation, `--desktop` provisions visible UI with tunnel-only VNC takeover on managed Linux, AWS native Windows, and AWS EC2 Mac targets. `crabbox desktop doctor` checks session, VNC, input tooling, browser, ffmpeg, screen size, screenshot capture, and WebVNC portal state; `desktop click/paste/type/key` provide first-class input helpers so agents do not hand-roll brittle `xdotool` snippets. Hetzner Windows is not a managed target; use AWS for managed Windows or `provider: ssh` for an existing Windows host.
- **Authenticated web portal.** Browser login opens owner-scoped lease and run views with searchable, paginated tables, muted external-runner rows, compact provider/OS/access icons, relative sortable times, recent run logs/events, WebVNC, code-server, and Linux lease/run telemetry charts. WebVNC is preferred for human demos because it preloads the VNC password; `webvnc status` reports local daemon, tunnel, target reachability, bridge/viewer state, recent events, URL/password, and native VNC fallback, while `webvnc reset` restarts only the selected lease's WebVNC/input stack. Admin sessions can also see non-owned runner leases behind `mine`/`system` filters.
- **Authenticated web portal.** Browser login opens owner-scoped and explicitly shared lease/run views with searchable, paginated tables, muted external-runner rows, compact provider/OS/access icons, relative sortable times, recent run logs/events, WebVNC, code-server, and Linux lease/run telemetry charts. `crabbox share` can grant a lease to one user or the owning org, and the lease page exposes the same sharing controls for owners/managers. WebVNC is preferred for human demos because it preloads the VNC password; `webvnc status` reports local daemon, tunnel, target reachability, bridge/viewer state, recent events, URL/password, and native VNC fallback, while `webvnc reset` restarts only the selected lease's WebVNC/input stack. Admin sessions can also see non-owned runner leases behind `mine`/`system` filters.
- **Hardened coordinator auth.** GitHub browser login, owner-scoped leases, admin-only routes, optional GitHub team allowlists, Cloudflare Access JWT verification, and service-token support keep normal use and operator automation separate.
- **OpenClaw plugin.** The repo root is a native OpenClaw plugin for box lifecycle operations: `crabbox_run`, `crabbox_warmup`, `crabbox_status`, `crabbox_list`, and `crabbox_stop`. Run inspection stays in the CLI and Crabbox skill.
- **Operator surface.** `doctor`, `init`, `status`, `inspect`, `list`, `usage`, `history`, `logs`, `results`, `cache`, `admin`, `cleanup`, plus `--json` output where it matters.

View File

@ -64,6 +64,8 @@ crabbox actions register --id <lease-id-or-slug> [--repo owner/name]
crabbox actions dispatch [--workflow <file|name|id>] [-f key=value]
crabbox status --id <lease-id-or-slug> [--network auto|tailscale|public] [--wait]
crabbox list [--json]
crabbox share --id <lease-id-or-slug> [--user <email>] [--org] [--role use|manage] [--list] [--json]
crabbox unshare --id <lease-id-or-slug> [--user <email>] [--org] [--all] [--json]
crabbox usage [--scope user|org|all] [--user <email>] [--org <name>] [--month YYYY-MM] [--json]
crabbox admin leases [--state active|released|expired|failed] [--owner <email>] [--org <name>] [--json]
crabbox admin release <lease-id-or-slug> [--delete]
@ -115,6 +117,8 @@ crabbox egress start --id blue-lobster --profile discord --daemon
crabbox desktop launch --id blue-lobster --browser --url https://discord.com/login --egress discord --webvnc --open
crabbox egress status --id blue-lobster
crabbox egress stop --id blue-lobster
crabbox share --id blue-lobster --user friend@example.com
crabbox share --id blue-lobster --org
crabbox screenshot --id blue-lobster --output desktop.png
crabbox media preview --input desktop.mp4 --output desktop-preview.gif --trimmed-video-output desktop-change.mp4
crabbox run --id blue-lobster --shell 'pnpm install --frozen-lockfile && pnpm test'

View File

@ -22,6 +22,8 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th
- [cache](cache.md)
- [status](status.md)
- [list](list.md)
- [share](share.md)
- [unshare](unshare.md)
- [image](image.md)
- [usage](usage.md)
- [admin](admin.md)

43
docs/commands/share.md Normal file
View File

@ -0,0 +1,43 @@
# share
`crabbox share` grants access to an existing coordinator lease.
```sh
crabbox share --id blue-lobster --user friend@example.com
crabbox share --id blue-lobster --user friend@example.com --role manage
crabbox share --id blue-lobster --org
crabbox share --id blue-lobster --org --role manage
crabbox share --id blue-lobster --list
crabbox share blue-lobster --list --json
```
Roles:
```text
use see the lease and use visible portal bridges such as WebVNC/code
manage use access plus changing sharing and stopping the lease
```
`--org` shares with authenticated users whose org matches the lease org.
`--user` is repeatable and stores normalized lowercase email addresses.
SSH-based commands still require a local private key accepted by the runner.
Sharing grants coordinator and portal access; it does not copy SSH private keys
between people.
Flags:
```text
--id <lease-id-or-slug>
--user <email>
--org
--role use|manage
--list
--json
```
Related docs:
- [unshare](unshare.md)
- [Auth and admin](../features/auth-admin.md)
- [Browser portal](../features/portal.md)

30
docs/commands/unshare.md Normal file
View File

@ -0,0 +1,30 @@
# unshare
`crabbox unshare` removes sharing from an existing coordinator lease.
```sh
crabbox unshare --id blue-lobster --user friend@example.com
crabbox unshare --id blue-lobster --org
crabbox unshare --id blue-lobster --all
crabbox unshare blue-lobster --all --json
```
Use `--user` to remove individual users, `--org` to remove org-wide access, or
`--all` to clear every sharing rule. Only the lease owner, a `manage` share, or
an admin session can change sharing.
Flags:
```text
--id <lease-id-or-slug>
--user <email>
--org
--all
--json
```
Related docs:
- [share](share.md)
- [Auth and admin](../features/auth-admin.md)
- [Browser portal](../features/portal.md)

View File

@ -26,6 +26,9 @@ crabbox login --no-browser
crabbox login --url <url> --token-stdin
crabbox whoami
crabbox logout
crabbox share --id blue-lobster --user friend@example.com
crabbox share --id blue-lobster --org
crabbox unshare --id blue-lobster --user friend@example.com
```
Trusted operator controls:
@ -41,15 +44,23 @@ Admin commands require the separate admin token. GitHub browser-login tokens can
Normal user tokens are owner/org scoped:
```text
GET /v1/leases own leases only
GET /v1/leases/{id-or-slug} exact ID and slug lookup must match owner/org
POST /v1/leases/{id}/heartbeat own leases only
POST /v1/leases/{id}/release own leases only
GET /v1/leases own and shared leases only
GET /v1/leases/{id-or-slug} exact ID and slug lookup must be visible
POST /v1/leases/{id}/heartbeat own or shared leases
PUT/DELETE /v1/leases/{id}/share owner, manage share, or admin only
POST /v1/leases/{id}/release owner, manage share, or admin only
GET /v1/runs and logs own runs only
GET /v1/usage own usage only
GET /v1/pool admin token only
```
Lease sharing grants coordinator and portal access without distributing the
shared bearer token or admin token. A `use` share can see the lease and open
visible portal bridges such as WebVNC/code. A `manage` share can also change
sharing and stop the lease. `--org` shares with authenticated users whose org
matches the lease org. SSH-based CLI use still requires a local private key
accepted by the runner; sharing does not copy SSH private keys between users.
Do not distribute the shared token or admin token to untrusted users. Keep the admin token narrower and more closely held than the shared automation token.
Related docs:

View File

@ -16,6 +16,8 @@ client-side JavaScript only for filtering, sorting, and clipboard copy.
```text
GET /portal
GET /portal/leases/{id-or-slug}
GET /portal/leases/{id-or-slug}/share
POST /portal/leases/{id-or-slug}/share
POST /portal/leases/{id-or-slug}/release
GET /portal/leases/{id-or-slug}/vnc
GET /portal/leases/{id-or-slug}/code/
@ -43,7 +45,8 @@ Default view rules:
- Defaults to active leases when any are active.
- Falls back to all visible leases when the active list is empty.
- Normal browser sessions see only their own owner/org leases.
- Normal browser sessions see their own leases plus leases shared directly
with them or with their org.
- Admin sessions also see non-owned runner leases. `mine` and `system`
filters distinguish personal leases from external runners (Blacksmith
Testboxes synced from CLI list output) so external rows do not leak to
@ -74,6 +77,11 @@ The lease detail page shows:
- a viewport-fitted "recent runs" grid with state filters;
- a stop action when the lease is releasable.
Owners and users with `manage` access see a share control in the top-right
lease header. The share page can add individual users, set org-wide access, or
clear sharing. `use` shares can open visible lease pages and portal bridges;
`manage` shares can also change sharing and stop the lease.
`/portal/leases/{id-or-slug}/vnc` and `/portal/leases/{id-or-slug}/code/`
are bridges, not portal pages. They proxy WebSocket and HTTP traffic to the
matching capability on the lease so a user does not need an SSH tunnel to

View File

@ -148,6 +148,8 @@ Commands:
cache Inspect, purge, or warm remote caches
status Show lease state; add --wait to block until ready
list List Crabbox machines
share Share a lease with users or the owning org
unshare Remove lease sharing
image Create or promote brokered AWS runner images
usage Show cost and usage estimates by user, org, or fleet
admin Lease admin controls for trusted operators
@ -175,6 +177,8 @@ Common Flows:
crabbox webvnc --id blue-lobster --open
crabbox code --id blue-lobster --open
crabbox egress start --id blue-lobster --profile discord --daemon
crabbox share --id blue-lobster --user friend@example.com
crabbox share --id blue-lobster --org
crabbox screenshot --id blue-lobster --output desktop.png
crabbox inspect --id blue-lobster --json
crabbox history --lease cbx_abcdef123456

View File

@ -30,6 +30,8 @@ type crabboxKongCLI struct {
Cache cacheKongCmd `cmd:"" help:"Inspect, purge, or warm remote caches."`
Status statusKongCmd `cmd:"" passthrough:"" help:"Show lease state; add --wait to block until ready."`
List listKongCmd `cmd:"" passthrough:"" help:"List Crabbox machines."`
Share shareKongCmd `cmd:"" passthrough:"" help:"Share a lease with users or the owning org."`
Unshare unshareKongCmd `cmd:"" passthrough:"" help:"Remove lease sharing."`
Image imageKongCmd `cmd:"" help:"Create or promote brokered AWS runner images."`
Usage usageKongCmd `cmd:"" passthrough:"" help:"Show cost and usage estimates by user, org, or fleet."`
Admin adminKongCmd `cmd:"" help:"Lease admin controls for trusted operators."`
@ -161,6 +163,12 @@ type statusKongCmd struct {
type listKongCmd struct {
Args []string `arg:"" optional:""`
}
type shareKongCmd struct {
Args []string `arg:"" optional:""`
}
type unshareKongCmd struct {
Args []string `arg:"" optional:""`
}
type usageKongCmd struct {
Args []string `arg:"" optional:""`
}
@ -333,6 +341,8 @@ func (c *attachKongCmd) Run(ctx context.Context, app App) error { return app.a
func (c *resultsKongCmd) Run(ctx context.Context, app App) error { return app.results(ctx, c.Args) }
func (c *statusKongCmd) Run(ctx context.Context, app App) error { return app.status(ctx, c.Args) }
func (c *listKongCmd) Run(ctx context.Context, app App) error { return app.list(ctx, c.Args) }
func (c *shareKongCmd) Run(ctx context.Context, app App) error { return app.share(ctx, c.Args) }
func (c *unshareKongCmd) Run(ctx context.Context, app App) error { return app.unshare(ctx, c.Args) }
func (c *usageKongCmd) Run(ctx context.Context, app App) error { return app.usage(ctx, c.Args) }
func (c *sshKongCmd) Run(ctx context.Context, app App) error { return app.ssh(ctx, c.Args) }
func (c *vncKongCmd) Run(ctx context.Context, app App) error { return app.vnc(ctx, c.Args) }

View File

@ -37,6 +37,7 @@ type CoordinatorLease struct {
Region string `json:"region,omitempty"`
Owner string `json:"owner"`
Org string `json:"org"`
Share *CoordinatorShare `json:"share,omitempty"`
Profile string `json:"profile"`
Class string `json:"class"`
ServerType string `json:"serverType"`
@ -64,6 +65,20 @@ type CoordinatorLease struct {
TelemetryHistory []*LeaseTelemetry `json:"telemetryHistory,omitempty"`
}
type CoordinatorShareRole string
const (
CoordinatorShareUse CoordinatorShareRole = "use"
CoordinatorShareManage CoordinatorShareRole = "manage"
)
type CoordinatorShare struct {
Users map[string]CoordinatorShareRole `json:"users,omitempty"`
Org CoordinatorShareRole `json:"org,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
UpdatedBy string `json:"updatedBy,omitempty"`
}
type ProvisioningAttempt struct {
Region string `json:"region,omitempty"`
ServerType string `json:"serverType"`
@ -474,6 +489,37 @@ func (c *CoordinatorClient) GetLease(ctx context.Context, id string) (Coordinato
return res.Lease, err
}
func (c *CoordinatorClient) LeaseShare(ctx context.Context, id string) (CoordinatorShare, error) {
var res struct {
Share CoordinatorShare `json:"share"`
}
err := c.do(ctx, http.MethodGet, "/v1/leases/"+url.PathEscape(id)+"/share", nil, &res)
return res.Share, err
}
func (c *CoordinatorClient) UpdateLeaseShare(ctx context.Context, id string, share CoordinatorShare) (CoordinatorShare, error) {
var res struct {
Share CoordinatorShare `json:"share"`
}
err := c.do(ctx, http.MethodPut, "/v1/leases/"+url.PathEscape(id)+"/share", share, &res)
return res.Share, err
}
func (c *CoordinatorClient) DeleteLeaseShare(ctx context.Context, id, user string, org bool) (CoordinatorShare, error) {
var res struct {
Share CoordinatorShare `json:"share"`
}
body := map[string]any{}
if strings.TrimSpace(user) != "" {
body["user"] = strings.TrimSpace(user)
}
if org {
body["org"] = true
}
err := c.do(ctx, http.MethodDelete, "/v1/leases/"+url.PathEscape(id)+"/share", body, &res)
return res.Share, err
}
func (c *CoordinatorClient) ReleaseLease(ctx context.Context, id string, deleteServer bool) (CoordinatorLease, error) {
var res struct {
Lease CoordinatorLease `json:"lease"`

160
internal/cli/share.go Normal file
View File

@ -0,0 +1,160 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
)
func (a App) share(ctx context.Context, args []string) error {
fs := newFlagSet("share", a.Stderr)
id := fs.String("id", "", "lease id or slug")
var users stringListFlag
fs.Var(&users, "user", "user email to share with; repeatable")
org := fs.Bool("org", false, "share with the lease org")
role := fs.String("role", string(CoordinatorShareUse), "role: use or manage")
list := fs.Bool("list", false, "list current sharing")
jsonOut := fs.Bool("json", false, "print JSON")
if err := parseFlags(fs, args); err != nil {
return err
}
setIDFromFirstArg(fs, id)
if *id == "" {
return exit(2, "usage: crabbox share --id <lease-id-or-slug> [--user <email>|--org|--list]")
}
shareRole, err := parseCoordinatorShareRole(*role)
if err != nil {
return err
}
coord, err := shareCoordinator()
if err != nil {
return err
}
current, err := coord.LeaseShare(ctx, *id)
if err != nil {
return err
}
if *list || (len(users) == 0 && !*org) {
return printCoordinatorShare(a.Stdout, current, *jsonOut)
}
if current.Users == nil {
current.Users = map[string]CoordinatorShareRole{}
}
for _, user := range users {
normalized := normalizeShareEmail(user)
if normalized == "" {
return exit(2, "invalid empty --user")
}
current.Users[normalized] = shareRole
}
if *org {
current.Org = shareRole
}
updated, err := coord.UpdateLeaseShare(ctx, *id, current)
if err != nil {
return err
}
return printCoordinatorShare(a.Stdout, updated, *jsonOut)
}
func (a App) unshare(ctx context.Context, args []string) error {
fs := newFlagSet("unshare", a.Stderr)
id := fs.String("id", "", "lease id or slug")
var users stringListFlag
fs.Var(&users, "user", "user email to remove; repeatable")
org := fs.Bool("org", false, "remove org sharing")
all := fs.Bool("all", false, "remove all sharing")
jsonOut := fs.Bool("json", false, "print JSON")
if err := parseFlags(fs, args); err != nil {
return err
}
setIDFromFirstArg(fs, id)
if *id == "" {
return exit(2, "usage: crabbox unshare --id <lease-id-or-slug> [--user <email>|--org|--all]")
}
if len(users) == 0 && !*org && !*all {
return exit(2, "usage: crabbox unshare --id <lease-id-or-slug> [--user <email>|--org|--all]")
}
coord, err := shareCoordinator()
if err != nil {
return err
}
var updated CoordinatorShare
if *all {
updated, err = coord.DeleteLeaseShare(ctx, *id, "", false)
} else {
updated, err = coord.LeaseShare(ctx, *id)
if err == nil {
if updated.Users == nil {
updated.Users = map[string]CoordinatorShareRole{}
}
for _, user := range users {
delete(updated.Users, normalizeShareEmail(user))
}
if *org {
updated.Org = ""
}
updated, err = coord.UpdateLeaseShare(ctx, *id, updated)
}
}
if err != nil {
return err
}
return printCoordinatorShare(a.Stdout, updated, *jsonOut)
}
func shareCoordinator() (*CoordinatorClient, error) {
cfg, err := loadConfig()
if err != nil {
return nil, err
}
coord, ok, err := newCoordinatorClient(cfg)
if err != nil {
return nil, err
}
if !ok {
return nil, exit(2, "share requires a configured coordinator")
}
return coord, nil
}
func parseCoordinatorShareRole(value string) (CoordinatorShareRole, error) {
switch CoordinatorShareRole(strings.TrimSpace(value)) {
case CoordinatorShareUse:
return CoordinatorShareUse, nil
case CoordinatorShareManage:
return CoordinatorShareManage, nil
default:
return "", exit(2, "share role must be use or manage")
}
}
func normalizeShareEmail(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func printCoordinatorShare(out interface{ Write([]byte) (int, error) }, share CoordinatorShare, jsonOut bool) error {
if jsonOut {
return json.NewEncoder(out).Encode(map[string]any{"share": share})
}
if share.Org != "" {
fmt.Fprintf(out, "org=%s\n", share.Org)
} else {
fmt.Fprintln(out, "org=off")
}
users := make([]string, 0, len(share.Users))
for user := range share.Users {
users = append(users, user)
}
sort.Strings(users)
if len(users) == 0 {
fmt.Fprintln(out, "users=none")
return nil
}
for _, user := range users {
fmt.Fprintf(out, "user=%s role=%s\n", user, share.Users[user])
}
return nil
}

View File

@ -16,6 +16,7 @@ import {
portalHome,
portalLeaseDetail,
portalRunDetail,
portalShareLease,
portalVNC,
webVNCBridgeCommand,
} from "./portal";
@ -35,6 +36,8 @@ import type {
ExternalRunnerSyncRequest,
LeaseRecord,
LeaseRequest,
LeaseShare,
LeaseShareRole,
LeaseTelemetry,
Provider,
ProviderImage,
@ -769,6 +772,9 @@ export class FleetDurableObject implements DurableObject {
if (method === "POST" && action === "release") {
return this.releaseLease(request, leaseID, false);
}
if (action === "share") {
return await this.shareLeaseRoute(request, leaseID);
}
return json({ error: "not_found" }, { status: 404 });
}
@ -834,11 +840,55 @@ export class FleetDurableObject implements DurableObject {
if (!lease) {
return notFound();
}
if (!this.leaseManageableByRequest(lease, request, admin)) {
return json({ error: "forbidden", message: "lease manage access required" }, { status: 403 });
}
const body = await optionalJson<{ delete?: boolean }>(request);
const shouldDelete = body.delete ?? !lease.keep;
return json({ lease: await this.releaseResolvedLease(lease, { deleteServer: shouldDelete }) });
}
private async shareLeaseRoute(request: Request, leaseID: string): Promise<Response> {
const method = request.method.toUpperCase();
const lease = await this.resolveLease(leaseID, request, isAdminRequest(request));
if (!lease) {
return notFound();
}
if (method === "GET") {
return json({ leaseID: lease.id, share: normalizedLeaseShare(lease.share) });
}
if (!this.leaseManageableByRequest(lease, request, isAdminRequest(request))) {
return json({ error: "forbidden", message: "lease manage access required" }, { status: 403 });
}
if (method === "PUT") {
const input = await readJson<Partial<LeaseShare>>(request);
lease.share = sanitizeLeaseShare(input, requestOwner(request));
lease.updatedAt = new Date().toISOString();
await this.putLease(lease);
return json({ leaseID: lease.id, share: normalizedLeaseShare(lease.share) });
}
if (method === "DELETE") {
const input = await optionalJson<{ user?: string; org?: boolean }>(request);
const share = normalizedLeaseShare(lease.share);
const user = normalizeShareUser(input.user);
if (user) {
delete share.users[user];
}
if (input.org) {
delete share.org;
}
if (!user && !input.org) {
lease.share = undefined;
} else {
lease.share = sanitizeLeaseShare(share, requestOwner(request));
}
lease.updatedAt = new Date().toISOString();
await this.putLease(lease);
return json({ leaseID: lease.id, share: normalizedLeaseShare(lease.share) });
}
return json({ error: "not_found" }, { status: 404 });
}
private whoami(request: Request): Response {
return json({
owner: requestOwner(request),
@ -871,6 +921,12 @@ export class FleetDurableObject implements DurableObject {
if (method === "GET" && parts[1] === "leases" && parts[2] && parts[3] === undefined) {
return await this.portalLeasePage(request, parts[2]);
}
if (method === "GET" && parts[1] === "leases" && parts[2] && parts[3] === "share") {
return await this.portalShareLeasePage(request, parts[2]);
}
if (method === "POST" && parts[1] === "leases" && parts[2] && parts[3] === "share") {
return await this.portalShareLeaseAction(request, parts[2]);
}
if (
method === "POST" &&
parts[1] === "leases" &&
@ -978,6 +1034,7 @@ export class FleetDurableObject implements DurableObject {
},
}
: bridgeStatus,
{ canManage: this.leaseManageableByRequest(lease, request, isAdminRequest(request)) },
);
}
@ -990,10 +1047,74 @@ export class FleetDurableObject implements DurableObject {
404,
);
}
if (!this.leaseManageableByRequest(lease, request, isAdminRequest(request))) {
return portalError("Stop unavailable", "Lease manage access is required.", 403);
}
await this.releaseResolvedLease(lease, { deleteServer: true, keep: false });
return new Response(null, { status: 303, headers: { location: "/portal" } });
}
private async portalShareLeasePage(request: Request, identifier: string): Promise<Response> {
const lease = await this.resolvePortalLease(identifier, request);
if (!lease) {
return portalError(
"Lease not found",
"That lease is not active or is not visible to you.",
404,
);
}
if (!this.leaseManageableByRequest(lease, request, isAdminRequest(request))) {
return portalError("Share unavailable", "Lease manage access is required.", 403);
}
return portalShareLease(lease);
}
private async portalShareLeaseAction(request: Request, identifier: string): Promise<Response> {
const lease = await this.resolvePortalLease(identifier, request);
if (!lease) {
return portalError(
"Lease not found",
"That lease is not active or is not visible to you.",
404,
);
}
if (!this.leaseManageableByRequest(lease, request, isAdminRequest(request))) {
return portalError("Share unavailable", "Lease manage access is required.", 403);
}
const form = await request.formData();
const action = String(form.get("action") || "");
const share = normalizedLeaseShare(lease.share);
if (action === "add-user") {
const user = normalizeShareUser(String(form.get("user") || ""));
const role = sanitizeShareRole(String(form.get("role") || "")) || "use";
if (user) {
share.users[user] = role;
}
} else if (action === "remove-user") {
const user = normalizeShareUser(String(form.get("user") || ""));
if (user) {
delete share.users[user];
}
} else if (action === "set-org") {
const role = sanitizeShareRole(String(form.get("role") || ""));
if (role) {
share.org = role;
} else {
delete share.org;
}
} else if (action === "clear") {
delete share.org;
share.users = {};
}
lease.share = sanitizeLeaseShare(share, requestOwner(request));
lease.updatedAt = new Date().toISOString();
await this.putLease(lease);
return new Response(null, {
status: 303,
headers: { location: `/portal/leases/${encodeURIComponent(lease.id)}/share` },
});
}
private async portalRunRoute(
request: Request,
runID: string,
@ -2375,8 +2496,6 @@ export class FleetDurableObject implements DurableObject {
if (!slug) {
return undefined;
}
const owner = requestOwner(request);
const org = requestOrg(request, this.env);
const now = Date.now();
let matches = (await this.leaseRecords()).filter(
(lease) =>
@ -2385,7 +2504,7 @@ export class FleetDurableObject implements DurableObject {
normalizeLeaseSlug(lease.slug) === slug,
);
if (!admin) {
matches = matches.filter((lease) => lease.owner === owner && lease.org === org);
matches = matches.filter((lease) => this.leaseVisibleToRequest(lease, request, false));
}
if (matches.length > 1) {
throw new Error(
@ -2431,18 +2550,41 @@ export class FleetDurableObject implements DurableObject {
}
private filterLeasesForRequest(leases: LeaseRecord[], request: Request): LeaseRecord[] {
const owner = requestOwner(request);
const org = requestOrg(request, this.env);
return this.filterLeases(leases, request).filter(
(lease) => lease.owner === owner && lease.org === org,
return this.filterLeases(leases, request).filter((lease) =>
this.leaseVisibleToRequest(lease, request, false),
);
}
private leaseVisibleToRequest(lease: LeaseRecord, request: Request, admin: boolean): boolean {
return (
return this.leaseAccessRole(lease, request, admin) !== undefined;
}
private leaseManageableByRequest(lease: LeaseRecord, request: Request, admin: boolean): boolean {
const role = this.leaseAccessRole(lease, request, admin);
return role === "owner" || role === "manage";
}
private leaseAccessRole(
lease: LeaseRecord,
request: Request,
admin: boolean,
): "owner" | LeaseShareRole | undefined {
if (
admin ||
(lease.owner === requestOwner(request) && lease.org === requestOrg(request, this.env))
);
) {
return "owner";
}
const share = normalizedLeaseShare(lease.share);
const userRole = share.users[normalizeShareUser(requestOwner(request))];
const orgRole = lease.org === requestOrg(request, this.env) ? share.org : undefined;
if (userRole === "manage" || orgRole === "manage") {
return "manage";
}
if (userRole === "use" || orgRole === "use") {
return "use";
}
return undefined;
}
private runVisibleToRequest(run: RunRecord, request: Request): boolean {
@ -3614,6 +3756,58 @@ function activeSlugCollision(
);
}
function normalizeShareUser(value: string | undefined): string {
return (value ?? "").trim().toLowerCase();
}
function sanitizeShareRole(value: string | undefined): LeaseShareRole | undefined {
return value === "manage" || value === "use" ? value : undefined;
}
type NormalizedLeaseShare = {
users: Record<string, LeaseShareRole>;
org?: LeaseShareRole;
updatedAt?: string;
updatedBy?: string;
};
function normalizedLeaseShare(share: LeaseShare | undefined): NormalizedLeaseShare {
const users: Record<string, LeaseShareRole> = {};
for (const [rawUser, rawRole] of Object.entries(share?.users ?? {})) {
const user = normalizeShareUser(rawUser);
const role = sanitizeShareRole(rawRole);
if (user && role) {
users[user] = role;
}
}
const role = sanitizeShareRole(share?.org);
const normalized: NormalizedLeaseShare = { users };
if (role) {
normalized.org = role;
}
if (share?.updatedAt) {
normalized.updatedAt = share.updatedAt;
}
if (share?.updatedBy) {
normalized.updatedBy = share.updatedBy;
}
return normalized;
}
function sanitizeLeaseShare(input: Partial<LeaseShare>, updatedBy: string): LeaseShare | undefined {
const share = normalizedLeaseShare(input);
const hasUsers = Object.keys(share.users).length > 0;
if (!hasUsers && !share.org) {
return undefined;
}
return {
users: hasUsers ? share.users : undefined,
org: share.org,
updatedAt: new Date().toISOString(),
updatedBy,
};
}
async function optionalJson<T>(request: Request): Promise<T> {
if (!request.headers.get("content-type")?.includes("application/json")) {
return {} as T;

View File

@ -5,6 +5,7 @@ const copyIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" st
const serverIcon = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h4"/></svg>`;
const vncIcon = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg>`;
const codeIcon = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 8-4 4 4 4"/><path d="m15 8 4 4-4 4"/><path d="m13 5-2 14"/></svg>`;
const shareIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 10.5 6.8-4"/><path d="m8.6 13.5 6.8 4"/></svg>`;
const portalBrand = "🦀 crabbox";
interface PortalHeaderOptions {
@ -118,6 +119,7 @@ export function portalLeaseDetail(
lease: LeaseRecord,
runs: RunRecord[],
bridgeStatus: PortalLeaseBridgeStatus,
options: { canManage?: boolean } = {},
): Response {
const slug = lease.slug || lease.id;
const target = lease.target || "linux";
@ -159,6 +161,11 @@ export function portalLeaseDetail(
${portalHeader({
meta: `${escapeHTML(slug)} · ${escapeHTML(lease.provider)} ${escapeHTML(target)} lease <span class="mono">${escapeHTML(lease.id)}</span>`,
actions: `
${
options.canManage
? `<a class="icon-btn" href="/portal/leases/${encodeURIComponent(lease.id)}/share" title="share lease" aria-label="share lease">${shareIcon}</a>`
: ""
}
<a class="button secondary" href="/portal">leases</a>
<a class="button secondary" href="/portal/logout">log out</a>
`,
@ -181,7 +188,7 @@ export function portalLeaseDetail(
</dl>
${leaseTelemetryTimeline(lease.telemetry, lease.telemetryHistory)}
${
active
active && options.canManage
? `<form method="post" action="/portal/leases/${encodeURIComponent(lease.id)}/release" class="stop-form">
<button class="button danger" type="submit">stop lease</button>
</form>`
@ -224,6 +231,78 @@ export function portalLeaseDetail(
);
}
export function portalShareLease(lease: LeaseRecord): Response {
const slug = lease.slug || lease.id;
const users = Object.entries(lease.share?.users ?? {}).toSorted(([a], [b]) => a.localeCompare(b));
const userRows = users.length
? users
.map(
([user, role]) => `<tr>
<td>${escapeHTML(user)}</td>
<td><span class="pill">${escapeHTML(role)}</span></td>
<td>
<form method="post" action="/portal/leases/${encodeURIComponent(lease.id)}/share">
<input type="hidden" name="action" value="remove-user">
<input type="hidden" name="user" value="${escapeHTML(user)}">
<button class="button secondary" type="submit">remove</button>
</form>
</td>
</tr>`,
)
.join("")
: `<tr><td colspan="3" class="empty">no shared users</td></tr>`;
return html(
`Share ${slug}`,
`<main class="portal-shell run-shell">
${portalHeader({
meta: `share ${escapeHTML(slug)} <span class="mono">${escapeHTML(lease.id)}</span>`,
actions: `
<a class="button secondary" href="/portal/leases/${encodeURIComponent(lease.id)}">lease</a>
<a class="button secondary" href="/portal">leases</a>
<a class="button secondary" href="/portal/logout">log out</a>
`,
})}
<section class="panel">
<div class="section-head">
<h2>org access</h2>
<span class="pill">${escapeHTML(lease.share?.org ?? "off")}</span>
</div>
<form class="share-form" method="post" action="/portal/leases/${encodeURIComponent(lease.id)}/share">
<input type="hidden" name="action" value="set-org">
<select name="role" aria-label="org role">
<option value=""${lease.share?.org ? "" : " selected"}>off</option>
<option value="use"${lease.share?.org === "use" ? " selected" : ""}>use</option>
<option value="manage"${lease.share?.org === "manage" ? " selected" : ""}>manage</option>
</select>
<button class="button" type="submit">save</button>
</form>
</section>
<section class="panel">
<div class="section-head"><h2>users</h2></div>
<form class="share-form" method="post" action="/portal/leases/${encodeURIComponent(lease.id)}/share">
<input type="hidden" name="action" value="add-user">
<input name="user" type="email" placeholder="friend@example.com" required>
<select name="role" aria-label="user role">
<option value="use">use</option>
<option value="manage">manage</option>
</select>
<button class="button" type="submit">add</button>
</form>
<div class="table-scroll">
<table>
<thead><tr><th>user</th><th>role</th><th></th></tr></thead>
<tbody>${userRows}</tbody>
</table>
</div>
</section>
<form method="post" action="/portal/leases/${encodeURIComponent(lease.id)}/share">
<input type="hidden" name="action" value="clear">
<button class="button danger" type="submit">clear sharing</button>
</form>
</main>`,
);
}
export function portalExternalRunnerDetail(
runner: ExternalRunnerRecord,
context: { admin: boolean },
@ -1663,6 +1742,9 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
.table-search { width:100%; height:28px; padding:0 9px; border:1px solid var(--line); border-radius:7px; background:#0c0e10; color:var(--fg); font:inherit; font-size:12px; }
.table-search::placeholder { color:#6b7280; }
.table-search:focus { outline:2px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset:1px; border-color:color-mix(in srgb, var(--accent) 55%, var(--line)); }
.share-form { display:flex; align-items:center; gap:8px; padding:10px; border-bottom:1px solid var(--line-soft); }
.share-form input,.share-form select { height:30px; min-width:0; padding:0 9px; border:1px solid var(--line); border-radius:7px; background:#0c0e10; color:var(--fg); font:inherit; font-size:12px; }
.share-form input { flex:1; }
.table-filters { display:flex; align-items:center; gap:3px; min-width:0; overflow-x:auto; padding:2px; border:1px solid var(--line); border-radius:7px; background:#0c0e10; scrollbar-width:none; }
.table-filters::-webkit-scrollbar { display:none; }
.table-filter { flex:0 0 auto; min-height:22px; padding:0 7px; border:0; border-radius:5px; background:transparent; color:var(--muted); cursor:pointer; font:inherit; font-size:11px; }

View File

@ -132,6 +132,7 @@ export interface LeaseRecord {
region?: string;
owner: string;
org: string;
share?: LeaseShare | undefined;
profile: string;
class: string;
serverType: string;
@ -163,6 +164,15 @@ export interface LeaseRecord {
endedAt?: string;
}
export type LeaseShareRole = "use" | "manage";
export interface LeaseShare {
users?: Record<string, LeaseShareRole> | undefined;
org?: LeaseShareRole | undefined;
updatedAt?: string | undefined;
updatedBy?: string | undefined;
}
export interface TailscaleMetadata {
enabled: boolean;
hostname?: string;

View File

@ -109,6 +109,97 @@ describe("fleet lease identity and idle", () => {
expect(found.lease.slug).toBe("blue-lobster");
});
it("shares leases with explicit users or the owning org", async () => {
const storage = new MemoryStorage();
const fleet = testFleet(storage);
const ownerHeaders = {
"x-crabbox-owner": "peter@example.com",
"x-crabbox-org": "openclaw",
};
const friendHeaders = {
"x-crabbox-owner": "friend@example.com",
"x-crabbox-org": "openclaw",
};
const strangerHeaders = {
"x-crabbox-owner": "stranger@example.com",
"x-crabbox-org": "elsewhere",
};
storage.seed(
"lease:cbx_000000000001",
testLease({
id: "cbx_000000000001",
slug: "blue-lobster",
owner: "peter@example.com",
org: "openclaw",
desktop: true,
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
}),
);
const hidden = await fleet.fetch(
request("GET", "/v1/leases/blue-lobster", { headers: friendHeaders }),
);
expect(hidden.status).toBe(404);
const shared = await fleet.fetch(
request("PUT", "/v1/leases/blue-lobster/share", {
headers: ownerHeaders,
body: { users: { "Friend@Example.com": "use" } },
}),
);
expect(shared.status).toBe(200);
await expect(shared.json()).resolves.toMatchObject({
leaseID: "cbx_000000000001",
share: { users: { "friend@example.com": "use" } },
});
const friendLease = await fleet.fetch(
request("GET", "/v1/leases/blue-lobster", { headers: friendHeaders }),
);
expect(friendLease.status).toBe(200);
await expect(friendLease.json()).resolves.toMatchObject({
lease: { id: "cbx_000000000001", share: { users: { "friend@example.com": "use" } } },
});
const friendTicket = await fleet.fetch(
request("POST", "/v1/leases/blue-lobster/webvnc/ticket", {
headers: friendHeaders,
body: {},
}),
);
expect(friendTicket.status).toBe(200);
const friendRelease = await fleet.fetch(
request("POST", "/v1/leases/blue-lobster/release", {
headers: friendHeaders,
body: {},
}),
);
expect(friendRelease.status).toBe(403);
const orgShared = await fleet.fetch(
request("PUT", "/v1/leases/blue-lobster/share", {
headers: ownerHeaders,
body: { users: { "friend@example.com": "use" }, org: "manage" },
}),
);
expect(orgShared.status).toBe(200);
await expect(orgShared.json()).resolves.toMatchObject({
share: { users: { "friend@example.com": "use" }, org: "manage" },
});
const friendSharePage = await fleet.fetch(
request("GET", "/portal/leases/blue-lobster/share", { headers: friendHeaders }),
);
expect(friendSharePage.status).toBe(200);
expect(await friendSharePage.text()).toContain("share blue-lobster");
const stranger = await fleet.fetch(
request("GET", "/v1/leases/blue-lobster", { headers: strangerHeaders }),
);
expect(stranger.status).toBe(404);
});
it("mints brokered Tailscale keys, records non-secret metadata, and accepts readiness updates", async () => {
const storage = new MemoryStorage();
let providerConfig: