feat: add lease sharing
This commit is contained in:
parent
62d5c1b3d5
commit
0d3a65dfc1
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
43
docs/commands/share.md
Normal 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
30
docs/commands/unshare.md
Normal 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)
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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
160
internal/cli/share.go
Normal 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
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user