From 68974f8809ce56f017021345cb0da30113d612c0 Mon Sep 17 00:00:00 2001 From: Bryan Ehrlich Date: Sat, 29 Aug 2026 11:05:19 -0400 Subject: [PATCH 1/3] fix: align hosted agent documentation and Fly edges --- Makefile | 9 +- README.md | 13 +- SKILL.md | 16 + cmd/wt/serve.go | 73 +++-- cmd/wt/serve_test.go | 74 +++++ ...n-wingthing-direct-control-field-report.md | 3 +- docs/direct-agent-manager-design.md | 11 +- docs/egg-inheritance-design.md | 2 +- docs/fly-ops.md | 299 +++++++++--------- docs/sandbox-enhancement-design.md | 7 +- docs/skills/create-egg/SKILL.md | 8 +- docs/testing.md | 10 +- fly.toml | 11 +- internal/docscheck/docs_test.go | 126 +++++++- internal/relay/pages.go | 30 +- internal/relay/relay_test.go | 50 ++- internal/relay/templates/docs.html | 6 + internal/relay/templates/home.html | 42 +-- internal/relay/templates/patterns.html | 17 +- patterns/SKILL.md | 2 + patterns/hosted-browser-wing/INSTRUCTIONS.md | 79 +++++ 21 files changed, 657 insertions(+), 231 deletions(-) create mode 100644 patterns/hosted-browser-wing/INSTRUCTIONS.md diff --git a/Makefile b/Makefile index 5ba347c..faad84d 100644 --- a/Makefile +++ b/Makefile @@ -77,6 +77,10 @@ deploy-edge: ifndef REGIONS $(error REGIONS is required. Example: make deploy-edge REGIONS=nrt,lhr) endif + @grep -Eq '^[[:space:]]*edge[[:space:]]*=' fly.toml || \ + { echo 'edge process is disabled in fly.toml; follow docs/fly-ops.md before scaling' >&2; exit 1; } + @awk 'BEGIN { section = 0; found = 0 } /^\[http_service\]$$/ { section = 1; next } /^\[/ { section = 0 } section && /^[[:space:]]*processes[[:space:]]*=/ && /"edge"/ { found = 1 } END { exit !found }' fly.toml || \ + { echo 'edge is not attached to http_service.processes in fly.toml' >&2; exit 1; } fly scale count edge=$(COUNT) --region $(REGIONS) --yes # Show all machines, regions, and process groups. @@ -164,8 +168,9 @@ test-linux-ubuntu: test-integ: | web/dist go test -count=1 -tags e2e -v -timeout 120s ./test/integ/... -# Black-box rolling-upgrade and rollback gate against the last published -# release. Requires the baseline tag to be available in the local clone. +# Black-box rolling-upgrade and rollback gate against the configured historical +# baseline (WT_COMPAT_BASELINE_REF, defaulted by the script). Requires that tag +# to be available in the local clone. test-compat: | web/dist scripts/test-backward-compat.sh diff --git a/README.md b/README.md index 56d60cb..47ef904 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,17 @@ ownership and audit attribution inside Wingthing, not a new operating-system security boundary. Optional grants and spawn bounds live in `~/.wingthing/clients.yaml`. -To give the parent agent one qualified inventory across remote wings, log in on -the client machine and use the direct connector instead: +To give the parent agent one qualified inventory across remote wings, first log +in and start Wingthing on every execution machine. Install and authenticate each +provider CLI there as the OS user who will own its runs: + +```bash +wt login +wt start +``` + +Then log in on the parent-agent machine and add the direct connector (if it is +also an execution wing, the commands above already satisfy its wing setup): ```bash wt login diff --git a/SKILL.md b/SKILL.md index f910c45..a2003c8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -18,6 +18,22 @@ Before launching, identify: Wingthing routes control. It does not create or synchronize workspaces, credentials, or memory across wings. +## Choose the remote transport deliberately + +- Native remote MCP uses `wt mcp connect`. The connector machine must run + `wt login`; every execution machine must run `wt login` and `wt start`, have the + requested provider CLI installed and authenticated for the execution owner, and + already contain the requested workspace. This path is direct WebRTC and never + falls back to the hosted relay. +- A hosted browser terminal requires both account-level hosted-relay access and an + execution wing whose effective `hosted_relay` policy is `allow` (the compatible + default). `hosted_relay: deny` overrides an otherwise entitled account until the + wing is restarted with the policy changed. +- A self-hosted roost does not require a wingthing.ai hosted-relay entitlement. Its + operator controls enrollment and relay policy, and its HTTP MCP endpoint controls + that roost's embedded wing rather than joining independent roosts into one + inventory. + ## Run safely 1. Call `wingthing_capabilities` before relying on an operation or agent. diff --git a/cmd/wt/serve.go b/cmd/wt/serve.go index 18fdc79..dc7392f 100644 --- a/cmd/wt/serve.go +++ b/cmd/wt/serve.go @@ -56,6 +56,51 @@ func saveLocalServeToken(configDir, token string) error { }) } +type serveRuntime struct { + config *config.Config + nodeRole string + loginAddr string + flyMachineID string + flyRegion string + flyApp string + autoRole bool + autoLogin bool +} + +func loadServeRuntime(flyDataDir string) (*serveRuntime, error) { + runtime := &serveRuntime{ + nodeRole: os.Getenv("WT_NODE_ROLE"), + loginAddr: os.Getenv("WT_LOGIN_ADDR"), + flyMachineID: os.Getenv("FLY_MACHINE_ID"), + flyRegion: os.Getenv("FLY_REGION"), + flyApp: os.Getenv("FLY_APP_NAME"), + } + + // Detect an unmounted Fly edge before config.Load can create its state + // directory under /data and make that edge look like the volume-owning login + // process. Explicit WT_NODE_ROLE always wins. + if runtime.flyMachineID != "" && runtime.nodeRole == "" { + if info, err := os.Stat(flyDataDir); err == nil && info.IsDir() { + runtime.nodeRole = "login" + } else { + runtime.nodeRole = "edge" + } + runtime.autoRole = true + } + + if runtime.nodeRole == "edge" && runtime.loginAddr == "" && runtime.flyApp != "" { + runtime.loginAddr = "http://login.process." + runtime.flyApp + ".internal:8080" + runtime.autoLogin = true + } + + cfg, err := config.Load() + if err != nil { + return nil, err + } + runtime.config = cfg + return runtime, nil +} + func serveCmd() *cobra.Command { var addrFlag string var devFlag bool @@ -73,30 +118,20 @@ func serveCmd() *cobra.Command { return err } } - cfg, err := config.Load() + runtime, err := loadServeRuntime("/data") if err != nil { return err } - - nodeRole := os.Getenv("WT_NODE_ROLE") - loginAddr := os.Getenv("WT_LOGIN_ADDR") - flyMachineID := os.Getenv("FLY_MACHINE_ID") - flyRegion := os.Getenv("FLY_REGION") - flyApp := os.Getenv("FLY_APP_NAME") - - // Auto-detect node role on Fly: volume mounted at /data → login, else edge. - if flyMachineID != "" && nodeRole == "" { - if info, err := os.Stat("/data"); err == nil && info.IsDir() { - nodeRole = "login" - } else { - nodeRole = "edge" - } + cfg := runtime.config + nodeRole := runtime.nodeRole + loginAddr := runtime.loginAddr + flyMachineID := runtime.flyMachineID + flyRegion := runtime.flyRegion + flyApp := runtime.flyApp + if runtime.autoRole { fmt.Printf("auto-detected node role: %s\n", nodeRole) } - - // Auto-derive login node address from Fly internal DNS. - if nodeRole == "edge" && loginAddr == "" && flyApp != "" { - loginAddr = "http://login.process." + flyApp + ".internal:8080" + if runtime.autoLogin { fmt.Printf("auto-derived login addr: %s\n", loginAddr) } diff --git a/cmd/wt/serve_test.go b/cmd/wt/serve_test.go index 3addfab..dbb0438 100644 --- a/cmd/wt/serve_test.go +++ b/cmd/wt/serve_test.go @@ -1,6 +1,8 @@ package main import ( + "os" + "path/filepath" "testing" "time" @@ -8,6 +10,78 @@ import ( "github.com/ehrlich-b/wingthing/internal/relay" ) +func TestLoadServeRuntimeDetectsFlyRoleBeforeConfigCreatesDataDirectory(t *testing.T) { + t.Run("unmounted edge", func(t *testing.T) { + dataDir := filepath.Join(t.TempDir(), "data") + t.Setenv("WINGTHING_DIR", dataDir) + t.Setenv("FLY_MACHINE_ID", "edge-machine") + t.Setenv("FLY_REGION", "lhr") + t.Setenv("FLY_APP_NAME", "wingthing-test") + t.Setenv("WT_NODE_ROLE", "") + t.Setenv("WT_LOGIN_ADDR", "") + + runtime, err := loadServeRuntime(dataDir) + if err != nil { + t.Fatal(err) + } + if runtime.nodeRole != "edge" || !runtime.autoRole { + t.Fatalf("role = %q auto=%v, want auto-detected edge", runtime.nodeRole, runtime.autoRole) + } + if want := "http://login.process.wingthing-test.internal:8080"; runtime.loginAddr != want || !runtime.autoLogin { + t.Fatalf("login address = %q auto=%v, want %q", runtime.loginAddr, runtime.autoLogin, want) + } + if _, err := os.Stat(dataDir); err != nil { + t.Fatalf("config load did not create its state directory after role detection: %v", err) + } + }) + + t.Run("mounted login", func(t *testing.T) { + dataDir := filepath.Join(t.TempDir(), "data") + if err := os.Mkdir(dataDir, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("WINGTHING_DIR", filepath.Join(dataDir, ".wingthing")) + t.Setenv("FLY_MACHINE_ID", "login-machine") + t.Setenv("FLY_APP_NAME", "wingthing-test") + t.Setenv("WT_NODE_ROLE", "") + t.Setenv("WT_LOGIN_ADDR", "") + + runtime, err := loadServeRuntime(dataDir) + if err != nil { + t.Fatal(err) + } + if runtime.nodeRole != "login" || !runtime.autoRole { + t.Fatalf("role = %q auto=%v, want auto-detected login", runtime.nodeRole, runtime.autoRole) + } + if runtime.loginAddr != "" || runtime.autoLogin { + t.Fatalf("login node derived an edge address: %q auto=%v", runtime.loginAddr, runtime.autoLogin) + } + }) +} + +func TestLoadServeRuntimePreservesExplicitNodeRoleAndLoginAddress(t *testing.T) { + dataDir := filepath.Join(t.TempDir(), "data") + if err := os.Mkdir(dataDir, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("WINGTHING_DIR", filepath.Join(dataDir, ".wingthing")) + t.Setenv("FLY_MACHINE_ID", "edge-machine") + t.Setenv("FLY_APP_NAME", "wingthing-test") + t.Setenv("WT_NODE_ROLE", "edge") + t.Setenv("WT_LOGIN_ADDR", "http://login.internal:9090") + + runtime, err := loadServeRuntime(dataDir) + if err != nil { + t.Fatal(err) + } + if runtime.nodeRole != "edge" || runtime.autoRole { + t.Fatalf("role = %q auto=%v, want explicit edge", runtime.nodeRole, runtime.autoRole) + } + if runtime.loginAddr != "http://login.internal:9090" || runtime.autoLogin { + t.Fatalf("login address = %q auto=%v, want explicit address", runtime.loginAddr, runtime.autoLogin) + } +} + func TestSaveLocalServeTokenPreservesOrdinaryPortalLogin(t *testing.T) { dir := t.TempDir() ordinary := auth.NewTokenStore(dir) diff --git a/docs/bryan-wingthing-direct-control-field-report.md b/docs/bryan-wingthing-direct-control-field-report.md index 8032a84..2e4b588 100644 --- a/docs/bryan-wingthing-direct-control-field-report.md +++ b/docs/bryan-wingthing-direct-control-field-report.md @@ -271,7 +271,8 @@ The direct manager is credible but not yet a polished default: separate directory, authorization, revocation, and conflict-resolution project. - Fresh authenticated enrollment and the remaining production account-cohort canaries still precede broad rollout. The physical two-machine canary and the - real N-1/candidate compatibility battery now pass. + configured historical-baseline/candidate compatibility battery now pass; the + pinned baseline is not an assertion about the immediately previous release. The two-machine canary also found three custom-roost UX/privacy defects. `wt start --roost` printed the public app URL, `wt wing status` validated the token against a diff --git a/docs/direct-agent-manager-design.md b/docs/direct-agent-manager-design.md index c519dd6..12c3142 100644 --- a/docs/direct-agent-manager-design.md +++ b/docs/direct-agent-manager-design.md @@ -164,8 +164,9 @@ The existing HTTP MCP endpoint remains available during migration, and the deplo The deterministic connector canary now crosses JSON-RPC stdio and two independent real WebRTC data channels, verifies qualified `home`/`office` routing, reconnects, and checks that the coordinator handled signaling only. The compatibility gate separately -runs real N-1 and candidate binaries in both gateway/wing upgrade orders, starts a PTY -through the old browser message shape, and proves the old binary can reopen candidate -state. The direct-MCP canary remains an in-process network test; the release gate still -requires the built `wt mcp connect` process and a real Codex/Claude client against two -distinct hosts, including the WSL rig. +runs the configured historical baseline and candidate binaries in both gateway/wing +upgrade orders, starts a PTY through the old browser message shape, and proves the +baseline binary can reopen candidate state. The pin is not automatically the +immediately previous release. The direct-MCP canary remains an in-process network +test; the release gate still requires the built `wt mcp connect` process and a real +Codex/Claude client against two distinct hosts, including the WSL rig. diff --git a/docs/egg-inheritance-design.md b/docs/egg-inheritance-design.md index c2859f7..7020d24 100644 --- a/docs/egg-inheritance-design.md +++ b/docs/egg-inheritance-design.md @@ -101,7 +101,7 @@ host when configured. | Field | Merge behavior | |-------|---------------| | `fs` | Appended. Explicit `ro:` or `rw:` for a path **overrides** a `deny:` of the same path from a parent. | -| `network` | Unioned. Child domains added to parent domains. `"*"` in any layer = full network. | +| `network` | Unioned. Child domains added to parent domains. `"*"` in any layer selects the broadest platform policy: any CONNECT destination on Linux without a general route, while macOS emits no Seatbelt network deny. | | `env` | Unioned. Child vars added to parent vars. `"*"` in any layer = all env. | | `resources` | Scalar override — child value wins per-field (cpu, memory, max_fds). | | `shell` | Scalar override — child wins. | diff --git a/docs/fly-ops.md b/docs/fly-ops.md index da98071..7162d50 100644 --- a/docs/fly-ops.md +++ b/docs/fly-ops.md @@ -1,210 +1,207 @@ # Fly Operations Guide -Wingthing is a typed agent manager for durable agent runs and terminals. The Fly -fleet hosts its public coordinator: identity, the authorized wing directory, key -exchange, WebRTC signaling, the portal, and the optional encrypted relay. Agents -still execute on a user's selected wing; neither Fly process group is a general -agent host. +Wingthing's Fly app is the public coordinator: identity, the authorized wing +directory, key exchange, WebRTC signaling, the portal, and the optional encrypted +relay. Agents execute only on a user's selected wing. A Fly machine is not an +execution wing and does not receive a user's workspace or provider credentials. + +## Checked-in topology + +The active `fly.toml` is deliberately login-only: + +- `[processes].login` is enabled; +- `[processes].edge` is commented out; +- `[http_service].processes` contains only `"login"`; +- the `wt_data` volume and the configured VM stanza apply only to `login`; and +- the service keeps at least one login machine running. + +The active service attachment is therefore: + +```toml +processes = ["login"] +``` + +That means an ordinary `fly deploy` of the checked-in file does not create or route +traffic to an edge process. Verify the live machine count with `make status`; the +configuration is the deployment target, not evidence of what an earlier manual +scale command left running. + +The login process owns the SQLite volume at `/data` and serves the public HTTP and +WebSocket surface. The binary also contains an optional edge role. An edge has no +durable volume, skips the relay database, proxies login-owned HTTP/API work to the +login process, and keeps synchronized session, wing, and entitlement caches. + +On Fly, an unset `WT_NODE_ROLE` is inferred before configuration loading: an +already-mounted `/data` directory means `login`; its absence means `edge`. This +ordering matters because configuration initialization itself may create state below +`/data`. An edge with `FLY_APP_NAME` and no explicit `WT_LOGIN_ADDR` derives +`http://login.process..internal:8080`. An explicit `WT_NODE_ROLE` or +`WT_LOGIN_ADDR` takes precedence. ## Placement and durable state | Decision | Public Fly deployment | | --- | --- | -| **Execution wing** | The access-filtered wing explicitly selected by `wing_id`. Login and edge machines coordinate connections but do not substitute themselves as execution targets. | -| **Workspace** | An existing `cwd` on the selected wing. No Fly service clones or synchronizes user repositories or untracked files. | -| **Display** | `agent_run` returns semantic state over direct MCP without a browser view. `agent_start` creates a persistent PTY; hosted browser/control relay is entitlement-gated, while CLI, SSH, and self-hosted displays remain compatible. | -| **Provider credentials** | The execution owner's agent home on the selected wing. Fly secrets are only service credentials such as JWT, OAuth, billing, or internal-node keys—not Claude, Codex, SSH, or other user provider credentials. | -| **Durable memory** | The login volume stores gateway account, organization, auth, entitlement, and routing records. Each wing remains authoritative for its task database, eggs, optional Wingthing memory, provider history, and workspaces. Edge machines are stateless. | +| **Execution wing** | The access-filtered wing explicitly selected by `wing_id`. The coordinator never substitutes a Fly process as the execution target. | +| **Workspace** | An existing `cwd` on the selected wing. The service does not clone or synchronize repositories or untracked files. | +| **Display** | `agent_run` returns semantic state over direct MCP. `agent_start` creates a persistent PTY; hosted browser/control relay is entitlement-gated, while CLI, SSH, and self-hosted displays remain separate paths. | +| **Provider credentials** | The execution owner's agent home on the selected wing. Fly secrets are service credentials, not Claude, Codex, SSH, or other user provider credentials. | +| **Durable memory** | The login volume stores gateway account, organization, auth, entitlement, and routing records. Each wing remains authoritative for its task database, sessions, provider history, optional Wingthing memory, and workspaces. | The public `direct-free` relay policy changes transport entitlement, not ownership -or organization authorization. Existing personal and organization directories, -roles, configured path scopes, grants, and bounds continue to apply at the wing. - -## Architecture +or organization authorization. Wing-side roles, paths, grants, and bounds still +apply. -Two process groups, one image: +## Internal-node trust -- **login** — has the SQLite volume at `/data`. Handles auth, API, social pages, WebSocket relay. There is exactly one. -- **edge** — stateless. Handles WebSocket relay only. Proxies API/auth to login. There can be zero or many. - -Role is auto-detected: if `/data` exists (volume mounted), it's login. Otherwise edge. No env vars to set per machine. - -Edge nodes discover the login node via Fly internal DNS: `login.process.wingthing.internal:8080`. -The `/internal/*` API accepts an unauthenticated network caller only when its -source is cluster-private and the receiving process is configured as a Fly app -machine. That path trusts the Fly organization's 6PN boundary; it does not prove a -cryptographic caller identity. Set the same separate `WT_INTERNAL_SECRET` on every -Fly process if other applications in the Fly organization are not equally trusted. -A standalone or non-Fly split deployment must set that secret. The JWT signing key -is never accepted as an HTTP credential. +The `/internal/*` API accepts a network caller without `WT_INTERNAL_SECRET` only +when the receiving process is configured as a Fly app machine and the request +arrives from a cluster-private address. That trusts the Fly organization's private +network boundary; it is not cryptographic caller authentication. Set the same +separate `WT_INTERNAL_SECRET` on every process if other applications in that Fly +organization are not equally trusted. A split non-Fly deployment must set the +secret and keep the node transport private and encrypted where it can cross an +untrusted network. Do not reuse `WT_JWT_KEY` as the internal secret. ## One-time setup -Generate an EC P-256 signing key so wings can auth against any node: +Generate an EC P-256 signing key so wings can authenticate against any public +process: -``` +```sh fly secrets set WT_JWT_KEY=$(wt keygen) ``` -## Deploy +## Release and deploy the login-only configuration The public website and installer are one versioned contract. Publish and verify the -matching GitHub release before deploying the site that documents it. From the exact +matching GitHub release before deploying a site that documents it. From the exact commit being promoted: -``` +```sh git tag vX.Y.Z git push origin vX.Y.Z -# wait for the release workflow to pass and publish all five assets +# wait for the release workflow to publish all five assets gh release view vX.Y.Z curl -fsSL https://wingthing.ai/install.sh | sh make deploy ``` -`make deploy` runs the local build/tests, release command-surface contract, and -real N-1/current rolling-upgrade compatibility gate before `fly deploy`; it does -not publish a GitHub release. The compatibility gate requires the published -baseline tag, so deploy from a full clone with tags. Deploying first creates an -installation outage: the newer site serves an installer that correctly refuses an -older binary whose command surface does not match the documentation. Verify that -the installed binary reports `vX.Y.Z` before continuing. - -Fly may roll the login and edge processes independently. The wire changes in this -release are additive, so mixed versions preserve the historical relay behavior, but -the new `direct-free` restriction is not a completed security boundary until every -gateway process is current. Check every machine and its image digest before declaring -the policy active. - -Current edges proxy the portal HTML and hashed static assets to the login process, -so one page load cannot mix bundles from two releases. The release that introduced -that rule needs one special split-fleet order: update every edge process first, then -update login. An older edge serves its own assets, so updating login first can pair a -new index with an old missing asset during that first rollout. The checked-in Fly -configuration currently exposes only the login process; this ordering applies when -the optional edge group is enabled. After every edge runs this release, ordinary -rolling order is safe for static assets. - -For that one split-fleet transition, run the gates above and deploy the same -checked-out commit in this order instead of using the all-groups `make deploy`: - -```bash -fly deploy --process-groups edge -# verify every edge is healthy and running the new image -fly deploy --process-groups login -``` +`make deploy` runs the web build, Go tests, binary build, release command-surface +contract, and the configured historical-baseline compatibility gate before +`fly deploy`; it does not publish a GitHub release. The compatibility script uses +`WT_COMPAT_BASELINE_REF` when set and otherwise uses the pinned default declared in +`scripts/test-backward-compat.sh`. It exercises that baseline and the candidate in +both gateway/wing orders; it is not a claim that the pin is always the immediately +previous release, and it does not exercise a mixed Fly login/edge fleet. -### Hosted relay policy +Deploying the site before the matching release creates an installation outage: the +newer installer refuses an older binary whose command surface does not match the +site. Confirm that the installed binary reports `vX.Y.Z` before continuing. -The `wt serve` gateway defaults to the backward-compatible `legacy` policy so an -upgrade cannot silently change an existing private gateway. The checked-in Fly -configuration explicitly sets `WT_RELAY_POLICY=direct-free`: free accounts may -use login, the wing directory, key exchange, bounded discovery/passkey messages, -and WebRTC signaling, but PTY and general control payload relay is denied. Pro -users retain relay access. +The new `direct-free` restriction is not a completed security boundary until every +public gateway process is current. Check every live machine and image digest before +declaring the policy active. + +## Hosted relay policy + +`wt serve` defaults to the backward-compatible `legacy` policy so an upgrade does +not silently change a private gateway. The checked-in Fly configuration explicitly +sets `WT_RELAY_POLICY=direct-free`: free accounts can use login, the authorized wing +directory, key exchange, bounded discovery/passkey messages, and WebRTC signaling, +but the gateway denies PTY and general control payload relay. Accounts with relay +access retain that hosted browser transport. On `direct-free`, the historical billing-free personal and organization upgrade -endpoints are disabled, and the account UI does not offer plan mutation. Existing -Pro/team entitlements and cancellation paths remain valid; new relay entitlements -must be provisioned by the deployment's billing or operator workflow. Legacy and -self-hosted gateways retain their previous self-service behavior. This does not -remove organization membership or wing sharing; it only prevents those endpoints -from granting new hosted-relay entitlement. - -The public deployment also sets an explicit temporary migration boundary in -`fly.toml`. Accounts created on or before that instant retain relay parity while -the transition is active. If the boundary changes, update the same RFC3339 value -on every login and edge process: +endpoints are disabled, and the account UI does not grant relay access. Existing +entitlements and cancellation paths remain valid; new relay access must come from +the deployment's billing or operator workflow. Private legacy gateways and +self-hosted roosts retain their operator-controlled relay behavior. A wing with +`hosted_relay: deny` refuses relayed payloads even when the account or self-hosted +gateway would otherwise permit them. + +The checked-in configuration also sets the migration cutoff explicitly: ```text WT_RELAY_MIGRATION_BEFORE=2026-08-26T00:00:00Z ``` -`WT_RELAY_GRANDFATHER_BEFORE` remains a deprecated compatibility alias. Startup -fails if both names are set to different values. The logged-in API reports -`relay_allowed` and `relay_reason`, and edge entitlement sync carries the same -decision made by the login node. - -## Scale +Accounts created on or before that instant retain temporary relay parity while the +migration rule is active. If the value changes, deploy the same RFC3339 value to all +gateway processes. Startup fails when the current and deprecated cutoff variable +names are both present with different values. The logged-in API publishes +`relay_allowed` and `relay_reason`; an enabled edge synchronizes the login node's +decision. -### Add edge nodes to a region +## Enable the optional edge group -``` -make deploy-edge REGIONS=nrt COUNT=1 # 1 edge in Tokyo -make deploy-edge REGIONS=lhr COUNT=1 # 1 edge in London -make deploy-edge REGIONS=nrt,lhr COUNT=2 # 2 edges split across Tokyo + London -``` +Do this only when edge capacity is intentionally part of the deployment. Two edits +are required in `fly.toml` before scaling: -### Set total counts +1. Uncomment `[processes].edge`. +2. Change the HTTP service attachment to: -``` -make scale LOGIN=1 EDGE=3 -``` + ```toml + processes = ["login", "edge"] + ``` -### Check what's running +Then deploy that configuration before creating edge machines: -``` +```sh +fly deploy make status +make deploy-edge REGIONS=nrt COUNT=1 +make deploy-edge REGIONS=lhr COUNT=1 ``` -## Middle-of-the-night playbook - -Only use this after the matching release has passed the promotion sequence above: +`make deploy-edge` only changes the edge count in the named regions. It now refuses +to run while the process command is commented out or the HTTP service excludes +`edge`. After scaling, use `make status`, check `/health` through the public service, +and inspect each process's startup log. Edge logs must say `auto-detected node role: +edge` and name the derived or configured login address; login logs must say +`auto-detected node role: login`. -``` -make deploy -make deploy-edge REGIONS=nrt,lhr,cdg COUNT=1 -``` +Current edges proxy portal HTML and hashed static assets to login, so those assets +come from the login release. When introducing that proxy rule to a fleet containing +older edges, update and verify edges before updating login; after that transition, +keep login and edge on the same promoted image rather than assuming the historical +compatibility pin covers mixed edge releases. -That's it. One login in ewr, one edge each in Tokyo, London, Paris. Wings and browsers auto-route to nearest via Fly anycast. +To set total counts across the process groups after edge is enabled: -## Region codes +```sh +make scale LOGIN=1 EDGE=3 +``` -| Code | City | -|------|------| -| ewr | Newark (login node) | -| nrt | Tokyo | -| lhr | London | -| cdg | Paris | -| sin | Singapore | -| syd | Sydney | -| gru | São Paulo | -| sea | Seattle | -| ord | Chicago | -| iad | Ashburn | +To remove Tokyo edges, or every edge respectively: -Full list: `fly platform regions` +```sh +fly scale count edge=0 --region nrt +make scale LOGIN=1 EDGE=0 +``` -## How it works +After the last edge is removed, return `fly.toml` to its checked-in login-only form +if edge is no longer an intended deployment option: remove `"edge"` from +`http_service.processes`, comment the edge command, and deploy that configuration. -1. `fly deploy` builds the Docker image and deploys to all machines -2. Login machine has the `wt_data` volume → auto-detects as login -3. Edge machines have no volume → auto-detect as edge -4. Edges proxy API/auth requests to login over Fly's private 6PN network -5. Edges cache entitlements (polled every 60s) and sessions (cached 5min) -6. Login drives gossip: pushes wing online/offline events to edges every 2s -7. If a browser on an edge needs a wing on another node, `fly-replay` header redirects the WebSocket upgrade transparently +## Optional edge request path -## Removing edge nodes +When the edge group is enabled and attached to the HTTP service: -``` -fly scale count edge=0 --region nrt # remove Tokyo edges -``` +1. the login machine is the only process with `wt_data`; +2. an edge starts without `/data`, detects `edge`, and skips SQLite; +3. login-owned HTTP and API work is proxied over the private Fly address; +4. the edge synchronizes login-owned session, entitlement, and wing state; and +5. a WebSocket for a wing connected elsewhere can receive a `fly-replay` response + that asks Fly to replay the upgrade on the owning machine. -Or remove all edges: +These are optional code paths, not a description of the active checked-in topology. -``` -make scale LOGIN=1 EDGE=0 -``` +## Self-hosted contrast -## Self-hosted - -The simplest self-hosted deployment is a single node with no `WT_NODE_ROLE`, no -`FLY_MACHINE_ID`, no gossip, and no `fly-replay`: use `wt roost start` for the -portal, gateway, and embedded wing, or `wt serve` for the gateway alone. An -OAuth gateway or roost should set `WT_ROOST_ALLOWED_EMAILS`; OAuth identifies an -account but does not by itself enroll that account in a private service. All -multi-node code paths are gated on Fly environment variables being present. -If you deliberately build a split non-Fly deployment, set the same high-entropy -`WT_INTERNAL_SECRET` on every node. Wingthing's built-in node clients send it as -`X-Internal-Secret`; keep the node transport private (and encrypted when it can -cross an untrusted network). Do not reuse `WT_JWT_KEY` for that purpose. +The simplest self-hosted deployment is one process with no `WT_NODE_ROLE`, +`FLY_MACHINE_ID`, gossip, or `fly-replay`: use `wt roost start` for a portal, +gateway, and embedded wing, or `wt serve` for the gateway alone. A private OAuth +gateway or roost should set `WT_ROOST_ALLOWED_EMAILS`; OAuth authenticates an +account but does not enroll it in a private service. Self-hosted relay policy is +operator-controlled and does not depend on a wingthing.ai hosted-relay entitlement. diff --git a/docs/sandbox-enhancement-design.md b/docs/sandbox-enhancement-design.md index 7666f65..2153564 100644 --- a/docs/sandbox-enhancement-design.md +++ b/docs/sandbox-enhancement-design.md @@ -245,9 +245,10 @@ unlisted webhook—stops working. There is a second, explicit compatibility change: on Linux `network: "*"` now allows any TCP destination presented through HTTP CONNECT, but no longer creates a general routed interface. Ordinary raw-socket clients, UDP, ICMP, and programs -that ignore proxy configuration fail. macOS `network: "*"` retains its unrestricted Seatbelt -network behavior. This asymmetry is visible in `wt egg explain`; it must not be -described as byte-for-byte runtime compatibility. +that ignore proxy configuration fail. On macOS, `network: "*"` emits no Seatbelt +network deny and therefore leaves networking subject to the host OS and any outer +boundary. This asymmetry is visible in `wt egg explain`; it must not be described +as byte-for-byte runtime compatibility. This is the "less anything insecure" carve-out. It is also the entire point: the policy starts meaning what it says. But it will break real setups, so it ships diff --git a/docs/skills/create-egg/SKILL.md b/docs/skills/create-egg/SKILL.md index 7515a89..8a9bdaf 100644 --- a/docs/skills/create-egg/SKILL.md +++ b/docs/skills/create-egg/SKILL.md @@ -89,7 +89,13 @@ fs: ### 2. NEVER use `network: "*"` unless the user can justify it -Unrestricted network defeats the entire sandbox. The agent can exfiltrate anything it can read. Push back hard. Most "I need network" requests are actually "I need one or two specific domains": +This is the broadest egress policy and can let the agent exfiltrate readable data. +Do not describe it as one uniform raw-networking mode. Linux permits any TCP +destination presented through HTTP CONNECT but retains a route-less namespace, so +ordinary raw sockets, UDP, ICMP, and clients that ignore the proxy still fail. On +macOS the wildcard emits no Seatbelt network deny, leaving host networking subject +to the OS and any outer boundary. Push back hard. Most "I need network" requests +are actually "I need one or two specific domains": - Need npm? Add `"registry.npmjs.org"` and `"*.npmjs.org"` - Need pip? Add `"pypi.org"` and `"files.pythonhosted.org"` diff --git a/docs/testing.md b/docs/testing.md index e7e2f70..6f3b2b7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -13,7 +13,7 @@ disagree about which sessions exist. | `make check` | web build, `make test`, binary build | every tagged and external E2E tier | | `make test-vuln` | pinned `govulncheck` plus npm's current advisory database | unreachable vulnerable Go symbols and non-Go/npm dependencies | | `make test-integ` | in-process relay, PTY routing, P2P, tunnel, and synthetic agent lifecycle | native sandbox enforcement and browser rendering | -| `make test-compat` | real last-release and candidate binaries: historical migrations, CLI/flag surface, task-state round trip, both gateway/wing upgrade orders, PTY startup, and rollback reopen | third-party wrappers and unsupported pre-baseline releases | +| `make test-compat` | real configured-baseline and candidate binaries: historical migrations, CLI/flag surface, task-state round trip, both gateway/wing upgrade orders, PTY startup, and rollback reopen. `WT_COMPAT_BASELINE_REF` overrides the script's pinned default | third-party wrappers, releases newer than a stale pin, and unsupported pre-baseline releases | | `make test-linux` | Debian container with privileged Linux sandbox, CLI, and namespace batteries | Ubuntu-specific behavior | | `make test-linux-ubuntu` | Ubuntu 24.04 version of the Linux battery | macOS and browser | | `make test-web` | seeded organization-mode roost (including per-identity provider-profile routing), empty-enrollment legacy org canary, and hosted direct-free/relay-entitlement deployments driven by Playwright | local MCP, headless runs, real OAuth provider | @@ -184,7 +184,8 @@ sufficient for authorization, transport, persistence, or sandbox work. Do not chase a repository-wide coverage percentage. Require evidence for claims: owner access is paired with outsider denial, allowed egress with proxy bypass denial, -fresh schema with deployed-schema upgrade, new/new components with N-1/N behavior, +fresh schema with deployed-schema upgrade, new/new components with the explicitly +configured historical-baseline/candidate behavior, and successful lifecycle with cancellation/restart. Every dogfood bug gets the narrowest deterministic regression test that would have caught it. @@ -196,8 +197,9 @@ The current CI shape is: - required Linux jobs: Debian and Ubuntu native-architecture batteries; - required browser job: `make test-web`; - required compatibility job: immutable historical migrations, CLI/flag surface, - task-store round trips, and live N-1/N gateway-wing PTY tests in both upgrade - orders; the browser job adds the four-principal organization-mode and legacy + task-store round trips, and live configured-baseline/candidate gateway-wing PTY + tests in both upgrade orders; the browser job adds the four-principal + organization-mode and legacy enrollment suites; - scheduled or protected-environment job: published agent and hosted-model canaries; and diff --git a/fly.toml b/fly.toml index 5be45fa..ff83cb4 100644 --- a/fly.toml +++ b/fly.toml @@ -8,6 +8,8 @@ primary_region = "ewr" [processes] login = "sh -c 'umask 077 && mkdir -p /data/.wingthing && HOME=/data exec ./wt serve --addr :8080'" + # Optional edges are disabled in the checked-in deployment. Enabling them also + # requires adding "edge" to http_service.processes before deploy and scale. # edge = "./wt serve --addr :8080" [env] @@ -45,10 +47,11 @@ primary_region = "ewr" memory = "512mb" processes = ["login"] -# Horizontal scaling — uncomment edge process above, then: -# fly deploy -# fly scale count login=1 edge=3 -# fly scale count edge=2 --region nrt,lhr +# Optional edge rollout: +# 1. Uncomment the edge process above. +# 2. Change http_service.processes to ["login", "edge"]. +# 3. Run fly deploy and verify login remains healthy. +# 4. Scale edges, for example: fly scale count edge=2 --region nrt,lhr # # One-time setup: # fly secrets set WT_JWT_KEY=$(wt keygen) diff --git a/internal/docscheck/docs_test.go b/internal/docscheck/docs_test.go index fa3cccd..ccb76e6 100644 --- a/internal/docscheck/docs_test.go +++ b/internal/docscheck/docs_test.go @@ -69,6 +69,11 @@ func TestPublicDocumentationContractsStayAligned(t *testing.T) { root := repositoryRoot(t) publicFiles := []string{ "README.md", + "SKILL.md", + "docs/fly-ops.md", + "docs/sandbox.md", + "docs/security.md", + "docs/skills/create-egg/SKILL.md", "web/index.html", "internal/relay/templates/docs.html", "internal/relay/templates/patterns.html", @@ -104,13 +109,16 @@ func TestPublicDocumentationContractsStayAligned(t *testing.T) { } mustContain := map[string][]string{ - "README.md": {"WT_ROOST_ALLOWED_EMAILS", "never silently falls back"}, - "patterns/shared-web-roost/INSTRUCTIONS.md": {"WT_ROOST_ALLOWED_EMAILS", "OAuth by itself proves"}, - "internal/relay/templates/docs.html": {"WT_BASE_URL=https://roost.example.com", "WT_ROOST_ALLOWED_EMAILS", "wt roost start --addr :8080", "wt serve --addr :8080", "never silently changes", "gateway database contains", "does not let an account grant itself relay access", "no request happens until you choose load or open", "200,000 serialized terminal characters", "a wing binary from before", "stop or isolate the wing"}, - "internal/relay/templates/privacy.html": {"gateway database contains", "embedded wing separately keeps", "200,000 serialized terminal characters", "Clearing the site's browser data removes"}, - "fly.toml": {`WT_RELAY_POLICY = "direct-free"`, "WT_RELAY_MIGRATION_BEFORE"}, - "docs/security.md": {"not a downgrade-compatible security policy", "stop the wing or isolate it"}, - "docs/fly-ops.md": {"matching GitHub release", "it does not", "publish a GitHub release", "not a completed security boundary until every"}, + "README.md": {"WT_ROOST_ALLOWED_EMAILS", "never silently falls back", "first log\nin and start Wingthing on every execution machine"}, + "SKILL.md": {"every execution machine must run `wt login` and `wt start`", "both account-level hosted-relay access", "does not require a wingthing.ai hosted-relay entitlement"}, + "patterns/shared-web-roost/INSTRUCTIONS.md": {"WT_ROOST_ALLOWED_EMAILS", "OAuth by itself proves"}, + "patterns/hosted-browser-wing/INSTRUCTIONS.md": {"Account access and wing policy are independent", "wt login", "wt start", "self-hosted roost", "application-encrypted"}, + "internal/relay/templates/docs.html": {"WT_BASE_URL=https://roost.example.com", "WT_ROOST_ALLOWED_EMAILS", "wt roost start --addr :8080", "wt serve --addr :8080", "never silently changes", "gateway database contains", "does not let an account grant itself relay access", "no request happens until you choose load or open", "200,000 serialized terminal characters", "a wing binary from before", "stop or isolate the wing", "On every execution machine", "wt login", "wt start"}, + "internal/relay/templates/patterns.html": {"/patterns/hosted-browser-wing/INSTRUCTIONS.md", "account with hosted-relay access", "hosted browser -> encrypted relay -> selected wing"}, + "internal/relay/templates/privacy.html": {"gateway database contains", "embedded wing separately keeps", "200,000 serialized terminal characters", "Clearing the site's browser data removes"}, + "fly.toml": {`WT_RELAY_POLICY = "direct-free"`, "WT_RELAY_MIGRATION_BEFORE"}, + "docs/security.md": {"not a downgrade-compatible security policy", "stop the wing or isolate it"}, + "docs/fly-ops.md": {"matching GitHub release", "it does not", "publish a GitHub release", "not a completed security boundary until every", "active `fly.toml` is deliberately login-only", "Two edits", `processes = ["login", "edge"]`, "does not exercise a mixed Fly login/edge fleet"}, } for rel, phrases := range mustContain { data, readErr := os.ReadFile(filepath.Join(root, rel)) @@ -166,6 +174,101 @@ func TestPublicDocumentationContractsStayAligned(t *testing.T) { } } +func TestFlyOperationsDocumentationMatchesActiveConfiguration(t *testing.T) { + root := repositoryRoot(t) + read := func(rel string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + t.Fatal(err) + } + return string(data) + } + + fly := read("fly.toml") + if !regexp.MustCompile(`(?m)^\s*login\s*=`).MatchString(fly) { + t.Fatal("fly.toml must define the active login process") + } + if regexp.MustCompile(`(?m)^\s*edge\s*=`).MatchString(fly) { + t.Fatal("the checked-in Fly contract expects the optional edge process to remain disabled") + } + + httpStart := strings.Index(fly, "[http_service]") + if httpStart < 0 { + t.Fatal("fly.toml has no http_service section") + } + httpEnd := strings.Index(fly[httpStart+1:], "\n[") + if httpEnd < 0 { + httpEnd = len(fly) - httpStart - 1 + } + httpService := fly[httpStart : httpStart+1+httpEnd] + if !strings.Contains(httpService, `processes = ["login"]`) || strings.Contains(httpService, `"edge"`) { + t.Fatalf("checked-in http_service must route only to login:\n%s", httpService) + } + for _, phrase := range []string{ + `processes = ["login"]`, + `processes = ["login", "edge"]`, + "ordinary `fly deploy` of the checked-in file does not create or route", + "before creating edge machines", + } { + if !strings.Contains(read("docs/fly-ops.md"), phrase) { + t.Errorf("docs/fly-ops.md must contain %q", phrase) + } + } + + makefile := read("Makefile") + for _, phrase := range []string{"edge process is disabled in fly.toml", "edge is not attached to http_service.processes"} { + if !strings.Contains(makefile, phrase) { + t.Errorf("deploy-edge must guard the inactive Fly topology with %q", phrase) + } + } + + serve := read("cmd/wt/serve.go") + detect := strings.Index(serve, `if runtime.flyMachineID != "" && runtime.nodeRole == ""`) + load := strings.Index(serve, "cfg, err := config.Load()") + if detect < 0 || load < 0 || detect > load { + t.Fatal("Fly role detection must remain before config.Load so config initialization cannot fabricate /data") + } +} + +func TestCompatibilityDocumentationNamesTheConfiguredBaseline(t *testing.T) { + root := repositoryRoot(t) + checks := []struct { + path string + mustHave string + mustNotHave []string + }{ + {path: "Makefile", mustHave: "configured historical", mustNotHave: []string{"against the last published"}}, + {path: "docs/fly-ops.md", mustHave: "configured historical-baseline", mustNotHave: []string{"real N-1/current"}}, + {path: "docs/testing.md", mustHave: "configured-baseline and candidate", mustNotHave: []string{"real last-release", "live N-1/N gateway-wing"}}, + {path: "docs/direct-agent-manager-design.md", mustHave: "configured historical baseline", mustNotHave: []string{"runs real N-1 and candidate"}}, + {path: "docs/bryan-wingthing-direct-control-field-report.md", mustHave: "configured historical-baseline/candidate", mustNotHave: []string{"real N-1/candidate"}}, + } + for _, check := range checks { + data, err := os.ReadFile(filepath.Join(root, check.path)) + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, check.mustHave) { + t.Errorf("%s must name %q", check.path, check.mustHave) + } + for _, stale := range check.mustNotHave { + if strings.Contains(text, stale) { + t.Errorf("%s overstates the pinned compatibility gate with %q", check.path, stale) + } + } + } + + script, err := os.ReadFile(filepath.Join(root, "scripts/test-backward-compat.sh")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(script), `BASELINE_REF="${WT_COMPAT_BASELINE_REF:-`) { + t.Fatal("compatibility script must keep an explicit overridable baseline") + } +} + func TestCurrentDesignDocsDoNotHardCodeMCPToolCounts(t *testing.T) { root := repositoryRoot(t) // Tool membership is a tested contract in internal/control. Numeric prose @@ -196,8 +299,13 @@ func TestSandboxDocumentationMatchesImplementedBoundary(t *testing.T) { }{ { path: "docs/skills/create-egg/SKILL.md", - mustHave: []string{"HOME write isolation", "not a filesystem allowlist", "locations outside HOME"}, - mustNotHave: []string{"root filesystem read-only", "read-only root mount"}, + mustHave: []string{"HOME write isolation", "not a filesystem allowlist", "locations outside HOME", "one uniform raw-networking mode", "route-less namespace", "no Seatbelt network deny"}, + mustNotHave: []string{"root filesystem read-only", "read-only root mount", "Unrestricted network defeats"}, + }, + { + path: "docs/egg-inheritance-design.md", + mustHave: []string{"broadest platform policy", "any CONNECT destination on Linux without a general route", "macOS emits no Seatbelt network deny"}, + mustNotHave: []string{`"*" in any layer = full network`}, }, { path: "docs/container-mode.md", diff --git a/internal/relay/pages.go b/internal/relay/pages.go index 46a020a..f9c45e2 100644 --- a/internal/relay/pages.go +++ b/internal/relay/pages.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/ehrlich-b/wingthing/internal/egg" patternfiles "github.com/ehrlich-b/wingthing/patterns" ) @@ -88,10 +89,22 @@ func (s *Server) template(cached *template.Template, files ...string) *template. } type pageData struct { - User *User - LocalMode bool - HeroVideo bool - AppURL string + User *User + LocalMode bool + HeroVideo bool + AppURL string + SandboxBuilderAgents []string + SandboxBuilderAgentProfiles map[string]egg.AgentProfile +} + +var sandboxBuilderAgents = []string{"claude", "codex", "cursor", "ollama", "gemini"} + +func sandboxBuilderAgentProfiles() map[string]egg.AgentProfile { + profiles := make(map[string]egg.AgentProfile, len(sandboxBuilderAgents)) + for _, agent := range sandboxBuilderAgents { + profiles[agent] = egg.Profile(agent) + } + return profiles } type loginPageData struct { @@ -109,7 +122,14 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/app/", http.StatusSeeOther) return } - data := pageData{User: s.sessionUser(r), LocalMode: s.LocalMode, HeroVideo: s.Config.HeroVideo != "", AppURL: s.appURL()} + data := pageData{ + User: s.sessionUser(r), + LocalMode: s.LocalMode, + HeroVideo: s.Config.HeroVideo != "", + AppURL: s.appURL(), + SandboxBuilderAgents: sandboxBuilderAgents, + SandboxBuilderAgentProfiles: sandboxBuilderAgentProfiles(), + } s.executePageTemplate(w, s.template(homeTmpl, "base.html", "home.html"), data) } diff --git a/internal/relay/relay_test.go b/internal/relay/relay_test.go index c82d681..a60e35e 100644 --- a/internal/relay/relay_test.go +++ b/internal/relay/relay_test.go @@ -7,9 +7,12 @@ import ( "io" "net/http" "net/http/httptest" + "reflect" "strings" "testing" "time" + + "github.com/ehrlich-b/wingthing/internal/egg" ) func mustTest(t *testing.T, err error) { @@ -349,6 +352,7 @@ func TestPatternsPageExplainsOnlySupportedSetups(t *testing.T) { "Run a durable, sandboxed agent on this computer", "Let your current AI launch local sub-agents", "Let one AI manage agents on several computers", + "Use the hosted browser on a remote wing", "Control a remote agent from a localhost browser", "Give a team a private browser-based agent host", "Let an AI control agents on your private roost", @@ -356,14 +360,15 @@ func TestPatternsPageExplainsOnlySupportedSetups(t *testing.T) { "an enrolled account on a roost", "You need:", "You get:", + "hosted browser -> encrypted relay -> selected wing -> agent", "localhost browser -> local portal -> SSH tunnel -> remote wing -> agent", } { if !strings.Contains(page, want) { t.Errorf("/patterns does not contain %q", want) } } - if got := strings.Count(page, `
`); got != 6 { - t.Errorf("/patterns contains %d setup cards, want 6", got) + if got := strings.Count(page, `
`); got != 7 { + t.Errorf("/patterns contains %d setup cards, want 7", got) } for _, internal := range []string{ "the workflows people are asking for", @@ -454,12 +459,53 @@ func TestSignedInHomeUsesConfiguredAppURLForLinkAndShortcut(t *testing.T) { } } +func TestHomeSandboxBuilderAgentProfilesMatchEggPolicy(t *testing.T) { + _, ts := testServer(t) + resp, err := http.Get(ts.URL + "/") + if err != nil { + t.Fatalf("GET /: %v", err) + } + defer closeTestBody(t, resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET / status = %d, want %d", resp.StatusCode, http.StatusOK) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read home page: %v", err) + } + + const prefix = "var profiles=" + start := strings.Index(string(body), prefix) + if start == -1 { + t.Fatal("home page omitted sandbox builder agent profiles") + } + encoded := string(body[start+len(prefix):]) + end := strings.Index(encoded, ";") + if end == -1 { + t.Fatal("sandbox builder agent profiles were not terminated") + } + + var rendered map[string]egg.AgentProfile + if err := json.Unmarshal([]byte(encoded[:end]), &rendered); err != nil { + t.Fatalf("decode sandbox builder agent profiles: %v", err) + } + if len(rendered) != len(sandboxBuilderAgents) { + t.Fatalf("rendered profiles = %d, want %d", len(rendered), len(sandboxBuilderAgents)) + } + for _, agent := range sandboxBuilderAgents { + if got, want := rendered[agent], egg.Profile(agent); !reflect.DeepEqual(got, want) { + t.Errorf("rendered %s profile = %+v, want %+v", agent, got, want) + } + } +} + func TestPatternMarkdownRoutesServeCheckedInRecipes(t *testing.T) { _, ts := testServer(t) paths := []string{ "/patterns/SKILL.md", "/patterns/local-sandbox/INSTRUCTIONS.md", "/patterns/local-subagents/INSTRUCTIONS.md", + "/patterns/hosted-browser-wing/INSTRUCTIONS.md", "/patterns/personal-remote-wing/INSTRUCTIONS.md", "/patterns/shared-web-roost/INSTRUCTIONS.md", "/patterns/shared-roost-agents/INSTRUCTIONS.md", diff --git a/internal/relay/templates/docs.html b/internal/relay/templates/docs.html index f9ed1de..0e1e7ba 100644 --- a/internal/relay/templates/docs.html +++ b/internal/relay/templates/docs.html @@ -79,6 +79,12 @@

run your own priv

This self-hosted path needs no Wingthing account. WT creates a localhost-only certificate on demand, installs only its public CA for your current user, and keeps its private keys on this machine.

give an agent access to all your remote wings

+

On every execution machine, install and authenticate the provider CLIs that may run there, then log in and start its wing:

+
+$ wt login
+$ wt start
+
+

On the parent-agent machine, log in to the same account and register the direct connector. If that machine is also an execution wing, it is already logged in:

$ wt login
$ codex mcp add wingthing -- wt mcp connect --client codex
diff --git a/internal/relay/templates/home.html b/internal/relay/templates/home.html index 8013977..050273b 100644 --- a/internal/relay/templates/home.html +++ b/internal/relay/templates/home.html @@ -211,14 +211,8 @@

resources

-

durable agent runs and terminals

-

Wingthing is a typed agent manager for Codex, Claude, and other agents. Start and supervise work on the wing where the code and credentials already live, then take over a persistent terminal when a person belongs in the loop.

+

local-first control for coding agents

+

Give an agent typed control of local agents on the code and provider login already on this machine. Move to direct remote MCP only when execution moves to another machine, and add a browser only when a person needs one.

+ {{if .User}} {{else}}
-
press . to start
+press . to install locally
{{end}} {{if .HeroVideo}}
{{end}}
@@ -113,11 +129,11 @@

durable agent runs and terminals

-

how it works

+

one wing owns each run

portal

-

The unified inventory and controls for your agents. An LLM uses typed MCP operations; a person can inspect or take over the same sessions.

+

An agent starts with the local stdio MCP portal. A person uses the CLI, or adds a self-hosted browser when visibility requires one.

wing

@@ -139,19 +155,19 @@

sandbox-first

direct by default

-

The hosted service coordinates identity, wing discovery, keys, and connection setup. Free agent clients send MCP payloads directly to their wing; the hosted encrypted terminal and control relay is a Pro feature.

+

When machines differ, wt mcp connect selects a wing explicitly and sends MCP payloads directly to it. It never falls back to hosted relay.

self-hostable

-

wt roost start --https runs a portal, gateway, and embedded wing on your machine. WT creates a localhost-only certificate on demand, installs only its public CA for your user, and keeps the private key on this machine. No hosted account needed.

+

wt roost start --https is the first browser route: a portal, gateway, and embedded wing on your machine. No hosted account or relay entitlement is needed.

any agent

wt egg claude, wt egg codex, wt egg gemini. Switch the provider, keep the execution contract and sandbox config.

-

passkey auth

-

WebAuthn passkeys. One-time challenges and signature verification happen on your wing against a locally pinned public key.

+

hosted relay is optional

+

The wingthing.ai browser relay is available only when the account is entitled and the selected wing allows it. The hosted service does not run agents.

@@ -206,6 +222,7 @@

resources

diff --git a/internal/relay/templates/install.html b/internal/relay/templates/install.html index 2201ee6..767c934 100644 --- a/internal/relay/templates/install.html +++ b/internal/relay/templates/install.html @@ -31,7 +31,7 @@ {{define "content"}}

install wt

-

Install Wingthing, the typed agent manager for durable agent runs and terminals. macOS and Linux, x64 and arm64.

+

Install Wingthing for local typed agent control first. Add a wing daemon, remote connector, or browser portal only when the work crosses those boundaries. macOS and Linux, x64 and arm64.

@@ -50,40 +50,53 @@

quick install

get started

-

Every launch uses an existing workspace and provider login on its execution wing. Wingthing keeps task and terminal state on that wing; it does not copy the workspace, credentials, or durable memory elsewhere.

+

Every launch uses an existing workspace and provider login on its execution wing. Wingthing keeps task and terminal state there; it does not copy code, credentials, or durable memory to another machine.

    -
  1. -

    start an agent terminal locally

    -

    Authenticate the provider CLI first and use an existing project directory. No Wingthing account or daemon is required. Detach with Ctrl+B, then Q.

    +
  2. +

    give a parent agent typed local control

    +

    Register local stdio MCP, restart the parent client, and ask it to call wingthing_capabilities. Child agents use the existing code and provider login on this computer. No Wingthing account or daemon is required.

    +
    +$ codex mcp add wingthing -- wt mcp stdio --client codex
    +# or: claude mcp add --scope user wingthing -- wt mcp stdio --client claude +
    +
  3. +
  4. +

    start a local sandboxed agent terminal yourself

    +

    Authenticate the provider CLI first and use an existing project directory. Detach with Ctrl+B, then Q.

    $ cd /path/to/existing/project
    $ wt egg claude --name work
    $ wt attach work
  5. -
  6. -

    give a parent agent typed local control

    -

    Register local MCP, restart the parent client, and ask it to call wingthing_capabilities. Use agent_run for a semantic result or agent_start for an attachable PTY.

    +
  7. +

    use direct remote MCP when machines differ

    +

    On each execution wing, run wt login, make sure the wing is authorized for the connector account personally or through an organization, then run wt start. Calls go directly to an explicit wing and never silently fall back to hosted relay.

    -$ codex mcp add wingthing -- wt mcp stdio --client codex
    -# or: claude mcp add --scope user wingthing -- wt mcp stdio --client claude +$ wt login
    +$ wt start +
    +

    Separately, run wt login on the parent/connector machine so wt mcp connect can load its connector token, then register it with the parent client. Run wt start on the parent only when that machine also executes agents.

    +
    +$ wt login
    +$ codex mcp add wingthing -- wt mcp connect --client codex
  8. -
  9. -

    optionally run your own browser portal

    -

    No Wingthing account is required. This creates and trusts a localhost-only certificate for your current user; Linux needs certutil from libnss3-tools or nss-tools for that trust step.

    +
  10. +

    self-host first when a person needs a browser

    +

    No Wingthing account or hosted-relay entitlement is required. This creates and trusts a localhost-only certificate for your current user; Linux needs certutil from libnss3-tools or nss-tools for that trust step.

    $ wt roost start --https
    $ open https://localhost:8443
  11. -
  12. -

    optionally connect a parent agent to remote wings

    -

    Run wt login and wt start on every execution machine, then register wt mcp connect with the parent Claude or Codex client. Free MCP payloads travel directly to the selected wing; the hosted browser terminal and control relay require relay access.

    +
  13. +

    use the entitled hosted browser only if needed

    +

    For this route, the hosted browser terminal and control relay require relay access on the account and hosted_relay: allow on the selected wing. This optional path is separate from direct remote MCP.

    $ wt login
    $ wt start
    -$ codex mcp add wingthing -- wt mcp connect --client codex +$ open https://app.wingthing.ai
@@ -99,6 +112,6 @@

update

$ wt update
-

Downloads the latest release and restarts the wing daemon if running. You can also trigger updates remotely from the web dashboard.

+

Downloads the latest release and restarts the local wing daemon if it is running.

{{end}} diff --git a/internal/relay/templates/patterns.html b/internal/relay/templates/patterns.html index 96debd0..59b5489 100644 --- a/internal/relay/templates/patterns.html +++ b/internal/relay/templates/patterns.html @@ -25,102 +25,103 @@ {{end}} {{define "content"}}
-

choose a durable agent workflow

-

Wingthing is a typed agent manager for durable agent runs and terminals. Each setup below works today; pick one to see exactly what must be installed, where execution and state live, and how the driver connects.

+

choose the smallest route

+

Start with local stdio MCP when an agent is driving, or a local sandboxed terminal when a person is driving. If machines differ, use direct remote MCP. If a person needs a browser, self-host a roost first. The hosted browser relay is the last, optional route and requires entitlement.

-
A wing is the computer that runs the work. Before launch, choose the execution wing, an existing workspace on it, a headless or terminal display, the owner whose provider home supplies credentials, and the wing that keeps durable state. Wingthing routes control; it does not copy workspaces, credentials, provider history, or Wingthing memory between wings.
+
Code, credentials, processes, and durable state stay on the execution wing. Wingthing routes control to an existing workspace; it does not copy workspaces, provider logins, provider history, or Wingthing memory between machines.
-
+
01
-
one computerhuman-driven
-

Run a durable, sandboxed agent on this computer

-

Start Claude, Codex, or another agent in a project without setting up a server or account.

+
one computeragent-drivenstdio MCP
+

Let your current AI launch local sub-agents

+

Give a parent Claude or Codex session typed tools for agents on the same computer, using the code and provider logins already there.

    -
  • You need: Wingthing and the agent CLI installed locally.
  • -
  • You get: project sandbox policy, a persistent terminal, and the ability to disconnect and reattach later.
  • +
  • You need: Wingthing registered as wt mcp stdio in the parent client. No account or daemon is required.
  • +
  • You get: typed start, wait, inspect, steer, and stop controls for local sandboxed child agents.
-
project folder -> sandboxed agent -> reattach later
-
read setup guide
+
parent AI -> local stdio MCP -> local child agents
+
read setup guide
-
+
02
-
one computerAI-driven
-

Let your current AI launch local sub-agents

-

Give a parent Claude or Codex session tools for delegating parts of a larger task to other agents on the same computer.

+
one computerhuman-driventerminal
+

Run a durable, sandboxed agent on this computer

+

Start Claude, Codex, or another agent in a local project without setting up a server or account.

    -
  • You need: Wingthing added as a local MCP server in the parent AI client.
  • -
  • You get: start, wait, inspect, steer, and stop controls for sandboxed child agents.
  • +
  • You need: Wingthing and the provider agent CLI installed and authenticated locally.
  • +
  • You get: project sandbox policy, a persistent terminal, and the ability to disconnect and reattach later.
-
parent AI -> Wingthing -> child agents on this computer
-
read setup guide
+
person -> local sandboxed agent terminal -> reattach later
+
read setup guide
-
+
03
several computersAI-driven

Let one AI manage agents on several computers

-

Run agents on a home machine, office VM, or other host from one parent Claude or Codex session.

+

When parent and execution machines differ, connect one Claude or Codex session directly to an explicitly selected wing.

    -
  • You need: each computer logged into the same Wingthing account and running wt start; the parent AI uses wt mcp connect.
  • -
  • You get: one list of online computers, explicit machine selection, direct encrypted control, and durable remote work without inbound ports.
  • +
  • Each execution wing needs: its own wt login, authorization for the connector account (personal or organization), and a running wt start.
  • +
  • The parent/connector needs: its own wt login token and wt mcp connect. It runs wt start only when it also executes agents.
  • +
  • You get: direct encrypted MCP control without inbound ports or silent fallback to hosted relay.
-
parent AI -> choose computer -> direct connection -> agent runs there
+
parent AI -> direct remote MCP -> selected wing -> agent
read setup guide
-
+
04
-
remote computerbrowserhosted relay
-

Use the hosted browser on a remote wing

-

Start and resume a persistent agent terminal from app.wingthing.ai when your account and wing both permit hosted relay.

-
    -
  • You need: an account with hosted-relay access; an execution machine running wt login and wt start; the provider already authenticated there; and effective wing policy hosted_relay: allow.
  • -
  • You get: a hosted browser terminal whose application-encrypted payloads are relayed to the selected wing. The hosted service still sees routing metadata and supplies the browser code.
  • -
-
hosted browser -> encrypted relay -> selected wing -> agent
-
read setup guide
-
- -
-
05
remote computerbrowserself-hosted

Control a remote agent from a localhost browser

-

Run the web app on the computer in front of you, carry it over SSH, and launch Claude or Codex on the remote computer.

+

When a person needs a browser, run the portal on the computer in front of them and carry the wing connection to the remote computer over SSH.

    -
  • You need: Wingthing on both computers, SSH access to the remote computer, and the agent already logged in there.
  • -
  • You get: a private https://localhost page for starting and resuming durable sessions on the remote computer, without using the hosted relay. WT creates a localhost-only certificate on demand, installs only its public CA for your user, and keeps the private key on this computer.
  • +
  • You need: Wingthing on both computers, SSH access to the remote computer, and the provider agent already authenticated there.
  • +
  • You get: a private https://localhost browser for remote sessions without a Wingthing account or hosted relay.
localhost browser -> local portal -> SSH tunnel -> remote wing -> agent
read setup guide
-
-
06
-
teamprivate serverbrowser
+
+
05
+
teamprivate serverself-hosted browser

Give a team a private browser-based agent host

-

Run Wingthing on one shared server so several people can launch and resume their own agent sessions through a private web UI.

+

Run one self-hosted roost so enrolled people can launch and resume their own agent sessions on a shared server.

    -
  • You need: a server, HTTPS hostname, OAuth login, an exact email enrollment list, and project directories on that server.
  • -
  • You get: a self-hosted portal, per-user sessions, and separate per-user agent homes on the shared host.
  • +
  • You need: a server, HTTPS hostname, OAuth login, an exact email enrollment list, and existing project directories on that server.
  • +
  • You get: a private portal, per-user sessions, and separate per-user agent homes without a wingthing.ai relay entitlement.
team browsers -> private roost -> agents on the shared server
read setup guide
-
-
07
-
private serverAI-driven
+
+
06
+
private serverAI-drivenself-hosted MCP

Let an AI control agents on your private roost

-

Connect a local Claude or Codex client to your self-hosted Wingthing server and let it manage work there.

+

Connect Claude or Codex to the authenticated HTTP MCP endpoint of a self-hosted roost.

    -
  • You need: an enrolled account on a roost with HTTPS and OAuth, plus its HTTP MCP endpoint added to the parent AI client.
  • -
  • You get: authenticated control of that user's agent runs and persistent sessions on the roost server.
  • +
  • You need: an enrolled account on a roost with HTTPS and OAuth, plus that endpoint added to the parent AI client.
  • +
  • You get: authenticated agent control of that user's runs and sessions on the roost's embedded wing.
-
parent AI -> private roost MCP -> agents on the roost server
+
parent AI -> private roost HTTP MCP -> embedded wing -> agents
read setup guide
+ +
+
07
+
remote computerbrowseroptional hosted relay
+

Use the entitled hosted browser on a remote wing

+

Choose this optional route only when running a self-hosted browser portal is not the desired setup and the account already has hosted-relay access.

+
    +
  • You need: an account with hosted-relay access; a wing running wt login and wt start; and effective wing policy hosted_relay: allow.
  • +
  • You get: a hosted browser terminal whose application-encrypted payloads are relayed to the selected wing. The service still sees routing metadata and supplies the browser code.
  • +
+
hosted browser -> encrypted relay -> selected wing -> agent
+
read setup guide
+