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..44bce4c 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ [![ci](https://github.com/ehrlich-b/wingthing/actions/workflows/ci.yml/badge.svg)](https://github.com/ehrlich-b/wingthing/actions/workflows/ci.yml) -Wingthing is a typed agent manager for durable agent runs and terminals. Give -Codex, Claude, or another parent agent one control plane for starting and -supervising work across all your machines. A person can inspect or take over the -same terminal sessions from a terminal or browser. +Wingthing is a local-first, agent-first manager for durable agent runs and +terminals. Start by giving Codex, Claude, or another parent agent typed MCP +control of agents on the same machine, using the code and provider login already +there. Add remote machines or a browser only when the work requires them. The agents run where the code and hardware already live. Wingthing keeps their terminals alive, records semantic runs as durable tasks, applies sandbox policy, @@ -13,7 +13,15 @@ and gives each caller an owner, actor, grant set, bound, and audit trail. https://github.com/user-attachments/assets/f1f04caf-4b07-4298-ba76-db5b226c38f2 -## Give an agent access to your agents +Choose the smallest route that fits: + +1. An agent manages local agents through stdio MCP. +2. A person starts a local sandboxed agent terminal. +3. An agent reaches another machine through direct remote MCP. +4. A person who needs a browser runs a self-hosted roost. +5. An entitled account may optionally use the hosted `wingthing.ai` browser relay. + +## 1. Local agent control: stdio MCP Install Wingthing, then register its local MCP server with the agent that will coordinate the work: @@ -43,65 +51,39 @@ Restart the client after registration. Ask it to call - exchange owner-scoped messages with another authenticated agent client; and - inspect the effective sandbox before launching anything. -The local server uses the current OS user's authority. `--client` supplies -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: - -```bash -wt login +The child agents use existing project directories and provider credentials on +this computer. Wingthing does not clone the code or copy a provider login. The +local server uses the current OS user's authority. `--client` supplies ownership +and audit attribution inside Wingthing, not a new operating-system security +boundary. Optional grants and spawn bounds live in `~/.wingthing/clients.yaml`. -# Codex -codex mcp add wingthing -- wt mcp connect --client codex - -# Claude Code -claude mcp add --scope user wingthing -- wt mcp connect --client claude -``` - -The agent calls `wing_list`, then supplies `wing_id` to every wing-owned tool. -`wingthing.ai` authenticates the peers, returns the access-filtered directory, -and carries the WebRTC offer/answer. MCP payloads travel directly to the selected -wing. The first native release expects a shared LAN or tailnet unless ICE servers -are configured. It never silently falls back to the hosted relay. - -Direct control has explicit wing-side grants and per-principal spawn/session bounds. -The compatible defaults require no config; operators can narrow grants, change bounds, -or disable native control under `direct_mcp` in `wing.yaml`. Organization members -remain owner- and path-scoped, while owners/admins retain all configured paths. See -the [security model](docs/security.md#native-direct-mcp-authority) for the policy shape. +## 2. Local human terminal: sandboxed agent -Hosted terminal relay is a separate transport decision. Existing configs remain -compatible, while a wing can refuse relayed payloads regardless of account -entitlement: +Use the same runtime directly when a person wants the terminal: ```bash -wt wing config set hosted_relay=deny -wt stop && wt start +cd /path/to/existing/project +wt egg claude --name research + +# Ctrl+B Q detaches without stopping the process +wt attach research ``` -Direct discovery/signaling still works; terminal and general control payloads must go -directly to the wing. The effective setting appears in wing capability metadata and -denials are audited without commands, paths, or payload content. +The provider CLI must already be installed and authenticated for the current OS +user. The project must already exist. No Wingthing account or wing daemon is +required. -## Use the same runtime yourself +The CLI also exposes persistent shells, arbitrary commands, and raw terminal +operations: ```bash -wt terminal --name work # persistent sandboxed shell +wt terminal --name work # persistent sandboxed shell wt terminal --name api -- npm run dev # persistent arbitrary command -wt egg claude --name research # persistent sandboxed agent # Ctrl+B Q detaches without stopping the process wt attach # list live sessions -wt attach research # reattach by name or ID -wt attach research --remote box # reattach over ordinary SSH -``` - -The CLI exposes the raw terminal layer for scripts: +wt attach work # reattach by name or ID -```bash wt session ps --json wt session read api --json wt session send api r --enter --json @@ -110,10 +92,51 @@ wt session rename api frontend --json wt session kill frontend --json ``` -Terminal snapshots are ANSI state. Wingthing doesn't infer that an agent is +Terminal snapshots are ANSI state. Wingthing does not infer that an agent is done because a string appeared on screen. Use `agent_run`, `agent_wait`, and `agent_result` when the caller needs semantic task state. +## 3. Different machines: direct remote MCP + +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 + +# Codex +codex mcp add wingthing -- wt mcp connect --client codex + +# Claude Code +claude mcp add --scope user wingthing -- wt mcp connect --client claude +``` + +The agent calls `wing_list`, then supplies `wing_id` to every wing-owned tool. +`wingthing.ai` authenticates the peers, returns the access-filtered directory, +and carries the WebRTC offer/answer. MCP payloads travel directly to the selected +wing. The first native release expects a shared LAN or tailnet unless ICE servers +are configured. It never silently falls back to the hosted relay. + +Direct control has explicit wing-side grants and per-principal spawn/session bounds. +The compatible defaults require no config; operators can narrow grants, change bounds, +or disable native control under `direct_mcp` in `wing.yaml`. Organization members +remain owner- and path-scoped, while owners/admins retain all configured paths. See +the [security model](docs/security.md#native-direct-mcp-authority) for the policy shape. + +The direct connector is the route to choose when execution moves to another +machine. It does not provide a browser display. Use `agent_run` for a semantic +result, or `agent_start` when a person will later attach through the execution +machine's CLI or SSH. + ## The runtime model The product is an agent control plane over runtimes: @@ -147,11 +170,10 @@ person: CLI or browser ----------------/ (inspect or take over) detachment, but not an unplanned host restart. A run's record and result persist; an active headless run still depends on the supervising Wingthing process. -`wingthing.ai` is the hosted identity, directory, key-exchange, and connection -coordination service—roughly the control plane in a tailnet. It does not run the -agents. New free accounts use direct remote MCP; Pro adds the encrypted hosted -terminal and control relay. Local MCP, SSH, and self-hosted roosts do not require -it. +`wingthing.ai` can supply identity, an access-filtered directory, key exchange, +and connection coordination for direct remote MCP. It does not run the agents or +carry direct MCP payloads. Local MCP, local terminals, SSH, and self-hosted roosts +do not require it. ### Shared control contract @@ -203,23 +225,21 @@ outer VM as the security boundary. Wingthing still provides persistence and the control plane, reports `outer-boundary` to MCP clients, and records that mode in the audit log. -## Browser and hosted service +## 4. Browser visibility: self-hosted roost -Browser access is optional: +When a person needs a browser, run the browser portal locally first: ```bash -wt login -wt start -open https://app.wingthing.ai +wt roost start --https +open https://localhost:8443 ``` -The wing connects outbound, so it needs no public inbound port. Free accounts -use the hosted site to register the wing and set up direct remote MCP; the -hosted browser terminal is not part of that free path. Accounts with hosted -relay access may use the application-encrypted browser terminal and control -relay. The coordinator sees account, routing, and connection metadata, and the -hosted browser still trusts JavaScript served by the service. Read -[security.md](docs/security.md) before making a stronger claim. +This self-hosted route needs no Wingthing account or hosted-relay entitlement. +For a remote execution machine, keep the portal on localhost and carry its wing +connection over SSH; follow the +[self-hosted remote-browser recipe](patterns/personal-remote-wing/INSTRUCTIONS.md). +For several people, configure OAuth, HTTPS, and an exact enrollment list as +described below. A portal may have several wings. The native CLI can query the same authorized roster and probe each wing through the encrypted tunnel: @@ -233,11 +253,11 @@ WINGTHING_DIR=~/.wingthing-lab wt login --roost https://lab.example.com WINGTHING_DIR=~/.wingthing-lab wt wings --roost https://lab.example.com --json ``` -The browser and native client select a wing by its stable `wing_id`. The hosted -directory aggregates every wing registered to the account or its organizations. -Peer-roost federation remains follow-up work. +The browser and native client select a wing by its stable `wing_id`. A +self-hosted gateway can list its embedded wing and other wings registered with +that same gateway. Independent roosts do not federate their directories. -## Shared roost +### Self-hosted roost details Run a self-hosted portal, gateway, and embedded wing in one process: @@ -305,6 +325,35 @@ This command shape follows the current uses `claude mcp add --scope user --transport http lab https://lab.example.com/mcp`. +## 5. Hosted browser relay: entitled and optional + +Use the hosted browser only when an account already has hosted-relay access and +the selected wing's effective `hosted_relay` policy is `allow`: + +```bash +wt login +wt start +open https://app.wingthing.ai +``` + +The wing connects outbound, so it needs no public inbound port. The hosted +browser terminal is not part of the free direct-MCP path. In hosted-browser mode, +the service relays application-encrypted terminal and control payloads, sees +account and routing metadata, and supplies the browser JavaScript. Read +[security.md](docs/security.md) for the exact boundary. + +Account entitlement and wing policy are separate. A wing can refuse hosted +payload relay: + +```bash +wt wing config set hosted_relay=deny +wt stop && wt start +``` + +Direct discovery and signaling still work. The effective setting appears in wing +capability metadata, and denials are audited without commands, paths, or payload +content. + ## Supported agents `wt doctor` reports what is installed. Interactive sessions support: diff --git a/SKILL.md b/SKILL.md index f910c45..ed6e750 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,10 +1,37 @@ --- name: wingthing -description: Use Wingthing's typed control plane to start and supervise coding agents in durable runs or terminals on local and remote wings. +description: Use Wingthing local-first to start and supervise coding agents through typed MCP, then add remote machines or human-visible terminals only when needed. --- # Use Wingthing +## Choose the smallest route + +Use this order. Do not start with a hosted service when a local route satisfies +the task. + +1. **Local agent control:** register `wt mcp stdio --client NAME` with the parent + agent. It manages agents on this computer using existing workspaces and the + current OS user's existing provider logins. No Wingthing account or daemon is + required. +2. **Local human terminal:** run `wt egg AGENT` from an existing project and + resume it with `wt attach`. This is the local sandboxed agent-terminal route. +3. **Direct remote MCP:** use `wt mcp connect` when parent and child execution are + on different machines. The connector machine must run `wt login`; every execution machine must run `wt login` and `wt start`, already contain the + workspace, and have the provider CLI authenticated. Calls select an explicit + `wing_id`, travel over direct WebRTC, and never fall back to hosted relay. +4. **Self-hosted browser:** when a person needs browser visibility, start with + `wt roost start --https`. A self-hosted roost does not require a wingthing.ai hosted-relay entitlement. For a remote wing, use the documented localhost + roost plus SSH tunnel; for a shared roost, require OAuth, HTTPS, and explicit + enrollment. +5. **Optional hosted browser:** use `app.wingthing.ai` only when the account has + hosted-relay access and the selected wing's effective `hosted_relay` policy is + `allow`. This path requires both account-level hosted-relay access and wing + permission; `hosted_relay: deny` wins. + +An authenticated self-hosted roost's HTTP MCP endpoint controls that roost's +embedded wing. It does not join independent roosts into one inventory. + ## Place the work first Before launching, identify: 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..0806ef2 100644 --- a/internal/docscheck/docs_test.go +++ b/internal/docscheck/docs_test.go @@ -69,7 +69,14 @@ 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/base.html", + "internal/relay/templates/home.html", "internal/relay/templates/docs.html", "internal/relay/templates/patterns.html", "internal/relay/templates/privacy.html", @@ -104,13 +111,18 @@ 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/base.html": {`href="/install" class="nav-cta">install locally`, `href="/login">login`, ">open app"}, + "internal/relay/templates/home.html": {"local-first control for coding agents", "wt mcp stdio", "wt egg", "wt mcp connect", "wt serve --local --https", "optional hosted browser"}, + "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", "Remote execution through direct MCP or the hosted browser needs a running wing", "do not require a separate wing daemon", "connector token", "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", "authorization for the connector account", "parent/connector needs"}, + "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)) @@ -156,7 +168,7 @@ func TestPublicDocumentationContractsStayAligned(t *testing.T) { t.Fatal(err) } installText := string(install) - for _, phrase := range []string{"wt roost start --https", "wt mcp connect", "hosted browser terminal and control relay require relay access", "certutil"} { + for _, phrase := range []string{"wt roost start --https", "wt mcp connect", "connector token", "authorized for the connector account", "hosted browser terminal and control relay require relay access", "certutil"} { if !strings.Contains(installText, phrase) { t.Errorf("public install flow must contain %q", phrase) } @@ -166,6 +178,289 @@ func TestPublicDocumentationContractsStayAligned(t *testing.T) { } } +func TestPublicEntryPointsKeepLocalAgentFirstHierarchy(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) + } + + ordered := map[string][]string{ + "README.md": { + "## 1. Local agent control: stdio MCP", + "## 2. Local human terminal: sandboxed agent", + "## 3. Different machines: direct remote MCP", + "## 4. Browser visibility: self-hosted roost", + "## 5. Hosted browser relay: entitled and optional", + }, + "SKILL.md": { + "1. **Local agent control:**", + "2. **Local human terminal:**", + "3. **Direct remote MCP:**", + "4. **Self-hosted browser:**", + "5. **Optional hosted browser:**", + }, + "patterns/SKILL.md": { + "1. **Local agent control with stdio MCP:**", + "2. **Local sandboxed agent terminal for a person:**", + "3. **Direct remote MCP when machines differ:**", + "4. **Self-hosted roost when a person needs a browser:**", + "5. **Optional entitled hosted relay:**", + }, + } + for _, rel := range []string{ + "internal/relay/templates/home.html", + "internal/relay/templates/docs.html", + "internal/relay/templates/install.html", + "internal/relay/templates/patterns.html", + } { + ordered[rel] = []string{ + `data-route="local-agent"`, + `data-route="local-human"`, + `data-route="direct-remote"`, + `data-route="self-hosted-browser"`, + `data-route="hosted-relay"`, + } + } + + for rel, markers := range ordered { + text := read(rel) + previous := -1 + for _, marker := range markers { + index := strings.Index(text, marker) + if index < 0 { + t.Errorf("%s is missing hierarchy marker %q", rel, marker) + continue + } + if index <= previous { + t.Errorf("%s places %q out of local-agent-first order", rel, marker) + } + previous = index + } + } + + contracts := map[string][]string{ + "README.md": { + "using the code and provider login already\nthere", + "No Wingthing account or wing daemon is\nrequired", + "It never silently falls back to the hosted relay", + "run the browser portal locally first", + "already has hosted-relay access", + }, + "internal/relay/templates/home.html": { + "wt mcp stdio · no account or daemon", + "wt egg · sandboxed and attachable", + "direct remote MCP to an explicit wing", + "wt serve --local --https · self-host first", + "requires hosted-relay entitlement and an allowing wing", + }, + "internal/relay/templates/docs.html": { + "existing project directories and the current OS user's existing provider logins", + "connector token", + "Run wt start on the parent only when that machine also executes agents", + "The connector never silently changes to hosted relay", + "Remote execution through direct MCP or the hosted browser needs a running wing", + "do not require a separate wing daemon", + "This self-hosted route needs no Wingthing account or hosted-relay entitlement", + "requires an account with hosted-relay access", + }, + "internal/relay/templates/install.html": { + "Child agents use the existing code and provider login on this computer", + "authorized for the connector account", + "parent/connector machine", + "connector token", + "Run wt start on the parent only when that machine also executes agents", + "never silently fall back to hosted relay", + "No Wingthing account or hosted-relay entitlement is required", + "hosted browser terminal and control relay require relay access", + }, + "internal/relay/templates/patterns.html": { + "parent AI -> local stdio MCP -> local child agents", + "person -> local sandboxed agent terminal -> reattach later", + "parent AI -> direct remote MCP -> selected wing -> agent", + "authorization for the connector account (personal or organization)", + "The parent/connector needs:", + "localhost browser -> local portal -> SSH tunnel -> remote wing -> agent", + "hosted browser -> encrypted relay -> selected wing -> agent", + }, + } + for rel, phrases := range contracts { + text := read(rel) + for _, phrase := range phrases { + if !strings.Contains(text, phrase) { + t.Errorf("%s must preserve hierarchy contract %q", rel, phrase) + } + } + } +} + +func TestUsageRecipesPointToTheCorrectRoute(t *testing.T) { + root := repositoryRoot(t) + contracts := map[string][]string{ + "patterns/local-subagents/INSTRUCTIONS.md": { + "Start here", + "Local stdio MCP uses the code and provider logins already on\nthis machine", + "no Wingthing account, daemon, roost, or hosted relay", + }, + "patterns/local-sandbox/INSTRUCTIONS.md": { + "when a person wants a local sandboxed", + "local stdio MCP setup", + }, + "patterns/remote-orchestration/INSTRUCTIONS.md": { + "only when the parent agent and execution wing are on different", + "prefer\nlocal `wt mcp stdio`", + "does not proxy direct MCP payloads or silently switch", + }, + "patterns/personal-remote-wing/INSTRUCTIONS.md": { + "when a person needs browser visibility", + "the first browser route to consider", + "does not use wingthing.ai or require a hosted-relay entitlement", + }, + "patterns/hosted-browser-wing/INSTRUCTIONS.md": { + "Use this optional route only", + "Prefer\na self-hosted roost", + "already has hosted-relay access", + }, + } + for rel, phrases := range contracts { + data, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + t.Fatal(err) + } + text := string(data) + for _, phrase := range phrases { + if !strings.Contains(text, phrase) { + t.Errorf("%s must preserve route contract %q", rel, phrase) + } + } + } +} + +func TestRemoteOrchestrationParentUsesAuthorizedAccount(t *testing.T) { + root := repositoryRoot(t) + data, err := os.ReadFile(filepath.Join(root, "patterns/remote-orchestration/INSTRUCTIONS.md")) + if err != nil { + t.Fatal(err) + } + text := string(data) + start := strings.Index(text, "## 2. Connect the parent AI") + end := strings.Index(text, "## 3. Use it") + if start < 0 || end <= start { + t.Fatal("remote orchestration recipe must preserve the parent connector section") + } + parentSection := text[start:end] + for _, phrase := range []string{ + "account authorized for the execution wings", + "personally or\nthrough organization membership", + } { + if !strings.Contains(parentSection, phrase) { + t.Errorf("parent connector instructions must contain %q", phrase) + } + } + if strings.Contains(parentSection, "same account") { + t.Error("parent connector instructions must not require the same account") + } +} + +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 +491,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..17c547f 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) { @@ -346,24 +349,50 @@ func TestPatternsPageExplainsOnlySupportedSetups(t *testing.T) { } page := body.String() for _, want := range []string{ - "Run a durable, sandboxed agent on this computer", "Let your current AI launch local sub-agents", + "Run a durable, sandboxed agent on this computer", "Let one AI manage agents on several computers", "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", + "Use the entitled hosted browser on a remote wing", "an exact email enrollment list", "an enrolled account on a roost", + "Each execution wing needs:", + "authorization for the connector account (personal or organization)", + "The parent/connector needs:", + "It runs wt start only when it also executes agents.", "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, `
open app`) { t.Fatalf("documentation nav did not use app URL %q: %q", test.wantURL, recorder.Body.String()) } + for _, forbidden := range []string{">hosted app", ">hosted login"} { + if strings.Contains(rendered, forbidden) { + t.Errorf("documentation nav uses deployment-specific label %q", forbidden) + } + } }) } } @@ -449,17 +485,191 @@ func TestSignedInHomeUsesConfiguredAppURLForLinkAndShortcut(t *testing.T) { if !strings.Contains(rendered, `href="https://app.example.test/" class="prompt-line" id="prompt-app"`) { t.Fatalf("signed-in home omitted configured app link: %q", rendered) } - if strings.Contains(rendered, "h==='wingthing.ai'") || !strings.Contains(rendered, "app?app.href:'/login'") { + if !strings.Contains(rendered, `href="https://app.example.test/" class="nav-cta">open app`) { + t.Fatalf("signed-in home omitted deployment-neutral app nav: %q", rendered) + } + if strings.Contains(rendered, ">hosted app") || strings.Contains(rendered, ">hosted login") { + t.Fatalf("signed-in home uses deployment-specific nav label: %q", rendered) + } + if strings.Contains(rendered, "h==='wingthing.ai'") || !strings.Contains(rendered, "app?app.href:'/install'") { t.Fatalf("home keyboard shortcut is not driven by the configured app link: %q", rendered) } } +func TestPublicHomeLeadsWithLocalAgentFirstRoutes(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) + body := new(strings.Builder) + if _, err := io.Copy(body, resp.Body); err != nil { + t.Fatalf("read /: %v", err) + } + page := body.String() + previous := -1 + for _, route := range []string{ + `href="/patterns/local-subagents/INSTRUCTIONS.md" data-route="local-agent"`, + `href="/patterns/local-sandbox/INSTRUCTIONS.md" data-route="local-human"`, + `href="/patterns/remote-orchestration/INSTRUCTIONS.md" data-route="direct-remote"`, + `href="/patterns/personal-remote-wing/INSTRUCTIONS.md" data-route="self-hosted-browser"`, + `href="/patterns/hosted-browser-wing/INSTRUCTIONS.md" data-route="hosted-relay"`, + } { + index := strings.Index(page, route) + if index < 0 { + t.Fatalf("home is missing route %q", route) + } + if index <= previous { + t.Fatalf("home route %q is out of local-agent-first order", route) + } + previous = index + } + for _, contract := range []string{ + "wt mcp stdio", + "no account or daemon", + "wt egg", + "wt mcp connect", + "direct remote MCP to an explicit wing", + "wt serve --local --https", + "self-host first", + "requires hosted-relay entitlement and an allowing wing", + `href="/install" class="nav-cta">install locally`, + `href="/login">login`, + `href="/install" class="prompt-line" id="prompt-app"`, + } { + if !strings.Contains(page, contract) { + t.Errorf("home does not contain hierarchy contract %q", contract) + } + } + for _, forbidden := range []string{">hosted app", ">hosted login"} { + if strings.Contains(page, forbidden) { + t.Errorf("home nav uses deployment-specific label %q", forbidden) + } + } +} + +func TestPublicDocsAndInstallRenderLocalFirstHierarchy(t *testing.T) { + _, ts := testServer(t) + for _, test := range []struct { + path string + contracts []string + }{ + { + path: "/install", + contracts: []string{ + "On each execution wing,", + "authorized for the connector account personally or through an organization", + "parent/connector machine", + "connector token", + "Run wt start on the parent only when that machine also executes agents", + }, + }, + { + path: "/docs", + contracts: []string{ + "connector token", + "Run wt start on the parent only when that machine also executes agents", + "Remote execution through direct MCP or the hosted browser needs a running wing.", + "Local stdio MCP, local terminal commands, and an embedded self-hosted roost do not require a separate wing daemon.", + }, + }, + } { + t.Run(test.path, func(t *testing.T) { + resp, err := http.Get(ts.URL + test.path) + if err != nil { + t.Fatalf("GET %s: %v", test.path, err) + } + defer closeTestBody(t, resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s status = %d, want %d", test.path, resp.StatusCode, http.StatusOK) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read %s: %v", test.path, err) + } + page := string(body) + previous := -1 + for _, route := range []string{ + `data-route="local-agent"`, + `data-route="local-human"`, + `data-route="direct-remote"`, + `data-route="self-hosted-browser"`, + `data-route="hosted-relay"`, + } { + index := strings.Index(page, route) + if index < 0 { + t.Fatalf("%s is missing rendered route %q", test.path, route) + } + if index <= previous { + t.Fatalf("%s renders route %q out of local-agent-first order", test.path, route) + } + previous = index + } + for _, contract := range append(test.contracts, + `href="/install" class="nav-cta">install locally`, + `href="/login">login`, + ) { + if !strings.Contains(page, contract) { + t.Errorf("%s does not contain rendered contract %q", test.path, contract) + } + } + for _, forbidden := range []string{">hosted app", ">hosted login"} { + if strings.Contains(page, forbidden) { + t.Errorf("%s uses deployment-specific nav label %q", test.path, forbidden) + } + } + }) + } +} + +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/base.html b/internal/relay/templates/base.html index 69dfab5..171ed16 100644 --- a/internal/relay/templates/base.html +++ b/internal/relay/templates/base.html @@ -27,13 +27,18 @@ .nav-cta:hover{background:var(--action-hover);color:#fff !important} .user-name{color:var(--text);font-size:13px} a{color:var(--action)}a:hover{color:var(--action-hover)} -@media(max-width:600px){.container{padding:12px 12px}} +@media(max-width:600px){ + .container{padding:12px 12px} + .site-nav{align-items:flex-start;flex-wrap:wrap;gap:10px} + .site-nav .nav-links{width:100%;flex-wrap:wrap;gap:8px 12px} + .site-nav .user-name{max-width:100%;overflow-wrap:anywhere} +} {{block "head" .}}{{end}}
-

documentation

-

Wingthing is a typed agent manager for durable agent runs and terminals across local and remote wings, with human takeover when useful.

+

Wingthing is a local-first, agent-first manager for durable agent runs and terminals. Start with local stdio MCP, then add another machine or a human display only when the task requires one.

quickstart

-

give Codex or Claude access to local agents

+

1. give Codex or Claude local stdio MCP control

$ curl -fsSL https://wingthing.ai/install.sh | sh

@@ -69,43 +69,52 @@

give Codex or Cla # Claude Code
$ claude mcp add --scope user wingthing -- wt mcp stdio --client claude

-

Restart the client, then ask it to call wingthing_capabilities. It can start persistent terminals or headless runs, wait for results, steer work, and stop it.

+

Restart the client, then ask it to call wingthing_capabilities. Child agents use existing project directories and the current OS user's existing provider logins on this computer. No Wingthing account or daemon is required.

-

run your own private browser portal

+

2. start a local sandboxed agent terminal yourself

-$ wt roost start --https # portal + gateway + local wing
-$ open https://localhost:8443 +$ cd /path/to/existing/project
+$ wt egg claude --name work
+$ wt attach work
-

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.

+

The provider CLI must already be installed and authenticated locally. wt egg applies the project sandbox and keeps the terminal attachable without an account or daemon.

-

give an agent access to all your remote wings

+

3. use direct remote MCP when machines differ

+

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

+
+$ wt login
+$ wt start
+
+

On the parent/connector machine, log in with an account authorized to access those wings so wt mcp connect can load its connector token, then register the direct connector:

$ wt login
$ codex mcp add wingthing -- wt mcp connect --client codex
# or: claude mcp add --scope user wingthing -- wt mcp connect --client claude
-

The agent calls wing_list, then passes an explicit wing_id to every operation. The hosted service supplies the access-filtered directory and WebRTC signaling; MCP payloads travel directly between the connector and selected wing. The first release expects the peers to share a LAN or tailnet unless you configure STUN or TURN under ice_servers in ~/.wingthing/wing.yaml; the several-computer pattern includes the exact YAML shape.

+

Run wt start on the parent only when that machine also executes agents.

+

The agent calls wing_list, then passes an explicit wing_id to every operation. The hosted service supplies identity, the access-filtered directory, and WebRTC signaling; MCP payloads travel directly between the connector and selected wing. The connector never silently changes to hosted relay. The first release expects the peers to share a LAN or tailnet unless you configure STUN or TURN under ice_servers in ~/.wingthing/wing.yaml; the several-computer pattern includes the exact YAML shape.

-

use the durable terminal yourself

+

4. self-host first when a person needs a browser

-$ wt terminal --name work
-$ wt attach work +$ wt roost start --https # portal + gateway + local wing
+$ open https://localhost:8443
+

This self-hosted route needs no Wingthing account or hosted-relay entitlement. 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. Use the localhost-plus-SSH pattern for a remote wing, or configure OAuth, HTTPS, and explicit enrollment for several people.

-

optional hosted portal

+

5. use the entitled hosted browser only if needed

$ wt login
$ wt start
$ open https://app.wingthing.ai
-

Free accounts see wing readiness and direct-agent setup in the portal. Accounts with hosted relay access can also start and resume browser terminals. The public direct-free service does not let an account grant itself relay access; Pro and team relay entitlements are provisioned separately.

+

The optional hosted browser terminal requires an account with hosted-relay access and a selected wing whose effective policy is hosted_relay: allow. The public direct-free service does not let an account grant itself relay access. In this mode, the service relays application-encrypted terminal and control payloads, sees routing metadata, and supplies the browser code.

-

Your machine is a wing, the runtime that owns the processes and workspaces. An LLM manages it through MCP; a person can use the same runtime through the CLI or browser. The hosted service coordinates direct connections by default. Pro includes its encrypted browser-terminal and control relay; the native connector never silently changes from direct transport to relay transport.

+

Your machine is a wing, the runtime that owns its processes, workspaces, agent homes, sessions, and runs. Local and remote adapters address that runtime; neither a connector nor a browser moves execution off the selected wing.

overview

-

wingthing runs Claude Code, Codex, Ollama, and other AI agents behind one local control plane. The interface can be a CLI, browser, or MCP client. Execution stays on the wing where the selected workspace and hardware live.

+

Wingthing runs Claude Code, Codex, Ollama, and other agents behind one wing-owned control plane. Local stdio MCP is the primary agent interface. The local CLI is the primary human terminal. Direct remote MCP connects different machines. A self-hosted roost adds browser visibility; the entitled hosted relay is an optional alternative. Execution stays on the wing where the selected workspace, provider login, and hardware live.

@@ -154,13 +163,13 @@

installation

wing setup

-

Three commands to go from install to remote access:

+

Remote execution through direct MCP or the hosted browser needs a running wing. Local stdio MCP, local terminal commands, and an embedded self-hosted roost do not require a separate wing daemon. Prepare each standalone execution wing from a terminal:

-$ wt login # authenticate with the hosted portal
-$ wt start # start the wing daemon
-$ open https://app.wingthing.ai # wing readiness and direct setup +$ wt login # authenticate for identity, directory, and signaling
+$ wt start # start the execution wing
+$ wt wing status # verify it is ready
-

wt start runs the wing as a background daemon. Hosted accounts with relay access also get browser terminal controls; direct-only free accounts get the native connector instructions instead. Use wt wing start --foreground for debugging.

+

wt start runs the wing as a background daemon. Register wt mcp connect on the parent-agent machine for direct control. Opening the hosted app is unnecessary for that path. Use wt wing start --foreground for debugging.

Other wing commands:

$ wt wing status # check daemon status + active sessions
diff --git a/internal/relay/templates/home.html b/internal/relay/templates/home.html index 8013977..bfb4b77 100644 --- a/internal/relay/templates/home.html +++ b/internal/relay/templates/home.html @@ -3,7 +3,14 @@
termwhat it is
portalThe inventory and controls presented to a person or LLM. The browser and MCP are portal adapters, though they do not yet expose every object with full parity.