diff --git a/.dockerignore b/.dockerignore index 01d1023c..f24cd6a0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,12 @@ .git +.agents +.codex +dist +test wt +wt-linux web/node_modules +web/dist *.db *.db-wal *.db-shm diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2694f455..ab173182 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.6' cache-dependency-path: go.sum - uses: actions/setup-node@v4 with: @@ -28,16 +28,89 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.6' cache-dependency-path: go.sum - run: make test-integ + compatibility: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: '1.26.6' + cache-dependency-path: go.sum + - run: make test-compat + linux-sandbox: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.6' cache-dependency-path: go.sum - run: make test-linux-ubuntu + + linux-sandbox-debian: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26.6' + cache-dependency-path: go.sum + - run: make test-linux + + browser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26.6' + cache-dependency-path: go.sum + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: web/package-lock.json + - run: make test-web + + static-race-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26.6' + cache-dependency-path: go.sum + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: web/package-lock.json + - run: make web + - run: go vet ./... + - run: make test-vuln + - run: go test -race ./... + - run: git diff --check + - run: make release-contract + + macos-sandbox: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26.6' + cache-dependency-path: go.sum + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: web/package-lock.json + - run: make web + - run: go test -count=1 -tags integration -v -timeout 120s ./internal/sandbox ./cmd/wt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a209c8e..1aa4f447 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,22 +8,74 @@ permissions: contents: write jobs: - release: + deterministic: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.6' cache-dependency-path: go.sum - uses: actions/setup-node@v4 with: node-version: '22' cache: npm cache-dependency-path: web/package-lock.json - - name: Build all platforms - run: make release VERSION=${{ github.ref_name }} + - run: make check + - run: go vet ./... + - run: make test-vuln + - run: go test -race ./... + - run: make test-integ + - run: make test-compat + - run: make test-linux + - run: make test-linux-ubuntu + - run: make test-web + - run: git diff --check + - name: Build release artifacts after deterministic gates + env: + VERSION: ${{ github.ref_name }} + run: make release VERSION="$VERSION" + - name: Preserve tested release artifacts + uses: actions/upload-artifact@v4 + with: + name: wingthing-release-${{ github.sha }} + path: dist/ + if-no-files-found: error + retention-days: 1 + + macos-sandbox: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26.6' + cache-dependency-path: go.sum + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: web/package-lock.json + - run: make web + - run: go test -count=1 -tags integration -v -timeout 120s ./internal/sandbox ./cmd/wt + + release: + needs: [deterministic, macos-sandbox] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Download artifacts from the deterministic gate + uses: actions/download-artifact@v4 + with: + name: wingthing-release-${{ github.sha }} + path: dist/ - name: Publish GitHub release env: GH_TOKEN: ${{ github.token }} - run: gh release create "${{ github.ref_name }}" dist/wt-* --generate-notes + run: >- + gh release create "${{ github.ref_name }}" + dist/wt-linux-amd64 dist/wt-linux-arm64 + dist/wt-darwin-amd64 dist/wt-darwin-arm64 + dist/SHA256SUMS --generate-notes diff --git a/CLAUDE.md b/CLAUDE.md index f49b2a9b..faa05c0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,13 +2,18 @@ ## What This Is -`wt` runs AI agents sandboxed on your machine, accessible from anywhere. The primary use case is `wt egg ` (sandboxed agent sessions) and `wt wing` (remote access via relay). Skills are a secondary feature. +`wt` puts AI agent runtimes behind one local control plane. A person uses that +runtime through the CLI or browser. An LLM uses it through MCP. The primary use +case is all of a person's agents in one inventory, for that person and their +agents to operate together. Skills are a secondary feature. - `wt egg claude` -- run Claude Code in a per-session sandbox with PTY persistence +- `wt mcp stdio --client codex` -- let a local LLM start and supervise agents - `wt start` -- connect your machine to the relay, access from app.wingthing.ai -- `wt serve` -- relay server (web UI, WebSocket relay, skill registry), HTTP + SQLite +- `wt roost start` -- self-hosted portal/gateway plus an embedded wing +- `wt serve` -- gateway only (web UI, WebSocket relay, skill registry), HTTP + SQLite -## Current Push: AI-Usable API +## Current Push: One Portal for People and LLMs **An AI must be able to orchestrate wingthing as easily as a human can.** This is the primary focus of the current work. Anything a human can do from the terminal @@ -17,6 +22,26 @@ same authority model and the same audit trail. The rule: **if you ship a capability only a human can drive, it is unfinished.** +"One portal" means one resource inventory, authority model, and lifecycle +contract. It does not mean every process or file moves to one machine. + +- A **portal** is the client-facing inventory and control surface. +- A **wing** is the execution runtime and source of truth for local state. +- A **session** is a persistent interactive terminal. +- A **run** is a supervised headless task with semantic state. +- An **egg** is the per-session process, PTY, and sandbox boundary. +- A **roost** is the self-hosted bundle from `wt roost start`: portal/gateway + plus an embedded wing. + +Browser and MCP adapters must address the same qualified resources. Cross-wing +resources need stable portal, wing, kind, and object IDs. Do not add mutable +"current wing" state to a server-side MCP session. + +Every remote workflow must place execution, workspace, display, credentials, +and durable memory explicitly. Current `cwd` values refer to existing paths on +the selected wing. Wingthing does not yet synchronize code or memory between +wings, and docs must not imply that it does. + - `wt mcp stdio` is the model-facing surface. Tools have closed JSON Schemas, return structured content, and declare read-only/mutating/destructive intent. - CLI subcommands take `--json` for scripting. Human-readable output is a @@ -63,17 +88,19 @@ completing the parent task via terminal scraping, ad hoc scripts, or a second orchestration system. Leave working paths alone unless a real task exposes a problem. -The destination is the shared roost: any useful local operation added while -dogfooding must be designed as a reusable runtime primitive that can also be -exposed through an authenticated, owner-scoped, typed, audited roost adapter. +The destination is one wing-owned control contract: any useful local operation +added while dogfooding must be a reusable runtime primitive that can also be +exposed through authenticated, owner-scoped, typed, audited browser and MCP +adapters. A local-only convenience is incomplete unless it is a deliberate intermediate step toward that parity. -The only current real user workflow is Slide's shared roost in the web UI. -Preserve it by default while iterating on other workflows. Breaking changes are -allowed when they materially simplify or improve the product, but first present -Bryan with the concrete benefit, affected workflow, and migration plan and get -his agreement. Compatibility remains a conscious tradeoff. +The Slide shared roost is the highest-risk compatibility canary, and personal +local/remote wings plus native direct MCP are also real workflows. Preserve all +deployed contracts by default while iterating. Breaking changes are allowed when +they materially simplify or improve the product, but first present Bryan with the +concrete benefit, affected workflow, and migration plan and get his agreement. +Compatibility remains a conscious tradeoff. ## Architecture @@ -145,7 +172,7 @@ Wings can be shared via organizations. The relay has a full org system: `wt roost` runs relay + wing in one process for self-hosted deployments. See `docs/roost_design.md`. -- Two auth modes: local (no OAuth, single user auto-created) and roost (with OAuth, all authenticated users access wings) +- Two auth modes: local (no OAuth, single user auto-created) and roost (with OAuth, enrolled users access the embedded wing). Private OAuth roosts should set exact emails in `WT_ROOST_ALLOWED_EMAILS`; empty retains the historical accept-any-authenticated-account behavior. - Daemon mode with `~/.wingthing/roost.pid` and `~/.wingthing/roost.log` - `wt roost start/stop/status` subcommands - **Key file:** `cmd/wt/roost.go` @@ -181,7 +208,8 @@ Single resolution path for all contexts: **CLI flag (`--agent`) > skill frontmat ## Skill System -Skills are the core abstraction. Markdown files with YAML frontmatter and a prompt template body. +Skills are portable workflow inputs, not the core runtime abstraction. They are +Markdown files with YAML frontmatter and a prompt template body. ### Philosophy - **Repo skills** (`skills/`) are the validated library -- curated, tested, checked in @@ -227,14 +255,19 @@ When `isolation` is `strict` or `standard` (no network), the sandbox automatical | Agent | Network | What it opens | |-------|---------|---------------| -| claude | HTTPS | **All outbound TCP 443/80 + DNS.** Required for api.anthropic.com. macOS seatbelt cannot filter by hostname or IP — only by port. | +| claude | HTTPS | Provider domains through the local CONNECT proxy. Required for api.anthropic.com. | | codex | HTTPS | Same as claude (for api.openai.com) | | gemini | HTTPS | Same as claude (for googleapis.com) | | cursor | HTTPS | Same as claude | | ollama | Local | Localhost only (127.0.0.1, no external) | | opencode | HTTPS | Same as claude (for anthropic, openai, googleapis) | -**Important:** `standard` isolation with a cloud agent (claude, codex, gemini, cursor) allows outbound HTTPS to **any host**, not just the agent's API. This is a platform limitation — macOS seatbelt cannot filter by domain or IP range. On Linux, the agent currently gets full network access (no port filtering in unprivileged namespaces). See `docs/egg-sandbox-design.md` for details and the roadmap for SNI-based domain filtering. +**Important:** cloud-agent HTTPS is forced through a domain-filtering CONNECT +proxy on both platforms. Linux keeps a route-less `CLONE_NEWNET` namespace and +exposes the proxy through an inherited-FD loopback relay, so ignoring +`HTTPS_PROXY` fails closed. Declared host-loopback ports use the same relay. +The Linux relay currently covers TCP proxy/loopback traffic, not arbitrary UDP, +ICMP, or non-proxied protocols. See `docs/egg-sandbox-design.md` for details. ## Key Packages @@ -327,14 +360,13 @@ CI runs via **GitHub Actions** (`.github/workflows/ci.yml` on push/PR; `release. **Prod (wingthing.fly.dev) is Bryan's daily driver.** Do not deploy to Fly during development unless explicitly asked. All development and testing happens locally. -### Vacation freeze: local-first branch only +### Release and deployment discipline -Through approximately 2026-08-20, treat `main` as frozen. Major runtime work -belongs on `feature-local-first-terminal-routing`. Do not merge it to `main`, -tag a version, create a release, deploy Fly, or change the Slide deployment. -Build the repository binary and use an isolated `WINGTHING_DIR` for local -dogfooding. See `docs/vacation-local-first.md` for the branch contract and -post-vacation promotion gate. +The August local-first vacation freeze has expired and is historical. Use the +current product brief plus CI/release workflows for promotion gates. Do not merge, +tag, deploy Fly, or change the Slide deployment unless the active task explicitly +authorizes that state change. Build the repository binary and use an isolated +`WINGTHING_DIR` for local dogfooding. - `make serve` starts a local relay on `:8080` - `wt wing --relay http://localhost:8080` connects a wing to the local relay diff --git a/Dockerfile b/Dockerfile index 5f9bc89a..e6527ad1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ RUN npm ci COPY web/ ./ RUN npm run build -FROM golang:1.25-alpine AS build +FROM golang:1.26.6-alpine AS build WORKDIR /app COPY go.mod go.sum ./ RUN go mod download @@ -19,5 +19,10 @@ WORKDIR /app COPY --from=build /app/wt . COPY hero.mp4 . ENV WT_HERO_VIDEO=/app/hero.mp4 +ENV HOME=/data EXPOSE 8080 -CMD ["sh", "-c", "mkdir -p /data/.wingthing && exec ./wt serve --addr :8080"] +# The standalone image defaults to the same loopback-only, no-login gateway as +# `wt serve` on a host. Fly's authenticated process command explicitly replaces +# the listener with :8080. Container users can opt into host networking (as the +# self-host docs show) or supply OAuth and an explicit public listener. +CMD ["sh", "-c", "umask 077 && mkdir -p /data/.wingthing && exec ./wt serve"] diff --git a/Makefile b/Makefile index b507597a..5ba347cc 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ -.PHONY: build test coverage check clean web serve release proto deploy deploy-edge scale status jail \ +.PHONY: build test coverage check clean web serve release release-contract proto deploy deploy-edge scale status jail \ build-linux build-mock-agent build-linux-tests build-linux-sandbox-tests test-linux test-linux-ubuntu test-integ test-e2e \ build-linux-wt-tests \ - test-provider-swap build-web-e2e test-web + test-provider-swap build-web-e2e test-web test-vuln test-compat VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) LDFLAGS := -s -w -X main.version=$(VERSION) @@ -22,6 +22,14 @@ build: | web/dist test: | web/dist go test ./... +# Pin the scanner for reproducible parsing while intentionally consulting the +# current Go vulnerability database. This is a promotion gate, not part of the +# offline `make check` path. +GOVULNCHECK_VERSION ?= v1.7.0 +test-vuln: | web/dist + go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./... + cd web && npm audit --audit-level=high + COVERAGE_OUT ?= /tmp/wingthing-coverage.out coverage: | web/dist go test -coverprofile=$(COVERAGE_OUT) ./... @@ -30,7 +38,7 @@ coverage: | web/dist check: web test build web: - cd web && npm ci && npm run build + cd web && npm ci && npm test && npm run build serve: build ./wt serve @@ -38,19 +46,29 @@ serve: build release: web @echo "Building $(VERSION) for all platforms..." @mkdir -p dist - @for platform in $(PLATFORMS); do \ + @set -e; for platform in $(PLATFORMS); do \ os=$${platform%/*}; \ arch=$${platform#*/}; \ output="dist/wt-$$os-$$arch"; \ + tmp="$$output.tmp"; \ echo " $$os/$$arch"; \ - CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -buildvcs=false -ldflags="$(LDFLAGS)" -o $$output ./cmd/wt; \ + rm -f "$$tmp"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -buildvcs=false -ldflags="$(LDFLAGS)" -o "$$tmp" ./cmd/wt; \ + mv "$$tmp" "$$output"; \ done + @CGO_ENABLED=0 go build -buildvcs=false -ldflags="$(LDFLAGS)" -o dist/wt-contract ./cmd/wt + @scripts/check-release-contract.sh dist/wt-contract + @rm -f dist/wt-contract + @cd dist && set -e; assets="wt-linux-amd64 wt-linux-arm64 wt-darwin-amd64 wt-darwin-arm64"; if command -v sha256sum >/dev/null 2>&1; then sha256sum $$assets > SHA256SUMS.tmp; else shasum -a 256 $$assets > SHA256SUMS.tmp; fi; mv SHA256SUMS.tmp SHA256SUMS @echo "Built $(VERSION) -> dist/ (publish via gh release create)" +release-contract: build + scripts/check-release-contract.sh ./wt + jail: build go test -tags integration -v ./internal/sandbox/ -run TestJail -deploy: check +deploy: check release-contract test-compat fly deploy # Add edge nodes to a region. Usage: make deploy-edge REGIONS=nrt,lhr COUNT=1 @@ -79,11 +97,15 @@ proto: protoc -I proto --go_out=paths=source_relative:internal/egg/pb --go-grpc_out=paths=source_relative:internal/egg/pb proto/egg.proto # Deploy artifacts target the x86-64 shared hosts by default. Security tests use -# the local machine's native architecture because qemu/Rosetta translate -# syscall numbers below seccomp and produce invalid sandbox results. +# the Docker daemon's native architecture because qemu/Rosetta translate +# syscall numbers below seccomp and produce invalid sandbox results. The daemon +# may differ from the CLI host (for example an amd64 Colima VM on an arm64 Mac). HOST_ARCH := $(shell uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/') LINUX_ARCH ?= amd64 -LINUX_TEST_ARCH ?= $(HOST_ARCH) +DOCKER_ARCH := $(shell arch=$$(docker info --format '{{.Architecture}}' 2>/dev/null); \ + if [ -n "$$arch" ]; then echo "$$arch" | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/'; \ + else echo $(HOST_ARCH); fi) +LINUX_TEST_ARCH ?= $(DOCKER_ARCH) build-linux: | web/dist CGO_ENABLED=0 GOOS=linux GOARCH=$(LINUX_ARCH) go build -buildvcs=false \ @@ -105,9 +127,11 @@ build-linux-wt-tests: | web/dist -o test/linux/wt-tests ./cmd/wt/ # Browser E2E tier: seeded shared-roost (org mode) + Playwright in Docker. -# Binaries are built for the docker host's native arch so the container can -# run them without emulation. Needs the real web dist (it tests the UI). -WEB_TEST_ARCH ?= $(HOST_ARCH) +# Binaries must match the Docker daemon, which may differ from the client host +# (for example an arm64 Mac pointed at an amd64 Colima/remote daemon). Fall back +# to the host only when Docker is unavailable; test-web itself will then report +# the ordinary daemon error. +WEB_TEST_ARCH ?= $(DOCKER_ARCH) build-web-e2e: web CGO_ENABLED=0 GOOS=linux GOARCH=$(WEB_TEST_ARCH) go build -buildvcs=false \ @@ -118,8 +142,8 @@ test-web: build-web-e2e test/web/run.sh test-linux: - @if [ "$(LINUX_TEST_ARCH)" != "$(HOST_ARCH)" ]; then \ - echo "cross-architecture seccomp tests are invalid (host=$(HOST_ARCH), requested=$(LINUX_TEST_ARCH)); run this battery on a native $(LINUX_TEST_ARCH) host"; \ + @if [ "$(LINUX_TEST_ARCH)" != "$(DOCKER_ARCH)" ]; then \ + echo "cross-architecture seccomp tests are invalid (docker=$(DOCKER_ARCH), requested=$(LINUX_TEST_ARCH)); run this battery on a native $(LINUX_TEST_ARCH) Docker daemon"; \ exit 1; \ fi $(MAKE) LINUX_ARCH=$(LINUX_TEST_ARCH) build-linux build-mock-agent build-linux-tests build-linux-sandbox-tests build-linux-wt-tests @@ -128,8 +152,8 @@ test-linux: '/root/run-tests -test.v -test.timeout 120s && /root/sandbox-tests -test.v -test.timeout 120s && /root/wt-tests -test.v -test.timeout 120s' test-linux-ubuntu: - @if [ "$(LINUX_TEST_ARCH)" != "$(HOST_ARCH)" ]; then \ - echo "cross-architecture seccomp tests are invalid (host=$(HOST_ARCH), requested=$(LINUX_TEST_ARCH)); run this battery on a native $(LINUX_TEST_ARCH) host"; \ + @if [ "$(LINUX_TEST_ARCH)" != "$(DOCKER_ARCH)" ]; then \ + echo "cross-architecture seccomp tests are invalid (docker=$(DOCKER_ARCH), requested=$(LINUX_TEST_ARCH)); run this battery on a native $(LINUX_TEST_ARCH) Docker daemon"; \ exit 1; \ fi $(MAKE) LINUX_ARCH=$(LINUX_TEST_ARCH) build-linux build-mock-agent build-linux-tests build-linux-sandbox-tests build-linux-wt-tests @@ -140,6 +164,11 @@ 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. +test-compat: | web/dist + scripts/test-backward-compat.sh + test-e2e: test-linux test-linux-ubuntu test-integ # Opt-in release gate for real, model-swapped harnesses. Requires the local diff --git a/README.md b/README.md index 4c812e46..eb9c4283 100644 --- a/README.md +++ b/README.md @@ -2,215 +2,340 @@ [![ci](https://github.com/ehrlich-b/wingthing/actions/workflows/ci.yml/badge.svg)](https://github.com/ehrlich-b/wingthing/actions/workflows/ci.yml) -Persistent, sandboxed agent terminals on machines you control. Use them locally, -reattach from your terminal over SSH, or opt into encrypted browser access. +Wingthing is an agent manager for agents. Give Codex, Claude, or another parent +agent one typed control plane for starting and supervising durable agents across +all your machines. A person can inspect or take over the same sessions from a +terminal or browser. + +The agents run where the code and hardware already live. Wingthing keeps their +terminals alive, records semantic runs as durable tasks, applies sandbox policy, +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 -``` -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 it -wt attach # list live local sessions -wt attach --select # choose interactively -wt attach research # names or immutable IDs both work -wt attach research --remote box # reattach over ordinary SSH -``` +Install Wingthing, then register its local MCP server with the agent that will +coordinate the work: -No account or hosted service is required. If you want browser access from -anywhere, `wt start` connects your machine outbound to the optional -`app.wingthing.ai` relay. +```bash +curl -fsSL https://wingthing.ai/install.sh | sh -## Runtime and security domains +# Codex +codex mcp add wingthing -- wt mcp stdio --client codex -**The egg** is a sandboxed agent session on your machine. Each `wt egg ` spawns a child process inside an OS-level sandbox (Seatbelt on macOS, user namespaces + seccomp on Linux). Same idea as containers but lighter weight. Filesystem access, network reach, system calls, and resource usage are all controlled. +# Claude Code +claude mcp add --scope user wingthing -- wt mcp stdio --client claude +``` -**The wing** is the durable runtime on your machine. It owns eggs and their -session state even when no client is attached. Local and SSH clients do not need -a hosted service. When browser access is enabled, terminal and wing API payloads -are application-encrypted (X25519 + AES-GCM) through the relay. The shipped relay -forwards ciphertext during normal operation; routing metadata remains visible, -and the hosted web client still trusts the service that delivers its JavaScript. -See the precise [security model](docs/security.md). +The installer verifies the release checksum and confirms that the downloaded +binary implements the command surface shown by the website before replacing an +existing installation. -**A roost** is a deployment bundle: a wing plus the gateway and web service in -one process. Self-host one with `wt roost start` for a shared workstation, team -appliance, or homelab. `wingthing.ai` offers the gateway/web side as an optional -hosted service; it is not required for local or SSH use. +Restart the client after registration. Ask it to call +`wingthing_capabilities` before it starts work. The model can then: -## Sandbox +- discover installed agent CLIs and their runtime requirements; +- start a persistent agent terminal that a person can reattach to; +- submit a headless `agent_run` with a provider and model, then wait for its + semantic result without parsing terminal output; +- coordinate bounded prompt loops and dependency graphs; +- exchange owner-scoped messages with another authenticated agent client; and +- inspect the effective sandbox before launching anything. -Out of the box, the sandbox is opinionated: CWD is writable, home is read-only, sensitive directories (`~/.ssh`, `~/.gnupg`, `~/.aws`, etc.) are denied, and only essential env vars are passed through. A local [CONNECT proxy](https://en.wikipedia.org/wiki/HTTP_tunnel) enforces domain-level filtering - agents can only reach their own API, not the entire internet. Claude gets `api.anthropic.com`, Ollama gets `localhost`, Gemini gets `*.googleapis.com`. Agent binaries, config directories, network rules, and env vars are all auto-detected. +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`. -Drop an `egg.yaml` in your project to customize. Configs are additive - you only declare what you're changing from the defaults. Put one at `~/.wingthing/egg.yaml` to use it as the default across projects. +To give the parent agent one qualified inventory across remote wings, log in on +the client machine and use the direct connector instead: -```yaml -# egg.yaml -fs: - - "ro:~/.ssh" # overrides the default deny for ~/.ssh -network: - - "github.com" # add a domain on top of agent defaults -env: - - SSH_AUTH_SOCK # pass SSH agent socket -``` +```bash +wt login -Use `base: none` for a blank slate. Use the [sandbox builder](https://wingthing.ai) on the homepage to generate configs visually. +# Codex +codex mcp add wingthing -- wt mcp connect --client codex -If the machine is already a disposable or access-segregated VM, use -`--unsandboxed` to make that outer VM the boundary. Wingthing still provides the -durable PTY, attach, CLI, and MCP control plane, but it does not apply nested -filesystem, network, syscall, or resource restrictions. See the -[sandboxed AI VM recipe](docs/sandboxed-ai-vm.md). +# 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. -## Native reattach +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. -`wt terminal` starts a shell or any command in the same durable PTY runtime used -for agents. Detach from `wt terminal` or `wt egg` with `Ctrl+B`, then `Q`. The -egg keeps running in its own process session. +Hosted terminal relay is a separate transport decision. Existing configs remain +compatible, while a wing can refuse relayed payloads regardless of account +entitlement: ```bash -wt terminal --name work -wt terminal --name dev-server -- npm run dev -wt egg claude --name research - -wt attach # list local sessions -wt attach --select # native interactive picker -wt attach work # attach by name or ID -wt attach work --remote box # `box` comes from ~/.ssh/config +wt wing config set hosted_relay=deny +wt stop && wt start ``` -The remote host needs `wt` installed. SSH keeps ownership of authentication, -host verification, bastions, ports, and VPN/tailnet routing. Use -`--remote-binary /path/to/wt` if it is not on the remote `PATH`. +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 local terminal API is also scriptable: +## Use the same runtime yourself + +```bash +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: ```bash wt session ps --json -wt session read dev-server --json -wt session send dev-server r --enter --json -wt session wait dev-server --contains ready --json -wt session rename dev-server frontend --json +wt session read api --json +wt session send api r --enter --json +wt session wait api --contains ready --json +wt session rename api frontend --json wt session kill frontend --json -wt terminal --name worker --json -- make worker ``` -LLMs and editor clients can use the same local runtime through MCP: +Terminal snapshots are ANSI state. Wingthing doesn't 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. + +## The runtime model + +The product is an agent control plane over runtimes: + +```text +parent agent: MCP --> agent manager --> selected wing --> sessions + runs + ^ +person: CLI or browser ----------------/ (inspect or take over) +``` + +| Term | Meaning | +| --- | --- | +| **Portal** | The unified inventory and controls exposed to an agent through MCP or to a person through the browser. | +| **Wing** | One machine running Wingthing. It owns the authoritative egg, terminal, and task state on that machine. | +| **Session** | A persistent interactive PTY for an agent, shell, or command. A person or model can detach and reattach. | +| **Run** | A supervised headless agent task with semantic status, events, output, and error data. It is separate from a PTY session. | +| **Egg** | The per-session execution boundary: process, PTY, sandbox policy, and local control socket. | +| **Roost** | The self-hosted bundle started by `wt roost start`: a portal/gateway and an embedded wing in one process. Other wings may register with its gateway. | + +`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. + +### Shared control contract + +Local stdio, authenticated HTTP, and direct remote MCP derive their tool schemas +from one registry. The remote surface has no mutable current wing: every +wing-owned call requires `wing_id`, while `wing_list` is coordinator-owned. A +terminal created with MCP is the same durable egg a browser or CLI can inspect. + +Headless runs do not yet have a browser view, locked wings still need a native +passkey ceremony, and independent self-hosted roosts do not yet federate their +directories. See the [direct agent manager design](docs/direct-agent-manager-design.md) +for the rollout and security boundary. + +## Sandbox + +Each egg resolves an `egg.yaml` policy. The defaults make the working directory +writable, mount home read-only, deny common credential directories, strip the +environment, and allow only the network domains required by the selected agent. + +- macOS uses Seatbelt. +- Linux uses user and PID namespaces, mount isolation, seccomp, and cgroups when + available. +- A local CONNECT proxy applies domain rules. Linux enforcement limits are + documented in the [security model](docs/security.md). + +On Linux, even `network: "*"` uses the route-less namespace and permits any TCP +target presented through HTTP CONNECT; it is not a general routed interface for +ordinary raw-socket clients, UDP, or programs that ignore proxy configuration. +CONNECT policies filter hosts rather than ports. See [sandbox limitations](docs/sandbox.md#network-protocol-coverage). + +Project policy is additive: + +```yaml +# egg.yaml +fs: + - "ro:~/.ssh" # override the default deny for ~/.ssh +network: + - "github.com" # add a domain to the agent profile +env: + - SSH_AUTH_SOCK # pass the SSH agent socket +``` + +Use `wt egg explain --json` or the MCP `sandbox_explain` tool to inspect +the resolved boundary before launch. Put a default policy at +`~/.wingthing/egg.yaml`, or use `base: none` for a blank slate. + +If the machine is already an access-segregated VM, `--unsandboxed` declares the +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 + +Browser access is optional: ```bash -wt mcp stdio --client claude-code +wt login +wt start +open https://app.wingthing.ai ``` -It exposes typed tools for terminal start/list/read/send/wait/rename/stop, -persistent agent start, -versioned prompt templates, one-shot runs, durable task inspection, bounded -loops, and dependency-aware swarms. It is local-user access: no account, -browser, or hosted router is involved. Named clients own the sessions they create, -and calls are attributed in `~/.wingthing/mcp-audit.log`. Optional grants and -spawn bounds live in `~/.wingthing/clients.yaml`; these guard against accidental -same-user interference, not a hostile process that can invoke `wt` directly. -See [the agent meta-layer design](docs/agent-meta-layer.md) and the -[supported-agent evidence matrix](docs/agent-support.md). +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. -For an agent running inside an already-isolated VM, register the trusted-host -mode explicitly: +A portal may have several wings. The native CLI can query the same authorized +roster and probe each wing through the encrypted tunnel: ```bash -claude mcp add wingthing -- wt mcp stdio --client claude-code --unsandboxed -wt egg claude --name claude --unsandboxed -- --permission-mode bypassPermissions +wt wings +wt wings --json + +# Keep a separate state and login profile for another portal. +WINGTHING_DIR=~/.wingthing-lab wt login --roost https://lab.example.com +WINGTHING_DIR=~/.wingthing-lab wt wings --roost https://lab.example.com --json ``` -The MCP server reports this mode to the model and records -`"isolation":"outer-boundary"` on every MCP audit entry. The model cannot toggle -the mode per tool call. +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 opt-in release smoke battery model-swaps Claude Code, Codex, Hermes, and -OpenCode onto local Qwen models, runs each directly and through Wingthing, then -exercises MCP prompt, saved-prompt, loop, and swarm paths. It asserts exact -artifacts rather than trusting exit codes: see [live release E2E](docs/release-e2e.md). +## Shared roost -Named prompts are ordinary local assets with immutable content revisions: +Run a self-hosted portal, gateway, and embedded wing in one process: ```bash -wt prompt save review --file review.prompt --variable target --agent codex --cwd "$PWD" -wt prompt list -wt prompt run review --var target=internal/parser +wt roost start --https +open https://localhost:8443 ``` -## Optional browser access +`--https` is an explicit, one-time local trust ceremony. WT creates a +localhost-only CA and server certificate on demand under +`~/.wingthing/local-tls`, keeps both private keys mode `0600` on this machine, +and installs only the public CA certificate in the current user's trust store. +The CA is name-constrained to localhost and loopback IPs. Inspect or undo the +trust change with `wt local-cert status` and `wt local-cert remove`. +macOS shows its native Certificate Trust Settings authorization dialog. Linux +uses the current user's Chromium NSS database and requires `certutil` from +`libnss3-tools` or `nss-tools`. +WT verifies the trust-store result before reporting the CA as installed; on +macOS it evaluates the generated leaf under the `localhost` SSL policy. A +failed or incomplete trust change is not cached as success. +See [the local HTTPS design](docs/local_https.md) for the listener topology, +certificate lifecycle, and compatibility matrix. + +Port 8443 is the browser listener. WT also keeps a loopback-only HTTP listener +on port 8080 for local wings and wings arriving through an SSH reverse tunnel; +it is not exposed to the LAN. Omit `--https` to keep an HTTP browser origin, but +single-user/no-login mode still rewrites the implicit listener to +`127.0.0.1:8080` and refuses an explicitly non-loopback address. Authenticated +organization and hosted listeners keep their configured bind behavior. + +Because local mode deliberately has no human login, WT also rejects non-loopback +Host headers, cross-origin browser mutations, and cross-origin browser WebSocket +handshakes. Native wings and CLI clients without browser Origin headers remain +compatible. These checks are defense in depth around the loopback-only bind, not +permission to publish a local-mode listener. + +With an OAuth provider and public HTTPS URL, the same deployment becomes a +multi-user shared host. Each terminal and run has an owner, each OAuth client +has a separate actor ID, workspaces are bounded by `wing.yaml`, and provider +credentials live in the owner's agent home. Public/shared deployments continue +to terminate their externally provisioned HTTPS at Caddy, nginx, a VPN proxy, +Fly, or another ingress; they do not use WT's device-local CA. + +OAuth proves identity; it does not decide who belongs on a private roost. Set an +exact email enrollment list on every shared roost: -``` -wt login # authenticate with GitHub or Google -wt start # background daemon -wt status # check it -wt stop # stop it +```bash +export WT_ROOST_ALLOWED_EMAILS=alice@example.com,bob@example.com ``` -Open [app.wingthing.ai](https://app.wingthing.ai) to browse your wings, start -sessions, and view history. Lock your wing with `wt wing lock` to require -passkey auth before sessions start. +Only those accounts may finish login, use tokens, see wings, or authorize MCP. +If the list is empty, any account accepted by the configured OAuth provider can +enroll. Set the list on every internet-reachable private roost unless the +provider or ingress already enforces the same membership boundary. -The native CLI can use the same authenticated roster as a wing finder. It then -probes each wing through the application-encrypted tunnel, so host and project -details do not become relay metadata: +An LLM connects to its HTTP MCP endpoint: ```bash -wt wings # alias: wt find -wt wings --json - -# Keep a separate login/key profile for a private or team roost. -WINGTHING_DIR=~/.wingthing-slide wt login --roost https://roost.example.com -WINGTHING_DIR=~/.wingthing-slide wt wings --roost https://roost.example.com --json +codex mcp add lab --url https://lab.example.com/mcp +codex mcp login lab ``` -Use `--no-probe` when only the relay's online wing IDs are needed. Native TOFU -identity pins are stored in the selected profile's `known_wings.json`. +This command shape follows the current +[Codex MCP documentation](https://learn.chatgpt.com/docs/extend/mcp). Claude Code +uses `claude mcp add --scope user --transport http lab +https://lab.example.com/mcp`. -## Agents +## Supported agents -`wt doctor` shows what's installed. Swap agents per-session. +`wt doctor` reports what is installed. Interactive sessions support: | Agent | CLI | -|-------|-----| +| --- | --- | | Claude Code | `claude` | | Codex | `codex` | | Cursor Agent | `agent` | -| Gemini | `gemini` | +| Gemini CLI | `gemini` | | Hermes Agent | `hermes` | | Ollama | `ollama` (`qwen3:4b` default) | | OpenCode | `opencode` | -## Install +Support has several evidence levels: catalog, exact headless invocation, +synthetic PTY lifecycle, real sandbox startup, live model completion, and MCP +orchestration. See [supported agent evidence](docs/agent-support.md) for the +latest checked-in verification snapshot and [testing](docs/testing.md) for the +promotion policy. + +## Install and build ```bash curl -fsSL https://wingthing.ai/install.sh | sh ``` -Or build from source (Go 1.25+, Node.js): +Or build from source with Go 1.26.6+ and Node.js: ```bash git clone https://github.com/ehrlich-b/wingthing.git -cd wingthing && make check -``` - -Update with `wt update`. - -## Self-hosting - -Single binary, SQLite, no external deps. - -```bash -wt roost start # server + wing, one command -open localhost:8080 # start sessions +cd wingthing +make check ``` -For multi-user, add GitHub or Google OAuth env vars. See the [docs](https://wingthing.ai/docs#self-hosting). +Update an installed binary with `wt update`. -## Docs +## Documentation -[wingthing.ai/docs](https://wingthing.ai/docs) +- [Choose a usage pattern](https://wingthing.ai/patterns) +- [Web documentation](https://wingthing.ai/docs) +- [Historical LLM-first architecture review](docs/llm-first-review.md) +- [Test strategy and commands](docs/testing.md) +- [Agent meta-layer](docs/agent-meta-layer.md) +- [AI API surface](docs/ai-api-surface.md) +- [Security model](docs/security.md) ## License diff --git a/TODO.md b/TODO.md index 15eb0d6e..40d62f49 100644 --- a/TODO.md +++ b/TODO.md @@ -1,21 +1,32 @@ # TODO — wingthing -**Your agentic swiss army knife.** One CLI, every backend, accessible from anywhere. +**An agent manager for agents.** One control plane for durable agents across the +machines where their code and credentials already live. + +The current product thesis, real user stories, implementation gaps, security +invariants, release gates, and next coding slice are recorded in +[`docs/agent-manager-product-brief.md`](docs/agent-manager-product-brief.md). Read +that brief before continuing `feature/direct-control-free-tier`; this backlog alone +does not describe the end-to-end agent-manager product. ## Where We Are Wings are live. PTY relay works end-to-end. E2E encryption, passkey auth, org support, per-process egg sandbox, folder-based ACLs (per-path member lists), `wt wing config` -with live SIGHUP reload, `wt roost` for single-process self-hosted mode. Single Fly -node (shared-cpu-2x, 512MB), horizontal scaling built and tested — edge nodes are one -uncomment away in fly.toml. - -VTE reconnect and browser P2P are implemented. The current architectural -direction is to make the existing runtime local-first and client-agnostic, then -layer collaboration on top; see `docs/local-first-architecture.md`. Major local -work is isolated on `feature-local-first-terminal-routing` under the temporary -freeze in `docs/vacation-local-first.md`; do not tag or deploy it before the -post-vacation review. +with live SIGHUP reload, and `wt roost` for single-process self-hosted mode. Production +currently uses one Fly `login` machine (shared-cpu-2x, 512MB) with the SQLite volume; +horizontal scaling is built and tested, while the `edge` process remains disabled and +scaled to zero in `fly.toml`. + +VTE reconnect and opt-in browser WebRTC migration are implemented, with legacy +replay and terminal-specific cleanup still present. Browser-direct transport is not +the free hosted terminal path: free remote MCP is direct, while browser terminal +startup still requires relay entitlement or a self-hosted roost. The current +architectural direction is to make the existing runtime local-first and +client-agnostic, then layer collaboration on top; see +`docs/local-first-architecture.md`. The former August vacation freeze is an expired +historical record; current scope and promotion gates live in the agent-manager +product brief and CI workflows. --- @@ -40,7 +51,8 @@ The bar: someone new can use a wing without confusion or broken UX. ### Docs - [x] Update docs for orgs, passkeys, wing config, allow/revoke, lock/unlock - [x] Self-hosting guide: `wt serve` on your own box, what you get, how sandbox works -- [x] Architecture overview: relay is a dumb pipe, wing owns all data, E2E encryption +- [x] Architecture overview: gateway/wing responsibilities, visible routing + metadata, wing-owned session state, and application payload encryption ### UX Polish - [ ] Split org and personal wings in dashboard UI — personal vs work icon when tabbing through wings @@ -63,7 +75,8 @@ The bar: someone new can use a wing without confusion or broken UX. ### Self-Hosting First Class - [x] `wt serve` should work standalone with zero config for single-user self-hosted - [x] Local user mode: auto-grant pro tier, no bandwidth cap for self-hosted -- [ ] Hide orgs UI in self-hosted mode — orgs are a hosted-relay concept +- [x] Hide orgs UI in self-hosted roost mode — covered by the organization-mode + browser E2E suite - [x] Uniform 3 Mbit/s rate for all tiers, only monthly cap differentiates free vs pro --- @@ -102,24 +115,31 @@ The bar: someone new can use a wing without confusion or broken UX. - [x] Tunnel passkey replay protection — `passkey.auth.begin`/`finish`, one-time wing nonce, full WebAuthn context validation, client-bound token - [ ] Authenticated ephemeral wing handshakes — replace TOFU-only static-wing ECDH with verified pairing and forward secrecy - [ ] Bind encrypted envelopes to wing/session/type/direction/request with AEAD associated data and replay counters -- [ ] Internal API trust boundary — mTLS or signed service tokens for node-to-node calls -- [ ] Invite consume transaction ordering — race condition in `internal/relay/org.go` +- [x] Internal API baseline — Fly nodes require a cluster-private source and + production-shaped Fly server config; non-Fly split deployments require a distinct + `WT_INTERNAL_SECRET`, which every built-in node client propagates. JWT signing + material is never accepted as an HTTP secret. +- [ ] Add cryptographic Fly node identity (mTLS or signed service tokens) so a split + deployment need not trust every application on the Fly organization's 6PN. Until + then, set `WT_INTERNAL_SECRET` on Fly when that network trust is too broad. +- [x] Invite consume transaction ordering — invite claim, membership, seat check, and + entitlement grant commit in one transaction, with rollback regressions. --- -## Next Targets — Prove the Concept +## Shipped foundations that still need cleanup ### VTE: Server-Side Virtual Terminal Emulator -Replace the 2MB replay buffer with a real terminal state machine (`charmbracelet/x/vt`). -On reconnect, paint the current screen (50 lines) instead of replaying megabytes of raw bytes. -Eliminates `findSafeCut`, `trackCursorPos`, `agentPreamble` hacks. Makes wingthing -"tailscale + tmux on the web" — nobody else has remote access + VTE + web terminal together. -See `docs/vt_design.md` for full design. +The VTE snapshot reconnect path is shipped. The 2MB raw replay path and +`findSafeCut`, `trackCursorPos`, and `agentPreamble` compatibility code remain for +fallback and older modes; remove them only after the VTE path has enough field time. +See `docs/vte/README.md` for the current phased cleanup plan. ### P2P: WebRTC Direct Connection for Same-LAN Wings -Bypass the relay entirely when browser and wing are on the same network. -WebRTC data channels for PTY I/O, encrypted tunnel stays E2E. -See `docs/webrtc-p2p-design.md` for full design. +Opt-in `p2p`/`p2p_only` browser migration and the native direct-MCP WebRTC transport +are shipped. Browser P2P still begins from entitled or self-hosted signaling and is +not a browser-direct free hosted terminal. See `docs/p2p_design.md` for the current +transport design. --- @@ -127,43 +147,13 @@ See `docs/webrtc-p2p-design.md` for full design. ### PTY: UTF-8 boundary safety in replay buffer trim and chunking -Replay buffer trimming and replay chunking are not UTF-8 aware. Multi-byte -sequences (emoji, box-drawing chars, CJK) that straddle a cut/chunk boundary -get split, producing permanent xterm rendering corruption (garbled status lines, -misplaced characters, mojibake). - -**Where it happens:** - -1. **`findSafeCut()` in `internal/egg/server.go` (~line 369)** - Searches for safe trim points (sync frames, CRLF) but never checks if the - chosen offset lands mid-UTF-8 sequence. The fallback returns `minOffset` - raw, which can land anywhere. A 4-byte emoji split at byte 2 = broken - decoder state in xterm. - -2. **`sendReplayChunked()` in `cmd/wt/wing.go` (~line 138)** - Splits replay data at fixed 128KB byte boundaries. Same problem — chunk - boundary can bisect a multi-byte character. Each chunk is gzipped - independently, so the halves never recombine. Only affects the web relay - path (browser reattach), not local egg sessions. - -3. **Browser gzip decompression in `web/src/pty.js` (~line 105)** - If a corrupted chunk fails to decompress, the error handler silently drops - it (`catch → null`). No logging, no user feedback — just a gap in the - replay stream. - -**Fix approach (when ready):** -- Add `isUTF8Boundary(buf, offset)` helper — check if byte at offset is a - valid UTF-8 start byte (high bits 0xxxxxxx or 11xxxxxx, not 10xxxxxx) -- In `findSafeCut()`: after finding a cut point, walk backward (max 3 bytes) - until on a UTF-8 boundary -- In `sendReplayChunked()`: same — adjust chunk end backward to nearest - UTF-8 boundary before slicing -- Low risk per-fix, but touching the trim path is dangerous in aggregate — - defer until we can test replay trim thoroughly - -**Severity:** Cosmetic jank, not data loss. Observed as garbled Claude Code -status line after pasting unusual UTF-8 into an egg session. Self-heals on -full screen redraw but annoying. +- [x] Replay-buffer trimming and independently compressed web replay chunks now + move a proposed cut back at most one UTF-8 sequence. Focused tests place a + four-byte emoji across each former boundary and retain bounded behavior for + arbitrary invalid PTY bytes. +- [ ] Surface browser replay decompression errors instead of silently dropping a + failed chunk. UTF-8 splitting no longer creates that failure, but corrupted or + truncated compressed data should still produce visible diagnostics. --- @@ -207,19 +197,16 @@ verification, attention state, project discovery, and log rotation. - [ ] Remove `goto authDone` in PTY passkey auth — restructure into early-return or extracted function -### Go: Relay race conditions +### Go: Relay concurrency follow-up -- [ ] `bandwidth.go` month-boundary race — two goroutines calling `counter()` - at month rollover both reset `b.counters`, second nuke first's data. Fix: - double-check under lock after acquiring it -- [ ] `workers.go` `WingRegistry.UpdateConfig()` returns mutable `*ConnectedWing` - pointer from inside the lock — callers can race on fields. Return a copy or - use accessor methods -- [ ] PTY route orphan cleanup — sessions register on `pty.start`, unregister - only on `pty.exited`. Crashed wings leave zombie entries forever. Add a sweep - goroutine with TTL -- [ ] `ntfySentNonces` global `sync.Map` grows unbounded — entries never deleted. - Add TTL or clear on session end +- [x] `bandwidth.go` month rollover is serialized under the meter lock. +- [x] `WingRegistry` publishes immutable connection snapshots; config and heartbeat + updates replace rather than mutate a previously returned entry. +- [x] Unconfirmed PTY routes and viewers are bounded and expired; browser disconnect + cleanup and wing-offline notification preserve reconnect behavior without an + attacker-growable provisional map. +- [x] Notification nonce dedup is server-scoped, per-user, and bounded to 10,000 + insertion-ordered entries. ### JS: Split render.js (2,135 lines) @@ -232,9 +219,10 @@ account management, org settings, audit display. `addEventListener` on every tab without removing old listeners, so after N re-renders each tab has N click handlers. Use event delegation on the container instead -- [ ] Investigate `nav.js:49` session switching guard — `if (sess && !sess.swept) return` - appears to bail when the session IS valid (preventing switch to active sessions). - Either inverted logic or compensated elsewhere — needs investigation +- [x] Investigated the session switching guard: `swept` means confirmed by the + latest wing inventory sweep. The guard intentionally refuses cached sessions after + a wing goes offline; tests cover the reconciliation state. Rename the field in a + later UI cleanup if the terminology continues to confuse readers. ### JS: Async correctness @@ -243,14 +231,14 @@ account management, org settings, audit display. - [ ] `bytesToB64()` in `helpers.js` uses O(n²) string concatenation in a loop on every encrypt/decrypt — use `String.fromCharCode.apply(null, bytes)` or typed array approach -- [ ] `terminal.js` `saveTermBuffer()` fires every 500ms serializing up to 200KB - to localStorage with no quota checking. 100 sessions = 20MB. Add cleanup on - session deletion and consider debouncing +- [ ] `terminal.js` `saveTermBuffer()` debounces at 500ms and clears a session's + buffer on deletion, but still serializes up to 200KB per retained session with no + global quota. Add oldest-entry eviction or a total storage budget. ### Tests -- [ ] Add tests for `internal/config/` (0% coverage) — config loading, wing ID - generation, var resolution, missing config fallback +- [x] Add config regressions for loading, concurrent wing-ID creation, variable + resolution, missing-config fallback, atomic persistence, and additive wing policy. - [ ] Add tests for agent adapters — `claude.go` (145 lines, 0%), `codex.go` (119 lines, 0%), `cursor.go` (81 lines, 0%). At minimum test stream parsing - [ ] Remove compile-time interface checks from runtime test functions diff --git a/cmd/wt/agent_sandbox.go b/cmd/wt/agent_sandbox.go index ee21d94c..ada683a5 100644 --- a/cmd/wt/agent_sandbox.go +++ b/cmd/wt/agent_sandbox.go @@ -1,8 +1,10 @@ package main import ( + "fmt" "net/url" "os" + "os/exec" "path/filepath" "sort" "strconv" @@ -12,6 +14,35 @@ import ( "github.com/ehrlich-b/wingthing/internal/sandbox" ) +// sandboxAgentExecutable resolves the adapter's command before entering the +// filesystem jail. The jail wrapper cannot safely search PATH after deny:/ has +// replaced the host root, and shared-host agents run from the copied runtime in +// their owner-scoped home rather than from the host installation. +func sandboxAgentExecutable(name, home string, sharedHost bool) (string, error) { + if filepath.IsAbs(name) { + return name, nil + } + if filepath.Base(name) != name || strings.ContainsAny(name, `/\\`) { + return "", fmt.Errorf("invalid sandbox agent command %q", name) + } + if sharedHost { + candidate := filepath.Join(home, ".local", "bin", name) + info, err := os.Stat(candidate) + if err != nil { + return "", fmt.Errorf("resolve shared-host agent command %q: %w", name, err) + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 { + return "", fmt.Errorf("shared-host agent command %q is not an executable regular file", candidate) + } + return candidate, nil + } + resolved, err := exec.LookPath(name) + if err != nil { + return "", fmt.Errorf("resolve sandbox agent command %q: %w", name, err) + } + return resolved, nil +} + // directAgentSandboxConfig applies the same agent capabilities used by the // interactive egg path to non-interactive `wt run` tasks. Keeping these paths // in sync is important: an agent that works in a terminal must not silently @@ -75,14 +106,16 @@ func directAgentSandboxConfigForTask(eggCfg *egg.EggConfig, agentName, isolation } result := sandbox.Config{ - Mounts: mounts, - Domains: domains, - CPULimit: declared.CPULimit, - MemLimit: declared.MemLimit, - MaxFDs: declared.MaxFDs, - PidLimit: declared.PidLimit, - UserHome: home, - Trace: declared.Trace, + Mounts: mounts, + NetworkMode: policy.Mode, + Domains: domains, + LocalPorts: append([]int(nil), policy.LocalPorts...), + CPULimit: declared.CPULimit, + MemLimit: declared.MemLimit, + MaxFDs: declared.MaxFDs, + PidLimit: declared.PidLimit, + UserHome: home, + Trace: declared.Trace, } if sharedHost { result.Deny = []string{"/"} @@ -187,7 +220,7 @@ func directAgentEnvWithPolicy(agentName, home string, proxyPort int, inheritHost } envMap["GIT_TERMINAL_PROMPT"] = "0" if proxyPort > 0 { - proxyURL := "http://localhost:" + strconv.Itoa(proxyPort) + proxyURL := "http://127.0.0.1:" + strconv.Itoa(proxyPort) envMap["HTTPS_PROXY"] = proxyURL envMap["HTTP_PROXY"] = proxyURL envMap["NODE_USE_ENV_PROXY"] = "1" diff --git a/cmd/wt/agent_sandbox_test.go b/cmd/wt/agent_sandbox_test.go index e8412b3f..eb8f6b7f 100644 --- a/cmd/wt/agent_sandbox_test.go +++ b/cmd/wt/agent_sandbox_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/ehrlich-b/wingthing/internal/agent" "github.com/ehrlich-b/wingthing/internal/egg" "github.com/ehrlich-b/wingthing/internal/sandbox" ) @@ -194,8 +195,8 @@ func TestDirectAgentEnvPreservesHomeAndAddsProxy(t *testing.T) { joined := "\n" + strings.Join(env, "\n") + "\n" for _, want := range []string{ "\nHOME=" + home + "\n", - "\nHTTPS_PROXY=http://localhost:43210\n", - "\nHTTP_PROXY=http://localhost:43210\n", + "\nHTTPS_PROXY=http://127.0.0.1:43210\n", + "\nHTTP_PROXY=http://127.0.0.1:43210\n", "\nNODE_USE_ENV_PROXY=1\n", "\nGIT_TERMINAL_PROMPT=0\n", } { @@ -236,6 +237,51 @@ func TestSharedHostDirectAgentEnvDropsAmbientProviderCredentials(t *testing.T) { } } +func TestSandboxAgentExecutableUsesOwnerScopedSharedRuntime(t *testing.T) { + home := t.TempDir() + command := filepath.Join(home, ".local", "bin", "claude") + if err := os.MkdirAll(filepath.Dir(command), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(command, []byte("fixture"), 0o700); err != nil { + t.Fatal(err) + } + got, err := sandboxAgentExecutable("claude", home, true) + if err != nil { + t.Fatal(err) + } + if got != command { + t.Fatalf("shared executable = %q, want %q", got, command) + } + if _, err := sandboxAgentExecutable("missing", home, true); err == nil { + t.Fatal("missing shared runtime was accepted") + } +} + +func TestSandboxAgentExecutableResolvesHostCommandForPersonalTask(t *testing.T) { + got, err := sandboxAgentExecutable("sh", "", false) + if err != nil { + t.Fatal(err) + } + if !filepath.IsAbs(got) { + t.Fatalf("personal executable = %q, want absolute path", got) + } +} + +func TestAgentRuntimeCommandMatchesSupportedAgentCatalog(t *testing.T) { + for _, definition := range agent.Definitions() { + if got := agentRuntimeCommand(definition.Name); got != definition.Command { + t.Errorf("agentRuntimeCommand(%q) = %q, want %q", definition.Name, got, definition.Command) + } + } + if got := agentRuntimeCommand("cursor"); got != "agent" { + t.Fatalf("Cursor isolated runtime command = %q, want agent", got) + } + if got := agentRuntimeCommand("custom-command"); got != "custom-command" { + t.Fatalf("unknown command fallback = %q", got) + } +} + func TestSharedHostDirectAgentUsesAllowlistJail(t *testing.T) { t.Setenv("WT_PROVIDER_BASE_URL", "") home := t.TempDir() diff --git a/cmd/wt/attach.go b/cmd/wt/attach.go index 2e4d2f91..3460defe 100644 --- a/cmd/wt/attach.go +++ b/cmd/wt/attach.go @@ -5,15 +5,16 @@ import ( "errors" "fmt" "io" + "log" "os" "os/exec" "os/signal" - "path/filepath" "strings" "syscall" "github.com/ehrlich-b/wingthing/internal/config" pb "github.com/ehrlich-b/wingthing/internal/egg/pb" + "github.com/ehrlich-b/wingthing/internal/ws" "github.com/spf13/cobra" "golang.org/x/term" "google.golang.org/grpc/codes" @@ -83,14 +84,7 @@ func attachCmd() *cobra.Command { } func validateSessionID(sessionID string) error { - if sessionID == "" || sessionID == "." || sessionID == ".." || filepath.Base(sessionID) != sessionID { - return fmt.Errorf("invalid session ID %q", sessionID) - } - for _, r := range sessionID { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { - continue - } + if !ws.ValidSessionID(sessionID) { return fmt.Errorf("invalid session ID %q", sessionID) } return nil @@ -188,7 +182,7 @@ func attachLocal(ctx context.Context, cfg *config.Config, sessionID string) (boo if err != nil { return false, err } - defer ec.Close() + defer closeWithLog("egg client", ec) sessionID = resolved.ID fd := int(os.Stdin.Fd()) @@ -210,7 +204,11 @@ func attachLocal(ctx context.Context, cfg *config.Config, sessionID string) (boo if rawErr != nil { return false, fmt.Errorf("put terminal in raw mode: %w", rawErr) } - defer term.Restore(fd, oldState) + defer func() { + if err := term.Restore(fd, oldState); err != nil { + log.Printf("restore terminal: %v", err) + } + }() } winchCh := make(chan os.Signal, 1) diff --git a/cmd/wt/attach_test.go b/cmd/wt/attach_test.go index 12e36fbd..0d0d8031 100644 --- a/cmd/wt/attach_test.go +++ b/cmd/wt/attach_test.go @@ -2,6 +2,7 @@ package main import ( "reflect" + "strings" "testing" ) @@ -50,7 +51,7 @@ func TestValidateSessionID(t *testing.T) { t.Errorf("validateSessionID(%q): %v", valid, err) } } - for _, invalid := range []string{"", ".", "..", "../egg", "a/b", "two words", "x\ncommand"} { + for _, invalid := range []string{"", ".", "..", "../egg", "a/b", `a\b`, "two words", "x\ncommand", strings.Repeat("a", 129)} { if err := validateSessionID(invalid); err == nil { t.Errorf("validateSessionID(%q) unexpectedly succeeded", invalid) } diff --git a/cmd/wt/claude_profile_test.go b/cmd/wt/claude_profile_test.go new file mode 100644 index 00000000..3dd8e6d8 --- /dev/null +++ b/cmd/wt/claude_profile_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPrepareIsolatedClaudeConfigLoadsStableWritableProfile(t *testing.T) { + home := t.TempDir() + legacy := filepath.Join(home, ".claude.json") + legacyData := []byte(`{"hasCompletedOnboarding":true,"wtFixture":"persisted"}`) + if err := os.WriteFile(legacy, legacyData, 0600); err != nil { + t.Fatal(err) + } + envMap := map[string]string{} + if err := prepareIsolatedClaudeConfig(home, envMap); err != nil { + t.Fatal(err) + } + + claudeDir := filepath.Join(home, ".claude") + if got := envMap["CLAUDE_CONFIG_DIR"]; got != claudeDir { + t.Fatalf("CLAUDE_CONFIG_DIR = %q, want %q", got, claudeDir) + } + profile := filepath.Join(claudeDir, ".claude.json") + data, err := os.ReadFile(profile) + if err != nil { + t.Fatal(err) + } + if string(data) != string(legacyData) { + t.Fatalf("migrated profile = %q, want %q", data, legacyData) + } + if info, err := os.Stat(profile); err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("profile mode = %v, %v", infoMode(info), err) + } +} + +func TestPrepareIsolatedClaudeConfigNeverOverwritesCurrentProfile(t *testing.T) { + home := t.TempDir() + claudeDir := filepath.Join(home, ".claude") + if err := os.MkdirAll(claudeDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".claude.json"), []byte(`{"wtFixture":"legacy"}`), 0600); err != nil { + t.Fatal(err) + } + profile := filepath.Join(claudeDir, ".claude.json") + if err := os.WriteFile(profile, []byte(`{"wtFixture":"current"}`), 0600); err != nil { + t.Fatal(err) + } + if err := prepareIsolatedClaudeConfig(home, map[string]string{}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(profile) + if err != nil { + t.Fatal(err) + } + if string(data) != `{"wtFixture":"current"}` { + t.Fatalf("current profile was overwritten: %s", data) + } +} + +func TestPrepareIsolatedClaudeConfigDoesNotFollowLegacySymlink(t *testing.T) { + home := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside.json") + if err := os.WriteFile(outside, []byte(`{"secret":"outside"}`), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(home, ".claude.json")); err != nil { + t.Fatal(err) + } + if err := prepareIsolatedClaudeConfig(home, map[string]string{}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(home, ".claude", ".claude.json")); !os.IsNotExist(err) { + t.Fatalf("legacy symlink was migrated: %v", err) + } +} + +func TestUserHashSeparatesDistinctAuthenticatedIdentities(t *testing.T) { + first := userHash("user-id-for-work-account") + second := userHash("user-id-for-personal-account") + if first == second { + t.Fatal("distinct authenticated identities shared an agent-home hash") + } + if first != userHash("user-id-for-work-account") { + t.Fatal("stable identity did not retain a stable agent-home hash") + } + if got := userHash("u-alice"); got != "e3fb03053ead" { + t.Fatalf("userHash(u-alice) = %q; browser identity fixture expects e3fb03053ead", got) + } +} diff --git a/cmd/wt/doctor.go b/cmd/wt/doctor.go index 7eb861da..c16bb845 100644 --- a/cmd/wt/doctor.go +++ b/cmd/wt/doctor.go @@ -144,7 +144,9 @@ func ollamaReachable(baseURL string) bool { if err != nil { return false } - resp.Body.Close() + if err := resp.Body.Close(); err != nil { + return false + } return resp.StatusCode == http.StatusOK } @@ -158,7 +160,9 @@ func roostReachable(roostURL string) bool { if err != nil { return false } - resp.Body.Close() + if err := resp.Body.Close(); err != nil { + return false + } return resp.StatusCode == http.StatusOK } diff --git a/cmd/wt/egg.go b/cmd/wt/egg.go index 2cdae1e5..5f1c90df 100644 --- a/cmd/wt/egg.go +++ b/cmd/wt/egg.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "log" "os" "os/exec" "os/signal" @@ -19,11 +20,11 @@ import ( "text/tabwriter" "time" + "github.com/ehrlich-b/wingthing/internal/agent" "github.com/ehrlich-b/wingthing/internal/config" "github.com/ehrlich-b/wingthing/internal/egg" pb "github.com/ehrlich-b/wingthing/internal/egg/pb" "github.com/ehrlich-b/wingthing/internal/sandbox" - "github.com/google/uuid" "github.com/spf13/cobra" "golang.org/x/term" ) @@ -79,6 +80,8 @@ func eggRunCmd() *cobra.Command { cols uint32 fsFlag []string networkFlag []string + localPortFlag []int + networkModeFlag string agentDomainsFlag string envFlag []string envFileRequired bool @@ -101,6 +104,7 @@ func eggRunCmd() *cobra.Command { kindFlag string commandFlag []string agentArgFlag []string + outerBoundaryFlag bool ) cmd := &cobra.Command{ @@ -154,6 +158,8 @@ func eggRunCmd() *cobra.Command { Shell: shell, FS: fsFlag, Network: networkFlag, + LocalPorts: localPortFlag, + NetworkMode: networkModeFlag, AgentDomains: agentDomainsFlag, Env: envMap, Rows: rows, @@ -174,6 +180,7 @@ func eggRunCmd() *cobra.Command { ResumeSessionID: resumeSessionFlag, ToolNames: toolNamesFlag, ToolSocketPath: toolSocketFlag, + OuterBoundary: outerBoundaryFlag, } ctx, cancel := context.WithCancel(cmd.Context()) @@ -203,10 +210,17 @@ func eggRunCmd() *cobra.Command { cmd.Flags().Uint32Var(&cols, "cols", 80, "terminal cols") cmd.Flags().StringArrayVar(&fsFlag, "fs", nil, "filesystem rules (rw:./, deny:~/.ssh)") cmd.Flags().StringArrayVar(&networkFlag, "network", nil, "network domains (api.anthropic.com, *, none)") + cmd.Flags().IntSliceVar(&localPortFlag, "local-port", nil, "host loopback port forwarded into the network namespace") + cmd.Flags().StringVar(&networkModeFlag, "network-mode", "", "network policy mode: enforce or observe (internal)") cmd.Flags().StringVar(&agentDomainsFlag, "agent-domains", "", "agent domain policy: merge or none (internal)") + if err := cmd.Flags().MarkHidden("network-mode"); err != nil { + panic(err) + } cmd.Flags().StringArrayVar(&envFlag, "env", nil, "environment variables (KEY=VAL)") cmd.Flags().BoolVar(&envFileRequired, "env-file-required", false, "require the internal environment payload") - cmd.Flags().MarkHidden("env-file-required") + if err := cmd.Flags().MarkHidden("env-file-required"); err != nil { + panic(err) + } cmd.Flags().BoolVar(&dangerouslySkipPermissions, "dangerously-skip-permissions", false, "skip agent permission prompts") cmd.Flags().StringVar(&cpuFlag, "cpu", "", "CPU time limit (e.g. 300s)") cmd.Flags().StringVar(&memFlag, "memory", "", "memory limit (e.g. 2GB)") @@ -226,7 +240,13 @@ func eggRunCmd() *cobra.Command { cmd.Flags().StringVar(&kindFlag, "kind", "agent", "session kind (internal)") cmd.Flags().StringArrayVar(&commandFlag, "command-arg", nil, "command argument (internal)") cmd.Flags().StringArrayVar(&agentArgFlag, "agent-arg", nil, "extra agent argument (internal)") - cmd.MarkFlagRequired("session-id") + cmd.Flags().BoolVar(&outerBoundaryFlag, "outer-boundary", false, "trust the parent host boundary (internal)") + if err := cmd.Flags().MarkHidden("outer-boundary"); err != nil { + panic(err) + } + if err := cmd.MarkFlagRequired("session-id"); err != nil { + panic(err) + } return cmd } @@ -295,13 +315,10 @@ func writeEggEnvironment(dir string, environment map[string]string) (string, err return "", fmt.Errorf("create egg environment: %w", err) } if _, err := file.Write(data); err != nil { - file.Close() - os.Remove(path) - return "", fmt.Errorf("write egg environment: %w", err) + return "", errors.Join(fmt.Errorf("write egg environment: %w", err), closeAndJoin("egg environment", file, nil), removeIfExists(path)) } if err := file.Close(); err != nil { - os.Remove(path) - return "", fmt.Errorf("close egg environment: %w", err) + return "", errors.Join(fmt.Errorf("close egg environment: %w", err), removeIfExists(path)) } return path, nil } @@ -328,7 +345,7 @@ func eggStopCmd() *cobra.Command { if err != nil { return err } - defer ec.Close() + defer closeWithLog("egg client", ec) if err := ec.Kill(cmd.Context(), session.ID); err != nil { return fmt.Errorf("stop session %s: %w", session.ID, err) } @@ -454,23 +471,26 @@ func fileExists(path string) bool { return err == nil && !st.IsDir() } -// explainEnforcement reports how the network policy is actually held, which is -// not the same on both platforms. macOS denies all egress in the seatbelt -// profile and allows only the proxy port. Linux strips CLONE_NEWNET for any -// need above NetworkNone, so HTTPS_PROXY is the only thing steering traffic and -// the sandboxed process is free to ignore it. Saying "proxy" there would be a -// lie, and this command exists to stop the sandbox being unauditable. -func explainEnforcement(need sandbox.NetworkNeed, goos string) string { +// explainEnforcement reports how the network policy is actually held. macOS +// Seatbelt allows only the proxy endpoint. Linux keeps CLONE_NEWNET and exposes +// only inherited proxy/loopback relays, so ignoring HTTPS_PROXY fails closed. +func explainEnforcement(need sandbox.NetworkNeed, goos, mode string) string { switch need { case sandbox.NetworkNone: return "none" case sandbox.NetworkFull: + if goos == "linux" { + return "proxy" + } return "unrestricted" } - if goos == "linux" { - return "advisory" - } if need == sandbox.NetworkHTTPS { + if mode == "observe" { + return "proxy-observe" + } + return "proxy" + } + if goos == "linux" { return "proxy" } return "kernel" @@ -501,7 +521,7 @@ func explainPolicyWithProvider(cfg *egg.EggConfig, agentName, home, source, prov ConfigSource: source, Isolation: isolation, NetworkNeed: resolved.NetworkNeed.String(), - Enforcement: explainEnforcement(resolved.NetworkNeed, runtime.GOOS), + Enforcement: explainEnforcement(resolved.NetworkNeed, runtime.GOOS, resolved.Mode), Domains: nonNilStrings(resolved.Domains), LocalPorts: resolved.LocalPorts, Mode: resolved.Mode, @@ -512,6 +532,12 @@ func explainPolicyWithProvider(cfg *egg.EggConfig, agentName, home, source, prov Derived: make([]explainedHole, 0, len(resolved.Derived)), Suppressed: make([]explainedHole, 0, len(resolved.Suppressed)), } + if isolation == "outer-boundary" { + // There is no wingthing network namespace or proxy boundary in this + // mode. In particular, do not claim Linux proxy enforcement merely + // because the resolved (unconfined) policy asks for full networking. + p.Enforcement = "unrestricted" + } if p.LocalPorts == nil { p.LocalPorts = []int{} } @@ -554,12 +580,18 @@ func renderPolicy(w io.Writer, p explainedPolicy) error { } tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) - fmt.Fprintf(tw, "agent\t%s\n", agentName) - fmt.Fprintf(tw, "config\t%s\n", p.ConfigSource) - fmt.Fprintf(tw, "isolation\t%s\n", p.Isolation) - fmt.Fprintf(tw, "network\t%s\n", p.NetworkNeed) - fmt.Fprintf(tw, "enforcement\t%s\n", p.Enforcement) - fmt.Fprintf(tw, "mode\t%s\n", mode) + for _, row := range []struct{ key, value string }{ + {"agent", agentName}, + {"config", p.ConfigSource}, + {"isolation", p.Isolation}, + {"network", string(p.NetworkNeed)}, + {"enforcement", p.Enforcement}, + {"mode", mode}, + } { + if err := writef(tw, "%s\t%s\n", row.key, row.value); err != nil { + return err + } + } if err := tw.Flush(); err != nil { return err } @@ -578,15 +610,23 @@ func renderPolicy(w io.Writer, p explainedPolicy) error { } if len(p.Domains) > 0 { - fmt.Fprintf(w, "\ndomains (%d)\n", len(p.Domains)) + if err := writef(w, "\ndomains (%d)\n", len(p.Domains)); err != nil { + return err + } tw = tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) for _, d := range p.Domains { if reason, ok := derivedDomains[d]; ok { - fmt.Fprintf(tw, " %s\tderived\t%s\n", d, reason) + if err := writef(tw, " %s\tderived\t%s\n", d, reason); err != nil { + return err + } } else if reason, ok := drilledDomains[d]; ok { - fmt.Fprintf(tw, " %s\tauto\t%s\n", d, reason) + if err := writef(tw, " %s\tauto\t%s\n", d, reason); err != nil { + return err + } } else { - fmt.Fprintf(tw, " %s\tdeclared\t\n", d) + if err := writef(tw, " %s\tdeclared\t\n", d); err != nil { + return err + } } } if err := tw.Flush(); err != nil { @@ -595,10 +635,14 @@ func renderPolicy(w io.Writer, p explainedPolicy) error { } if len(p.Suppressed) > 0 { - fmt.Fprintln(w, "\nsuppressed agent domains") + if err := writeln(w, "\nsuppressed agent domains"); err != nil { + return err + } tw = tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) for _, h := range p.Suppressed { - fmt.Fprintf(tw, " %s\tsuppressed\t%s\n", h.Value, h.Reason) + if err := writef(tw, " %s\tsuppressed\t%s\n", h.Value, h.Reason); err != nil { + return err + } } if err := tw.Flush(); err != nil { return err @@ -606,21 +650,29 @@ func renderPolicy(w io.Writer, p explainedPolicy) error { } if len(p.LocalPorts) > 0 { - fmt.Fprintf(w, "\nforwarded loopback ports\n") + if err := writeln(w, "\nforwarded loopback ports"); err != nil { + return err + } for _, port := range p.LocalPorts { - fmt.Fprintf(w, " %d\n", port) + if err := writef(w, " %d\n", port); err != nil { + return err + } } } if len(p.Mounts) > 0 { - fmt.Fprintf(w, "\nmounts (%d)\n", len(p.Mounts)) + if err := writef(w, "\nmounts (%d)\n", len(p.Mounts)); err != nil { + return err + } tw = tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) for _, m := range p.Mounts { access := "rw" if m.ReadOnly { access = "ro" } - fmt.Fprintf(tw, " %s\t%s\n", access, m.Source) + if err := writef(tw, " %s\t%s\n", access, m.Source); err != nil { + return err + } } if err := tw.Flush(); err != nil { return err @@ -634,17 +686,25 @@ func renderPolicy(w io.Writer, p explainedPolicy) error { if len(section.paths) == 0 { continue } - fmt.Fprintf(w, "\n%s (%d)\n", section.title, len(section.paths)) + if err := writef(w, "\n%s (%d)\n", section.title, len(section.paths)); err != nil { + return err + } for _, path := range section.paths { - fmt.Fprintf(w, " %s\n", path) + if err := writef(w, " %s\n", path); err != nil { + return err + } } } if len(p.Drilled) > 0 { - fmt.Fprintf(w, "\nauto-drilled for %s (%d)\n", p.Agent, len(p.Drilled)) + if err := writef(w, "\nauto-drilled for %s (%d)\n", p.Agent, len(p.Drilled)); err != nil { + return err + } tw = tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) for _, h := range p.Drilled { - fmt.Fprintf(tw, " %s\t%s\t%s\n", h.Kind, h.Value, h.Reason) + if err := writef(tw, " %s\t%s\t%s\n", h.Kind, h.Value, h.Reason); err != nil { + return err + } } if err := tw.Flush(); err != nil { return err @@ -703,12 +763,15 @@ func eggSpawn(ctx context.Context, agentName, configPath string, trace bool, res } } - sessionID := uuid.New().String()[:8] + sessionID := newRuntimeID() // Handle --resume: restore chat history and get agent session ID var agentResumeID string if resumeID != "" { - home, _ := os.UserHomeDir() + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("resolve user home: %w", err) + } eggDir := filepath.Join(cfg.Dir, "eggs", resumeID) var restoreErr error agentResumeID, restoreErr = egg.RestoreSessionHistory(agentName, cwd, eggDir, home) @@ -722,7 +785,7 @@ func eggSpawn(ctx context.Context, agentName, configPath string, trace bool, res if err != nil { return fmt.Errorf("spawn egg: %w", err) } - defer ec.Close() + defer closeWithLog("egg client", ec) stream, err := ec.AttachSession(ctx, sessionID) if err != nil { @@ -733,7 +796,11 @@ func eggSpawn(ctx context.Context, agentName, configPath string, trace bool, res if term.IsTerminal(fd) { oldState, err := term.MakeRaw(fd) if err == nil { - defer term.Restore(fd, oldState) + defer func() { + if err := term.Restore(fd, oldState); err != nil { + log.Printf("restore terminal: %v", err) + } + }() } } @@ -745,13 +812,16 @@ func eggSpawn(ctx context.Context, agentName, configPath string, trace bool, res go func() { for range winchCh { if w, h, err := term.GetSize(fd); err == nil { - ec.Resize(ctx, sessionID, uint32(h), uint32(w)) + if err := ec.Resize(ctx, sessionID, uint32(h), uint32(w)); err != nil { + log.Printf("resize session %s: %v", sessionID, err) + } } } }() // Read output from egg → stdout exitCode := 0 + var outputErr error done := make(chan struct{}) go func() { defer close(done) @@ -762,7 +832,10 @@ func eggSpawn(ctx context.Context, agentName, configPath string, trace bool, res } switch p := msg.Payload.(type) { case *pb.SessionMsg_Output: - os.Stdout.Write(p.Output) + if _, err := os.Stdout.Write(p.Output); err != nil { + outputErr = fmt.Errorf("write terminal output: %w", err) + return + } case *pb.SessionMsg_ExitCode: exitCode = int(p.ExitCode) return @@ -809,12 +882,17 @@ func eggSpawn(ctx context.Context, agentName, configPath string, trace bool, res return nil case <-done: } + if outputErr != nil { + return outputErr + } if exitCode != 0 { // Dump egg.log so the user can see why the agent crashed logPath := filepath.Join(cfg.Dir, "eggs", sessionID, "egg.log") if logData, err := os.ReadFile(logPath); err == nil && len(logData) > 0 { - os.Stderr.Write(logData) + if _, err := os.Stderr.Write(logData); err != nil { + return errors.Join(fmt.Errorf("agent exited with code %d", exitCode), fmt.Errorf("write egg log: %w", err)) + } } return fmt.Errorf("agent exited with code %d", exitCode) } @@ -888,12 +966,48 @@ func NormalizeUser(email string) string { return strings.Trim(s, "-") } -// userHash returns the first 12 hex chars of the SHA256 of the email. -func userHash(email string) string { - h := sha256.Sum256([]byte(email)) +// userHash returns the first 12 hex chars of the SHA256 of a stable identity. +// Org/shared-host callers deliberately pass the authenticated user ID, not an +// email address: two distinct Wingthing accounts must not silently share agent +// credentials merely because an identity provider reports the same email. +func userHash(stableIdentity string) string { + h := sha256.Sum256([]byte(stableIdentity)) return hex.EncodeToString(h[:])[:12] } +func prepareIsolatedClaudeConfig(home string, envMap map[string]string) error { + claudeDir := filepath.Join(home, ".claude") + envMap["CLAUDE_CONFIG_DIR"] = claudeDir + + // One-time migration: users who already completed onboarding under the old + // layout have their config at ~/.claude.json. Relocating + // CLAUDE_CONFIG_DIR would leave that behind and re-prompt them once on + // release. Seed the new path from the old file if it hasn't been created + // yet. Only a regular file is migrated — a symlink at the root is the + // shared empty stub, whose users never had persisted state to preserve. + newCfg := filepath.Join(claudeDir, ".claude.json") + oldCfg := filepath.Join(home, ".claude.json") + if _, err := os.Stat(newCfg); errors.Is(err, os.ErrNotExist) { + if fi, legacyErr := os.Lstat(oldCfg); legacyErr == nil && fi.Mode().IsRegular() { + data, readErr := os.ReadFile(oldCfg) + if readErr != nil { + return fmt.Errorf("read legacy Claude config: %w", readErr) + } + if err := os.MkdirAll(claudeDir, 0700); err != nil { + return fmt.Errorf("prepare Claude config directory: %w", err) + } + if err := writeAtomicMetadataFile(newCfg, data, 0600); err != nil { + return fmt.Errorf("migrate Claude config: %w", err) + } + } else if legacyErr != nil && !errors.Is(legacyErr, os.ErrNotExist) { + return fmt.Errorf("inspect legacy Claude config: %w", legacyErr) + } + } else if err != nil { + return fmt.Errorf("inspect Claude config: %w", err) + } + return nil +} + func writeEggOwner(dir, userID, email string) error { if userID == "" { return nil @@ -977,10 +1091,11 @@ func spawnEgg(cfg *config.Config, sessionID, agentName string, eggCfg *egg.EggCo } eggCfg = sealed } + outerBoundary := !egg.RequiresSandbox(eggCfg, agentName) // Pre-flight: verify the sandbox can work before spawning a child process. // Catches AppArmor userns restrictions, missing sysctl, etc. with a clear // error instead of a silent 5s timeout. - if egg.RequiresSandbox(eggCfg, agentName) { + if !outerBoundary { if ok, help := sandbox.CheckCapability(); !ok { return nil, fmt.Errorf("sandbox not available: %s\nrun: wt doctor --fix", help) } @@ -1015,6 +1130,9 @@ func spawnEgg(cfg *config.Config, sessionID, agentName string, eggCfg *egg.EggCo "--rows", strconv.Itoa(int(rows)), "--cols", strconv.Itoa(int(cols)), } + if outerBoundary { + args = append(args, "--outer-boundary") + } for _, arg := range o.Command { args = append(args, "--command-arg="+arg) } @@ -1030,6 +1148,14 @@ func spawnEgg(cfg *config.Config, sessionID, agentName string, eggCfg *egg.EggCo if eggCfg.DangerouslySkipPermissions { args = append(args, "--dangerously-skip-permissions") } + // Compute the effective home before expanding FS policy. Besides ~ rules, + // this lets the policy mask a live SSH agent socket when ~/.ssh is denied. + realHome, _ := os.UserHomeDir() + effectiveHome := realHome + isolatedUser := identity.UserID != "" && (identity.OrgWing || identity.SharedHost) + if isolatedUser { + effectiveHome = filepath.Join(cfg.Dir, "user-homes", userHash(identity.UserID)) + } for _, entry := range eggCfg.FS { // Resolve relative paths in fs entries mode, path, ok := strings.Cut(entry, ":") @@ -1044,9 +1170,18 @@ func spawnEgg(cfg *config.Config, sessionID, agentName string, eggCfg *egg.EggCo } args = append(args, "--fs", mode+":"+path) } + for _, path := range eggCfg.SSHAgentSocketDenyPaths(effectiveHome, identity.SharedHost) { + args = append(args, "--fs", "deny:"+path) + } for _, d := range eggCfg.Network.Domains { args = append(args, "--network", d) } + for _, port := range eggCfg.Network.LocalPorts { + args = append(args, "--local-port", strconv.Itoa(port)) + } + if eggCfg.Network.Mode != "" { + args = append(args, "--network-mode", eggCfg.Network.Mode) + } if eggCfg.Network.AgentDomains != "" { args = append(args, "--agent-domains", eggCfg.Network.AgentDomains) } @@ -1056,12 +1191,6 @@ func spawnEgg(cfg *config.Config, sessionID, agentName string, eggCfg *egg.EggCo // On org wings, ALL users get per-user homes for isolation. // Computed before BuildEnvMap so ~ expansion in FS rules (e.g. deny:~/.ssh) // resolves against the correct home. - realHome, _ := os.UserHomeDir() - effectiveHome := realHome - isolatedUser := identity.UserID != "" && (identity.OrgWing || identity.SharedHost) - if isolatedUser { - effectiveHome = filepath.Join(cfg.Dir, "user-homes", userHash(identity.UserID)) - } envMap := eggCfg.BuildEnvMap(effectiveHome) if identity.SharedHost { safe := map[string]bool{ @@ -1121,9 +1250,15 @@ func spawnEgg(cfg *config.Config, sessionID, agentName string, eggCfg *egg.EggCo src := filepath.Join(realHome, rc) dst := filepath.Join(perUserHome, rc) if _, err := os.Stat(src); err == nil { - if _, err := os.Lstat(dst); err != nil { - os.Symlink(src, dst) + if _, err := os.Lstat(dst); errors.Is(err, os.ErrNotExist) { + if err := os.Symlink(src, dst); err != nil { + return nil, fmt.Errorf("seed shell config %s: %w", rc, err) + } + } else if err != nil { + return nil, fmt.Errorf("inspect shell config %s: %w", rc, err) } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("inspect source shell config %s: %w", rc, err) } } } @@ -1135,14 +1270,19 @@ func spawnEgg(cfg *config.Config, sessionID, agentName string, eggCfg *egg.EggCo return nil, fmt.Errorf("prepare agent bin directory: %w", err) } } - if agentBin, err := exec.LookPath(agentName); err == nil { - dst := filepath.Join(localBin, agentName) + runtimeCommand := agentRuntimeCommand(agentName) + if agentBin, err := exec.LookPath(runtimeCommand); err == nil { + dst := filepath.Join(localBin, runtimeCommand) if identity.SealedFS { - if err := installSharedAgentBinary(agentBin, perUserHome, agentName); err != nil { + if err := installSharedAgentBinary(agentBin, perUserHome, runtimeCommand); err != nil { return nil, fmt.Errorf("prepare shared-host %s runtime: %w", agentName, err) } - } else if _, err := os.Lstat(dst); err != nil { - os.Symlink(agentBin, dst) + } else if _, err := os.Lstat(dst); errors.Is(err, os.ErrNotExist) { + if err := os.Symlink(agentBin, dst); err != nil { + return nil, fmt.Errorf("link agent runtime: %w", err) + } + } else if err != nil { + return nil, fmt.Errorf("inspect agent runtime link: %w", err) } } args = append(args, "--user-home", perUserHome) @@ -1159,23 +1299,8 @@ func spawnEgg(cfg *config.Config, sessionID, agentName string, eggCfg *egg.EggCo // the per-user home: onboarding and theme now land there immediately and // survive across sessions regardless of how the previous one ended. if isolatedUser && agentName == "claude" { - claudeDir := filepath.Join(effectiveHome, ".claude") - envMap["CLAUDE_CONFIG_DIR"] = claudeDir - // One-time migration: users who already completed onboarding under the - // old layout have their config at ~/.claude.json (HOME root). Relocating - // CLAUDE_CONFIG_DIR would leave that behind and re-prompt them once on - // release. Seed the new path from the old file if it hasn't been created - // yet. Only a regular file is migrated — a symlink at the root is the - // shared empty stub, whose users never had persisted state to preserve. - newCfg := filepath.Join(claudeDir, ".claude.json") - oldCfg := filepath.Join(effectiveHome, ".claude.json") - if _, err := os.Stat(newCfg); os.IsNotExist(err) { - if fi, lerr := os.Lstat(oldCfg); lerr == nil && fi.Mode().IsRegular() { - if data, rerr := os.ReadFile(oldCfg); rerr == nil { - os.MkdirAll(claudeDir, 0700) - os.WriteFile(newCfg, data, 0600) - } - } + if err := prepareIsolatedClaudeConfig(effectiveHome, envMap); err != nil { + return nil, err } } // Rebuild agent settings every session for org wing users. @@ -1185,11 +1310,17 @@ func spawnEgg(cfg *config.Config, sessionID, agentName string, eggCfg *egg.EggCo agentProfile := egg.Profile(agentName) if agentProfile.SettingsFile != "" { settingsDst := filepath.Join(effectiveHome, agentProfile.SettingsFile) - os.MkdirAll(filepath.Dir(settingsDst), 0700) + if err := os.MkdirAll(filepath.Dir(settingsDst), 0700); err != nil { + return nil, fmt.Errorf("prepare agent settings directory: %w", err) + } baseSettings := make(map[string]any) // Read existing session settings to preserve user preferences if data, err := os.ReadFile(settingsDst); err == nil { - json.Unmarshal(data, &baseSettings) + if err := json.Unmarshal(data, &baseSettings); err != nil { + return nil, fmt.Errorf("parse agent settings: %w", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("read agent settings: %w", err) } // Layer host settings on top (permissions from host always win) if srcPath, ok := eggCfg.AgentSettings[agentName]; ok { @@ -1213,8 +1344,12 @@ func spawnEgg(cfg *config.Config, sessionID, agentName string, eggCfg *egg.EggCo } } if len(baseSettings) > 0 { - if data, err := json.MarshalIndent(baseSettings, "", " "); err == nil { - os.WriteFile(settingsDst, append(data, '\n'), 0644) + data, err := json.MarshalIndent(baseSettings, "", " ") + if err != nil { + return nil, fmt.Errorf("encode agent settings: %w", err) + } + if err := writeAtomicMetadataFile(settingsDst, append(data, '\n'), 0644); err != nil { + return nil, fmt.Errorf("write agent settings: %w", err) } } } @@ -1223,7 +1358,9 @@ func spawnEgg(cfg *config.Config, sessionID, agentName string, eggCfg *egg.EggCo // it. The key never enters the agent's environment. The file lives at // effectiveHome/.anthropic_key (not per-session) so the settings.json // path doesn't go stale when sessions end or race with each other. - setupAPIKeyHelper(agentName, envMap, effectiveHome) + if err := setupAPIKeyHelper(agentName, envMap, effectiveHome); err != nil { + return nil, err + } sessionEnv := make(map[string]string, len(envMap)+6) for k, v := range envMap { // Skip WT_ prefix — reserved for session identity injection @@ -1298,10 +1435,10 @@ func spawnEgg(cfg *config.Config, sessionID, agentName string, eggCfg *egg.EggCo } args, envPath, err := prepareEggEnvironmentTransport(dir, args, sessionEnv) if err != nil { - logFile.Close() + closeWithLog("egg log", logFile) return nil, err } - defer os.Remove(envPath) + defer removeWithLog(envPath) child := exec.Command(exe, args...) // Always build a clean env for the wt-egg-run child process. @@ -1329,10 +1466,13 @@ func spawnEgg(cfg *config.Config, sessionID, agentName string, eggCfg *egg.EggCo child.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := child.Start(); err != nil { - logFile.Close() + closeWithLog("egg log", logFile) return nil, fmt.Errorf("start egg: %w", err) } - logFile.Close() + if err := logFile.Close(); err != nil { + abandonStartedDaemon(child) + return nil, fmt.Errorf("close egg log: %w", err) + } // Poll for socket sockPath := filepath.Join(dir, "egg.sock") @@ -1445,13 +1585,23 @@ func parseMemFlag(s string) uint64 { return n * multiplier } +// agentRuntimeCommand is the single translation from Wingthing's public agent +// name to the executable placed in an isolated user's PATH. Most names match; +// Cursor is intentionally exposed as "cursor" while its CLI binary is "agent". +func agentRuntimeCommand(agentName string) string { + if definition, ok := agent.LookupDefinition(agentName); ok { + return definition.Command + } + return agentName +} + // setupAPIKeyHelper moves ANTHROPIC_API_KEY out of the environment and into a // stable file + apiKeyHelper setting. This prevents the key from entering the // agent's env and avoids the v0.128.0 race where per-session paths in // settings.json went stale. -func setupAPIKeyHelper(agentName string, envMap map[string]string, effectiveHome string) { +func setupAPIKeyHelper(agentName string, envMap map[string]string, effectiveHome string) error { if agentName != "claude" { - return + return nil } v, ok := envMap["ANTHROPIC_API_KEY"] if ok { @@ -1466,23 +1616,38 @@ func setupAPIKeyHelper(agentName string, envMap map[string]string, effectiveHome v = os.Getenv("ANTHROPIC_API_KEY") } if v == "" { - return + return nil } keyFile := filepath.Join(effectiveHome, ".anthropic_key") - os.Remove(keyFile) // remove old 0400 file so WriteFile can create fresh - os.WriteFile(keyFile, []byte(v), 0400) + if err := os.MkdirAll(effectiveHome, 0700); err != nil { + return fmt.Errorf("prepare API key helper directory: %w", err) + } + if err := writeAtomicMetadataFile(keyFile, []byte(v), 0400); err != nil { + return fmt.Errorf("write API key helper: %w", err) + } agentProfile := egg.Profile(agentName) if agentProfile.SettingsFile == "" { - return + return nil } settingsDst := filepath.Join(effectiveHome, agentProfile.SettingsFile) - os.MkdirAll(filepath.Dir(settingsDst), 0700) + if err := os.MkdirAll(filepath.Dir(settingsDst), 0700); err != nil { + return fmt.Errorf("prepare API key settings directory: %w", err) + } settings := make(map[string]any) if data, err := os.ReadFile(settingsDst); err == nil { - json.Unmarshal(data, &settings) + if err := json.Unmarshal(data, &settings); err != nil { + return fmt.Errorf("parse API key settings: %w", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read API key settings: %w", err) } settings["apiKeyHelper"] = "cat " + keyFile - if data, err := json.MarshalIndent(settings, "", " "); err == nil { - os.WriteFile(settingsDst, append(data, '\n'), 0644) + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return fmt.Errorf("encode API key settings: %w", err) + } + if err := writeAtomicMetadataFile(settingsDst, append(data, '\n'), 0644); err != nil { + return fmt.Errorf("write API key settings: %w", err) } + return nil } diff --git a/cmd/wt/egg_explain_test.go b/cmd/wt/egg_explain_test.go index 18347740..5fce4914 100644 --- a/cmd/wt/egg_explain_test.go +++ b/cmd/wt/egg_explain_test.go @@ -12,14 +12,9 @@ import ( "github.com/ehrlich-b/wingthing/internal/sandbox" ) -// TestExplainEnforcementReportsPlatformTruth pins the central defect from -// docs/sandbox-enhancement-design.md: the same egg.yaml is a real boundary on -// macOS and a suggestion on Linux. `wt egg explain` is worse than useless if it -// hides that, so the enforcement label is per-platform and tested per-platform. -// -// The linux https/local rows are expected to flip to "proxy" when Phase 3 lands -// (keep CLONE_NEWNET, force traffic through the proxy). This test failing after -// that change is the change working. +// TestExplainEnforcementReportsPlatformTruth pins the kernel-enforced network +// boundary. Linux keeps CLONE_NEWNET and admits only inherited relays, so its +// domain and loopback modes are no longer advisory. func TestExplainEnforcementReportsPlatformTruth(t *testing.T) { tests := []struct { goos string @@ -31,18 +26,21 @@ func TestExplainEnforcementReportsPlatformTruth(t *testing.T) { {"darwin", sandbox.NetworkHTTPS, "proxy"}, {"darwin", sandbox.NetworkFull, "unrestricted"}, {"linux", sandbox.NetworkNone, "none"}, - {"linux", sandbox.NetworkLocal, "advisory"}, - {"linux", sandbox.NetworkHTTPS, "advisory"}, - {"linux", sandbox.NetworkFull, "unrestricted"}, + {"linux", sandbox.NetworkLocal, "proxy"}, + {"linux", sandbox.NetworkHTTPS, "proxy"}, + {"linux", sandbox.NetworkFull, "proxy"}, } for _, tc := range tests { t.Run(tc.goos+"/"+tc.need.String(), func(t *testing.T) { - if got := explainEnforcement(tc.need, tc.goos); got != tc.want { + if got := explainEnforcement(tc.need, tc.goos, ""); got != tc.want { t.Errorf("explainEnforcement(%v, %q) = %q, want %q", tc.need, tc.goos, got, tc.want) } }) } + if got := explainEnforcement(sandbox.NetworkHTTPS, "linux", "observe"); got != "proxy-observe" { + t.Fatalf("Linux observe enforcement = %q", got) + } } // TestExplainPolicyAttributesEveryAgentHole is the point of the command: a hole diff --git a/cmd/wt/embed.go b/cmd/wt/embed.go index 9dc25254..fec01d68 100644 --- a/cmd/wt/embed.go +++ b/cmd/wt/embed.go @@ -86,7 +86,9 @@ func embedCmd() *cobra.Command { } case "raw": for _, v := range vecs { - os.Stdout.Write(embedding.VecAsBytes(v)) + if _, err := os.Stdout.Write(embedding.VecAsBytes(v)); err != nil { + return fmt.Errorf("write raw embedding: %w", err) + } } default: return fmt.Errorf("unknown format %q — use json or raw", formatFlag) diff --git a/cmd/wt/error_helpers.go b/cmd/wt/error_helpers.go new file mode 100644 index 00000000..9a770a0c --- /dev/null +++ b/cmd/wt/error_helpers.go @@ -0,0 +1,84 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "time" +) + +const maxCLIAPIResponseBytes = 1 << 20 + +var cliHTTPClient = &http.Client{Timeout: 15 * time.Second} + +func decodeCLIAPIResponse(body io.Reader, destination any) error { + data, err := io.ReadAll(io.LimitReader(body, maxCLIAPIResponseBytes+1)) + if err != nil { + return err + } + if len(data) > maxCLIAPIResponseBytes { + return fmt.Errorf("API response exceeds %d bytes", maxCLIAPIResponseBytes) + } + return json.Unmarshal(data, destination) +} + +type commandExitError struct { + code int + message string +} + +func (e *commandExitError) Error() string { return e.message } + +func exitError(code int, format string, args ...any) error { + return &commandExitError{code: code, message: fmt.Sprintf(format, args...)} +} + +func writef(writer io.Writer, format string, args ...any) error { + _, err := fmt.Fprintf(writer, format, args...) + return err +} + +func writeln(writer io.Writer, args ...any) error { + _, err := fmt.Fprintln(writer, args...) + return err +} + +func closeWithLog(name string, closer io.Closer) { + if err := closer.Close(); err != nil { + log.Printf("close %s: %v", name, err) + } +} + +func closeAndJoin(name string, closer io.Closer, prior error) error { + if err := closer.Close(); err != nil { + return errors.Join(prior, fmt.Errorf("close %s: %w", name, err)) + } + return prior +} + +func removeWithLog(path string) { + if err := removeIfExists(path); err != nil { + log.Printf("remove %s: %v", path, err) + } +} + +func removeIfExists(path string) error { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func removeFiles(paths ...string) error { + var result error + for _, path := range paths { + if err := removeIfExists(path); err != nil { + result = errors.Join(result, err) + } + } + return result +} diff --git a/cmd/wt/keygen.go b/cmd/wt/keygen.go index a911db32..29777fd3 100644 --- a/cmd/wt/keygen.go +++ b/cmd/wt/keygen.go @@ -24,8 +24,8 @@ func keygenCmd() *cobra.Command { } fmt.Println(encoded) - fmt.Fprintf(cmd.ErrOrStderr(), "\npublic key: %s\n", pubKey) - return nil + _, err = fmt.Fprintf(cmd.ErrOrStderr(), "\npublic key: %s\n", pubKey) + return err }, } } diff --git a/cmd/wt/local_https.go b/cmd/wt/local_https.go new file mode 100644 index 00000000..7ee2701f --- /dev/null +++ b/cmd/wt/local_https.go @@ -0,0 +1,445 @@ +package main + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "os" + "runtime" + "strconv" + "strings" + "time" + + "github.com/ehrlich-b/wingthing/internal/config" + "github.com/ehrlich-b/wingthing/internal/localtls" + "github.com/ehrlich-b/wingthing/internal/relay" + "github.com/spf13/cobra" +) + +const ( + defaultLocalHTTPAddr = "127.0.0.1:8080" + defaultLocalHTTPSAddr = "127.0.0.1:8443" +) + +type localHTTPSConfig struct { + HTTPAddr string + HTTPSAddr string + URL string + Material *localtls.Material +} + +func prepareLocalHTTPS(ctx context.Context, configDir, httpAddr, httpsAddr string, httpAddrExplicit bool) (*localHTTPSConfig, error) { + httpAddr = localHTTPAddrForHTTPS(httpAddr, httpAddrExplicit) + if err := requireLoopbackAddress("wing HTTP", httpAddr); err != nil { + return nil, err + } + if err := requireLoopbackAddress("browser HTTPS", httpsAddr); err != nil { + return nil, err + } + if err := requireLocalCertificateAddress(httpsAddr); err != nil { + return nil, err + } + if sameAddress(httpAddr, httpsAddr) { + return nil, fmt.Errorf("wing HTTP and browser HTTPS listeners must use different addresses") + } + + m, err := localtls.Ensure(configDir, time.Now()) + if err != nil { + return nil, fmt.Errorf("prepare local HTTPS certificate: %w", err) + } + printLocalCertDisclosure(m) + installed, err := installLocalTrust(ctx, m) + if err != nil { + return nil, err + } + if installed { + fmt.Println(" installed: public CA certificate → current user's trust store") + } else { + fmt.Println(" trusted: public CA certificate is already installed for this user") + } + browserURL, err := browserHTTPSURL(httpsAddr) + if err != nil { + return nil, err + } + return &localHTTPSConfig{HTTPAddr: httpAddr, HTTPSAddr: httpsAddr, URL: browserURL, Material: m}, nil +} + +func installLocalTrust(ctx context.Context, m *localtls.Material) (bool, error) { + store := localtls.SystemTrustStore() + trusted, err := store.Trusted(ctx, m) + if err != nil { + return false, err + } + if !trusted { + printTrustPrompt(m) + } + return store.Install(ctx, m) +} + +func localHTTPAddrForHTTPS(addr string, explicit bool) string { + if !explicit && addr == ":8080" { + return defaultLocalHTTPAddr + } + return addr +} + +func prepareLocalHTTPAddress(addr string, explicit bool) (string, error) { + addr = localHTTPAddrForHTTPS(addr, explicit) + if err := requireLoopbackAddress("local roost HTTP", addr); err != nil { + return "", err + } + return addr, nil +} + +func printTrustPrompt(m *localtls.Material) { + fmt.Printf(" installing: %s (public certificate only)\n", m.CACertPath) + if runtime.GOOS == "darwin" { + fmt.Println(" approval: macOS may ask you to approve this user trust-store change") + } +} + +func printLocalCertDisclosure(m *localtls.Material) { + action := "using the existing" + if m.CreatedCA { + action = "created an on-demand" + } + fmt.Printf("local HTTPS: %s localhost-only certificate authority\n", action) + fmt.Printf(" private CA: %s (mode 0600; stays on this machine)\n", m.CAKeyPath) + fmt.Printf(" private TLS: %s (mode 0600; stays on this machine)\n", m.KeyPath) + fmt.Printf(" public CA: %s\n", m.CACertPath) + fmt.Println(" scope: certificate is constrained to localhost and loopback IPs") + fmt.Println(" trust: only the public CA certificate is installed; no private key leaves this box") +} + +func requireLoopbackAddress(label, addr string) error { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return fmt.Errorf("%s address %q: %w", label, addr, err) + } + if host == "" { + return fmt.Errorf("%s address %q listens on every interface; local HTTPS requires an explicit loopback host", label, addr) + } + if !strings.EqualFold(host, "localhost") { + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return fmt.Errorf("%s address %q is not loopback; local HTTPS refuses LAN or public listeners", label, addr) + } + } + portNumber, err := strconv.Atoi(port) + if err != nil || portNumber < 1 || portNumber > 65535 { + return fmt.Errorf("%s address %q has an invalid port", label, addr) + } + return nil +} + +func requireLocalCertificateAddress(addr string) error { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return fmt.Errorf("browser HTTPS address %q: %w", addr, err) + } + if strings.EqualFold(host, "localhost") || host == "127.0.0.1" || host == "::1" { + return nil + } + return fmt.Errorf("browser HTTPS address %q is not covered by the localhost certificate; use localhost, 127.0.0.1, or ::1", addr) +} + +func sameAddress(a, b string) bool { + aHost, aPort, aErr := net.SplitHostPort(a) + bHost, bPort, bErr := net.SplitHostPort(b) + if aErr != nil || bErr != nil || aPort != bPort { + return false + } + if strings.EqualFold(aHost, "localhost") || strings.EqualFold(bHost, "localhost") { + return isLoopbackListenerHost(aHost) && isLoopbackListenerHost(bHost) + } + return normalizeLoopbackHost(aHost) == normalizeLoopbackHost(bHost) +} + +func isLoopbackListenerHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func normalizeLoopbackHost(host string) string { + if strings.EqualFold(host, "localhost") { + return "localhost" + } + ip := net.ParseIP(host) + if ip != nil && ip.IsLoopback() { + if ip.To4() != nil { + return "ipv4-loopback" + } + return "ipv6-loopback" + } + return strings.ToLower(host) +} + +func browserHTTPSURL(addr string) (string, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return "", fmt.Errorf("browser HTTPS address %q: %w", addr, err) + } + if err := requireLocalCertificateAddress(addr); err != nil { + return "", err + } + browserHost := "localhost" + if host == "::1" { + browserHost = "[::1]" + } + if port == "443" { + return "https://" + browserHost, nil + } + return "https://" + net.JoinHostPort(strings.Trim(browserHost, "[]"), port), nil +} + +func localHTTPURL(addr string) string { + if strings.HasPrefix(addr, ":") { + return "http://localhost" + addr + } + return "http://" + addr +} + +func defaultBaseURL(localHTTPS *localHTTPSConfig) string { + if localHTTPS != nil { + return localHTTPS.URL + } + if value := os.Getenv("WT_BASE_URL"); value != "" { + return value + } + return "http://localhost:8080" +} + +func validateLocalHTTPSMode(requested, localMode, edgeMode bool) error { + if !requested { + return nil + } + if edgeMode { + return fmt.Errorf("--https is for a self-hosted local roost and is not compatible with edge mode") + } + if !localMode { + return fmt.Errorf("--https installs a device-local CA and is only available in single-user local mode; hosted and org deployments keep their existing HTTPS configuration") + } + return nil +} + +func authProvidersConfigured() bool { + return strings.TrimSpace(os.Getenv("GITHUB_CLIENT_ID")) != "" || + strings.TrimSpace(os.Getenv("GOOGLE_CLIENT_ID")) != "" || + strings.TrimSpace(os.Getenv("SMTP_HOST")) != "" +} + +func validateAuthProviderEnvironment() error { + for _, provider := range []struct { + name string + idEnv string + secretEnv string + }{ + {name: "GitHub", idEnv: "GITHUB_CLIENT_ID", secretEnv: "GITHUB_CLIENT_SECRET"}, + {name: "Google", idEnv: "GOOGLE_CLIENT_ID", secretEnv: "GOOGLE_CLIENT_SECRET"}, + } { + hasID := strings.TrimSpace(os.Getenv(provider.idEnv)) != "" + hasSecret := strings.TrimSpace(os.Getenv(provider.secretEnv)) != "" + if hasID == hasSecret { + continue + } + missing := provider.idEnv + present := provider.secretEnv + if hasID { + missing = provider.secretEnv + present = provider.idEnv + } + return fmt.Errorf("incomplete %s OAuth configuration: %s is required when %s is set", provider.name, missing, present) + } + return nil +} + +type relayListeners struct { + http *http.Server + https *http.Server + errCh chan namedServerError +} + +type namedServerError struct { + listener string + err error +} + +const ( + relayReadHeaderTimeout = 10 * time.Second + relayIdleTimeout = 2 * time.Minute + relayMaxHeaderBytes = 1 << 20 +) + +func newRelayHTTPServer(handler http.Handler, address string) *http.Server { + return &http.Server{ + Addr: address, + Handler: handler, + ReadHeaderTimeout: relayReadHeaderTimeout, + IdleTimeout: relayIdleTimeout, + MaxHeaderBytes: relayMaxHeaderBytes, + } +} + +func newRelayListeners(handler http.Handler, httpAddr string, localHTTPS *localHTTPSConfig) *relayListeners { + count := 1 + if localHTTPS != nil { + count++ + } + listeners := &relayListeners{ + http: newRelayHTTPServer(handler, httpAddr), + errCh: make(chan namedServerError, count), + } + if localHTTPS != nil { + listeners.https = newRelayHTTPServer(handler, localHTTPS.HTTPSAddr) + } + return listeners +} + +func (l *relayListeners) Start(localHTTPS *localHTTPSConfig) error { + if l.https != nil { + if localHTTPS == nil || localHTTPS.Material == nil { + return fmt.Errorf("browser HTTPS listener is missing certificate material") + } + if _, err := tls.LoadX509KeyPair(localHTTPS.Material.CertPath, localHTTPS.Material.KeyPath); err != nil { + return fmt.Errorf("browser HTTPS certificate: %w", err) + } + } + + httpListener, err := net.Listen("tcp", l.http.Addr) + if err != nil { + return listenerResult(namedServerError{listener: "wing HTTP", err: err}) + } + var httpsListener net.Listener + if l.https != nil { + httpsListener, err = net.Listen("tcp", l.https.Addr) + if err != nil { + _ = httpListener.Close() + return listenerResult(namedServerError{listener: "browser HTTPS", err: err}) + } + } + + go func() { + l.errCh <- namedServerError{listener: "wing HTTP", err: l.http.Serve(httpListener)} + }() + if l.https != nil { + go func() { + l.errCh <- namedServerError{ + listener: "browser HTTPS", + err: l.https.ServeTLS(httpsListener, localHTTPS.Material.CertPath, localHTTPS.Material.KeyPath), + } + }() + } + return nil +} + +func (l *relayListeners) Shutdown(srv *relay.Server, timeout time.Duration) error { + // GracefulShutdown broadcasts relay.restart and closes wing connections once. + httpErr := srv.GracefulShutdown(l.http, timeout) + if l.https == nil { + return httpErr + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return errors.Join(httpErr, l.https.Shutdown(ctx)) +} + +func listenerResult(result namedServerError) error { + if result.err == nil || errors.Is(result.err, http.ErrServerClosed) { + return nil + } + return fmt.Errorf("%s listener: %w", result.listener, result.err) +} + +func localCertCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "local-cert", + Short: "Manage the on-demand localhost HTTPS certificate", + Long: "Create and trust Wingthing's localhost-only CA. The private key is created on demand in WINGTHING_DIR, remains mode 0600, and never leaves this machine. Only the public CA certificate is installed in the current user's trust store.", + } + cmd.AddCommand(&cobra.Command{ + Use: "install", + Short: "Create and trust the localhost certificate", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + m, err := localtls.Ensure(cfg.Dir, time.Now()) + if err != nil { + return err + } + printLocalCertDisclosure(m) + installed, err := installLocalTrust(cmd.Context(), m) + if err != nil { + return err + } + if installed { + fmt.Println("installed only the public CA certificate in the current user's trust store") + } else { + fmt.Println("public CA certificate is already trusted for the current user") + } + return nil + }, + }) + cmd.AddCommand(&cobra.Command{ + Use: "remove", + Short: "Remove the public CA from this user's trust store", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + m, err := localtls.Load(cfg.Dir) + if errors.Is(err, localtls.ErrNotFound) { + fmt.Println("no Wingthing localhost certificate has been created for this profile") + return nil + } + if err != nil { + return err + } + removed, err := localtls.SystemTrustStore().Remove(cmd.Context(), m) + if err != nil { + return err + } + if removed { + fmt.Println("removed the public Wingthing localhost CA from this user's trust store") + } else { + fmt.Println("the public Wingthing localhost CA is not marked as trusted") + } + fmt.Printf("private keys were left on this machine in %s\n", m.Dir) + return nil + }, + }) + cmd.AddCommand(&cobra.Command{ + Use: "status", + Short: "Show local certificate and trust status", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + m, err := localtls.Load(cfg.Dir) + if errors.Is(err, localtls.ErrNotFound) { + fmt.Println("no Wingthing localhost certificate has been created for this profile") + return nil + } + if err != nil { + return err + } + trusted, err := localtls.SystemTrustStore().Trusted(cmd.Context(), m) + if err != nil { + return err + } + printLocalCertDisclosure(m) + fmt.Printf(" installed: %t\n", trusted) + return nil + }, + }) + return cmd +} diff --git a/cmd/wt/local_https_test.go b/cmd/wt/local_https_test.go new file mode 100644 index 00000000..38cbf747 --- /dev/null +++ b/cmd/wt/local_https_test.go @@ -0,0 +1,437 @@ +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "io" + "net" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/ehrlich-b/wingthing/internal/localtls" +) + +func TestRequireLoopbackAddress(t *testing.T) { + for _, addr := range []string{"127.0.0.1:8080", "localhost:8443", "[::1]:8443"} { + if err := requireLoopbackAddress("test", addr); err != nil { + t.Errorf("%s: %v", addr, err) + } + } + for _, addr := range []string{"127.0.0.1:0", ":8080", "0.0.0.0:8080", "192.168.1.5:8080", "example.com:443", "bad"} { + if err := requireLoopbackAddress("test", addr); err == nil { + t.Errorf("%s unexpectedly accepted", addr) + } + } +} + +func TestLocalHTTPSRewritesOnlyTheImplicitWildcardDefault(t *testing.T) { + if got := localHTTPAddrForHTTPS(":8080", false); got != defaultLocalHTTPAddr { + t.Fatalf("implicit default = %q", got) + } + if got := localHTTPAddrForHTTPS(":8080", true); got != ":8080" { + t.Fatalf("explicit address changed to %q", got) + } + if got := localHTTPAddrForHTTPS("127.0.0.1:9000", false); got != "127.0.0.1:9000" { + t.Fatalf("custom address changed to %q", got) + } +} + +func TestUnauthenticatedLocalHTTPDefaultsToLoopbackAndRejectsExposure(t *testing.T) { + got, err := prepareLocalHTTPAddress(":8080", false) + if err != nil || got != defaultLocalHTTPAddr { + t.Fatalf("implicit local address = %q, %v", got, err) + } + for _, addr := range []string{":8080", "0.0.0.0:8080", "192.168.1.20:8080"} { + if _, err := prepareLocalHTTPAddress(addr, true); err == nil { + t.Errorf("explicit unauthenticated bind %q was accepted", addr) + } + } + if got, err := prepareLocalHTTPAddress("[::1]:8080", true); err != nil || got != "[::1]:8080" { + t.Fatalf("explicit IPv6 loopback = %q, %v", got, err) + } +} + +func TestPrepareLocalHTTPSRejectsUnsafeAddressesBeforeCreatingAKey(t *testing.T) { + for _, tc := range []struct { + httpAddr string + httpsAddr string + explicit bool + }{ + {"0.0.0.0:8080", defaultLocalHTTPSAddr, true}, + {":8080", defaultLocalHTTPSAddr, true}, + {defaultLocalHTTPAddr, "192.168.1.20:8443", true}, + {defaultLocalHTTPAddr, "127.0.0.2:8443", true}, + {"127.0.0.1:8443", "127.1.2.3:8443", true}, + } { + dir := t.TempDir() + if _, err := prepareLocalHTTPS(context.Background(), dir, tc.httpAddr, tc.httpsAddr, tc.explicit); err == nil { + t.Fatalf("prepareLocalHTTPS(%q, %q) succeeded", tc.httpAddr, tc.httpsAddr) + } + if _, err := os.Stat(dir + "/local-tls"); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unsafe address created TLS material: %v", err) + } + } +} + +func TestBrowserHTTPSURL(t *testing.T) { + for _, tc := range []struct { + addr string + want string + }{ + {"127.0.0.1:8443", "https://localhost:8443"}, + {"localhost:443", "https://localhost"}, + {"[::1]:9443", "https://[::1]:9443"}, + } { + got, err := browserHTTPSURL(tc.addr) + if err != nil || got != tc.want { + t.Errorf("browserHTTPSURL(%q) = %q, %v; want %q", tc.addr, got, err, tc.want) + } + } +} + +func TestLocalHTTPURLPreservesLegacyAndExplicitLoopbackForms(t *testing.T) { + for _, tc := range []struct { + addr string + want string + }{ + {":8080", "http://localhost:8080"}, + {"127.0.0.1:8080", "http://127.0.0.1:8080"}, + {"[::1]:8080", "http://[::1]:8080"}, + } { + if got := localHTTPURL(tc.addr); got != tc.want { + t.Errorf("localHTTPURL(%q) = %q, want %q", tc.addr, got, tc.want) + } + } +} + +func TestSameAddressRecognizesEquivalentLoopbackListeners(t *testing.T) { + if !sameAddress("127.0.0.1:8443", "127.1.2.3:8443") { + t.Fatal("IPv4 loopback aliases should collide") + } + if !sameAddress("localhost:8443", "127.0.0.1:8443") { + t.Fatal("localhost may resolve to the IPv4 loopback listener") + } + if !sameAddress("localhost:8443", "[::1]:8443") { + t.Fatal("localhost may resolve to the IPv6 loopback listener") + } + if sameAddress("127.0.0.1:8080", "127.0.0.1:8443") { + t.Fatal("different ports should not collide") + } + if sameAddress("127.0.0.1:8443", "[::1]:8443") { + t.Fatal("IPv4 and IPv6 require distinct sockets") + } +} + +func TestDefaultBaseURLPreservesHostedHTTPS(t *testing.T) { + t.Setenv("WT_BASE_URL", "https://wingthing.example.test") + got := defaultBaseURL(nil) + if got != "https://wingthing.example.test" { + t.Fatalf("defaultBaseURL = %q", got) + } +} + +func TestDefaultBaseURLChangesOnlyWhenLocalHTTPSRequested(t *testing.T) { + t.Setenv("WT_BASE_URL", "") + if got := defaultBaseURL(nil); got != "http://localhost:8080" { + t.Fatalf("legacy default = %q", got) + } + if got := defaultBaseURL(&localHTTPSConfig{URL: "https://localhost:8443"}); got != "https://localhost:8443" { + t.Fatalf("local HTTPS default = %q", got) + } +} + +func TestLocalHTTPSCannotInheritAStalePublicBaseURL(t *testing.T) { + t.Setenv("WT_BASE_URL", "https://public.example.test") + if got := defaultBaseURL(&localHTTPSConfig{URL: "https://localhost:8443"}); got != "https://localhost:8443" { + t.Fatalf("local HTTPS inherited public origin: %q", got) + } +} + +func TestListenerResultIgnoresServerClosedOnly(t *testing.T) { + if err := listenerResult(namedServerError{}); err != nil { + t.Fatal(err) + } + if err := listenerResult(namedServerError{err: http.ErrServerClosed}); err != nil { + t.Fatal(err) + } + err := listenerResult(namedServerError{listener: "browser HTTPS", err: os.ErrPermission}) + if err == nil || !strings.Contains(err.Error(), "browser HTTPS") { + t.Fatalf("err = %v", err) + } + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("wrapped err = %v", err) + } +} + +func TestRelayListenersBoundRequestHeadersWithoutTimingOutWebSockets(t *testing.T) { + listeners := newRelayListeners(http.NotFoundHandler(), "127.0.0.1:8080", &localHTTPSConfig{HTTPSAddr: "127.0.0.1:8443"}) + for name, server := range map[string]*http.Server{"http": listeners.http, "https": listeners.https} { + if server.ReadHeaderTimeout != relayReadHeaderTimeout || server.IdleTimeout != relayIdleTimeout || server.MaxHeaderBytes != relayMaxHeaderBytes { + t.Errorf("%s limits = header %s idle %s bytes %d", name, server.ReadHeaderTimeout, server.IdleTimeout, server.MaxHeaderBytes) + } + if server.ReadTimeout != 0 || server.WriteTimeout != 0 { + t.Errorf("%s has whole-connection timeout that would terminate long-lived WebSockets: read=%s write=%s", name, server.ReadTimeout, server.WriteTimeout) + } + } +} + +func TestRelayListenersServeSameHandlerOverLoopbackHTTPAndHTTPS(t *testing.T) { + m, err := localtls.Ensure(t.TempDir(), time.Now()) + if err != nil { + t.Fatal(err) + } + httpAddr := unusedLoopbackAddress(t) + httpsAddr := unusedLoopbackAddress(t) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Wingthing-Test", r.URL.Path) + _, _ = w.Write([]byte("local HTTPS works")) + }) + localHTTPS := &localHTTPSConfig{ + HTTPAddr: httpAddr, + HTTPSAddr: httpsAddr, + URL: "https://" + httpsAddr, + Material: m, + } + listeners := newRelayListeners(handler, httpAddr, localHTTPS) + if err := listeners.Start(localHTTPS); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = listeners.http.Shutdown(ctx) + _ = listeners.https.Shutdown(ctx) + }) + + roots := x509.NewCertPool() + roots.AddCert(m.CACert) + httpsClient := &http.Client{ + Timeout: time.Second, + Transport: &http.Transport{TLSClientConfig: &tls.Config{ + RootCAs: roots, + ServerName: "localhost", + MinVersion: tls.VersionTLS12, + }}, + } + assertEventuallyResponse(t, http.DefaultClient, "http://"+httpAddr+"/plain", "local HTTPS works") + assertEventuallyResponse(t, httpsClient, "https://"+httpsAddr+"/secure", "local HTTPS works") + + untrustedClient := &http.Client{Timeout: time.Second} + if response, err := untrustedClient.Get("https://" + httpsAddr + "/untrusted"); err == nil { + closeForTest(t, "unexpected untrusted response body", response.Body) + t.Fatal("locally generated CA was unexpectedly trusted before installation") + } +} + +func TestRelayListenersFailAtomicallyWhenHTTPSAddressIsOccupied(t *testing.T) { + m, err := localtls.Ensure(t.TempDir(), time.Now()) + if err != nil { + t.Fatal(err) + } + httpAddr := unusedLoopbackAddress(t) + occupied, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer closeForTest(t, "occupied listener", occupied) + localHTTPS := &localHTTPSConfig{HTTPSAddr: occupied.Addr().String(), Material: m} + listeners := newRelayListeners(http.NotFoundHandler(), httpAddr, localHTTPS) + if err := listeners.Start(localHTTPS); err == nil || !strings.Contains(err.Error(), "browser HTTPS") { + t.Fatalf("Start error = %v, want browser HTTPS bind failure", err) + } + + // The first listener must be closed again when the second bind fails. A + // partially running roost is especially confusing because its printed HTTPS + // URL can never become usable. + rebound, err := net.Listen("tcp", httpAddr) + if err != nil { + t.Fatalf("wing HTTP listener leaked after HTTPS failure: %v", err) + } + _ = rebound.Close() +} + +func TestRelayListenersRejectMissingHTTPSMaterialBeforeBinding(t *testing.T) { + httpAddr := unusedLoopbackAddress(t) + listeners := newRelayListeners(http.NotFoundHandler(), httpAddr, &localHTTPSConfig{HTTPSAddr: unusedLoopbackAddress(t)}) + if err := listeners.Start(nil); err == nil || !strings.Contains(err.Error(), "certificate material") { + t.Fatalf("Start error = %v", err) + } + rebound, err := net.Listen("tcp", httpAddr) + if err != nil { + t.Fatalf("listener bound before certificate validation: %v", err) + } + _ = rebound.Close() +} + +func unusedLoopbackAddress(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + if errors.Is(err, os.ErrPermission) { + t.Skipf("sandbox does not permit loopback listeners: %v", err) + } + t.Fatal(err) + } + addr := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatal(err) + } + return addr +} + +func assertEventuallyResponse(t *testing.T, client *http.Client, rawURL, want string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + response, err := client.Get(rawURL) + if err == nil { + body, readErr := io.ReadAll(response.Body) + closeForTest(t, "eventual response body", response.Body) + if readErr != nil { + t.Fatal(readErr) + } + if string(body) != want { + t.Fatalf("%s body = %q, want %q", rawURL, body, want) + } + return + } + if time.Now().After(deadline) { + t.Fatalf("GET %s: %v", rawURL, err) + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestLocalCertHelpStatesPrivateKeyBoundary(t *testing.T) { + cmd := localCertCmd() + if !strings.Contains(cmd.Long, "private key") || !strings.Contains(cmd.Long, "never leaves this machine") { + t.Fatalf("local-cert help omits key boundary: %q", cmd.Long) + } +} + +func TestValidateLocalHTTPSModeLeavesHostedAndOrgModesAlone(t *testing.T) { + if err := validateLocalHTTPSMode(false, false, false); err != nil { + t.Fatalf("ordinary hosted mode changed: %v", err) + } + if err := validateLocalHTTPSMode(false, true, false); err != nil { + t.Fatalf("ordinary local HTTP mode changed: %v", err) + } + if err := validateLocalHTTPSMode(true, false, false); err == nil || !strings.Contains(err.Error(), "hosted and org") { + t.Fatalf("authenticated deployment accepted local CA mode: %v", err) + } + if err := validateLocalHTTPSMode(true, true, true); err == nil || !strings.Contains(err.Error(), "edge mode") { + t.Fatalf("edge deployment accepted local CA mode: %v", err) + } + if err := validateLocalHTTPSMode(true, true, false); err != nil { + t.Fatalf("local HTTPS rejected: %v", err) + } +} + +func TestAuthProvidersConfiguredMatchesRoostAuthModes(t *testing.T) { + for _, key := range []string{"GITHUB_CLIENT_ID", "GOOGLE_CLIENT_ID", "SMTP_HOST"} { + t.Run(key, func(t *testing.T) { + t.Setenv("GITHUB_CLIENT_ID", "") + t.Setenv("GOOGLE_CLIENT_ID", "") + t.Setenv("SMTP_HOST", "") + if authProvidersConfigured() { + t.Fatal("empty auth environment detected") + } + t.Setenv(key, "configured") + if !authProvidersConfigured() { + t.Fatalf("%s was ignored", key) + } + }) + } + + t.Setenv("GITHUB_CLIENT_ID", " \t ") + t.Setenv("GOOGLE_CLIENT_ID", "") + t.Setenv("SMTP_HOST", "") + if authProvidersConfigured() { + t.Fatal("whitespace-only auth environment detected") + } +} + +func TestValidateAuthProviderEnvironmentRejectsPartialOAuthPairs(t *testing.T) { + for _, key := range []string{ + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + } { + t.Run(key, func(t *testing.T) { + for _, env := range []string{ + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + } { + t.Setenv(env, "") + } + t.Setenv(key, "configured") + if err := validateAuthProviderEnvironment(); err == nil || !strings.Contains(err.Error(), "incomplete") { + t.Fatalf("partial %s configuration error = %v", key, err) + } + }) + } +} + +func TestValidateAuthProviderEnvironmentAcceptsCompleteAndSMTPModes(t *testing.T) { + for _, env := range []string{ + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + "SMTP_HOST", + } { + t.Setenv(env, "") + } + if err := validateAuthProviderEnvironment(); err != nil { + t.Fatalf("empty local mode: %v", err) + } + + t.Setenv("GITHUB_CLIENT_ID", "id") + t.Setenv("GITHUB_CLIENT_SECRET", "secret") + if err := validateAuthProviderEnvironment(); err != nil { + t.Fatalf("complete GitHub mode: %v", err) + } + + t.Setenv("GITHUB_CLIENT_ID", "") + t.Setenv("GITHUB_CLIENT_SECRET", "") + t.Setenv("SMTP_HOST", "smtp.example.com") + if err := validateAuthProviderEnvironment(); err != nil { + t.Fatalf("SMTP mode: %v", err) + } +} + +func TestServeHTTPSFlagsAreOptIn(t *testing.T) { + cmd := serveCmd() + https, err := cmd.Flags().GetBool("https") + if err != nil { + t.Fatal(err) + } + if https { + t.Fatal("hosted/upstream serve unexpectedly enables a local CA") + } + if got, err := cmd.Flags().GetString("https-addr"); err != nil || got != defaultLocalHTTPSAddr { + t.Fatalf("https-addr = %q, %v", got, err) + } +} + +func TestRoostHTTPSFlagsAreOptIn(t *testing.T) { + cmd := roostStartCmd() + https, err := cmd.Flags().GetBool("https") + if err != nil { + t.Fatal(err) + } + if https { + t.Fatal("existing roost unexpectedly enables trust-store mutation") + } + if got, err := cmd.Flags().GetString("https-addr"); err != nil || got != defaultLocalHTTPSAddr { + t.Fatalf("https-addr = %q, %v", got, err) + } +} diff --git a/cmd/wt/local_sessions.go b/cmd/wt/local_sessions.go index d759a023..eadf8b86 100644 --- a/cmd/wt/local_sessions.go +++ b/cmd/wt/local_sessions.go @@ -27,6 +27,11 @@ import ( const ( sessionNameFile = "session.name" sessionPrincipalFile = "session.principal" + // Interactive TUIs such as Claude Code distinguish pasted text from a + // separately pressed Enter. Writing both in one PTY frame can leave the + // text in the editor instead of submitting it. Preserve the terminal-send + // contract by separating Enter from a non-empty text write. + sessionEnterDelay = 50 * time.Millisecond ) type localSession struct { @@ -327,7 +332,9 @@ func printActiveSessions(ctx context.Context, cfg *config.Config, jsonOutput boo } w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) - fmt.Fprintln(w, "NAME\tID\tKIND\tPROCESS\tISOLATION\tREADERS\tUPTIME\tIDLE\tCWD") + if _, err := fmt.Fprintln(w, "NAME\tID\tKIND\tPROCESS\tISOLATION\tREADERS\tUPTIME\tIDLE\tCWD"); err != nil { + return err + } for _, session := range sessions { name := session.Name if name == "" { @@ -344,12 +351,14 @@ func printActiveSessions(ctx context.Context, cfg *config.Config, jsonOutput boo if isolation == "" { isolation = "unknown" } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n", + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n", name, session.ID, session.Kind, process, isolation, session.Readers, humanDuration(time.Duration(session.UptimeSecs)*time.Second), humanDuration(time.Duration(session.IdleSecs)*time.Second), shortenPath(session.CWD), - ) + ); err != nil { + return err + } } return w.Flush() } @@ -411,7 +420,7 @@ func readSessionSnapshot(ctx context.Context, cfg *config.Config, ref string) (l if err != nil { return localSession{}, nil, err } - defer ec.Close() + defer closeWithLog("egg client", ec) stream, err := ec.AttachSession(ctx, session.ID) if err != nil { return localSession{}, nil, fmt.Errorf("read session %s: %w", session.ID, err) @@ -429,12 +438,23 @@ func readSessionSnapshot(ctx context.Context, cfg *config.Config, ref string) (l return session, payload.Output, nil } -func sendSessionBytes(ctx context.Context, cfg *config.Config, ref string, input []byte) (localSession, error) { +func sessionInputChunks(input []byte, enter bool) [][]byte { + chunks := make([][]byte, 0, 2) + if len(input) > 0 { + chunks = append(chunks, input) + } + if enter { + chunks = append(chunks, []byte{'\r'}) + } + return chunks +} + +func sendSessionInput(ctx context.Context, cfg *config.Config, ref string, input []byte, enter bool) (localSession, error) { session, ec, err := openLocalEgg(ctx, cfg, ref) if err != nil { return localSession{}, err } - defer ec.Close() + defer closeWithLog("egg client", ec) stream, err := ec.AttachSession(ctx, session.ID) if err != nil { return localSession{}, fmt.Errorf("send to session %s: %w", session.ID, err) @@ -444,8 +464,22 @@ func sendSessionBytes(ctx context.Context, cfg *config.Config, ref string, input if _, err := stream.Recv(); err != nil { return localSession{}, fmt.Errorf("send to session %s: %w", session.ID, err) } - if err := stream.Send(&pb.SessionMsg{SessionId: session.ID, Payload: &pb.SessionMsg_Input{Input: input}}); err != nil { - return localSession{}, fmt.Errorf("send to session %s: %w", session.ID, err) + chunks := sessionInputChunks(input, enter) + for index, chunk := range chunks { + if index > 0 && len(input) > 0 { + timer := time.NewTimer(sessionEnterDelay) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return localSession{}, ctx.Err() + case <-timer.C: + } + } + if err := stream.Send(&pb.SessionMsg{SessionId: session.ID, Payload: &pb.SessionMsg_Input{Input: chunk}}); err != nil { + return localSession{}, fmt.Errorf("send to session %s: %w", session.ID, err) + } } _ = stream.Send(&pb.SessionMsg{SessionId: session.ID, Payload: &pb.SessionMsg_Detach{Detach: true}}) _ = stream.CloseSend() @@ -457,7 +491,7 @@ func waitForSessionText(ctx context.Context, cfg *config.Config, ref, needle str if err != nil { return localSession{}, err } - defer ec.Close() + defer closeWithLog("egg client", ec) stream, err := ec.AttachSession(ctx, session.ID) if err != nil { return localSession{}, fmt.Errorf("wait for session %s: %w", session.ID, err) diff --git a/cmd/wt/local_sessions_test.go b/cmd/wt/local_sessions_test.go index 02976096..4733cb0d 100644 --- a/cmd/wt/local_sessions_test.go +++ b/cmd/wt/local_sessions_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "os" "path/filepath" "strings" @@ -10,6 +11,32 @@ import ( "github.com/ehrlich-b/wingthing/internal/config" ) +func TestSessionInputChunksSeparatesEnterFromPastedText(t *testing.T) { + tests := []struct { + name string + input []byte + enter bool + want [][]byte + }{ + {name: "text only", input: []byte("hello"), want: [][]byte{[]byte("hello")}}, + {name: "enter only", enter: true, want: [][]byte{{'\r'}}}, + {name: "text and enter", input: []byte("hello"), enter: true, want: [][]byte{[]byte("hello"), {'\r'}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := sessionInputChunks(test.input, test.enter) + if len(got) != len(test.want) { + t.Fatalf("chunks = %#v, want %#v", got, test.want) + } + for index := range got { + if !bytes.Equal(got[index], test.want[index]) { + t.Fatalf("chunk %d = %q, want %q", index, got[index], test.want[index]) + } + } + }) + } +} + func TestValidateSessionName(t *testing.T) { for _, valid := range []string{"", "work", "dev-server", "api_2", "repo.main"} { if err := validateSessionName(valid); err != nil { diff --git a/cmd/wt/main.go b/cmd/wt/main.go index 4d0fa299..0b5a7056 100644 --- a/cmd/wt/main.go +++ b/cmd/wt/main.go @@ -8,12 +8,13 @@ import ( "errors" "fmt" "io" + "log" + "net/url" "os" "os/exec" "os/signal" "path/filepath" "runtime" - "strconv" "strings" "syscall" "text/tabwriter" @@ -29,7 +30,6 @@ import ( "github.com/ehrlich-b/wingthing/internal/skill" "github.com/ehrlich-b/wingthing/internal/store" "github.com/ehrlich-b/wingthing/internal/thread" - "github.com/google/uuid" "github.com/spf13/cobra" ) @@ -43,12 +43,30 @@ func main() { return } + root := newRootCommand() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := root.ExecuteContext(ctx); err != nil { + var exitErr *commandExitError + if errors.As(err, &exitErr) { + if exitErr.message != "" { + _, _ = fmt.Fprintln(os.Stderr, exitErr.message) + } + os.Exit(exitErr.code) + } + _, _ = fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } +} + +func newRootCommand() *cobra.Command { root := &cobra.Command{ - Use: "wt", - Short: "wingthing — persistent, sandboxed agent terminals", - Long: "A local-first runtime for persistent, sandboxed agent terminals. Attach locally, over SSH, or through the optional browser gateway.", - Version: version, - SilenceUsage: true, + Use: "wt", + Short: "wingthing — an agent manager for agents", + Long: "An agent manager for agents: one typed control plane for durable agent runs and terminals across your machines, with human inspection and takeover when useful.", + Version: version, + SilenceErrors: true, + SilenceUsage: true, } root.AddCommand( @@ -83,13 +101,9 @@ func main() { toolCallCmd(), toolListCmd(), mcpCmd(), + localCertCmd(), ) - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - if err := root.ExecuteContext(ctx); err != nil { - os.Exit(1) - } + return root } func startCmd() *cobra.Command { @@ -156,19 +170,22 @@ func stopCmd() *cobra.Command { Use: "stop", Short: "Stop the daemon (alias for wt wing stop / wt daemon stop)", RunE: func(cmd *cobra.Command, args []string) error { - pid, err := readPid() + lifecycleLock, lockErr := acquireDaemonLifecycleLock() + if lockErr != nil { + return lockErr + } + defer closeWithLog("daemon lifecycle lock", lifecycleLock) + pid, kind, err := readDaemon() if err != nil { return fmt.Errorf("no wing daemon running") } - proc, _ := os.FindProcess(pid) - if err := proc.Signal(syscall.SIGTERM); err != nil { - return fmt.Errorf("kill pid %d: %w", pid, err) + if err := stopDaemonAndWait(pid, kind, 5*time.Second); err != nil { + return err } // Clean up both wing and roost pid/args files - os.Remove(wingPidPath()) - os.Remove(wingArgsPath()) - os.Remove(roostPidPath()) - os.Remove(roostArgsPath()) + if err := removeFiles(wingPidPath(), wingArgsPath(), roostPidPath(), roostArgsPath()); err != nil { + return fmt.Errorf("remove daemon metadata: %w", err) + } fmt.Printf("wing daemon stopped (pid %d)\n", pid) return nil }, @@ -176,7 +193,7 @@ func stopCmd() *cobra.Command { } func genTaskID() string { - return fmt.Sprintf("t-%s-%s", time.Now().Format("20060102-150405"), uuid.NewString()[:8]) + return fmt.Sprintf("t-%s-%s", time.Now().Format("20060102-150405"), newRuntimeID()) } func runCmd() *cobra.Command { @@ -203,7 +220,7 @@ func runCmd() *cobra.Command { if err != nil { return fmt.Errorf("open db: %w", err) } - defer s.Close() + defer closeWithLog("store", s) cwd, err := os.Getwd() if err != nil { @@ -335,18 +352,28 @@ type taskRunOptions struct { AllowedPaths []string } -func runTaskToWithOptions(ctx context.Context, cfg *config.Config, s *store.Store, t *store.Task, destination io.Writer, options taskRunOptions) error { - s.UpdateTaskStatus(t.ID, "running") - s.AppendLog(t.ID, "started", nil) +func runTaskToWithOptions(ctx context.Context, cfg *config.Config, s *store.Store, t *store.Task, destination io.Writer, options taskRunOptions) (runErr error) { + if err := s.UpdateTaskStatus(t.ID, "running"); err != nil { + return fmt.Errorf("mark task running: %w", err) + } + defer func() { + if runErr == nil { + return + } + if err := s.SetTaskError(t.ID, runErr.Error()); err != nil { + runErr = errors.Join(runErr, fmt.Errorf("record task failure: %w", err)) + } + }() + if err := s.AppendLog(t.ID, "started", nil); err != nil { + return fmt.Errorf("record task start: %w", err) + } if options.SharedHost && runtime.GOOS != "linux" { err := errors.New("shared-host credential isolation requires the Linux filesystem jail") - s.SetTaskError(t.ID, err.Error()) return err } if options.SharedHost { _, canonical, err := sharedHostFilesystemRules(cfg, options.AllowedPaths) if err != nil { - s.SetTaskError(t.ID, err.Error()) return err } options.AllowedPaths = canonical @@ -368,25 +395,29 @@ func runTaskToWithOptions(ctx context.Context, cfg *config.Config, s *store.Stor pr, err := builder.Build(ctx, t.ID) if err != nil { - s.SetTaskError(t.ID, err.Error()) return fmt.Errorf("build prompt: %w", err) } if err := s.SetTaskResolved(t.ID, pr.Agent, pr.Isolation); err != nil { - s.SetTaskError(t.ID, err.Error()) return fmt.Errorf("record resolved task: %w", err) } t.Agent = pr.Agent t.Isolation = pr.Isolation promptDetail := pr.Prompt - s.AppendLog(t.ID, "prompt_built", &promptDetail) + if err := s.AppendLog(t.ID, "prompt_built", &promptDetail); err != nil { + return fmt.Errorf("record built prompt: %w", err) + } // Use the agent resolved by the builder (respects CLI flag > skill > config) agentName := pr.Agent a, ok := agents[agentName] if !ok { err := fmt.Errorf("unsupported agent %q", agentName) - s.SetTaskError(t.ID, err.Error()) + return err + } + agentDefinition, ok := agent.LookupDefinition(agentName) + if !ok { + err := fmt.Errorf("unsupported agent %q", agentName) return err } @@ -395,7 +426,6 @@ func runTaskToWithOptions(ctx context.Context, cfg *config.Config, s *store.Stor if workDir == "" { workDir, err = os.Getwd() if err != nil { - s.SetTaskError(t.ID, err.Error()) return fmt.Errorf("resolve working directory: %w", err) } } @@ -404,18 +434,29 @@ func runTaskToWithOptions(ctx context.Context, cfg *config.Config, s *store.Stor if statErr == nil { statErr = fmt.Errorf("not a directory") } - s.SetTaskError(t.ID, statErr.Error()) return fmt.Errorf("working directory %q: %w", workDir, statErr) } if options.SharedHost { canonicalWorkDir := canonicalSessionPath(workDir) if len(options.AllowedPaths) == 0 || !isUnderPaths(canonicalWorkDir, options.AllowedPaths) { err := fmt.Errorf("working directory %q is outside this user's roost paths", workDir) - s.SetTaskError(t.ID, err.Error()) return err } workDir = canonicalWorkDir } + var resolvedEggCfg *egg.EggConfig + if pr.Isolation == "privileged" { + // Privileged means the discovered/configured sandbox policy is not in + // force. Use the explicit outer-boundary policy for both execution + // metadata and the durable egress audit. + resolvedEggCfg = egg.UnsandboxedEggConfig() + } else { + var configErr error + resolvedEggCfg, configErr = taskEggConfig(t, workDir) + if configErr != nil { + return configErr + } + } var runOpts agent.RunOpts var sandboxDiagnosticPath string runOpts.WorkDir = workDir @@ -433,9 +474,20 @@ func runTaskToWithOptions(ctx context.Context, cfg *config.Config, s *store.Stor // the agent's default isolation is configured. if options.SharedHost && pr.Isolation == "privileged" { msg := "privileged isolation is not available on a shared host" - s.SetTaskError(t.ID, msg) return errors.New(msg) } + if pr.Isolation == "privileged" { + home, _ := os.UserHomeDir() + policy, policyErr := egg.ResolvePolicyWithProvider(resolvedEggCfg, agentName, home, os.Getenv("WT_PROVIDER_BASE_URL")) + if policyErr != nil { + return fmt.Errorf("resolve unconfined network policy: %w", policyErr) + } + detail, auditErr := appendNetworkEnforcementAudit(s, t.ID, "unconfined_egress", "outer-boundary", policy.NetworkNeed, policy.Domains, policy.LocalPorts) + if auditErr != nil { + return fmt.Errorf("record unconfined egress audit: %w", auditErr) + } + log.Printf("SECURITY: task %s is unsandboxed; %s", t.ID, detail) + } if pr.Isolation != "privileged" { home := options.UserHome @@ -443,7 +495,6 @@ func runTaskToWithOptions(ctx context.Context, cfg *config.Config, s *store.Stor var homeErr error home, homeErr = os.UserHomeDir() if homeErr != nil { - s.SetTaskError(t.ID, homeErr.Error()) return fmt.Errorf("resolve user home: %w", homeErr) } } @@ -457,54 +508,65 @@ func runTaskToWithOptions(ctx context.Context, cfg *config.Config, s *store.Stor stateErr = prepareDirectAgentState(agentName, home) } if stateErr != nil { - s.SetTaskError(t.ID, stateErr.Error()) return fmt.Errorf("prepare %s state: %w", agentName, stateErr) } if options.SharedHost { - agentBin, lookupErr := exec.LookPath(agentName) + agentBin, lookupErr := exec.LookPath(agentDefinition.Command) if lookupErr != nil { - s.SetTaskError(t.ID, lookupErr.Error()) - return fmt.Errorf("find shared-host %s runtime: %w", agentName, lookupErr) + return fmt.Errorf("find shared-host %s runtime: %w", agentDefinition.Command, lookupErr) } - if installErr := installSharedAgentBinary(agentBin, home, agentName); installErr != nil { - s.SetTaskError(t.ID, installErr.Error()) + if installErr := installSharedAgentBinary(agentBin, home, agentDefinition.Command); installErr != nil { return fmt.Errorf("prepare shared-host %s runtime: %w", agentName, installErr) } + // Shared-host tasks intentionally drop ambient provider credentials. + // Give Claude the same file-backed helper used by interactive org + // sessions so the secret never enters the agent environment. + if err := setupAPIKeyHelper(agentName, map[string]string{}, home); err != nil { + return fmt.Errorf("prepare shared-host credential helper: %w", err) + } } mountPaths := taskSandboxMountPaths(pr.Mounts, workDir, options) - eggCfg, configErr := taskEggConfig(t, workDir) - if configErr != nil { - s.SetTaskError(t.ID, configErr.Error()) - return configErr - } - sbCfg, policyErr := directAgentSandboxConfigForTask(eggCfg, agentName, pr.Isolation, home, workDir, mountPaths, options.SharedHost) + sbCfg, policyErr := directAgentSandboxConfigForTask(resolvedEggCfg, agentName, pr.Isolation, home, workDir, mountPaths, options.SharedHost) if policyErr != nil { - s.SetTaskError(t.ID, policyErr.Error()) return fmt.Errorf("resolve sandbox network policy: %w", policyErr) } sbCfg.SessionID = t.ID - var domainProxy *sandbox.DomainProxy - if sbCfg.NetworkNeed == sandbox.NetworkHTTPS && len(sbCfg.Domains) > 0 { - domainProxy, err = sandbox.StartProxy(sbCfg.Domains) - if err != nil { - s.AppendLog(t.ID, "domain_proxy_unavailable", nil) - } else { - defer domainProxy.Close() - sbCfg.ProxyPort = domainProxy.Port() + domainProxy, proxyErr := sandbox.StartPolicyProxyWithMode(sbCfg.NetworkNeed, sbCfg.Domains, sbCfg.NetworkMode) + if proxyErr != nil { + detail := proxyErr.Error() + if err := s.AppendLog(t.ID, "domain_proxy_unavailable", &detail); err != nil { + return errors.Join(fmt.Errorf("start enforcing network proxy: %w", proxyErr), fmt.Errorf("record proxy failure: %w", err)) } + return fmt.Errorf("start enforcing network proxy: %w", proxyErr) } + if domainProxy != nil { + defer domainProxy.Close() + sbCfg.ProxyPort = domainProxy.Port() + } + detail, auditErr := appendNetworkEnforcementAudit(s, t.ID, "sandbox_enforcement", explainEnforcement(sbCfg.NetworkNeed, runtime.GOOS, sbCfg.NetworkMode), sbCfg.NetworkNeed, sbCfg.Domains, sbCfg.LocalPorts) + if auditErr != nil { + return fmt.Errorf("record sandbox enforcement audit: %w", auditErr) + } + log.Printf("task %s sandbox: %s", t.ID, detail) sb, sbErr := sandbox.New(sbCfg) if sbErr != nil { - s.SetTaskError(t.ID, sbErr.Error()) return fmt.Errorf("create sandbox: %w", sbErr) } - defer sb.Destroy() + defer func() { + if err := sb.Destroy(); err != nil { + runErr = errors.Join(runErr, fmt.Errorf("destroy sandbox: %w", err)) + } + }() sandboxDiagnosticPath = sb.DiagLog() agentEnv := directAgentEnvWithPolicy(agentName, home, sbCfg.ProxyPort, !options.SharedHost) runOpts.CmdFactory = func(ctx context.Context, name string, args []string) (*exec.Cmd, error) { - cmd, execErr := sb.Exec(ctx, name, args) + executable, resolveErr := sandboxAgentExecutable(name, home, options.SharedHost) + if resolveErr != nil { + return nil, resolveErr + } + cmd, execErr := sb.Exec(ctx, executable, args) if execErr != nil { return nil, execErr } @@ -525,7 +587,6 @@ func runTaskToWithOptions(ctx context.Context, cfg *config.Config, s *store.Stor } stream, err := a.Run(runCtx, pr.Prompt, runOpts) if err != nil { - s.SetTaskError(t.ID, err.Error()) return fmt.Errorf("run agent: %w", err) } @@ -535,47 +596,70 @@ func runTaskToWithOptions(ctx context.Context, cfg *config.Config, s *store.Stor if !ok { break } - fmt.Fprint(destination, chunk.Text) + if _, err := fmt.Fprint(destination, chunk.Text); err != nil { + return fmt.Errorf("write agent output: %w", err) + } + } + if _, err := fmt.Fprintln(destination); err != nil { + return fmt.Errorf("finish agent output: %w", err) } - fmt.Fprintln(destination) if err := stream.Err(); err != nil { diagnostics := mergeAgentFailureDiagnostics(err, readSandboxDiagnostics(sandboxDiagnosticPath)) - _ = s.SetTaskOutput(t.ID, mergeAgentFailureOutput(stream.Text(), diagnostics)) - s.SetTaskError(t.ID, err.Error()) + if outputErr := s.SetTaskOutput(t.ID, mergeAgentFailureOutput(stream.Text(), diagnostics)); outputErr != nil { + return errors.Join(fmt.Errorf("agent error: %w", err), fmt.Errorf("record failed agent output: %w", outputErr)) + } if diagnostics != "" { - fmt.Fprintln(destination, diagnostics) + if _, writeErr := fmt.Fprintln(destination, diagnostics); writeErr != nil { + return errors.Join(fmt.Errorf("agent error: %w", err), fmt.Errorf("write agent diagnostics: %w", writeErr)) + } } return fmt.Errorf("agent error: %w", err) } if err := runCtx.Err(); err != nil { - s.SetTaskError(t.ID, err.Error()) return fmt.Errorf("agent run ended after cancellation: %w", err) } // Store result output := stream.Text() - s.SetTaskOutput(t.ID, output) - s.UpdateTaskStatus(t.ID, "done") - s.AppendLog(t.ID, "done", nil) + if err := s.SetTaskOutput(t.ID, output); err != nil { + return fmt.Errorf("record task output: %w", err) + } + if err := s.UpdateTaskStatus(t.ID, "done"); err != nil { + return fmt.Errorf("mark task done: %w", err) + } + if err := s.AppendLog(t.ID, "done", nil); err != nil { + return fmt.Errorf("record task completion: %w", err) + } // Record tokens in thread inputTok, outputTok := stream.Tokens() totalTok := inputTok + outputTok if totalTok > 0 { - s.AppendThread(&store.ThreadEntry{ + if err := s.AppendThread(&store.ThreadEntry{ TaskID: &t.ID, WingID: cfg.WingID, Agent: &agentName, UserInput: &t.What, Summary: truncate(output, 200), TokensUsed: &totalTok, - }) + }); err != nil { + return fmt.Errorf("record task thread entry: %w", err) + } } return nil } +func networkEnforcementDetail(enforcement string, need sandbox.NetworkNeed, domains []string, localPorts []int) string { + return fmt.Sprintf("network=%s enforcement=%s domains=%d local_ports=%v", need, enforcement, len(domains), localPorts) +} + +func appendNetworkEnforcementAudit(s *store.Store, taskID, event, enforcement string, need sandbox.NetworkNeed, domains []string, localPorts []int) (string, error) { + detail := networkEnforcementDetail(enforcement, need, domains, localPorts) + return detail, s.AppendLog(taskID, event, &detail) +} + func taskSandboxMountPaths(promptMounts []string, workDir string, options taskRunOptions) []string { if options.SharedHost { return append([]string(nil), options.AllowedPaths...) @@ -646,7 +730,7 @@ func timelineCmd() *cobra.Command { if err != nil { return fmt.Errorf("open db: %w", err) } - defer s.Close() + defer closeWithLog("store", s) tasks, err := s.ListRecent(20) if err != nil { @@ -657,16 +741,19 @@ func timelineCmd() *cobra.Command { return nil } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "ID\tSTATUS\tAGENT\tWHAT\tRUN AT") + if _, err := fmt.Fprintln(w, "ID\tSTATUS\tAGENT\tWHAT\tRUN AT"); err != nil { + return err + } for _, t := range tasks { what := t.What if len(what) > 50 { what = what[:47] + "..." } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", t.ID, t.Status, t.Agent, what, t.RunAt.Format(time.RFC3339)) + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", t.ID, t.Status, t.Agent, what, t.RunAt.Format(time.RFC3339)); err != nil { + return err + } } - w.Flush() - return nil + return w.Flush() }, } } @@ -685,7 +772,7 @@ func threadCmd() *cobra.Command { if err != nil { return fmt.Errorf("open db: %w", err) } - defer s.Close() + defer closeWithLog("store", s) date := time.Now().UTC() if yesterday { @@ -720,20 +807,33 @@ func statusCmd() *cobra.Command { if err != nil { return fmt.Errorf("open db: %w", err) } - defer s.Close() + defer closeWithLog("store", s) var pending, running int - s.DB().QueryRow("SELECT COUNT(*) FROM tasks WHERE status = 'pending'").Scan(&pending) - s.DB().QueryRow("SELECT COUNT(*) FROM tasks WHERE status = 'running'").Scan(&running) - agents, _ := s.ListAgents() + if err := s.DB().QueryRow("SELECT COUNT(*) FROM tasks WHERE status = 'pending'").Scan(&pending); err != nil { + return fmt.Errorf("count pending tasks: %w", err) + } + if err := s.DB().QueryRow("SELECT COUNT(*) FROM tasks WHERE status = 'running'").Scan(&running); err != nil { + return fmt.Errorf("count running tasks: %w", err) + } + agents, err := s.ListAgents() + if err != nil { + return fmt.Errorf("list agents: %w", err) + } now := time.Now().UTC() todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) weekStart := todayStart.AddDate(0, 0, -6) tomorrow := todayStart.AddDate(0, 0, 1) - tokensToday, _ := s.SumTokensByDateRange(todayStart, tomorrow) - tokensWeek, _ := s.SumTokensByDateRange(weekStart, tomorrow) + tokensToday, err := s.SumTokensByDateRange(todayStart, tomorrow) + if err != nil { + return fmt.Errorf("sum today's tokens: %w", err) + } + tokensWeek, err := s.SumTokensByDateRange(weekStart, tomorrow) + if err != nil { + return fmt.Errorf("sum weekly tokens: %w", err) + } fmt.Printf("pending: %d\nrunning: %d\nagents: %d\ntokens: %d today / %d this week\n", pending, running, len(agents), tokensToday, tokensWeek) return nil @@ -757,7 +857,7 @@ func logCmd() *cobra.Command { if err != nil { return fmt.Errorf("open db: %w", err) } - defer s.Close() + defer closeWithLog("store", s) taskID := "" if len(args) > 0 { @@ -819,7 +919,7 @@ func agentCmd() *cobra.Command { if err != nil { return fmt.Errorf("open db: %w", err) } - defer s.Close() + defer closeWithLog("store", s) agents, err := s.ListAgents() if err != nil { @@ -830,16 +930,19 @@ func agentCmd() *cobra.Command { return nil } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "NAME\tADAPTER\tHEALTHY\tCONTEXT") + if _, err := fmt.Fprintln(w, "NAME\tADAPTER\tHEALTHY\tCONTEXT"); err != nil { + return err + } for _, a := range agents { healthy := "no" if a.Healthy { healthy = "yes" } - fmt.Fprintf(w, "%s\t%s\t%s\t%d\n", a.Name, a.Adapter, healthy, a.ContextWindow) + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%d\n", a.Name, a.Adapter, healthy, a.ContextWindow); err != nil { + return err + } } - w.Flush() - return nil + return w.Flush() }, }) return ag @@ -862,7 +965,7 @@ func scheduleCmd() *cobra.Command { if err != nil { return fmt.Errorf("open db: %w", err) } - defer s.Close() + defer closeWithLog("store", s) tasks, err := s.ListRecurring() if err != nil { @@ -873,7 +976,9 @@ func scheduleCmd() *cobra.Command { return nil } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "ID\tSTATUS\tCRON\tWHAT\tNEXT RUN") + if _, err := fmt.Fprintln(w, "ID\tSTATUS\tCRON\tWHAT\tNEXT RUN"); err != nil { + return err + } for _, t := range tasks { what := t.What if len(what) > 40 { @@ -883,10 +988,11 @@ func scheduleCmd() *cobra.Command { if t.Cron != nil { cronExpr = *t.Cron } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", t.ID, t.Status, cronExpr, what, t.RunAt.Format(time.RFC3339)) + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", t.ID, t.Status, cronExpr, what, t.RunAt.Format(time.RFC3339)); err != nil { + return err + } } - w.Flush() - return nil + return w.Flush() }, }) sc.AddCommand(&cobra.Command{ @@ -902,7 +1008,7 @@ func scheduleCmd() *cobra.Command { if err != nil { return fmt.Errorf("open db: %w", err) } - defer s.Close() + defer closeWithLog("store", s) t, err := s.GetTask(args[0]) if err != nil { @@ -935,7 +1041,7 @@ func retryCmd() *cobra.Command { if err != nil { return fmt.Errorf("open db: %w", err) } - defer s.Close() + defer closeWithLog("store", s) t, err := s.GetTask(args[0]) if err != nil { @@ -996,14 +1102,22 @@ func initCmd() *cobra.Command { // Seed index.md indexPath := filepath.Join(cfg.MemoryDir(), "index.md") - if _, err := os.Stat(indexPath); os.IsNotExist(err) { - os.WriteFile(indexPath, []byte("# Memory Index\n\nThis file is always loaded into every prompt.\n"), 0644) + if _, err := os.Stat(indexPath); errors.Is(err, os.ErrNotExist) { + if err := os.WriteFile(indexPath, []byte("# Memory Index\n\nThis file is always loaded into every prompt.\n"), 0644); err != nil { + return fmt.Errorf("seed memory index: %w", err) + } + } else if err != nil { + return fmt.Errorf("inspect memory index: %w", err) } // Seed identity.md idPath := filepath.Join(cfg.MemoryDir(), "identity.md") - if _, err := os.Stat(idPath); os.IsNotExist(err) { - os.WriteFile(idPath, []byte("---\nname: \"\"\n---\n# Identity\n\nEdit this file with your name, role, and preferences.\n"), 0644) + if _, err := os.Stat(idPath); errors.Is(err, os.ErrNotExist) { + if err := os.WriteFile(idPath, []byte("---\nname: \"\"\n---\n# Identity\n\nEdit this file with your name, role, and preferences.\n"), 0644); err != nil { + return fmt.Errorf("seed identity: %w", err) + } + } else if err != nil { + return fmt.Errorf("inspect identity: %w", err) } // Init database @@ -1011,7 +1125,9 @@ func initCmd() *cobra.Command { if err != nil { return fmt.Errorf("init db: %w", err) } - s.Close() + if err := s.Close(); err != nil { + return fmt.Errorf("close initialized database: %w", err) + } // Detect agents fmt.Println("initialized:", cfg.Dir) @@ -1051,10 +1167,17 @@ func loginCmd() *cobra.Command { if err != nil { return err } - if ts.IsValid(existing) { + reusable, err := reusableLoginForTarget(ts, existing, roostFlag, auth.ValidateTokenRemote) + if err != nil { + return err + } + if reusable { fmt.Println("already logged in") return nil } + if ts.IsValid(existing) && roostFlag != "" { + fmt.Println("existing login is not accepted by the requested roost; starting a new device login") + } if cfg.RoostURL == "" { cfg.RoostURL = "https://wingthing.ai" @@ -1073,12 +1196,16 @@ func loginCmd() *cobra.Command { fmt.Printf("Visit: %s\n", dcr.VerificationURL) - // Try to open browser, fail silently + // Opening the browser is a convenience; the printed URL remains usable. switch runtime.GOOS { case "darwin": - exec.Command("open", dcr.VerificationURL).Start() + if err := exec.Command("open", dcr.VerificationURL).Start(); err != nil { + log.Printf("open login URL: %v", err) + } case "linux": - exec.Command("xdg-open", dcr.VerificationURL).Start() + if err := exec.Command("xdg-open", dcr.VerificationURL).Start(); err != nil { + log.Printf("open login URL: %v", err) + } } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) @@ -1118,6 +1245,23 @@ func loginCmd() *cobra.Command { return cmd } +func reusableLoginForTarget(store *auth.TokenStore, existing *auth.DeviceToken, explicitTarget string, validate func(string, string) error) (bool, error) { + if !store.IsValid(existing) { + return false, nil + } + if explicitTarget == "" { + return true, nil + } + target := strings.TrimRight(explicitTarget, "/") + if err := validate(target, existing.Token); err != nil { + if errors.Is(err, auth.ErrAuthFailed) { + return false, nil + } + return false, fmt.Errorf("verify existing login with requested roost: %w", err) + } + return true, nil +} + func logoutCmd() *cobra.Command { return &cobra.Command{ Use: "logout", @@ -1128,23 +1272,21 @@ func logoutCmd() *cobra.Command { return err } + lifecycleLock, lockErr := acquireDaemonLifecycleLock() + if lockErr != nil { + return lockErr + } + defer closeWithLog("daemon lifecycle lock", lifecycleLock) + // Stop wing daemon if running (prevents orphaned daemon with revoked token) - if pid, pidErr := readPid(); pidErr == nil { + if pid, kind, pidErr := readDaemon(); pidErr == nil { fmt.Printf("stopping wing daemon (pid %d)...\n", pid) - if proc, findErr := os.FindProcess(pid); findErr == nil { - proc.Signal(syscall.SIGTERM) - // Wait briefly for clean shutdown before deleting token - for i := 0; i < 10; i++ { - time.Sleep(200 * time.Millisecond) - if !ownedProcessIsAlive(pid) { - break // process exited - } - } + if stopErr := stopDaemonAndWait(pid, kind, 5*time.Second); stopErr != nil { + return fmt.Errorf("refusing to delete login while daemon is still running: %w", stopErr) + } + if err := removeFiles(wingPidPath(), wingArgsPath(), roostPidPath(), roostArgsPath()); err != nil { + return fmt.Errorf("remove stopped daemon metadata: %w", err) } - os.Remove(wingPidPath()) - os.Remove(wingArgsPath()) - os.Remove(roostPidPath()) - os.Remove(roostArgsPath()) } ts := auth.NewTokenStore(cfg.Dir) @@ -1161,9 +1303,18 @@ func logoutCmd() *cobra.Command { // restartWingDaemonIfRunning stops the running wing daemon and starts a new one // with the same args so it picks up the new auth token. func restartWingDaemonIfRunning() error { - pid, err := readPid() + lifecycleLock, lockErr := acquireDaemonLifecycleLock() + if lockErr != nil { + return lockErr + } + defer closeWithLog("daemon lifecycle lock", lifecycleLock) + + pid, err := readPidFrom(wingPidPath(), wingDaemon) if err != nil { - return nil // no daemon running, nothing to do + if daemonAbsentError(err) { + return nil // no standalone wing daemon running, nothing to do + } + return fmt.Errorf("inspect wing daemon state: %w", err) } // Read saved args so we can restart with same flags @@ -1171,22 +1322,19 @@ func restartWingDaemonIfRunning() error { if err != nil { return fmt.Errorf("can't read wing.args (stop and restart manually: wt stop && wt start): %w", err) } - savedArgs := strings.Split(strings.TrimSpace(string(argsData)), "\n") + savedArgs, err := parseSavedDaemonArgs(argsData, wingDaemon) + if err != nil { + return fmt.Errorf("can't use wing.args (stop and restart manually: wt stop && wt start): %w", err) + } // Stop the old daemon fmt.Printf("restarting wing daemon (pid %d)...\n", pid) - if proc, findErr := os.FindProcess(pid); findErr == nil { - proc.Signal(syscall.SIGTERM) - for i := 0; i < 15; i++ { - time.Sleep(200 * time.Millisecond) - if !ownedProcessIsAlive(pid) { - break - } - } + if err := stopDaemonAndWait(pid, wingDaemon, 5*time.Second); err != nil { + return fmt.Errorf("refusing to start a competing daemon: %w", err) + } + if err := removeFiles(wingPidPath(), wingArgsPath(), wingStatusPath()); err != nil { + return fmt.Errorf("remove stopped daemon metadata: %w", err) } - os.Remove(wingPidPath()) - os.Remove(wingArgsPath()) - os.Remove(wingStatusPath()) // Start new daemon with same args exe, err := os.Executable() @@ -1194,13 +1342,19 @@ func restartWingDaemonIfRunning() error { return fmt.Errorf("find executable: %w", err) } - rotateLog(wingLogPath()) + if err := rotateLog(wingLogPath()); err != nil { + return err + } logFile, err := os.OpenFile(wingLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { return fmt.Errorf("open log: %w", err) } - home, _ := os.UserHomeDir() + home, err := os.UserHomeDir() + if err != nil { + closeWithLog("wing log", logFile) + return fmt.Errorf("resolve user home: %w", err) + } child := exec.Command(exe, savedArgs...) child.Dir = home child.Stdout = logFile @@ -1208,13 +1362,15 @@ func restartWingDaemonIfRunning() error { child.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := child.Start(); err != nil { - logFile.Close() + closeWithLog("wing log", logFile) return fmt.Errorf("start daemon: %w", err) } - logFile.Close() + closeWithLog("wing log", logFile) - os.WriteFile(wingPidPath(), []byte(strconv.Itoa(child.Process.Pid)), 0644) - os.WriteFile(wingArgsPath(), argsData, 0644) + if err := writeDaemonMetadata(wingPidPath(), wingArgsPath(), child.Process.Pid, savedArgs); err != nil { + abandonStartedDaemon(child) + return fmt.Errorf("restart daemon: %w", err) + } result := waitForWingStatus(child.Process.Pid, 5*time.Second) switch result { @@ -1222,11 +1378,18 @@ func restartWingDaemonIfRunning() error { fmt.Printf("wing daemon restarted (pid %d)\n", child.Process.Pid) fmt.Printf(" relay: connected\n") case "auth_failed": - fmt.Printf("wing daemon restarted but auth failed — run: wt logout && wt login\n") + abandonStartedDaemon(child) + if err := removeFiles(wingPidPath(), wingArgsPath(), wingStatusPath()); err != nil { + return errors.Join(fmt.Errorf("wing daemon restarted but auth failed — run: wt logout && wt login"), fmt.Errorf("remove failed daemon metadata: %w", err)) + } + return fmt.Errorf("wing daemon restarted but auth failed — run: wt logout && wt login") default: fmt.Printf("wing daemon restarted (pid %d)\n", child.Process.Pid) fmt.Printf(" relay: connecting...\n") } + if err := child.Process.Release(); err != nil { + log.Printf("warning: failed to release restarted daemon process handle: %v", err) + } return nil } @@ -1241,16 +1404,118 @@ func resolveRelayHTTPURL(cfg *config.Config) string { if relayURL == "" { relayURL = "https://ws.wingthing.ai" } + return normalizeRelayHTTPURL(relayURL) +} + +// normalizeRelayHTTPURL converts a wing/coordinator URL to an HTTP base URL. +func normalizeRelayHTTPURL(relayURL string) string { + if relayURL == "" { + return "" + } relayURL = strings.TrimRight(relayURL, "/") relayURL = strings.Replace(relayURL, "wss://", "https://", 1) relayURL = strings.Replace(relayURL, "ws://", "http://", 1) - // Ensure we have a scheme — bare hostnames break http.Client if !strings.HasPrefix(relayURL, "http://") && !strings.HasPrefix(relayURL, "https://") { relayURL = "https://" + relayURL } return relayURL } +// relayMetadataURL removes URL components that must not be persisted or copied +// into support bundles. Coordinator API routing can retain a path prefix, but +// userinfo, queries, and fragments are never part of the coordinator identity. +func relayMetadataURL(relayURL string) string { + normalized := normalizeRelayHTTPURL(relayURL) + parsed, err := url.Parse(normalized) + if err != nil || parsed.Hostname() == "" { + return "" + } + parsed.User = nil + parsed.RawQuery = "" + parsed.Fragment = "" + return strings.TrimRight(parsed.String(), "/") +} + +// resolveWingRelayHTTPURL mirrors the daemon's precedence: explicit flag, +// wing.yaml, local default, config.yaml, then the hosted coordinator. +func resolveWingRelayHTTPURL(cfg *config.Config, explicit string, local bool) string { + relayURL := explicit + if relayURL == "" && cfg != nil { + if wingCfg, err := config.LoadWingConfig(cfg.Dir); err == nil { + relayURL = wingCfg.Roost + } + } + if relayURL == "" && local { + relayURL = "http://localhost:8080" + } + if relayURL == "" && cfg != nil { + relayURL = cfg.RoostURL + } + if relayURL == "" { + relayURL = "https://ws.wingthing.ai" + } + return normalizeRelayHTTPURL(relayURL) +} + +// roostBrowserURL returns the UI served by the selected coordinator. The +// public service has split ws/app hosts; a self-hosted roost serves its app at +// /app/ on the same origin. +func roostBrowserURL(roostURL string) string { + httpURL := normalizeRelayHTTPURL(roostURL) + parsed, err := url.Parse(httpURL) + if err != nil || parsed.Hostname() == "" { + return httpURL + } + // The browser destination is display output. Never echo URL credentials, + // even if a caller supplied a credentialed coordinator URL. + parsed.User = nil + switch strings.ToLower(parsed.Hostname()) { + case "ws.wingthing.ai", "wingthing.ai", "app.wingthing.ai": + parsed.Scheme = "https" + parsed.Host = "app.wingthing.ai" + parsed.Path = "/" + default: + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/app/" + } + parsed.RawPath = "" + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String() +} + +func wingRoostFlags(args []string) (roost string, local bool) { + for index := 0; index < len(args); index++ { + arg := args[index] + switch { + case arg == "--local": + local = true + case arg == "--roost" && index+1 < len(args): + index++ + roost = args[index] + case strings.HasPrefix(arg, "--roost="): + roost = strings.TrimPrefix(arg, "--roost=") + } + } + return roost, local +} + +// activeWingRelayHTTPURL uses the exact coordinator recorded by a new daemon, +// then falls back to saved launch args for an already-running older daemon. +func activeWingRelayHTTPURL(cfg *config.Config, status *wingStatus) string { + if status != nil && status.RoostURL != "" { + if relayURL := relayMetadataURL(status.RoostURL); relayURL != "" { + return relayURL + } + } + if data, err := os.ReadFile(wingArgsPath()); err == nil { + if args, parseErr := parseSavedDaemonArgs(data, wingDaemon); parseErr == nil { + roost, local := wingRoostFlags(args) + return resolveWingRelayHTTPURL(cfg, roost, local) + } + } + return resolveWingRelayHTTPURL(cfg, "", false) +} + // formatUserIdentity formats a user identity string from auth.UserInfo. func formatUserIdentity(info *auth.UserInfo) string { identity := info.DisplayName @@ -1308,7 +1573,7 @@ func supportCmd() *cobra.Command { return &cobra.Command{ Use: "support", Short: "Collect diagnostic bundle for troubleshooting", - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) (runErr error) { cfg, err := config.Load() if err != nil { return err @@ -1320,9 +1585,17 @@ func supportCmd() *cobra.Command { if err != nil { return fmt.Errorf("create zip: %w", err) } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + runErr = errors.Join(runErr, fmt.Errorf("close support bundle: %w", err)) + } + }() zw := zip.NewWriter(f) - defer zw.Close() + defer func() { + if err := zw.Close(); err != nil { + runErr = errors.Join(runErr, fmt.Errorf("finalize support bundle: %w", err)) + } + }() // meta.json hostname, _ := os.Hostname() @@ -1341,70 +1614,96 @@ func supportCmd() *cobra.Command { meta["token_expires_at"] = tok.ExpiresAt meta["token_device_id"] = tok.DeviceID } - if s, sErr := readWingStatus(); sErr == nil { - meta["wing_status"] = s.State - if s.Error != "" { - meta["wing_status_error"] = s.Error + var currentWingStatus *wingStatus + if status, statusErr := readWingStatus(); statusErr == nil { + currentWingStatus = status + meta["wing_status"] = status.State + if roostURL := relayMetadataURL(status.RoostURL); roostURL != "" { + meta["wing_status_roost"] = roostURL + } + if status.Error != "" { + meta["wing_status_error"] = status.Error } } // Try whoami if tok != nil { - relayURL := resolveRelayHTTPURL(cfg) + relayURL := activeWingRelayHTTPURL(cfg, currentWingStatus) if info, infoErr := auth.FetchUserInfo(relayURL, tok.Token); infoErr == nil { meta["account"] = formatUserIdentity(info) } else { meta["account_error"] = infoErr.Error() } } - metaJSON, _ := json.MarshalIndent(meta, "", " ") - addZipFile(zw, "meta.json", metaJSON) + metaJSON, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("encode support metadata: %w", err) + } + if err := addZipFile(zw, "meta.json", metaJSON); err != nil { + return err + } // wing.log (last 10000 lines) - addZipTail(zw, "wing.log", wingLogPath(), 10000) + if err := addZipTail(zw, "wing.log", wingLogPath(), 10000); err != nil { + return err + } // egg.log (last 1000 lines) - addZipTail(zw, "egg.log", filepath.Join(cfg.Dir, "egg.log"), 1000) + if err := addZipTail(zw, "egg.log", filepath.Join(cfg.Dir, "egg.log"), 1000); err != nil { + return err + } // Session logs (preserved from ~/.wingthing/logs/) logsDir := filepath.Join(cfg.Dir, "logs") if logEntries, logErr := os.ReadDir(logsDir); logErr == nil { for _, e := range logEntries { - addZipTail(zw, "logs/"+e.Name(), filepath.Join(logsDir, e.Name()), 500) + if err := addZipTail(zw, "logs/"+e.Name(), filepath.Join(logsDir, e.Name()), 500); err != nil { + return err + } } } // wing.yaml (redact secrets) - addZipRedacted(zw, "wing.yaml", filepath.Join(cfg.Dir, "wing.yaml"), - []string{"jwt_key:", "allow_keys:", "- public_key:"}) + if err := addZipRedacted(zw, "wing.yaml", filepath.Join(cfg.Dir, "wing.yaml"), + []string{"jwt_key:", "allow_keys:", "- public_key:"}); err != nil { + return err + } // wing.status - addZipCopy(zw, "wing.status", wingStatusPath()) + if err := addZipCopy(zw, "wing.status", wingStatusPath()); err != nil { + return err + } // doctor output if doctorOut, doctorErr := exec.Command(os.Args[0], "doctor").CombinedOutput(); doctorErr == nil { - addZipFile(zw, "doctor.txt", doctorOut) + if err := addZipFile(zw, "doctor.txt", doctorOut); err != nil { + return err + } } - zw.Close() - f.Close() fmt.Printf("diagnostic bundle: %s\n", zipPath) return nil }, } } -func addZipFile(zw *zip.Writer, name string, data []byte) { +func addZipFile(zw *zip.Writer, name string, data []byte) error { w, err := zw.Create(name) if err != nil { - return + return fmt.Errorf("create support bundle entry %s: %w", name, err) + } + if _, err := w.Write(data); err != nil { + return fmt.Errorf("write support bundle entry %s: %w", name, err) } - w.Write(data) + return nil } -func addZipRedacted(zw *zip.Writer, name, srcPath string, redactPrefixes []string) { +func addZipRedacted(zw *zip.Writer, name, srcPath string, redactPrefixes []string) error { data, err := os.ReadFile(srcPath) if err != nil { - return + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("read support source %s: %w", srcPath, err) } var out []string for _, line := range strings.Split(string(data), "\n") { @@ -1421,23 +1720,29 @@ func addZipRedacted(zw *zip.Writer, name, srcPath string, redactPrefixes []strin out = append(out, line) } } - addZipFile(zw, name, []byte(strings.Join(out, "\n"))) + return addZipFile(zw, name, []byte(strings.Join(out, "\n"))) } -func addZipCopy(zw *zip.Writer, name, srcPath string) { +func addZipCopy(zw *zip.Writer, name, srcPath string) error { data, err := os.ReadFile(srcPath) if err != nil { - return + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("read support source %s: %w", srcPath, err) } - addZipFile(zw, name, data) + return addZipFile(zw, name, data) } -func addZipTail(zw *zip.Writer, name, srcPath string, maxLines int) { +func addZipTail(zw *zip.Writer, name, srcPath string, maxLines int) error { f, err := os.Open(srcPath) if err != nil { - return + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("open support source %s: %w", srcPath, err) } - defer f.Close() + defer closeWithLog("support source", f) var lines []string scanner := bufio.NewScanner(f) @@ -1448,12 +1753,18 @@ func addZipTail(zw *zip.Writer, name, srcPath string, maxLines int) { lines = lines[1:] } } + if err := scanner.Err(); err != nil { + return fmt.Errorf("scan support source %s: %w", srcPath, err) + } w, err := zw.Create(name) if err != nil { - return + return fmt.Errorf("create support bundle entry %s: %w", name, err) } for _, line := range lines { - io.WriteString(w, line+"\n") + if _, err := io.WriteString(w, line+"\n"); err != nil { + return fmt.Errorf("write support bundle entry %s: %w", name, err) + } } + return nil } diff --git a/cmd/wt/main_task_test.go b/cmd/wt/main_task_test.go new file mode 100644 index 00000000..55d3efac --- /dev/null +++ b/cmd/wt/main_task_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "errors" + "io" + "regexp" + "strings" + "testing" + "time" + + "github.com/ehrlich-b/wingthing/internal/auth" + "github.com/ehrlich-b/wingthing/internal/config" + "github.com/ehrlich-b/wingthing/internal/store" +) + +func TestReusableLoginForExplicitRoostVerifiesItsAuthority(t *testing.T) { + store := auth.NewTokenStore(t.TempDir()) + existing := &auth.DeviceToken{Token: "current-token"} + + called := false + reusable, err := reusableLoginForTarget(store, existing, "https://private.example/", func(target, token string) error { + called = true + if target != "https://private.example" || token != existing.Token { + t.Fatalf("validation target/token = %q/%q", target, token) + } + return nil + }) + if err != nil || !reusable || !called { + t.Fatalf("accepted explicit login: reusable=%v called=%v err=%v", reusable, called, err) + } + + reusable, err = reusableLoginForTarget(store, existing, "https://other.example", func(string, string) error { + return auth.ErrAuthFailed + }) + if err != nil || reusable { + t.Fatalf("foreign login: reusable=%v err=%v", reusable, err) + } + + sentinel := errors.New("network down") + if reusable, err = reusableLoginForTarget(store, existing, "https://offline.example", func(string, string) error { + return sentinel + }); reusable || !errors.Is(err, sentinel) { + t.Fatalf("unverifiable explicit login: reusable=%v err=%v", reusable, err) + } +} + +func TestReusableLoginKeepsCompatibleImplicitAndExpiredBehavior(t *testing.T) { + store := auth.NewTokenStore(t.TempDir()) + called := false + validate := func(string, string) error { + called = true + return nil + } + if reusable, err := reusableLoginForTarget(store, &auth.DeviceToken{Token: "current-token"}, "", validate); err != nil || !reusable || called { + t.Fatalf("implicit login: reusable=%v called=%v err=%v", reusable, called, err) + } + expired := &auth.DeviceToken{Token: "expired", ExpiresAt: time.Now().Add(-time.Minute).Unix()} + if reusable, err := reusableLoginForTarget(store, expired, "https://new.example", validate); err != nil || reusable || called { + t.Fatalf("expired login: reusable=%v called=%v err=%v", reusable, called, err) + } +} + +func TestRootHelpLeadsWithAgentManager(t *testing.T) { + root := newRootCommand() + if !strings.Contains(strings.ToLower(root.Short), "agent manager") || + !strings.Contains(strings.ToLower(root.Long), "agent manager") { + t.Fatalf("root help does not lead with the product's agent-manager role: short=%q long=%q", root.Short, root.Long) + } +} + +func TestNewRuntimeIDHasSixtyFourBitsOfReadableEntropy(t *testing.T) { + want := regexp.MustCompile(`^[0-9a-f]{16}$`) + seen := make(map[string]struct{}, 1000) + for range 1000 { + id := newRuntimeID() + if !want.MatchString(id) { + t.Fatalf("runtime ID %q is not 16 lowercase hex characters", id) + } + if _, exists := seen[id]; exists { + t.Fatalf("duplicate runtime ID %q", id) + } + seen[id] = struct{}{} + } +} + +func TestRunTaskPersistsFailureFromEveryEarlyExit(t *testing.T) { + cfg := &config.Config{Dir: t.TempDir(), DefaultAgent: "claude", WingID: "test-wing"} + taskStore, err := store.Open(cfg.DBPath()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := taskStore.Close(); err != nil { + t.Errorf("close task store: %v", err) + } + }) + task := &store.Task{ + ID: "early-failure", + Type: "prompt", + What: "must not run", + RunAt: time.Now(), + Agent: "claude", + Isolation: "privileged", + CWD: t.TempDir(), + } + if err := taskStore.CreateTask(task); err != nil { + t.Fatal(err) + } + + err = runTaskToWithOptions(context.Background(), cfg, taskStore, task, io.Discard, taskRunOptions{SharedHost: true}) + if err == nil { + t.Fatal("expected shared-host task to fail closed") + } + stored, getErr := taskStore.GetTask(task.ID) + if getErr != nil { + t.Fatal(getErr) + } + if stored == nil || stored.Status != "failed" || stored.Error == nil || *stored.Error == "" { + t.Fatalf("failed task state was not persisted: %#v", stored) + } +} diff --git a/cmd/wt/mcp_connect.go b/cmd/wt/mcp_connect.go new file mode 100644 index 00000000..43e92e29 --- /dev/null +++ b/cmd/wt/mcp_connect.go @@ -0,0 +1,398 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/ehrlich-b/wingthing/internal/auth" + "github.com/ehrlich-b/wingthing/internal/config" + "github.com/ehrlich-b/wingthing/internal/control" + webrtcpkg "github.com/ehrlich-b/wingthing/internal/webrtc" + "github.com/ehrlich-b/wingthing/internal/ws" + pionwebrtc "github.com/pion/webrtc/v4" + "github.com/spf13/cobra" +) + +type connectMCPServer struct { + in io.Reader + out io.Writer + actor string + tunnel connectMCPTunnel + timeout time.Duration + mu sync.Mutex + controls map[string]*webrtcpkg.ControlClient + connecting map[string]*controlConnectAttempt +} + +type controlConnectAttempt struct { + done chan struct{} + client *webrtcpkg.ControlClient + err error +} + +const maxConcurrentConnectMCPCalls = 64 + +type connectMCPTunnel interface { + ListWings(ctx context.Context) ([]ws.WingInfo, error) + DiscoverWing(ctx context.Context, wingID string) (*ws.WingInfo, error) + Stream(ctx context.Context, wingID, wingPublicKey string, inner any, onChunk func([]byte) error) error +} + +func connectMCPCmd() *cobra.Command { + var clientName string + var roost string + var connectTimeout time.Duration + command := &cobra.Command{ + Use: "connect", + Short: "Manage agents on remote wings over direct encrypted connections", + Long: "Run one local MCP server for every accessible wing. Wingthing uses the roost for " + + "identity, inventory, and WebRTC signaling; control payloads go directly to the selected wing.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + actor := strings.TrimSpace(clientName) + if actor == "" { + actor = strings.TrimSpace(os.Getenv("WT_MCP_CLIENT")) + } + if actor == "" { + actor = "default" + } + if err := validateSessionName(actor); err != nil { + return fmt.Errorf("invalid MCP client name: %w", err) + } + tokenStore := auth.NewTokenStore(cfg.Dir) + token, err := tokenStore.Load() + if err != nil || !tokenStore.IsValid(token) { + return fmt.Errorf("not logged in — run: wt login --roost ") + } + privateKey, err := auth.LoadPrivateKey(cfg.Dir) + if err != nil { + return fmt.Errorf("load native client key: %w", err) + } + server := &connectMCPServer{ + in: os.Stdin, out: os.Stdout, actor: actor, timeout: connectTimeout, + tunnel: &ws.TunnelClient{ + RelayURL: finderRelayURL(cfg, roost), DeviceToken: token.Token, + PrivKey: privateKey, KnownWingsPath: filepath.Join(cfg.Dir, "known_wings.json"), + }, + controls: make(map[string]*webrtcpkg.ControlClient), + } + defer server.close() + return server.serve(cmd.Context()) + }, + } + command.Flags().StringVar(&clientName, "client", "", "MCP actor name used for attribution (or WT_MCP_CLIENT)") + command.Flags().StringVar(&roost, "roost", "", "coordination roost URL (default: config or wingthing.ai)") + command.Flags().DurationVar(&connectTimeout, "connect-timeout", 15*time.Second, "deadline for establishing each direct wing connection") + return command +} + +func (s *connectMCPServer) serve(ctx context.Context) error { + callCtx, cancelCalls := context.WithCancel(ctx) + defer cancelCalls() + scanner := bufio.NewScanner(s.in) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + encoder := json.NewEncoder(s.out) + var calls sync.WaitGroup + requestSlots := make(chan struct{}, maxConcurrentConnectMCPCalls) + var encodeMu sync.Mutex + var encodeErr error + write := func(response localMCPResponse) { + encodeMu.Lock() + defer encodeMu.Unlock() + if encodeErr == nil { + encodeErr = encoder.Encode(response) + } + } + for scanner.Scan() { + select { + case <-ctx.Done(): + cancelCalls() + calls.Wait() + return ctx.Err() + default: + } + var request localMCPRequest + if err := json.Unmarshal(scanner.Bytes(), &request); err != nil { + write(localMCPResponse{JSONRPC: "2.0", Error: &localMCPError{Code: -32700, Message: "parse error"}}) + continue + } + dispatch := func() { + response, respond := s.handle(callCtx, request) + if respond { + write(response) + } + } + if request.Method == "tools/call" { + select { + case requestSlots <- struct{}{}: + default: + if len(request.ID) > 0 { + write(localMCPResponse{ + JSONRPC: "2.0", ID: request.ID, + Error: &localMCPError{Code: -32000, Message: "too many concurrent tool calls"}, + }) + } + continue + } + calls.Add(1) + go func() { + defer calls.Done() + defer func() { <-requestSlots }() + dispatch() + }() + } else { + dispatch() + } + } + scanErr := scanner.Err() + // The parent MCP process closing stdin is a transport disconnect. Cancel + // outstanding waits/connection attempts without stopping durable wing work. + cancelCalls() + calls.Wait() + if scanErr != nil { + return fmt.Errorf("read MCP request: %w", scanErr) + } + return encodeErr +} + +func (s *connectMCPServer) handle(ctx context.Context, request localMCPRequest) (localMCPResponse, bool) { + response := localMCPResponse{JSONRPC: "2.0", ID: request.ID} + if request.JSONRPC != "2.0" || request.Method == "" { + response.Error = &localMCPError{Code: -32600, Message: "invalid request"} + return response, len(request.ID) > 0 + } + switch request.Method { + case "initialize": + response.Result = map[string]any{ + "protocolVersion": localMCPProtocolVersion, + "capabilities": map[string]any{"tools": map[string]any{}}, + "serverInfo": map[string]any{"name": "wingthing-agent-manager", "version": version, "actor": s.actor}, + "instructions": "Wingthing manages durable agents across machines. Call wing_list, then pass an explicit wing_id to every wing-owned tool. Remote control travels directly to that wing; the roost is used for identity, directory, and signaling.", + } + case "notifications/initialized", "notifications/cancelled": + return localMCPResponse{}, false + case "ping": + response.Result = map[string]any{} + case "tools/list": + response.Result = map[string]any{"tools": control.Tools(control.SurfaceDirectMCP)} + case "tools/call": + var call localMCPToolCallParams + if err := decodeStrict(request.Params, &call); err != nil || call.Name == "" { + message := "name is required" + if err != nil { + message = err.Error() + } + response.Error = &localMCPError{Code: -32602, Message: "invalid tools/call params: " + message} + break + } + if len(call.Arguments) == 0 { + call.Arguments = json.RawMessage(`{}`) + } + result, isError, err := s.callTool(ctx, call.Name, call.Arguments) + if err != nil { + result = map[string]any{"error": err.Error()} + isError = true + } + response.Result = localMCPToolResult(result, isError) + default: + if len(request.ID) == 0 { + return localMCPResponse{}, false + } + response.Error = &localMCPError{Code: -32601, Message: "method not found: " + request.Method} + } + return response, len(request.ID) > 0 +} + +func (s *connectMCPServer) callTool(ctx context.Context, name string, arguments json.RawMessage) (map[string]any, bool, error) { + tool, ok := control.Lookup(name) + if !ok || !tool.Supports(control.SurfaceDirectMCP) { + return nil, true, fmt.Errorf("unknown direct MCP tool %q", name) + } + if tool.Authority == control.AuthorityPortal { + if name != "wing_list" { + return nil, true, fmt.Errorf("portal control handler unavailable for %q", name) + } + var empty struct{} + if err := decodeStrict(arguments, &empty); err != nil { + return nil, true, fmt.Errorf("wing_list arguments: %w", err) + } + wings, err := s.tunnel.ListWings(ctx) + if err != nil { + return nil, true, err + } + entries := make([]map[string]any, 0, len(wings)) + for _, wing := range wings { + hostedRelay := ws.HostedRelayDeny + if ws.HostedRelayAllowed(wing.HostedRelay) { + hostedRelay = ws.HostedRelayAllow + } + entry := map[string]any{ + "wing_id": wing.WingID, "public_key": wing.PublicKey, + "owner": wing.Owner, "org_id": wing.OrgID, "online": true, + "mcp_control": wing.PurposeBinding && wing.DirectMCP && !wing.Locked, "mcp_transport": "direct-webrtc", "hosted_relay": hostedRelay, + } + if !wing.PurposeBinding { + entry["mcp_control_reason"] = "wing-upgrade-required" + } else if !wing.DirectMCP { + entry["mcp_control_reason"] = "wing-direct-control-disabled" + } else if wing.Locked { + entry["mcp_control_reason"] = "native-passkey-not-supported" + } + entries = append(entries, entry) + } + return map[string]any{"wings": entries, "count": len(entries), "control_scope": "qualified-direct"}, false, nil + } + wingID, forwarded, err := control.SplitWingTarget(arguments) + if err != nil { + return nil, true, err + } + client, err := s.controlClient(ctx, wingID) + if err != nil { + return nil, true, fmt.Errorf("direct connection to %s failed: %w; the native connector does not use the hosted relay—put both peers on the same LAN/tailnet, configure ICE, use SSH, or connect through a self-hosted roost", wingID, err) + } + result, isError, err := client.Call(ctx, name, forwarded) + if err != nil { + if client.Closed() { + s.evictControl(wingID, client) + } + return nil, true, err + } + return control.QualifyResult(wingID, result), isError, nil +} + +func (s *connectMCPServer) controlClient(ctx context.Context, wingID string) (*webrtcpkg.ControlClient, error) { + for { + s.mu.Lock() + if existing := s.controls[wingID]; existing != nil { + if !existing.Closed() { + s.mu.Unlock() + return existing, nil + } + delete(s.controls, wingID) + s.mu.Unlock() + _ = existing.Close() + continue + } + if attempt := s.connecting[wingID]; attempt != nil { + s.mu.Unlock() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-attempt.done: + return attempt.client, attempt.err + } + } + if s.connecting == nil { + s.connecting = make(map[string]*controlConnectAttempt) + } + attempt := &controlConnectAttempt{done: make(chan struct{})} + s.connecting[wingID] = attempt + s.mu.Unlock() + + client, err := s.establishControlClient(ctx, wingID) + attempt.client = client + attempt.err = err + s.mu.Lock() + if s.connecting[wingID] == attempt { + delete(s.connecting, wingID) + } + if err == nil { + if s.controls == nil { + s.controls = make(map[string]*webrtcpkg.ControlClient) + } + s.controls[wingID] = client + } + close(attempt.done) + s.mu.Unlock() + return client, err + } +} + +func (s *connectMCPServer) establishControlClient(ctx context.Context, wingID string) (*webrtcpkg.ControlClient, error) { + connectCtx, cancel := context.WithTimeout(ctx, s.timeout) + defer cancel() + wing, err := s.tunnel.DiscoverWing(connectCtx, wingID) + if err != nil { + return nil, err + } + if !wing.PurposeBinding { + return nil, fmt.Errorf("wing does not advertise purpose-bound signaling; upgrade wt on the wing before using native direct MCP") + } + if !wing.DirectMCP { + return nil, fmt.Errorf("wing does not have its WebRTC direct-control endpoint enabled; change connection_mode from direct or use that wing's configured direct endpoint") + } + if wing.Locked { + return nil, fmt.Errorf("wing requires passkey authentication, which native direct MCP does not support in this release") + } + var wingDetails struct { + ICEServers []config.ICEServer `json:"ice_servers"` + } + if err := s.tunnel.Stream(connectCtx, wing.WingID, wing.PublicKey, map[string]any{ + "type": "wing.info", + }, func(payload []byte) error { return json.Unmarshal(payload, &wingDetails) }); err != nil { + return nil, fmt.Errorf("read direct connection metadata: %w", err) + } + iceServers := make([]pionwebrtc.ICEServer, 0, len(wingDetails.ICEServers)) + for _, server := range wingDetails.ICEServers { + iceServers = append(iceServers, pionwebrtc.ICEServer{ + URLs: server.URLs, Username: server.Username, Credential: server.Credential, + }) + } + client, err := webrtcpkg.NewControlClient(s.actor, iceServers) + if err != nil { + return nil, err + } + offer, err := client.Offer(connectCtx) + if err == nil { + var answer struct { + SDP string `json:"sdp"` + } + err = s.tunnel.Stream(connectCtx, wing.WingID, wing.PublicKey, map[string]any{ + "type": "webrtc.offer", "sdp": offer, + }, func(payload []byte) error { return json.Unmarshal(payload, &answer) }) + if err == nil && answer.SDP == "" { + err = fmt.Errorf("wing returned no WebRTC answer") + } + if err == nil { + err = client.AcceptAnswer(answer.SDP) + } + if err == nil { + err = client.WaitReady(connectCtx) + } + } + if err != nil { + closeWithLog("WebRTC client", client) + return nil, err + } + return client, nil +} + +func (s *connectMCPServer) evictControl(wingID string, client *webrtcpkg.ControlClient) { + s.mu.Lock() + defer s.mu.Unlock() + if s.controls[wingID] == client { + delete(s.controls, wingID) + _ = client.Close() + } +} + +func (s *connectMCPServer) close() { + s.mu.Lock() + defer s.mu.Unlock() + for wingID, client := range s.controls { + _ = client.Close() + delete(s.controls, wingID) + } +} diff --git a/cmd/wt/mcp_connect_test.go b/cmd/wt/mcp_connect_test.go new file mode 100644 index 00000000..042b7a62 --- /dev/null +++ b/cmd/wt/mcp_connect_test.go @@ -0,0 +1,573 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/ehrlich-b/wingthing/internal/config" + webrtcpkg "github.com/ehrlich-b/wingthing/internal/webrtc" + "github.com/ehrlich-b/wingthing/internal/ws" + pionwebrtc "github.com/pion/webrtc/v4" +) + +type directConnectorTestWing struct { + info ws.WingInfo + manager *webrtcpkg.PeerManager + cfg *config.Config + wingCfg *config.WingConfig + admission *mcpAdmissionState +} + +type directConnectorTestTunnel struct { + wings map[string]*directConnectorTestWing + mu sync.Mutex + seen []string +} + +type blockingConnectMCPTunnel struct { + started chan struct{} + release chan struct{} +} + +type blockingDiscoverTunnel struct { + mu sync.Mutex + count int + started chan struct{} + release chan struct{} +} + +type disconnectAwareConnectTunnel struct { + started chan struct{} + canceled chan struct{} +} + +func (t *disconnectAwareConnectTunnel) ListWings(ctx context.Context) ([]ws.WingInfo, error) { + close(t.started) + <-ctx.Done() + close(t.canceled) + return nil, ctx.Err() +} + +func (*disconnectAwareConnectTunnel) DiscoverWing(context.Context, string) (*ws.WingInfo, error) { + return nil, fmt.Errorf("unused") +} + +func (*disconnectAwareConnectTunnel) Stream(context.Context, string, string, any, func([]byte) error) error { + return fmt.Errorf("unused") +} + +func (*blockingDiscoverTunnel) ListWings(context.Context) ([]ws.WingInfo, error) { + return nil, fmt.Errorf("unused") +} + +func (t *blockingDiscoverTunnel) DiscoverWing(ctx context.Context, _ string) (*ws.WingInfo, error) { + t.mu.Lock() + t.count++ + if t.count == 1 { + close(t.started) + } + t.mu.Unlock() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-t.release: + return nil, fmt.Errorf("planned discovery failure") + } +} + +func (*blockingDiscoverTunnel) Stream(context.Context, string, string, any, func([]byte) error) error { + return fmt.Errorf("unused") +} + +func (t *blockingDiscoverTunnel) discoveryCount() int { + t.mu.Lock() + defer t.mu.Unlock() + return t.count +} + +func (t *blockingConnectMCPTunnel) ListWings(context.Context) ([]ws.WingInfo, error) { + t.started <- struct{}{} + <-t.release + return nil, nil +} + +func (*blockingConnectMCPTunnel) DiscoverWing(context.Context, string) (*ws.WingInfo, error) { + return nil, fmt.Errorf("unused") +} + +func (*blockingConnectMCPTunnel) Stream(context.Context, string, string, any, func([]byte) error) error { + return fmt.Errorf("unused") +} + +func newDirectConnectorTestTunnel(t *testing.T) *directConnectorTestTunnel { + t.Helper() + tunnel := &directConnectorTestTunnel{wings: map[string]*directConnectorTestWing{}} + for _, wingID := range []string{"office", "home"} { + wing := &directConnectorTestWing{ + info: ws.WingInfo{WingID: wingID, PublicKey: wingID + "-public-key", PurposeBinding: true, DirectMCP: true, HostedRelay: ws.HostedRelayDeny}, + manager: webrtcpkg.NewPeerManager(nil), + cfg: &config.Config{Dir: t.TempDir(), DefaultAgent: "claude", WingID: wingID}, + wingCfg: &config.WingConfig{WingID: wingID, HostedRelay: config.HostedRelayDeny}, + admission: newMCPAdmissionState(), + } + wing.manager.OnDC(func(_ string, _ string, identity webrtcpkg.PeerIdentity, dc *pionwebrtc.DataChannel) { + serveDirectMCPChannel(wing.cfg, wing.wingCfg, wing.cfg.Dir, false, nil, wing.admission, identity, dc) + }) + t.Cleanup(wing.manager.Close) + tunnel.wings[wingID] = wing + } + return tunnel +} + +func (tunnel *directConnectorTestTunnel) ListWings(context.Context) ([]ws.WingInfo, error) { + wingIDs := make([]string, 0, len(tunnel.wings)) + for wingID := range tunnel.wings { + wingIDs = append(wingIDs, wingID) + } + sort.Strings(wingIDs) + wings := make([]ws.WingInfo, 0, len(wingIDs)) + for _, wingID := range wingIDs { + wings = append(wings, tunnel.wings[wingID].info) + } + return wings, nil +} + +func (tunnel *directConnectorTestTunnel) DiscoverWing(_ context.Context, wingID string) (*ws.WingInfo, error) { + wing := tunnel.wings[wingID] + if wing == nil { + return nil, fmt.Errorf("wing %s not found", wingID) + } + info := wing.info + return &info, nil +} + +func (tunnel *directConnectorTestTunnel) Stream(_ context.Context, wingID, _ string, inner any, onChunk func([]byte) error) error { + wing := tunnel.wings[wingID] + if wing == nil { + return fmt.Errorf("wing %s not found", wingID) + } + payload, err := json.Marshal(inner) + if err != nil { + return err + } + var request struct { + Type string `json:"type"` + SDP string `json:"sdp"` + } + if err := json.Unmarshal(payload, &request); err != nil { + return err + } + tunnel.mu.Lock() + tunnel.seen = append(tunnel.seen, request.Type) + tunnel.mu.Unlock() + switch request.Type { + case "wing.info": + return onChunk([]byte(`{"ice_servers":[],"hosted_relay":"deny"}`)) + case "webrtc.offer": + answer, err := wing.manager.HandleOffer( + "native-client-public-key", "owner-user", "owner@example.com", "owner", nil, request.SDP, + ) + if err != nil { + return err + } + response, _ := json.Marshal(map[string]string{"sdp": answer}) + return onChunk(response) + default: + return fmt.Errorf("unexpected coordinator payload type %q", request.Type) + } +} + +func (tunnel *directConnectorTestTunnel) observedTypes() []string { + tunnel.mu.Lock() + defer tunnel.mu.Unlock() + return append([]string(nil), tunnel.seen...) +} + +type connectMCPStdioHarness struct { + t *testing.T + ctx context.Context + cancel context.CancelFunc + input *io.PipeWriter + output *json.Decoder + done <-chan error + nextID int + server *connectMCPServer +} + +func newConnectMCPStdioHarness(t *testing.T, tunnel connectMCPTunnel) *connectMCPStdioHarness { + t.Helper() + inputReader, inputWriter := io.Pipe() + outputReader, outputWriter := io.Pipe() + ctx, cancel := context.WithCancel(context.Background()) + server := &connectMCPServer{ + in: inputReader, out: outputWriter, actor: "codex", tunnel: tunnel, + timeout: 10 * time.Second, controls: map[string]*webrtcpkg.ControlClient{}, + } + done := make(chan error, 1) + go func() { + done <- server.serve(ctx) + _ = outputWriter.Close() + }() + return &connectMCPStdioHarness{ + t: t, ctx: ctx, cancel: cancel, input: inputWriter, + output: json.NewDecoder(outputReader), done: done, nextID: 1, server: server, + } +} + +func (h *connectMCPStdioHarness) tool(name string, arguments map[string]any) map[string]any { + h.t.Helper() + id := h.nextID + h.nextID++ + request := map[string]any{ + "jsonrpc": "2.0", "id": id, "method": "tools/call", + "params": map[string]any{ + "name": name, "arguments": arguments, + "_meta": map[string]any{"progressToken": fmt.Sprintf("claude-%d", id)}, + }, + } + if err := json.NewEncoder(h.input).Encode(request); err != nil { + h.t.Fatal(err) + } + var response struct { + Error *localMCPError `json:"error"` + Result struct { + Structured map[string]any `json:"structuredContent"` + IsError bool `json:"isError"` + } `json:"result"` + } + if err := h.output.Decode(&response); err != nil { + h.t.Fatal(err) + } + if response.Error != nil { + h.t.Fatalf("%s JSON-RPC error: %#v", name, response.Error) + } + response.Result.Structured["_is_error"] = response.Result.IsError + return response.Result.Structured +} + +func (h *connectMCPStdioHarness) close() { + h.t.Helper() + _ = h.input.Close() + select { + case err := <-h.done: + if err != nil { + h.t.Fatalf("connector serve: %v", err) + } + case <-time.After(2 * time.Second): + h.t.Fatal("connector did not stop after stdin closed") + } + h.server.close() + h.cancel() +} + +func TestConnectMCPStdioRoutesTwoWingsDirectlyAndPersistsAcrossReconnect(t *testing.T) { + tunnel := newDirectConnectorTestTunnel(t) + connector := newConnectMCPStdioHarness(t, tunnel) + + listed := connector.tool("wing_list", map[string]any{}) + if listed["count"] != float64(2) { + t.Fatalf("wing inventory = %#v", listed) + } + wingIDs := []string{} + for _, raw := range listed["wings"].([]any) { + entry := raw.(map[string]any) + wingIDs = append(wingIDs, entry["wing_id"].(string)) + if entry["hosted_relay"] != ws.HostedRelayDeny || entry["mcp_transport"] != "direct-webrtc" { + t.Fatalf("wing transport metadata = %#v", entry) + } + if entry["mcp_control"] != true || entry["mcp_control_reason"] != nil { + t.Fatalf("wing control capability = %#v", entry) + } + } + if !reflect.DeepEqual(wingIDs, []string{"home", "office"}) { + t.Fatalf("wing IDs = %#v", wingIDs) + } + + missingTarget := connector.tool("message_list", map[string]any{}) + if missingTarget["_is_error"] != true || missingTarget["error"] != "wing_id is required" { + t.Fatalf("missing target result = %#v", missingTarget) + } + + sent := connector.tool("message_send", map[string]any{ + "wing_id": "office", "content": "office-only durable state", "kind": "evidence", + }) + if sent["_is_error"] != false || sent["wing_id"] != "office" { + t.Fatalf("message_send result = %#v", sent) + } + office := connector.tool("message_list", map[string]any{"wing_id": "office", "include_sent": true}) + home := connector.tool("message_list", map[string]any{"wing_id": "home", "include_sent": true}) + if len(office["messages"].([]any)) != 1 || len(home["messages"].([]any)) != 0 { + t.Fatalf("cross-wing state leaked: office=%#v home=%#v", office, home) + } + if office["wing_id"] != "office" || home["wing_id"] != "home" { + t.Fatalf("results are not qualified: office=%#v home=%#v", office, home) + } + if got := office["messages"].([]any)[0].(map[string]any)["wing_id"]; got != "office" { + t.Fatalf("nested message wing_id = %#v; result=%#v", got, office) + } + unreachable := connector.tool("message_list", map[string]any{"wing_id": "missing", "include_sent": true}) + errText, _ := unreachable["error"].(string) + if unreachable["_is_error"] != true || !strings.Contains(errText, "native connector does not use the hosted relay") || strings.Contains(errText, "enable Pro relay") { + t.Fatalf("unreachable-wing remediation is misleading: %#v", unreachable) + } + connector.close() + + // A new MCP process establishes a fresh data channel, then discovers state + // held by the wing rather than by the previous connector process. + reconnected := newConnectMCPStdioHarness(t, tunnel) + defer reconnected.close() + afterReconnect := reconnected.tool("message_list", map[string]any{"wing_id": "office", "include_sent": true}) + messages := afterReconnect["messages"].([]any) + if len(messages) != 1 || messages[0].(map[string]any)["content"] != "office-only durable state" || messages[0].(map[string]any)["wing_id"] != "office" { + t.Fatalf("durable state after reconnect = %#v", afterReconnect) + } + + for _, observed := range tunnel.observedTypes() { + if observed != "wing.info" && observed != "webrtc.offer" { + t.Fatalf("coordinator observed direct MCP payload type %q", observed) + } + } +} + +func TestConnectMCPStdioBoundsConcurrentToolCalls(t *testing.T) { + tunnel := &blockingConnectMCPTunnel{ + started: make(chan struct{}, maxConcurrentConnectMCPCalls), + release: make(chan struct{}), + } + var input strings.Builder + for id := 1; id <= maxConcurrentConnectMCPCalls+1; id++ { + fmt.Fprintf(&input, `{"jsonrpc":"2.0","id":%d,"method":"tools/call","params":{"name":"wing_list","arguments":{}}}`+"\n", id) + } + var output strings.Builder + server := &connectMCPServer{ + in: strings.NewReader(input.String()), out: &output, actor: "test", tunnel: tunnel, + controls: map[string]*webrtcpkg.ControlClient{}, + } + done := make(chan error, 1) + go func() { done <- server.serve(context.Background()) }() + for index := 0; index < maxConcurrentConnectMCPCalls; index++ { + select { + case <-tunnel.started: + case <-time.After(2 * time.Second): + t.Fatalf("only %d tool calls reached the bounded worker set", index) + } + } + close(tunnel.release) + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("bounded connector did not drain") + } + + decoder := json.NewDecoder(strings.NewReader(output.String())) + overloaded := false + responses := 0 + for { + var response localMCPResponse + if err := decoder.Decode(&response); err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + responses++ + if string(response.ID) == fmt.Sprintf("%d", maxConcurrentConnectMCPCalls+1) && response.Error != nil && strings.Contains(response.Error.Message, "too many") { + overloaded = true + } + } + if responses != maxConcurrentConnectMCPCalls+1 || !overloaded { + t.Fatalf("responses=%d overloaded=%v output=%s", responses, overloaded, output.String()) + } +} + +func TestConnectMCPStdioEOFCancelsOutstandingRemoteWait(t *testing.T) { + inputReader, inputWriter := io.Pipe() + tunnel := &disconnectAwareConnectTunnel{started: make(chan struct{}), canceled: make(chan struct{})} + var output strings.Builder + server := &connectMCPServer{ + in: inputReader, out: &output, actor: "test", tunnel: tunnel, + controls: map[string]*webrtcpkg.ControlClient{}, + } + done := make(chan error, 1) + go func() { done <- server.serve(context.Background()) }() + if _, err := io.WriteString(inputWriter, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"wing_list","arguments":{}}}`+"\n"); err != nil { + t.Fatal(err) + } + select { + case <-tunnel.started: + case <-time.After(time.Second): + t.Fatal("remote wait did not start") + } + if err := inputWriter.Close(); err != nil { + t.Fatal(err) + } + select { + case <-tunnel.canceled: + case <-time.After(time.Second): + t.Fatal("stdin EOF did not cancel the outstanding remote wait") + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("connector did not exit after canceling its outstanding call") + } +} + +func TestConnectMCPCoalescesConcurrentSetupForOneWing(t *testing.T) { + tunnel := &blockingDiscoverTunnel{started: make(chan struct{}), release: make(chan struct{})} + server := &connectMCPServer{ + actor: "test", tunnel: tunnel, timeout: 2 * time.Second, + controls: make(map[string]*webrtcpkg.ControlClient), + } + leaderDone := make(chan error, 1) + go func() { + _, err := server.controlClient(context.Background(), "office") + leaderDone <- err + }() + select { + case <-tunnel.started: + case <-time.After(time.Second): + t.Fatal("connection leader did not start discovery") + } + + // Every follower has its own bounded wait, but none may start another + // offer for the same wing while the leader is still negotiating. + for index := range 8 { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + _, err := server.controlClient(ctx, "office") + cancel() + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("follower %d error = %v, want deadline", index, err) + } + } + if got := tunnel.discoveryCount(); got != 1 { + t.Fatalf("same-wing discovery attempts = %d, want 1", got) + } + + close(tunnel.release) + select { + case err := <-leaderDone: + if err == nil || !strings.Contains(err.Error(), "planned discovery failure") { + t.Fatalf("leader error = %v", err) + } + case <-time.After(time.Second): + t.Fatal("connection leader did not finish") + } + + // A completed failure must leave no poisoned in-flight entry; a later call + // gets a fresh attempt. + _, err := server.controlClient(context.Background(), "office") + if err == nil || !strings.Contains(err.Error(), "planned discovery failure") { + t.Fatalf("retry error = %v", err) + } + if got := tunnel.discoveryCount(); got != 2 { + t.Fatalf("discovery attempts after retry = %d, want 2", got) + } +} + +func TestConnectMCPReportsLegacyWingAsUpgradeRequired(t *testing.T) { + tunnel := newDirectConnectorTestTunnel(t) + legacy := tunnel.wings["office"] + legacy.info.PurposeBinding = false + connector := newConnectMCPStdioHarness(t, tunnel) + defer connector.close() + + listed := connector.tool("wing_list", map[string]any{}) + var office map[string]any + for _, raw := range listed["wings"].([]any) { + entry := raw.(map[string]any) + if entry["wing_id"] == "office" { + office = entry + } + } + if office == nil || office["mcp_control"] != false || office["mcp_control_reason"] != "wing-upgrade-required" { + t.Fatalf("legacy wing capability = %#v", office) + } + + result := connector.tool("message_list", map[string]any{"wing_id": "office"}) + errText, _ := result["error"].(string) + if result["_is_error"] != true || !strings.Contains(errText, "upgrade wt on the wing") { + t.Fatalf("legacy wing control result = %#v", result) + } + if got := tunnel.observedTypes(); len(got) != 0 { + t.Fatalf("legacy wing should fail before signaling, observed %#v", got) + } +} + +func TestDirectMCPRequestSlotsAreBounded(t *testing.T) { + slots := make(chan struct{}, maxConcurrentDirectMCPRequests) + for i := 0; i < maxConcurrentDirectMCPRequests; i++ { + if !acquireDirectMCPRequestSlot(slots) { + t.Fatalf("slot %d unexpectedly rejected", i) + } + } + if acquireDirectMCPRequestSlot(slots) { + t.Fatal("request beyond direct MCP concurrency bound was admitted") + } + <-slots + if !acquireDirectMCPRequestSlot(slots) { + t.Fatal("released direct MCP request slot was not reusable") + } +} + +func TestConnectMCPReportsConfiguredDirectEndpointWithoutWebRTC(t *testing.T) { + tunnel := newDirectConnectorTestTunnel(t) + direct := tunnel.wings["office"] + direct.info.DirectMCP = false + connector := newConnectMCPStdioHarness(t, tunnel) + defer connector.close() + + listed := connector.tool("wing_list", map[string]any{}) + for _, raw := range listed["wings"].([]any) { + entry := raw.(map[string]any) + if entry["wing_id"] == "office" && (entry["mcp_control"] != false || entry["mcp_control_reason"] != "wing-direct-control-disabled") { + t.Fatalf("configured direct wing capability = %#v", entry) + } + } + result := connector.tool("message_list", map[string]any{"wing_id": "office"}) + errText, _ := result["error"].(string) + if result["_is_error"] != true || !strings.Contains(errText, "WebRTC direct-control endpoint") { + t.Fatalf("configured direct wing result = %#v", result) + } + if got := tunnel.observedTypes(); len(got) != 0 { + t.Fatalf("disabled direct MCP should fail before signaling, observed %#v", got) + } +} + +func TestConnectMCPReportsLockedWingPasskeyLimitation(t *testing.T) { + tunnel := newDirectConnectorTestTunnel(t) + locked := tunnel.wings["office"] + locked.info.Locked = true + connector := newConnectMCPStdioHarness(t, tunnel) + defer connector.close() + + listed := connector.tool("wing_list", map[string]any{}) + for _, raw := range listed["wings"].([]any) { + entry := raw.(map[string]any) + if entry["wing_id"] == "office" && (entry["mcp_control"] != false || entry["mcp_control_reason"] != "native-passkey-not-supported") { + t.Fatalf("locked wing capability = %#v", entry) + } + } + result := connector.tool("message_list", map[string]any{"wing_id": "office"}) + errText, _ := result["error"].(string) + if result["_is_error"] != true || !strings.Contains(errText, "requires passkey authentication") { + t.Fatalf("locked wing result = %#v", result) + } + if got := tunnel.observedTypes(); len(got) != 0 { + t.Fatalf("locked wing should fail before signaling, observed %#v", got) + } +} diff --git a/cmd/wt/mcp_direct_wing.go b/cmd/wt/mcp_direct_wing.go new file mode 100644 index 00000000..aa2fe572 --- /dev/null +++ b/cmd/wt/mcp_direct_wing.go @@ -0,0 +1,295 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "strings" + "sync" + "time" + + "github.com/ehrlich-b/wingthing/internal/config" + "github.com/ehrlich-b/wingthing/internal/control" + webrtcpkg "github.com/ehrlich-b/wingthing/internal/webrtc" + pionwebrtc "github.com/pion/webrtc/v4" +) + +const ( + defaultDirectMCPMaxSessions = 8 + defaultDirectMCPMaxSpawnsPerHour = 60 + // Coordinator-derived organization identity is a lease, not a permanent + // capability. Closing direct channels periodically forces a new access check + // and signaling exchange without interrupting the durable agent itself. + directMCPIdentityLease = 15 * time.Minute +) + +// Keep this list explicit: a new direct operation with a new grant must fail a +// compatibility test until its default remote authority is consciously reviewed. +var defaultDirectMCPGrants = []string{ + "capabilities.read", + "message.send", "message.read", + "sandbox.read", + "terminal.read", "terminal.send", "terminal.start", "terminal.rename", "terminal.stop", + "agent.run", "agent.read", "agent.stop", +} + +func knownDirectMCPGrants() map[string]bool { + known := make(map[string]bool, len(defaultDirectMCPGrants)) + for _, grant := range defaultDirectMCPGrants { + known[grant] = true + } + return known +} + +func validateDirectMCPGrantConfig(wingCfg *config.WingConfig) error { + if wingCfg == nil || wingCfg.DirectMCP == nil { + return nil + } + known := knownDirectMCPGrants() + for field, values := range map[string][]string{ + "allow_grants": wingCfg.DirectMCP.AllowGrants, + "deny_grants": wingCfg.DirectMCP.DenyGrants, + } { + for _, grant := range values { + if !known[grant] { + return fmt.Errorf("direct_mcp %s contains unknown direct grant %q", field, grant) + } + } + } + return nil +} + +type directMCPPolicy struct { + role string + grants map[string]bool + maxSessions int + maxSpawnsPerHour int + allowedPaths []string + enforcePathBounds bool + identity EggIdentity +} + +func resolveDirectMCPPolicy(wingCfg *config.WingConfig, home string, sharedHost bool, identity webrtcpkg.PeerIdentity) (directMCPPolicy, error) { + if wingCfg == nil { + return directMCPPolicy{}, fmt.Errorf("wing policy is unavailable") + } + if strings.TrimSpace(identity.UserID) == "" { + return directMCPPolicy{}, fmt.Errorf("authenticated user identity is required") + } + if wingCfg.DirectMCP != nil && wingCfg.DirectMCP.Disabled { + return directMCPPolicy{}, fmt.Errorf("direct MCP is disabled by this wing's local policy") + } + + role := strings.TrimSpace(identity.OrgRole) + if wingCfg.IsAdmin(identity.Email) && (role == "" || role == "member") { + role = "admin" + } + if role == "" { + if !sharedHost { + return directMCPPolicy{}, fmt.Errorf("missing authenticated organization role") + } + // Existing OAuth roosts historically encode an ordinary shared-roost user + // with an empty org role. Preserve that deployment shape at member privilege. + role = "member" + } + if role != "owner" && role != "admin" && role != "member" { + return directMCPPolicy{}, fmt.Errorf("unsupported authenticated organization role %q", role) + } + + if err := validateDirectMCPGrantConfig(wingCfg); err != nil { + return directMCPPolicy{}, err + } + knownGrants := knownDirectMCPGrants() + grants := make(map[string]bool, len(knownGrants)) + for grant := range knownGrants { + grants[grant] = true + } + maxSessions := defaultDirectMCPMaxSessions + maxSpawnsPerHour := defaultDirectMCPMaxSpawnsPerHour + if configured := wingCfg.DirectMCP; configured != nil { + if configured.MaxSessions > 0 { + maxSessions = configured.MaxSessions + } + if configured.MaxSpawnsPerHour > 0 { + maxSpawnsPerHour = configured.MaxSpawnsPerHour + } + if len(configured.AllowGrants) > 0 { + grants = make(map[string]bool, len(configured.AllowGrants)) + for _, grant := range configured.AllowGrants { + grants[grant] = true + } + } + for _, grant := range configured.DenyGrants { + delete(grants, grant) + } + } + + member := role == "member" + paths := canonicalPaths(pathsForRequest(wingCfg.Paths, identity.Email, role, home)) + sealedBoundary := sharedHost || member + return directMCPPolicy{ + role: role, grants: grants, + maxSessions: maxSessions, maxSpawnsPerHour: maxSpawnsPerHour, + allowedPaths: paths, enforcePathBounds: member, + identity: EggIdentity{ + UserID: identity.UserID, Email: identity.Email, + OrgWing: wingCfg.Org != "", SharedHost: sealedBoundary, + AllowedPaths: append([]string(nil), paths...), SealedFS: sealedBoundary, + }, + }, nil +} + +func serveDirectMCPChannel(cfg *config.Config, wingCfg *config.WingConfig, home string, sharedHost bool, allowedKeys []config.AllowKey, admission *mcpAdmissionState, identity webrtcpkg.PeerIdentity, dc *pionwebrtc.DataChannel) { + serveDirectMCPChannelWithPolicySource(cfg, home, sharedHost, admission, identity, dc, func() (*config.WingConfig, []config.AllowKey) { + return wingCfg.Clone(), append([]config.AllowKey(nil), allowedKeys...) + }) +} + +func serveDirectMCPChannelWithPolicySource(cfg *config.Config, home string, sharedHost bool, admission *mcpAdmissionState, identity webrtcpkg.PeerIdentity, dc *pionwebrtc.DataChannel, policySource func() (*config.WingConfig, []config.AllowKey)) { + serveDirectMCPChannelWithPolicySourceAndLease(cfg, home, sharedHost, admission, identity, dc, policySource, directMCPIdentityLease) +} + +func serveDirectMCPChannelWithPolicySourceAndLease(cfg *config.Config, home string, sharedHost bool, admission *mcpAdmissionState, identity webrtcpkg.PeerIdentity, dc *pionwebrtc.DataChannel, policySource func() (*config.WingConfig, []config.AllowKey), identityLease time.Duration) { + actor := strings.TrimPrefix(dc.Label(), control.DirectChannelPrefix) + if actor == dc.Label() || validateSessionName(actor) != nil || identity.UserID == "" || identityLease <= 0 { + log.Printf("[P2P] rejected direct MCP channel %q: invalid actor or identity", dc.Label()) + _ = dc.Close() + return + } + ctx, cancel := context.WithTimeout(context.Background(), identityLease) + var sendMu sync.Mutex + requestSlots := make(chan struct{}, maxConcurrentDirectMCPRequests) + send := func(response control.DirectResponse) { + payload := marshalDirectMCPResponse(response) + sendMu.Lock() + err := dc.Send(payload) + sendMu.Unlock() + if err != nil { + log.Printf("[P2P] direct MCP response: %v", err) + } + } + dc.OnClose(cancel) + go func() { + <-ctx.Done() + if ctx.Err() == context.DeadlineExceeded { + log.Printf("[P2P] direct MCP identity lease expired for actor %q; reconnecting revalidates access", actor) + _ = dc.Close() + } + }() + dc.OnMessage(func(message pionwebrtc.DataChannelMessage) { + if ctx.Err() != nil { + return + } + // Keep the control plane bounded independently of SCTP implementation + // limits. Large terminal snapshots belong in a future stream protocol. + if len(message.Data) > maxDirectMCPEnvelopeBytes { + send(control.DirectResponse{Version: control.ContractVersion, Error: "request exceeds 1 MiB"}) + return + } + var request control.DirectRequest + if err := json.Unmarshal(message.Data, &request); err != nil { + send(control.DirectResponse{Version: control.ContractVersion, Error: "invalid control request"}) + return + } + if !acquireDirectMCPRequestSlot(requestSlots) { + send(control.DirectResponse{Version: control.ContractVersion, ID: request.ID, Error: "too many concurrent direct control requests"}) + return + } + go func() { + defer func() { <-requestSlots }() + if ctx.Err() != nil { + return + } + response := control.DirectResponse{Version: control.ContractVersion, ID: request.ID} + wingCfg, allowedKeys := policySource() + policy, policyErr := resolveDirectMCPPolicy(wingCfg, home, sharedHost, identity) + authorizationError := directMCPAuthorizationError(wingCfg, allowedKeys, identity.UserID) + if policyErr != nil { + authorizationError = policyErr.Error() + } + if authorizationError != "" { + response.Error = authorizationError + send(response) + return + } + tool, known := control.Lookup(request.Tool) + if request.Version != control.ContractVersion || request.ID == "" || !known || tool.Authority != control.AuthorityWing || !tool.Supports(control.SurfaceDirectMCP) { + response.Error = fmt.Sprintf("unsupported %s control operation %q", request.Version, request.Tool) + send(response) + return + } + server := &localMCPServer{ + cfg: cfg, logs: os.Stderr, + principal: roostSessionPrincipal(identity.UserID), + actor: actor, + surface: control.SurfaceDirectMCP, + grants: policy.grants, + maxSessions: policy.maxSessions, + maxSpawnsPerHour: policy.maxSpawnsPerHour, + admission: admission, + allowedPaths: policy.allowedPaths, + enforcePathBounds: policy.enforcePathBounds, + identity: policy.identity, + } + arguments := request.Arguments + if len(arguments) == 0 { + arguments = json.RawMessage(`{}`) + } + result, isError, protocolErr := server.callTool(ctx, request.Tool, arguments) + response.Result = result + response.IsError = isError + if protocolErr != nil { + response.Error = protocolErr.Message + } + send(response) + }() + }) +} + +const ( + maxConcurrentDirectMCPRequests = 32 + maxDirectMCPEnvelopeBytes = 1024 * 1024 +) + +func marshalDirectMCPResponse(response control.DirectResponse) []byte { + payload, err := json.Marshal(response) + if err == nil && len(payload) <= maxDirectMCPEnvelopeBytes { + return payload + } + message := "response exceeds 1 MiB" + if err != nil { + message = "response could not be encoded" + } + fallback, _ := json.Marshal(control.DirectResponse{ + Version: control.ContractVersion, + ID: response.ID, + IsError: true, + Error: message, + }) + return fallback +} + +func acquireDirectMCPRequestSlot(slots chan struct{}) bool { + select { + case slots <- struct{}{}: + return true + default: + return false + } +} + +func directMCPAuthorizationError(wingCfg *config.WingConfig, allowedKeys []config.AllowKey, userID string) string { + if wingCfg == nil { + return "wing policy is unavailable" + } + protectedUser := len(passkeysForSubject(allowedKeys, userID)) > 0 + if wingCfg.Locked && !protectedUser { + return "direct MCP access denied by this wing's local lock policy" + } + if wingCfg.Locked || protectedUser { + return "passkey authentication is required; the native direct MCP passkey ceremony is not available in this release" + } + return "" +} diff --git a/cmd/wt/mcp_direct_wing_test.go b/cmd/wt/mcp_direct_wing_test.go new file mode 100644 index 00000000..ba59f8c8 --- /dev/null +++ b/cmd/wt/mcp_direct_wing_test.go @@ -0,0 +1,423 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/ehrlich-b/wingthing/internal/config" + "github.com/ehrlich-b/wingthing/internal/control" + webrtcpkg "github.com/ehrlich-b/wingthing/internal/webrtc" + pionwebrtc "github.com/pion/webrtc/v4" +) + +func TestResolveDirectMCPPolicyCompatibilityMatrix(t *testing.T) { + home := t.TempDir() + openPath := filepath.Join(home, "open") + memberPath := filepath.Join(home, "member") + otherPath := filepath.Join(home, "other") + wingCfg := &config.WingConfig{ + Org: "test-org", + Paths: config.PathList{ + {Path: openPath}, + {Path: memberPath, Members: []string{"member@example.com"}}, + {Path: otherPath, Members: []string{"other@example.com"}}, + }, + Admins: []string{"wing-admin@example.com"}, + } + + tests := []struct { + name string + identity webrtcpkg.PeerIdentity + sharedHost bool + wantRole string + wantPaths []string + wantEnforcePaths bool + wantSharedHost bool + wantSealedFS bool + wantErr string + }{ + { + name: "personal or org owner keeps administrative path visibility", + identity: webrtcpkg.PeerIdentity{UserID: "owner", Email: "owner@example.com", OrgRole: "owner"}, + wantRole: "owner", wantPaths: []string{openPath, memberPath, otherPath}, + }, + { + name: "ordinary org member is owner and path scoped", + identity: webrtcpkg.PeerIdentity{UserID: "member", Email: "member@example.com", OrgRole: "member"}, + wantRole: "member", wantPaths: []string{openPath, memberPath}, wantEnforcePaths: true, + wantSharedHost: true, wantSealedFS: true, + }, + { + name: "wing admin override preserves existing owner visibility", + identity: webrtcpkg.PeerIdentity{UserID: "admin", Email: "wing-admin@example.com", OrgRole: "member"}, + wantRole: "admin", wantPaths: []string{openPath, memberPath, otherPath}, + }, + { + name: "shared roost legacy empty role remains a sealed member", + identity: webrtcpkg.PeerIdentity{UserID: "shared-user", Email: "member@example.com"}, + sharedHost: true, wantRole: "member", wantPaths: []string{openPath, memberPath}, + wantEnforcePaths: true, wantSharedHost: true, wantSealedFS: true, + }, + { + name: "empty role outside a shared roost fails closed", + identity: webrtcpkg.PeerIdentity{UserID: "unknown", Email: "unknown@example.com"}, + wantErr: "missing authenticated organization role", + }, + { + name: "unknown role fails closed", + identity: webrtcpkg.PeerIdentity{UserID: "outsider", Email: "outsider@example.com", OrgRole: "outsider"}, + wantErr: "unsupported authenticated organization role", + }, + { + name: "missing user fails closed", + identity: webrtcpkg.PeerIdentity{Email: "owner@example.com", OrgRole: "owner"}, + wantErr: "authenticated user identity is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policy, err := resolveDirectMCPPolicy(wingCfg, home, tt.sharedHost, tt.identity) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("resolve error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if policy.role != tt.wantRole { + t.Errorf("role = %q, want %q", policy.role, tt.wantRole) + } + if strings.Join(policy.allowedPaths, "\x00") != strings.Join(tt.wantPaths, "\x00") { + t.Errorf("allowed paths = %#v, want %#v", policy.allowedPaths, tt.wantPaths) + } + if policy.enforcePathBounds != tt.wantEnforcePaths { + t.Errorf("enforce paths = %v, want %v", policy.enforcePathBounds, tt.wantEnforcePaths) + } + if policy.identity.SharedHost != tt.wantSharedHost || policy.identity.SealedFS != tt.wantSealedFS { + t.Errorf("identity boundary = %#v, want shared=%v sealed=%v", policy.identity, tt.wantSharedHost, tt.wantSealedFS) + } + if policy.maxSessions <= 0 || policy.maxSpawnsPerHour <= 0 { + t.Errorf("direct policy must be bounded: %#v", policy) + } + if policy.grants == nil { + t.Fatal("direct grants must be explicit, not nil/full-access sentinel") + } + for _, tool := range control.ToolsForAuthority(control.SurfaceDirectMCP, control.AuthorityWing) { + if !policy.grants[tool.Grant] { + t.Errorf("default policy omitted direct grant %q for %s", tool.Grant, tool.Name) + } + } + }) + } +} + +func TestResolveDirectMCPPolicyHonorsAdditiveWingRestrictions(t *testing.T) { + wingCfg := &config.WingConfig{DirectMCP: &config.DirectMCPConfig{ + AllowGrants: []string{"capabilities.read"}, + MaxSessions: 2, + MaxSpawnsPerHour: 3, + }} + policy, err := resolveDirectMCPPolicy(wingCfg, t.TempDir(), false, webrtcpkg.PeerIdentity{ + UserID: "owner", Email: "owner@example.com", OrgRole: "owner", + }) + if err != nil { + t.Fatal(err) + } + if !policy.grants["capabilities.read"] || policy.grants["terminal.read"] { + t.Fatalf("restricted grants = %#v", policy.grants) + } + if policy.maxSessions != 2 || policy.maxSpawnsPerHour != 3 { + t.Fatalf("restricted bounds = sessions:%d spawns:%d", policy.maxSessions, policy.maxSpawnsPerHour) + } + server := &localMCPServer{ + cfg: &config.Config{Dir: t.TempDir()}, principal: "owner", actor: "codex", + surface: control.SurfaceDirectMCP, grants: policy.grants, + } + result, isError, protocolErr := server.callTool(context.Background(), "terminal_list", json.RawMessage(`{}`)) + if protocolErr != nil || !isError || !strings.Contains(result["error"].(string), "lacks grant") { + t.Fatalf("denied direct tool = result %#v, isError %v, protocolErr %v", result, isError, protocolErr) + } +} + +func TestResolveDirectMCPPolicyRejectsDisabledAndUnknownGrant(t *testing.T) { + identity := webrtcpkg.PeerIdentity{UserID: "owner", Email: "owner@example.com", OrgRole: "owner"} + for name, testCase := range map[string]struct { + direct *config.DirectMCPConfig + want string + }{ + "disabled": {direct: &config.DirectMCPConfig{Disabled: true}, want: "disabled by this wing"}, + "unknown allow grant": { + direct: &config.DirectMCPConfig{AllowGrants: []string{"host.root"}}, + want: `unknown direct grant "host.root"`, + }, + "unknown deny grant": { + direct: &config.DirectMCPConfig{DenyGrants: []string{"host.root"}}, + want: `unknown direct grant "host.root"`, + }, + } { + t.Run(name, func(t *testing.T) { + _, err := resolveDirectMCPPolicy(&config.WingConfig{DirectMCP: testCase.direct}, t.TempDir(), false, identity) + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("resolve error = %v, want %q", err, testCase.want) + } + }) + } +} + +func TestDirectMCPSpawnRateSurvivesClientReconnect(t *testing.T) { + admission := newMCPAdmissionState() + one := &localMCPServer{principal: "same-owner", maxSpawnsPerHour: 1, admission: admission} + two := &localMCPServer{principal: "same-owner", maxSpawnsPerHour: 1, admission: admission} + other := &localMCPServer{principal: "other-owner", maxSpawnsPerHour: 1, admission: admission} + + if err := one.admitSpawn(func() error { return nil }); err != nil { + t.Fatalf("first spawn: %v", err) + } + if err := two.admitSpawn(func() error { return nil }); err == nil || !strings.Contains(err.Error(), "max_spawns_per_hour=1") { + t.Fatalf("reconnected owner spawn error = %v", err) + } + if err := other.admitSpawn(func() error { return nil }); err != nil { + t.Fatalf("other owner should have an independent bound: %v", err) + } +} + +func TestDirectMCPMaxSessionsIsSharedAcrossConnections(t *testing.T) { + dir := t.TempDir() + sessionDir := filepath.Join(dir, "eggs", "existing") + if err := os.MkdirAll(sessionDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessionDir, "egg.pid"), []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessionDir, "egg.meta"), []byte("kind=command\ncwd="+dir+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := writeSessionPrincipal(sessionDir, "same-owner"); err != nil { + t.Fatal(err) + } + + admission := newMCPAdmissionState() + sameOwner := &localMCPServer{ + cfg: &config.Config{Dir: dir}, principal: "same-owner", maxSessions: 1, admission: admission, + } + otherOwner := &localMCPServer{ + cfg: &config.Config{Dir: dir}, principal: "other-owner", maxSessions: 1, admission: admission, + } + if err := sameOwner.admitSpawn(func() error { return nil }); err == nil || !strings.Contains(err.Error(), "max_sessions=1") { + t.Fatalf("same owner session bound error = %v", err) + } + if err := otherOwner.admitSpawn(func() error { return nil }); err != nil { + t.Fatalf("other owner should have an independent session bound: %v", err) + } +} + +func connectDirectMCPTestClient(t *testing.T, cfg *config.Config, wingCfg *config.WingConfig, home string, sharedHost bool, identity webrtcpkg.PeerIdentity) (*webrtcpkg.ControlClient, context.Context) { + return connectDirectMCPTestClientWithPolicySource(t, cfg, home, sharedHost, identity, func() (*config.WingConfig, []config.AllowKey) { + return wingCfg.Clone(), nil + }) +} + +func connectDirectMCPTestClientWithPolicySource(t *testing.T, cfg *config.Config, home string, sharedHost bool, identity webrtcpkg.PeerIdentity, policySource func() (*config.WingConfig, []config.AllowKey)) (*webrtcpkg.ControlClient, context.Context) { + return connectDirectMCPTestClientWithPolicySourceAndLease(t, cfg, home, sharedHost, identity, policySource, directMCPIdentityLease) +} + +func connectDirectMCPTestClientWithPolicySourceAndLease(t *testing.T, cfg *config.Config, home string, sharedHost bool, identity webrtcpkg.PeerIdentity, policySource func() (*config.WingConfig, []config.AllowKey), identityLease time.Duration) (*webrtcpkg.ControlClient, context.Context) { + t.Helper() + manager := webrtcpkg.NewPeerManager(nil) + t.Cleanup(manager.Close) + admission := newMCPAdmissionState() + manager.OnDC(func(_ string, _ string, authenticated webrtcpkg.PeerIdentity, dc *pionwebrtc.DataChannel) { + serveDirectMCPChannelWithPolicySourceAndLease(cfg, home, sharedHost, admission, authenticated, dc, policySource, identityLease) + }) + client, err := webrtcpkg.NewControlClient("codex", nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + offer, err := client.Offer(ctx) + if err != nil { + t.Fatal(err) + } + answer, err := manager.HandleOffer("native-client-public-key", identity.UserID, identity.Email, identity.OrgRole, nil, offer) + if err != nil { + t.Fatal(err) + } + if err := client.AcceptAnswer(answer); err != nil { + t.Fatal(err) + } + if err := client.WaitReady(ctx); err != nil { + t.Fatal(err) + } + return client, ctx +} + +func TestDirectMCPIdentityIsALimitedCoordinatorLease(t *testing.T) { + cfg := &config.Config{Dir: t.TempDir(), DefaultAgent: "claude"} + wingCfg := &config.WingConfig{} + client, _ := connectDirectMCPTestClientWithPolicySourceAndLease(t, cfg, t.TempDir(), false, webrtcpkg.PeerIdentity{ + UserID: "leased-user", Email: "owner@example.com", OrgRole: "owner", + }, func() (*config.WingConfig, []config.AllowKey) { return wingCfg.Clone(), nil }, 200*time.Millisecond) + deadline := time.Now().Add(3 * time.Second) + for !client.Closed() && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if !client.Closed() { + t.Fatal("direct channel remained usable after its coordinator identity lease expired") + } +} + +func TestDirectMCPTransportAppliesConfiguredGrants(t *testing.T) { + cfg := &config.Config{Dir: t.TempDir(), DefaultAgent: "claude"} + wingCfg := &config.WingConfig{DirectMCP: &config.DirectMCPConfig{ + AllowGrants: []string{"capabilities.read"}, + }} + client, ctx := connectDirectMCPTestClient(t, cfg, wingCfg, t.TempDir(), false, webrtcpkg.PeerIdentity{ + UserID: "grant-owner", Email: "owner@example.com", OrgRole: "owner", + }) + result, isError, err := client.Call(ctx, "terminal_list", json.RawMessage(`{}`)) + if err != nil || !isError { + t.Fatalf("restricted direct call error = %v, isError = %v, result = %#v", err, isError, result) + } + message, _ := result["error"].(string) + if !strings.Contains(message, `lacks grant "terminal.read"`) { + t.Fatalf("restricted direct result = %#v", result) + } +} + +func TestDirectMCPTransportPreservesOrgPathBoundary(t *testing.T) { + home := t.TempDir() + memberPath := filepath.Join(home, "member") + ownerPath := filepath.Join(home, "owner-visible") + for _, path := range []string{memberPath, ownerPath} { + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + } + wingCfg := &config.WingConfig{Org: "test-org", Paths: config.PathList{ + {Path: memberPath, Members: []string{"member@example.com"}}, + {Path: ownerPath, Members: []string{"someone-else@example.com"}}, + }} + cfg := &config.Config{Dir: t.TempDir(), DefaultAgent: "claude"} + + member, memberCtx := connectDirectMCPTestClient(t, cfg, wingCfg, home, false, webrtcpkg.PeerIdentity{ + UserID: "member", Email: "member@example.com", OrgRole: "member", + }) + result, isError, err := member.Call(memberCtx, "sandbox_explain", json.RawMessage(`{"cwd":`+strconv.Quote(ownerPath)+`}`)) + if err != nil || !isError { + t.Fatalf("member outside-path call error = %v, isError = %v, result = %#v", err, isError, result) + } + if message, _ := result["error"].(string); !strings.Contains(message, "outside this user's roost paths") { + t.Fatalf("member outside-path result = %#v", result) + } + + owner, ownerCtx := connectDirectMCPTestClient(t, cfg, wingCfg, home, false, webrtcpkg.PeerIdentity{ + UserID: "owner", Email: "owner@example.com", OrgRole: "owner", + }) + result, isError, err = owner.Call(ownerCtx, "sandbox_explain", json.RawMessage(`{"cwd":`+strconv.Quote(ownerPath)+`}`)) + if err != nil || isError { + t.Fatalf("owner outside-path compatibility call error = %v, isError = %v, result = %#v", err, isError, result) + } +} + +func TestDirectMCPExecutesOnAuthenticatedWing(t *testing.T) { + cfg := &config.Config{Dir: t.TempDir(), DefaultAgent: "claude"} + wingCfg := &config.WingConfig{} + client, ctx := connectDirectMCPTestClient(t, cfg, wingCfg, t.TempDir(), false, webrtcpkg.PeerIdentity{ + UserID: "user-direct", Email: "owner@example.com", OrgRole: "owner", + }) + result, isError, err := client.Call(ctx, "wingthing_capabilities", json.RawMessage(`{}`)) + if err != nil || isError { + t.Fatalf("capabilities error = %v, isError = %v, result = %#v", err, isError, result) + } + if result["actor"] != "codex" || result["principal"] != roostSessionPrincipal("user-direct") { + t.Fatalf("authenticated control identity = %#v", result) + } +} + +func TestDirectMCPFailsClosedForLockedWing(t *testing.T) { + cfg := &config.Config{Dir: t.TempDir(), DefaultAgent: "claude"} + wingCfg := &config.WingConfig{Locked: true} + client, ctx := connectDirectMCPTestClient(t, cfg, wingCfg, t.TempDir(), false, webrtcpkg.PeerIdentity{ + UserID: "user-direct", Email: "owner@example.com", OrgRole: "owner", + }) + _, _, err := client.Call(ctx, "wingthing_capabilities", json.RawMessage(`{}`)) + if err == nil || !strings.Contains(err.Error(), "local lock policy") { + t.Fatalf("locked direct call error = %v", err) + } +} + +func TestDirectMCPExistingChannelRechecksLiveWingPolicy(t *testing.T) { + cfg := &config.Config{Dir: t.TempDir(), DefaultAgent: "claude"} + var policyMu sync.Mutex + wingCfg := &config.WingConfig{} + policySource := func() (*config.WingConfig, []config.AllowKey) { + policyMu.Lock() + defer policyMu.Unlock() + return wingCfg.Clone(), nil + } + client, ctx := connectDirectMCPTestClientWithPolicySource(t, cfg, t.TempDir(), false, webrtcpkg.PeerIdentity{ + UserID: "user-direct", Email: "owner@example.com", OrgRole: "owner", + }, policySource) + if _, isError, err := client.Call(ctx, "wingthing_capabilities", json.RawMessage(`{}`)); err != nil || isError { + t.Fatalf("initial direct call = isError %v, err %v", isError, err) + } + + policyMu.Lock() + wingCfg.DirectMCP = &config.DirectMCPConfig{Disabled: true} + policyMu.Unlock() + _, _, err := client.Call(ctx, "wingthing_capabilities", json.RawMessage(`{}`)) + if err == nil || !strings.Contains(err.Error(), "disabled by this wing's local policy") { + t.Fatalf("live-disabled direct call error = %v", err) + } + + policyMu.Lock() + wingCfg.DirectMCP = nil + wingCfg.Locked = true + policyMu.Unlock() + _, _, err = client.Call(ctx, "wingthing_capabilities", json.RawMessage(`{}`)) + if err == nil || !strings.Contains(err.Error(), "local lock policy") { + t.Fatalf("live-locked direct call error = %v", err) + } +} + +func TestDirectMCPResponseEnvelopeIsBoundedAndCorrelated(t *testing.T) { + for name, response := range map[string]control.DirectResponse{ + "oversized": { + Version: control.ContractVersion, + ID: "large-request", + Result: map[string]any{"output": strings.Repeat("x", maxDirectMCPEnvelopeBytes)}, + }, + "unencodable": { + Version: control.ContractVersion, + ID: "invalid-request", + Result: map[string]any{"invalid": make(chan struct{})}, + }, + } { + t.Run(name, func(t *testing.T) { + payload := marshalDirectMCPResponse(response) + if len(payload) > maxDirectMCPEnvelopeBytes { + t.Fatalf("response envelope = %d bytes", len(payload)) + } + var decoded control.DirectResponse + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatal(err) + } + if decoded.ID != response.ID || !decoded.IsError || decoded.Error == "" || decoded.Result != nil { + t.Fatalf("bounded response = %#v", decoded) + } + }) + } +} diff --git a/cmd/wt/mcp_local.go b/cmd/wt/mcp_local.go index d7d4949e..a06d4ba3 100644 --- a/cmd/wt/mcp_local.go +++ b/cmd/wt/mcp_local.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io" + "log" "os" "os/exec" "path/filepath" @@ -20,6 +21,7 @@ import ( agentpkg "github.com/ehrlich-b/wingthing/internal/agent" "github.com/ehrlich-b/wingthing/internal/config" + "github.com/ehrlich-b/wingthing/internal/control" "github.com/ehrlich-b/wingthing/internal/egg" mcppkg "github.com/ehrlich-b/wingthing/internal/mcp" "github.com/ehrlich-b/wingthing/internal/promptmgr" @@ -30,6 +32,8 @@ import ( const localMCPProtocolVersion = "2025-11-25" +const maxConcurrentLocalMCPCalls = 64 + func mcpCmd() *cobra.Command { cmd := &cobra.Command{ Use: "mcp", @@ -88,6 +92,7 @@ func mcpCmd() *cobra.Command { server := &localMCPServer{ cfg: cfg, in: os.Stdin, out: os.Stdout, logs: os.Stderr, principal: owner, actor: clientID, unsandboxed: unsandboxed, + surface: control.SurfaceLocalMCP, } if configured { server.grants = grantSet(clientConfig.Grants) @@ -100,6 +105,7 @@ func mcpCmd() *cobra.Command { stdioCmd.Flags().StringVar(&clientName, "client", "", "local MCP principal name (or WT_MCP_CLIENT)") stdioCmd.Flags().BoolVar(&unsandboxed, "unsandboxed", false, "trust an outer VM/container boundary for all sessions and prompt runs") cmd.AddCommand(stdioCmd) + cmd.AddCommand(connectMCPCmd()) return cmd } @@ -116,13 +122,28 @@ type localMCPServer struct { spawnMu sync.Mutex admitMu sync.Mutex // held across bounds check + spawn + record spawnTimes []time.Time + admission *mcpAdmissionState // shared by remote connections on one wing identity EggIdentity actor string + surface control.Surface allowedPaths []string enforcePathBounds bool runAgentTask func(context.Context, *config.Config, *store.Store, *store.Task, taskRunOptions) error } +// mcpAdmissionState keeps process-local spawn admission shared across reconnecting +// remote MCP clients. The filesystem-backed max-sessions check is also serialized by +// this lock, so two data channels cannot race through the final available slot. +// This remains a guardrail rather than a durable quota across wing restarts. +type mcpAdmissionState struct { + mu sync.Mutex + spawnTimes map[string][]time.Time +} + +func newMCPAdmissionState() *mcpAdmissionState { + return &mcpAdmissionState{spawnTimes: map[string][]time.Time{}} +} + type activeMCPAgentRun struct { principal string cancel context.CancelFunc @@ -149,8 +170,8 @@ func (s *localMCPServer) toolAllowed(name string) bool { if s.grants == nil { return true } - grant, known := localMCPToolGrants[name] - return known && s.grants[grant] + tool, known := control.Lookup(name) + return known && s.grants[tool.Grant] } type localMCPRequest struct { @@ -160,6 +181,15 @@ type localMCPRequest struct { Params json.RawMessage `json:"params,omitempty"` } +// MCP clients may attach protocol metadata such as progress tokens to a tool +// call. It is coordinator metadata, not a tool argument, so accept it at the +// envelope boundary while keeping strict decoding for every other field. +type localMCPToolCallParams struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + Meta json.RawMessage `json:"_meta,omitempty"` +} + type localMCPResponse struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id,omitempty"` @@ -172,19 +202,16 @@ type localMCPError struct { Message string `json:"message"` } -type localMCPTool struct { - Name string `json:"name"` - Title string `json:"title,omitempty"` - Description string `json:"description"` - InputSchema map[string]any `json:"inputSchema"` - Annotations map[string]any `json:"annotations,omitempty"` -} +type localMCPTool = control.Tool func (s *localMCPServer) serve(ctx context.Context) error { + callCtx, cancelCalls := context.WithCancel(ctx) + defer cancelCalls() scanner := bufio.NewScanner(s.in) scanner.Buffer(make([]byte, 64*1024), 1024*1024) encoder := json.NewEncoder(s.out) var calls sync.WaitGroup + requestSlots := make(chan struct{}, maxConcurrentLocalMCPCalls) var encodeMu sync.Mutex var encodeErr error writeResponse := func(response localMCPResponse) { @@ -195,15 +222,18 @@ func (s *localMCPServer) serve(ctx context.Context) error { } } dispatch := func(request localMCPRequest) { - response, respond := s.handle(ctx, request) + response, respond := s.handle(callCtx, request) if respond { writeResponse(response) } } + contextCanceled := false +scanLoop: for scanner.Scan() { select { case <-ctx.Done(): - return ctx.Err() + contextCanceled = true + break scanLoop default: } @@ -219,22 +249,48 @@ func (s *localMCPServer) serve(ctx context.Context) error { // independently lets the same stdio client send agent_stop, steering, // and status calls while another request is waiting. if request.Method == "tools/call" { + if !acquireLocalMCPCallSlot(requestSlots) { + if len(request.ID) > 0 { + writeResponse(localMCPResponse{ + JSONRPC: "2.0", ID: request.ID, + Error: &localMCPError{Code: -32000, Message: "too many concurrent tool calls"}, + }) + } + continue + } calls.Add(1) go func() { defer calls.Done() + defer func() { <-requestSlots }() dispatch(request) }() continue } dispatch(request) } - if err := scanner.Err(); err != nil { - return fmt.Errorf("read MCP request: %w", err) - } + scanErr := scanner.Err() + // stdin EOF means the owning MCP client is gone. Cancel bounded waits and + // transport calls, but not the durable sessions/runs they were observing. + cancelCalls() calls.Wait() + if scanErr != nil { + return fmt.Errorf("read MCP request: %w", scanErr) + } + if contextCanceled { + return ctx.Err() + } return encodeErr } +func acquireLocalMCPCallSlot(slots chan struct{}) bool { + select { + case slots <- struct{}{}: + return true + default: + return false + } +} + func (s *localMCPServer) handle(ctx context.Context, request localMCPRequest) (localMCPResponse, bool) { response := localMCPResponse{JSONRPC: "2.0", ID: request.ID} if request.JSONRPC != "2.0" || request.Method == "" { @@ -272,10 +328,7 @@ func (s *localMCPServer) handle(ctx context.Context, request localMCPRequest) (l } response.Result = map[string]any{"tools": tools} case "tools/call": - var call struct { - Name string `json:"name"` - Arguments json.RawMessage `json:"arguments"` - } + var call localMCPToolCallParams if err := decodeStrict(request.Params, &call); err != nil { response.Error = &localMCPError{Code: -32602, Message: "invalid tools/call params: " + err.Error()} break @@ -303,7 +356,7 @@ func (s *localMCPServer) handle(ctx context.Context, request localMCPRequest) (l } func (s *localMCPServer) mcpInstructions() string { - base := "Wingthing is a local-first runtime. Use terminal tools for persistent PTYs, agent_run for supervised semantic work, prompt_loop for bounded iteration, and swarm_run for a dependency DAG." + base := "Wingthing is an agent manager for agents. Use terminal tools for persistent PTYs, agent_run for supervised semantic work, prompt_loop for bounded iteration, and swarm_run for a dependency DAG." if s.unsandboxed { return base + " This server trusts an outer VM/container boundary: spawned processes have the full authority of the local OS user." } @@ -311,308 +364,7 @@ func (s *localMCPServer) mcpInstructions() string { } func localMCPTools() []localMCPTool { - stringProperty := func(description string) map[string]any { - return map[string]any{"type": "string", "description": description} - } - objectSchema := func(properties map[string]any, required ...string) map[string]any { - schema := map[string]any{ - "type": "object", - "properties": properties, - "additionalProperties": false, - } - if len(required) > 0 { - schema["required"] = required - } - return schema - } - readOnly := map[string]any{"readOnlyHint": true, "destructiveHint": false, "openWorldHint": false} - mutating := map[string]any{"readOnlyHint": false, "destructiveHint": false, "openWorldHint": false} - modelCall := map[string]any{"readOnlyHint": false, "destructiveHint": false, "openWorldHint": true} - - tools := []localMCPTool{ - { - Name: "wingthing_capabilities", Title: "Wingthing capabilities", - Description: "Discover supported and installed agent CLIs plus the local runtime primitives available on this machine.", - InputSchema: objectSchema(map[string]any{}), Annotations: readOnly, - }, - { - Name: "message_send", Title: "Send owner message", - Description: "Send a durable message to another Codex, Claude, or other client authenticated as the same Wingthing owner.", - InputSchema: objectSchema(map[string]any{ - "content": stringProperty("Message body; stored owner-scoped and omitted from audit logs"), - "channel": stringProperty("Conversation channel; defaults to factory"), - "to_actor": stringProperty("Optional recipient actor ID; empty broadcasts to the owner's other clients"), - "kind": map[string]any{ - "type": "string", "enum": []string{"message", "status", "question", "answer", "evidence", "error"}, - "default": "message", "description": "Structured message kind", - }, - "reply_to": stringProperty("Optional owner-scoped message ID being answered"), - "ttl_seconds": map[string]any{ - "type": "integer", "minimum": 60, "maximum": 604800, "default": 86400, - "description": "Retention time from one minute through seven days", - }, - }, "content"), Annotations: mutating, - }, - { - Name: "message_list", Title: "List owner messages", - Description: "List durable messages visible to this authenticated actor in ascending order, with a cursor for the next call.", - InputSchema: objectSchema(map[string]any{ - "channel": stringProperty("Conversation channel; defaults to factory"), - "after_id": stringProperty("Return messages after this owner-scoped message ID"), - "limit": map[string]any{"type": "integer", "minimum": 1, "maximum": 20, "default": 20}, - "include_sent": map[string]any{"type": "boolean", "default": false, "description": "Include messages sent by this actor"}, - }), Annotations: readOnly, - }, - { - Name: "message_wait", Title: "Wait for owner message", - Description: "Wait until another same-owner client sends a visible message after the supplied cursor, or until the bounded timeout expires.", - InputSchema: objectSchema(map[string]any{ - "channel": stringProperty("Conversation channel; defaults to factory"), - "after_id": stringProperty("Wait for messages after this owner-scoped message ID"), - "limit": map[string]any{"type": "integer", "minimum": 1, "maximum": 20, "default": 20}, - "timeout_seconds": map[string]any{"type": "number", "minimum": 0.1, "maximum": 3600, "default": 30}, - }), Annotations: readOnly, - }, - { - Name: "sandbox_explain", Title: "Explain sandbox policy", - Description: "Resolve the effective sandbox policy for an agent: mounts, denied paths, network domains, whether the network boundary is actually enforced on this platform, and every hole drilled automatically for the agent with the reason for it.", - InputSchema: objectSchema(map[string]any{ - "agent": stringProperty("Agent name; omit for a plain shell session"), - "config": stringProperty("Path to an egg.yaml; discovered from the working directory when omitted"), - "cwd": stringProperty("Directory to discover egg.yaml in; defaults to the MCP server's current directory"), - "provider_base_url": stringProperty("Optional provider URL whose exact host becomes the derived egress domain"), - }), Annotations: readOnly, - }, - { - Name: "terminal_list", Title: "List persistent terminals", - Description: "List live local Wingthing sessions with stable IDs, labels, process kind, agent, activity, and working directory.", - InputSchema: objectSchema(map[string]any{}), Annotations: readOnly, - }, - { - Name: "terminal_read", Title: "Read terminal snapshot", - Description: "Read the current ANSI snapshot of one persistent terminal. This is raw terminal state, not semantic agent state.", - InputSchema: objectSchema(map[string]any{ - "session": stringProperty("Session ID, unique ID prefix, or label"), - }, "session"), Annotations: readOnly, - }, - { - Name: "terminal_send", Title: "Send terminal input", - Description: "Send text to a persistent PTY, optionally followed by Enter.", - InputSchema: objectSchema(map[string]any{ - "session": stringProperty("Session ID, unique ID prefix, or label"), - "input": stringProperty("Text to send"), - "enter": map[string]any{"type": "boolean", "description": "Append Enter after the text", "default": false}, - }, "session", "input"), Annotations: mutating, - }, - { - Name: "terminal_wait", Title: "Wait for terminal output", - Description: "Wait without polling until a terminal produces text or becomes idle.", - InputSchema: objectSchema(map[string]any{ - "session": stringProperty("Session ID, unique ID prefix, or label"), - "contains": stringProperty("Text to wait for; omit to wait for idle"), - "idle_seconds": map[string]any{"type": "number", "minimum": 0.2, "description": "Idle duration when contains is omitted", "default": 2}, - "timeout_seconds": map[string]any{"type": "number", "minimum": 0.1, "maximum": 3600, "description": "Maximum wait", "default": 30}, - }, "session"), Annotations: readOnly, - }, - { - Name: "terminal_start", Title: "Start persistent terminal", - Description: "Start a durable shell or command terminal under the MCP server's declared isolation mode and return immediately with its session ID.", - InputSchema: objectSchema(map[string]any{ - "command": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "default": []string{}, "description": "Executable and arguments; omit to start $SHELL"}, - "cwd": stringProperty("Working directory; defaults to the MCP server's current directory"), - "label": stringProperty("Optional stable human-readable session label"), - }), Annotations: mutating, - }, - { - Name: "agent_start", Title: "Start persistent agent terminal", - Description: "Start a supported agent in a durable PTY under the MCP server's declared isolation mode and return immediately with its session ID.", - InputSchema: objectSchema(map[string]any{ - "agent": stringProperty("Supported agent name"), - "model": stringProperty("Provider model name, such as opus or gpt-5.6-terra"), - "cwd": stringProperty("Working directory; defaults to the MCP server's current directory"), - "label": stringProperty("Optional stable human-readable session label"), - "unattended": map[string]any{"type": "boolean", "description": "Enable the agent's unattended permission mode", "default": false}, - "args": map[string]any{ - "type": "array", "items": map[string]any{"type": "string"}, "default": []string{}, - "description": "Extra arguments passed to the agent CLI verbatim, after Wingthing's own flags. Use the agent's native syntax, for example [\"--model\",\"sonnet\"] for claude or [\"-m\",\"gpt-5.6-terra\"] for codex.", - }, - }, "agent"), Annotations: modelCall, - }, - { - Name: "agent_run", Title: "Run agent task", - Description: "Start a supervised headless agent run and return immediately with an owner-scoped run ID. Use agent_wait and agent_result instead of reading terminal ANSI.", - InputSchema: objectSchema(map[string]any{ - "prompt": stringProperty("Task for the agent"), - "agent": stringProperty("Supported agent name, such as codex or claude"), - "model": stringProperty("Provider model name, such as gpt-5.6-terra or opus"), - "cwd": stringProperty("Working directory; defaults to the MCP server's current directory"), - "label": stringProperty("Short human-readable purpose recorded with the run"), - "timeout_seconds": map[string]any{"type": "integer", "minimum": 10, "maximum": 7200, "default": 900, "description": "Provider process deadline"}, - }, "prompt", "agent"), Annotations: modelCall, - }, - { - Name: "agent_status", Title: "Get agent run status", - Description: "Read bounded lifecycle metadata for one run owned by this MCP principal.", - InputSchema: objectSchema(map[string]any{ - "run_id": stringProperty("Wingthing agent run ID"), - }, "run_id"), Annotations: readOnly, - }, - { - Name: "agent_wait", Title: "Wait for agent run", - Description: "Wait without polling until an agent run reaches a terminal state or the requested timeout expires.", - InputSchema: objectSchema(map[string]any{ - "run_id": stringProperty("Wingthing agent run ID"), - "timeout_seconds": map[string]any{"type": "number", "minimum": 0.1, "maximum": 3600, "default": 30}, - }, "run_id"), Annotations: readOnly, - }, - { - Name: "agent_result", Title: "Read agent result", - Description: "Read the final semantic output or error for one completed run, with an explicit response bound.", - InputSchema: objectSchema(map[string]any{ - "run_id": stringProperty("Wingthing agent run ID"), - "max_chars": map[string]any{"type": "integer", "minimum": 1, "maximum": 200000, "default": 50000}, - }, "run_id"), Annotations: readOnly, - }, - { - Name: "agent_events", Title: "Read agent run events", - Description: "Read bounded lifecycle events for one owned run.", - InputSchema: objectSchema(map[string]any{ - "run_id": stringProperty("Wingthing agent run ID"), - "limit": map[string]any{"type": "integer", "minimum": 1, "maximum": 200, "default": 50}, - }, "run_id"), Annotations: readOnly, - }, - { - Name: "agent_steer", Title: "Steer agent run", - Description: "Queue an owner-scoped follow-up run that receives the prior request and result plus new direction.", - InputSchema: objectSchema(map[string]any{ - "run_id": stringProperty("Run to follow up"), - "prompt": stringProperty("New direction for the agent"), - "model": stringProperty("Optional model override for the follow-up"), - }, "run_id", "prompt"), Annotations: modelCall, - }, - { - Name: "agent_stop", Title: "Stop agent run", - Description: "Cancel an active owner-scoped run and its provider process tree.", - InputSchema: objectSchema(map[string]any{ - "run_id": stringProperty("Wingthing agent run ID"), - }, "run_id"), - Annotations: map[string]any{"readOnlyHint": false, "destructiveHint": true, "openWorldHint": false}, - }, - { - Name: "terminal_rename", Title: "Rename persistent terminal", - Description: "Assign a stable human-readable label to a terminal owned by this MCP principal.", - InputSchema: objectSchema(map[string]any{ - "session": stringProperty("Session ID, unique ID prefix, or current label"), - "name": stringProperty("New session label"), - }, "session", "name"), Annotations: mutating, - }, - { - Name: "terminal_stop", Title: "Stop persistent terminal", - Description: "Stop one Wingthing session and its process tree.", - InputSchema: objectSchema(map[string]any{ - "session": stringProperty("Session ID, unique ID prefix, or label"), - }, "session"), - Annotations: map[string]any{"readOnlyHint": false, "destructiveHint": true, "openWorldHint": false}, - }, - { - Name: "prompt_list", Title: "List saved prompts", - Description: "List current named prompt assets with immutable revisions, variables, default agents, and working directories.", - InputSchema: objectSchema(map[string]any{}), Annotations: readOnly, - }, - { - Name: "prompt_get", Title: "Get saved prompt", - Description: "Read the current or an immutable historical revision of a named prompt asset.", - InputSchema: objectSchema(map[string]any{ - "name": stringProperty("Prompt asset name"), - "revision": stringProperty("Optional immutable revision; current revision when omitted"), - }, "name"), Annotations: readOnly, - }, - { - Name: "prompt_save", Title: "Save prompt", - Description: "Create or atomically update a named prompt template while preserving a content-addressed historical revision.", - InputSchema: objectSchema(map[string]any{ - "name": stringProperty("Prompt asset name"), - "description": stringProperty("Human-readable purpose"), - "template": stringProperty("Go text/template prompt body; variables use {{.name}}"), - "variables": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "default": []string{}}, - "agent": stringProperty("Optional default supported agent"), - "cwd": stringProperty("Optional absolute default working directory"), - "expected_revision": stringProperty("Reject the update unless this is still the current revision"), - }, "name", "template"), Annotations: mutating, - }, - { - Name: "prompt_run", Title: "Run one prompt", - Description: "Run either a raw prompt or a named immutable prompt revision through a supported agent, under the MCP server's declared isolation mode and with durable task provenance.", - InputSchema: objectSchema(map[string]any{ - "prompt": stringProperty("Raw prompt to execute; mutually exclusive with prompt_name"), - "prompt_name": stringProperty("Saved prompt asset; mutually exclusive with prompt"), - "revision": stringProperty("Optional immutable saved-prompt revision"), - "variables": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, "default": map[string]string{}}, - "agent": stringProperty("Agent override; then prompt default; then Wingthing default"), - "cwd": stringProperty("Working-directory override; then prompt default; then MCP server cwd"), - }), Annotations: modelCall, - }, - { - Name: "task_get", Title: "Get prompt task", - Description: "Get structured status, output, error, timing, agent, and dependency data for a Wingthing task.", - InputSchema: objectSchema(map[string]any{ - "task_id": stringProperty("Wingthing task ID"), - }, "task_id"), Annotations: readOnly, - }, - { - Name: "prompt_loop", Title: "Run bounded prompt loop", - Description: "Run a prompt sequentially for a bounded number of iterations. Each iteration receives the prior result and stops early when until_contains matches.", - InputSchema: objectSchema(map[string]any{ - "prompt": stringProperty("Base prompt for every iteration"), - "agent": stringProperty("Supported agent name; defaults to Wingthing configuration"), - "cwd": stringProperty("Working directory shared by every iteration"), - "max_iterations": map[string]any{"type": "integer", "minimum": 1, "maximum": 12, "default": 3}, - "until_contains": stringProperty("Stop when an iteration's output contains this text"), - }, "prompt"), Annotations: modelCall, - }, - { - Name: "swarm_run", Title: "Run agent swarm DAG", - Description: "Run a bounded dependency graph of prompts. Independent nodes execute in parallel; completed dependency outputs are injected into downstream prompts.", - InputSchema: objectSchema(map[string]any{ - "name": stringProperty("Human-readable swarm purpose"), - "cwd": stringProperty("Working directory shared by every node"), - "max_parallel": map[string]any{"type": "integer", "minimum": 1, "maximum": 4, "default": 2}, - "nodes": map[string]any{ - "type": "array", "minItems": 1, "maxItems": 16, - "items": objectSchema(map[string]any{ - "id": stringProperty("Unique logical node ID"), - "prompt": stringProperty("Prompt executed by this node"), - "agent": stringProperty("Supported agent name; defaults to Wingthing configuration"), - "depends_on": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "default": []string{}}, - }, "id", "prompt"), - }, - }, "nodes"), Annotations: modelCall, - }, - } - return tools -} - -var roostControlToolNames = map[string]bool{ - "wingthing_capabilities": true, - "message_send": true, - "message_list": true, - "message_wait": true, - "sandbox_explain": true, - "terminal_list": true, - "terminal_read": true, - "terminal_send": true, - "terminal_wait": true, - "terminal_start": true, - "agent_start": true, - "agent_run": true, - "agent_status": true, - "agent_wait": true, - "agent_result": true, - "agent_events": true, - "agent_steer": true, - "agent_stop": true, - "terminal_rename": true, - "terminal_stop": true, + return control.Tools(control.SurfaceLocalMCP) } // roostNativeMCPTools adapts the local typed control surface to authenticated @@ -620,10 +372,8 @@ var roostControlToolNames = map[string]bool{ // bearer-token verification and never accepted from tool arguments. func roostNativeMCPTools(cfg *config.Config, sharedHost bool) []mcppkg.NativeTool { var tools []mcppkg.NativeTool - for _, localTool := range localMCPTools() { - if !roostControlToolNames[localTool.Name] { - continue - } + admission := newMCPAdmissionState() + for _, localTool := range control.ToolsForAuthority(control.SurfaceHTTPMCP, control.AuthorityWing) { tool := localTool tools = append(tools, mcppkg.NativeTool{ Name: tool.Name, Title: tool.Title, Description: tool.Description, @@ -636,17 +386,7 @@ func roostNativeMCPTools(cfg *config.Config, sharedHost bool) []mcppkg.NativeToo if err != nil { return nil, true, err } - server := &localMCPServer{ - cfg: cfg, logs: os.Stderr, - principal: roostSessionPrincipal(principal.UserID), - actor: principal.ClientID, - allowedPaths: paths, - enforcePathBounds: true, - identity: EggIdentity{ - UserID: principal.UserID, Email: principal.Email, SharedHost: sharedHost, - AllowedPaths: append([]string(nil), paths...), SealedFS: sharedHost, - }, - } + server := newRoostNativeMCPServer(cfg, sharedHost, admission, principal, paths) data, isError, protocolErr := server.callTool(ctx, tool.Name, arguments) if protocolErr != nil { return map[string]any{"error": protocolErr.Message}, true, nil @@ -658,6 +398,29 @@ func roostNativeMCPTools(cfg *config.Config, sharedHost bool) []mcppkg.NativeToo return tools } +func newRoostNativeMCPServer(cfg *config.Config, sharedHost bool, admission *mcpAdmissionState, principal mcppkg.Principal, paths []string) *localMCPServer { + grants := grantSet(defaultDirectMCPGrants) + if portalTool, ok := control.Lookup("wing_list"); ok { + grants[portalTool.Grant] = true + } + return &localMCPServer{ + cfg: cfg, logs: os.Stderr, + principal: roostSessionPrincipal(principal.UserID), + actor: principal.ClientID, + surface: control.SurfaceHTTPMCP, + grants: grants, + maxSessions: defaultDirectMCPMaxSessions, + maxSpawnsPerHour: defaultDirectMCPMaxSpawnsPerHour, + admission: admission, + allowedPaths: append([]string(nil), paths...), + enforcePathBounds: true, + identity: EggIdentity{ + UserID: principal.UserID, Email: principal.Email, SharedHost: sharedHost, + AllowedPaths: append([]string(nil), paths...), SealedFS: sharedHost, + }, + } +} + func roostSessionPrincipal(userID string) string { digest := sha256.Sum256([]byte(userID)) return "user-" + hex.EncodeToString(digest[:10]) @@ -707,11 +470,18 @@ func (s *localMCPServer) callTool(ctx context.Context, name string, arguments js decision = "error" } if auditErr := s.auditToolCall(name, arguments, data, decision); auditErr != nil { - fmt.Fprintf(s.logs, "wingthing MCP audit: %v\n", auditErr) + if logErr := writef(s.logs, "wingthing MCP audit: %v\n", auditErr); logErr != nil { + log.Printf("write MCP audit failure: %v", logErr) + } } }() + tool, known := control.Lookup(name) + if !known || !tool.Supports(s.controlSurface()) { + err = fmt.Errorf("unknown tool: %s", name) + return nil, false, &localMCPError{Code: -32602, Message: err.Error()} + } if !s.toolAllowed(name) { - err = fmt.Errorf("principal %q lacks grant %q", s.clientPrincipal(), localMCPToolGrants[name]) + err = fmt.Errorf("principal %q lacks grant %q", s.clientPrincipal(), tool.Grant) return map[string]any{"error": err.Error()}, true, nil } switch name { @@ -770,38 +540,23 @@ func (s *localMCPServer) callTool(ctx context.Context, name string, arguments js case "swarm_run": data, isError, err = s.toolSwarmRun(ctx, arguments) default: - err = fmt.Errorf("unknown tool: %s", name) - return nil, false, &localMCPError{Code: -32602, Message: "unknown tool: " + name} + err = fmt.Errorf("tool %q has no handler on %s", name, s.controlSurface()) + return nil, false, &localMCPError{Code: -32603, Message: err.Error()} } if err != nil { - fmt.Fprintf(s.logs, "wingthing MCP %s: %v\n", name, err) + if logErr := writef(s.logs, "wingthing MCP %s: %v\n", name, err); logErr != nil { + log.Printf("write MCP failure: %v", logErr) + } return map[string]any{"error": err.Error()}, true, nil } return data, isError, nil } -func (s *localMCPServer) auditToolCall(tool string, arguments json.RawMessage, result map[string]any, decision string) error { +func (s *localMCPServer) auditToolCall(tool string, arguments json.RawMessage, result map[string]any, decision string) (resultErr error) { if s.cfg == nil || s.cfg.Dir == "" { return nil } - target := "" - var parsed map[string]any - if json.Unmarshal(arguments, &parsed) == nil { - for _, key := range []string{"session", "run_id", "task_id", "message_id", "after_id", "reply_to", "prompt_name", "name"} { - if value, ok := parsed[key].(string); ok && value != "" { - target = value - break - } - } - } - if target == "" && result != nil { - for _, key := range []string{"session", "run_id", "task_id", "message_id"} { - if value, ok := result[key].(string); ok && value != "" { - target = value - break - } - } - } + target := control.AuditTarget(tool, arguments, result) digest := sha256.Sum256(arguments) record := map[string]any{ "timestamp": time.Now().UTC().Format(time.RFC3339Nano), @@ -825,7 +580,11 @@ func (s *localMCPServer) auditToolCall(tool string, arguments json.RawMessage, r if err != nil { return err } - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("close MCP audit log: %w", err)) + } + }() if err := file.Chmod(0600); err != nil { return err } @@ -856,13 +615,25 @@ func (s *localMCPServer) toolCapabilities(arguments json.RawMessage) (map[string "persistent_storage": append(append([]string(nil), profile.WriteRegex...), profile.WriteDirs...), }) } + surface := s.controlSurface() + operations := make([]string, 0) + for _, tool := range control.Tools(surface) { + if s.toolAllowed(tool.Name) { + operations = append(operations, tool.Name) + } + } return map[string]any{ "version": version, "principal": s.clientPrincipal(), "agents": agents, "actor": s.clientActor(), - "objects": []string{"terminal", "agent_run", "message", "prompt_asset", "task", "loop", "swarm", "sandbox_policy"}, + "objects": control.ObjectKinds(surface), "session_isolation": s.sessionIsolationMode(), + "control_contract": map[string]any{ + "version": control.ContractVersion, + "surface": string(surface), + "operations": operations, + }, "transports": map[string]any{ "local": true, "ssh": true, @@ -871,6 +642,13 @@ func (s *localMCPServer) toolCapabilities(arguments json.RawMessage) (map[string }, nil } +func (s *localMCPServer) controlSurface() control.Surface { + if s.surface == "" { + return control.SurfaceLocalMCP + } + return s.surface +} + const maxMessageContentBytes = 32 << 10 var messageKinds = map[string]bool{ @@ -931,7 +709,7 @@ func (s *localMCPServer) toolMessageSend(arguments json.RawMessage) (map[string] if err != nil { return nil, err } - defer db.Close() + defer closeWithLog("message store", db) if err := db.PurgeExpiredMessages(); err != nil { return nil, err } @@ -969,7 +747,7 @@ func (s *localMCPServer) toolMessageList(arguments json.RawMessage) (map[string] if err != nil { return nil, err } - defer db.Close() + defer closeWithLog("message store", db) if err := db.PurgeExpiredMessages(); err != nil { return nil, err } @@ -992,7 +770,7 @@ func (s *localMCPServer) toolMessageWait(ctx context.Context, arguments json.Raw if err != nil { return nil, err } - defer db.Close() + defer closeWithLog("message store", db) if err := db.PurgeExpiredMessages(); err != nil { return nil, err } @@ -1105,7 +883,7 @@ func normalizeMessageID(id, field string) (string, error) { return "", fmt.Errorf("%s is not a Wingthing message ID", field) } for _, r := range id { - if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-') { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { return "", fmt.Errorf("%s is not a Wingthing message ID", field) } } @@ -1114,7 +892,7 @@ func normalizeMessageID(id, field string) (string, error) { func (s *localMCPServer) openMessageStore() (*store.Store, error) { if s.cfg == nil || s.cfg.Dir == "" { - return nil, errors.New("Wingthing state directory is required for messages") + return nil, errors.New("wingthing state directory is required for messages") } if err := os.MkdirAll(s.cfg.Dir, 0700); err != nil { return nil, err @@ -1182,7 +960,10 @@ func (s *localMCPServer) toolSandboxExplain(arguments json.RawMessage) (map[stri } var eggCfg *egg.EggConfig var source string - if s.unsandboxed && args.Config == "" { + if s.unsandboxed && args.Config != "" { + return nil, errors.New("sandbox_explain config cannot be combined with MCP server --unsandboxed; spawned processes use the outer host boundary") + } + if s.unsandboxed { eggCfg = egg.UnsandboxedEggConfig() source = "MCP server --unsandboxed" } else { @@ -1291,6 +1072,21 @@ func (s *localMCPServer) recordSpawn() { // recording happen under one lock so concurrent tool calls cannot both observe // a free slot and together exceed max_sessions or max_spawns_per_hour. func (s *localMCPServer) admitSpawn(spawn func() error) error { + if s.admission != nil { + s.admission.mu.Lock() + defer s.admission.mu.Unlock() + if err := s.checkSharedSpawnBounds(); err != nil { + return err + } + if err := spawn(); err != nil { + return err + } + if s.maxSpawnsPerHour > 0 { + principal := s.clientPrincipal() + s.admission.spawnTimes[principal] = append(s.admission.spawnTimes[principal], time.Now()) + } + return nil + } s.admitMu.Lock() defer s.admitMu.Unlock() if err := s.checkSpawnBounds(); err != nil { @@ -1303,6 +1099,45 @@ func (s *localMCPServer) admitSpawn(spawn func() error) error { return nil } +// checkSharedSpawnBounds runs with admission.mu held. +func (s *localMCPServer) checkSharedSpawnBounds() error { + if s.maxSessions > 0 { + sessions, err := discoverSessionRefs(s.cfg) + if err != nil { + return err + } + owned := 0 + for _, session := range sessions { + if s.ownsSession(session) { + owned++ + } + } + if owned >= s.maxSessions { + return fmt.Errorf("principal %q reached max_sessions=%d", s.clientPrincipal(), s.maxSessions) + } + } + if s.maxSpawnsPerHour > 0 { + principal := s.clientPrincipal() + cutoff := time.Now().Add(-time.Hour) + history := s.admission.spawnTimes[principal] + kept := history[:0] + for _, timestamp := range history { + if timestamp.After(cutoff) { + kept = append(kept, timestamp) + } + } + if len(kept) == 0 { + delete(s.admission.spawnTimes, principal) + } else { + s.admission.spawnTimes[principal] = kept + } + if len(kept) >= s.maxSpawnsPerHour { + return fmt.Errorf("principal %q reached max_spawns_per_hour=%d", principal, s.maxSpawnsPerHour) + } + } + return nil +} + func (s *localMCPServer) toolTerminalList(ctx context.Context, arguments json.RawMessage) (map[string]any, error) { if err := requireEmptyObject(arguments); err != nil { return nil, err @@ -1360,18 +1195,19 @@ func (s *localMCPServer) toolTerminalSend(ctx context.Context, arguments json.Ra return nil, errors.New("session is required") } input := []byte(args.Input) - if args.Enter { - input = append(input, '\r') - } owned, err := s.resolveOwnedSession(ctx, args.Session) if err != nil { return nil, err } - session, err := sendSessionBytes(ctx, s.cfg, owned.ID, input) + session, err := sendSessionInput(ctx, s.cfg, owned.ID, input, args.Enter) if err != nil { return nil, err } - return map[string]any{"session": session.ID, "bytes_sent": len(input)}, nil + bytesSent := len(input) + if args.Enter { + bytesSent++ + } + return map[string]any{"session": session.ID, "bytes_sent": bytesSent}, nil } func (s *localMCPServer) toolTerminalWait(ctx context.Context, arguments json.RawMessage) (map[string]any, error) { @@ -1416,7 +1252,7 @@ func (s *localMCPServer) toolTerminalWait(ctx context.Context, arguments json.Ra if err != nil { return nil, err } - defer ec.Close() + defer closeWithLog("egg client", ec) ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { @@ -1462,14 +1298,14 @@ func (s *localMCPServer) toolTerminalStart(arguments json.RawMessage) (map[strin if err != nil { return nil, err } - sessionID := uuid.NewString()[:8] + sessionID := newRuntimeID() if err := s.admitSpawn(func() error { ec, spawnErr := spawnEgg(s.cfg, sessionID, "", eggCfg, 24, 80, args.CWD, false, false, false, s.identity, 0, spawnEggOpts{Label: args.Label, Kind: kind, Command: args.Command, Principal: s.clientPrincipal()}) if spawnErr != nil { return spawnErr } - _ = ec.Close() + closeWithLog("spawned terminal egg client", ec) return nil }); err != nil { return nil, err @@ -1520,14 +1356,14 @@ func (s *localMCPServer) toolAgentStart(arguments json.RawMessage) (map[string]a eggCfg = ©Cfg eggCfg.DangerouslySkipPermissions = true } - sessionID := uuid.NewString()[:8] + sessionID := newRuntimeID() if err := s.admitSpawn(func() error { ec, spawnErr := spawnEgg(s.cfg, sessionID, args.Agent, eggCfg, 24, 80, args.CWD, false, false, false, s.identity, 0, spawnEggOpts{Label: args.Label, Kind: "agent", AgentArgs: args.Args, Principal: s.clientPrincipal()}) if spawnErr != nil { return spawnErr } - _ = ec.Close() + closeWithLog("spawned agent egg client", ec) return nil }); err != nil { return nil, err @@ -1564,6 +1400,13 @@ type agentRunArgs struct { TimeoutSeconds int `json:"timeout_seconds"` } +type agentRunFollowup struct { + parentID string + direction string +} + +const maxAgentSteerPriorResultChars = 200000 + func (s *localMCPServer) toolAgentRun(arguments json.RawMessage) (map[string]any, error) { var args agentRunArgs if err := decodeStrict(arguments, &args); err != nil { @@ -1572,7 +1415,7 @@ func (s *localMCPServer) toolAgentRun(arguments json.RawMessage) (map[string]any return s.submitAgentRun(args, nil) } -func (s *localMCPServer) submitAgentRun(args agentRunArgs, parentID *string) (map[string]any, error) { +func (s *localMCPServer) submitAgentRun(args agentRunArgs, followup *agentRunFollowup) (map[string]any, error) { if strings.TrimSpace(args.Prompt) == "" { return nil, errors.New("prompt is required") } @@ -1599,10 +1442,13 @@ func (s *localMCPServer) submitAgentRun(args agentRunArgs, parentID *string) (ma return nil, err } var dependsOn *string - if parentID != nil { - encoded, _ := json.Marshal([]string{*parentID}) + var parentID *string + if followup != nil { + encoded, _ := json.Marshal([]string{followup.parentID}) value := string(encoded) dependsOn = &value + parent := followup.parentID + parentID = &parent } now := time.Now().UTC() task := &store.Task{ @@ -1620,19 +1466,21 @@ func (s *localMCPServer) submitAgentRun(args agentRunArgs, parentID *string) (ma if openErr != nil { return openErr } - defer taskStore.Close() + defer closeWithLog("task store", taskStore) if createErr := taskStore.CreateTask(task); createErr != nil { return createErr } if args.Label != "" { label := args.Label - _ = taskStore.AppendLog(task.ID, "label", &label) + if labelErr := taskStore.AppendLog(task.ID, "label", &label); labelErr != nil { + log.Printf("record label for agent run %s: %v", task.ID, labelErr) + } } return nil }); err != nil { return nil, err } - s.startAgentRun(task.ID, parentID) + s.startAgentRun(task.ID, followup) data := agentRunStatusData(task) data["run_id"] = task.ID if args.Label != "" { @@ -1641,38 +1489,51 @@ func (s *localMCPServer) submitAgentRun(args agentRunArgs, parentID *string) (ma return data, nil } -func (s *localMCPServer) startAgentRun(runID string, parentID *string) { +func (s *localMCPServer) startAgentRun(runID string, followup *agentRunFollowup) { + options, optionsErr := s.agentTaskRunOptions() + if optionsErr != nil { + s.setAgentRunError(runID, optionsErr) + return + } runCtx, cancel := context.WithCancel(context.Background()) key := s.agentRunKey(runID) done := make(chan struct{}) activeMCPAgentRuns.Store(key, activeMCPAgentRun{principal: s.clientPrincipal(), cancel: cancel, done: done}) - options := s.agentTaskRunOptions() go func() { defer close(done) defer cancel() defer activeMCPAgentRuns.Delete(key) - if parentID != nil { - if err := s.waitForAgentRunTerminal(runCtx, *parentID); err != nil { + var resolvedFollowupPrompt string + if followup != nil { + if err := s.waitForAgentRunTerminal(runCtx, followup.parentID); err != nil { s.setAgentRunError(runID, err) return } - parent, parentStore, err := s.ownedAgentRun(*parentID) + parent, parentStore, err := s.ownedAgentRun(followup.parentID) if err != nil { s.setAgentRunError(runID, err) return } - parentStore.Close() + if err := parentStore.Close(); err != nil { + s.setAgentRunError(runID, fmt.Errorf("close parent task store: %w", err)) + return + } if parent.Status != "done" { s.setAgentRunError(runID, fmt.Errorf("parent agent run %s finished with status %s", parent.ID, parent.Status)) return } + var parentResult string + if parent.Output != nil { + parentResult = *parent.Output + } + resolvedFollowupPrompt = agentSteerPrompt(parent.What, parentResult, followup.direction) } taskStore, err := store.Open(s.cfg.DBPath()) if err != nil { s.setAgentRunError(runID, err) return } - defer taskStore.Close() + defer closeWithLog("task store", taskStore) task, err := taskStore.GetTask(runID) if err != nil || task == nil { if err == nil { @@ -1681,15 +1542,26 @@ func (s *localMCPServer) startAgentRun(runID string, parentID *string) { s.setAgentRunError(runID, err) return } + if followup != nil { + if err := taskStore.SetTaskWhat(runID, resolvedFollowupPrompt); err != nil { + s.setAgentRunError(runID, fmt.Errorf("record resolved follow-up prompt: %w", err)) + return + } + task.What = resolvedFollowupPrompt + } + var runErr error if s.runAgentTask != nil { - _ = s.runAgentTask(runCtx, s.cfg, taskStore, task, options) + runErr = s.runAgentTask(runCtx, s.cfg, taskStore, task, options) } else { - _ = runTaskToWithOptions(runCtx, s.cfg, taskStore, task, io.Discard, options) + runErr = runTaskToWithOptions(runCtx, s.cfg, taskStore, task, io.Discard, options) + } + if runErr != nil { + s.setAgentRunError(runID, runErr) } }() } -func (s *localMCPServer) agentTaskRunOptions() taskRunOptions { +func (s *localMCPServer) agentTaskRunOptions() (taskRunOptions, error) { options := taskRunOptions{ SharedHost: s.identity.SharedHost, AllowedPaths: append([]string(nil), s.identity.AllowedPaths...), @@ -1697,10 +1569,12 @@ func (s *localMCPServer) agentTaskRunOptions() taskRunOptions { if s.identity.UserID != "" && (s.identity.SharedHost || s.identity.OrgWing) { options.UserHome = filepath.Join(s.cfg.Dir, "user-homes", userHash(s.identity.UserID)) if !s.identity.SharedHost { - _ = os.MkdirAll(options.UserHome, 0700) + if err := os.MkdirAll(options.UserHome, 0700); err != nil { + return taskRunOptions{}, fmt.Errorf("create isolated agent home: %w", err) + } } } - return options + return options, nil } func (s *localMCPServer) setAgentRunError(runID string, runErr error) { @@ -1708,9 +1582,13 @@ func (s *localMCPServer) setAgentRunError(runID string, runErr error) { return } taskStore, err := store.Open(s.cfg.DBPath()) - if err == nil { - defer taskStore.Close() - _ = taskStore.SetTaskError(runID, runErr.Error()) + if err != nil { + log.Printf("record agent run %s failure: open store: %v", runID, err) + return + } + defer closeWithLog("task store", taskStore) + if err := taskStore.SetTaskError(runID, runErr.Error()); err != nil { + log.Printf("record agent run %s failure: %v", runID, err) } } @@ -1728,19 +1606,21 @@ func (s *localMCPServer) ownedAgentRun(runID string) (*store.Task, *store.Store, } task, err := taskStore.GetTask(runID) if err != nil { - taskStore.Close() - return nil, nil, err + return nil, nil, closeAndJoin("task store", taskStore, err) } if task == nil || task.Type != "agent_run" || !s.ownsTask(task) { - taskStore.Close() - return nil, nil, fmt.Errorf("agent run %q not found or not owned by caller", runID) + return nil, nil, closeAndJoin("task store", taskStore, fmt.Errorf("agent run %q not found or not owned by caller", runID)) } if (task.Status == "pending" || task.Status == "running") && task.RunnerPID > 0 && !ownedProcessIsAlive(task.RunnerPID) { - _ = taskStore.SetTaskError(task.ID, fmt.Sprintf("supervising Wingthing process %d exited", task.RunnerPID)) + if err := taskStore.SetTaskError(task.ID, fmt.Sprintf("supervising Wingthing process %d exited", task.RunnerPID)); err != nil { + return nil, nil, closeAndJoin("task store", taskStore, fmt.Errorf("mark orphaned agent run failed: %w", err)) + } task, err = taskStore.GetTask(runID) if err != nil { - taskStore.Close() - return nil, nil, err + return nil, nil, closeAndJoin("task store", taskStore, err) + } + if task == nil { + return nil, nil, closeAndJoin("task store", taskStore, fmt.Errorf("agent run %q disappeared after orphan cleanup", runID)) } } return task, taskStore, nil @@ -1777,7 +1657,7 @@ func (s *localMCPServer) toolAgentStatus(arguments json.RawMessage) (map[string] if err != nil { return nil, err } - defer taskStore.Close() + defer closeWithLog("task store", taskStore) return agentRunStatusData(task), nil } @@ -1802,7 +1682,7 @@ func (s *localMCPServer) toolAgentWait(ctx context.Context, arguments json.RawMe if loadErr != nil { return nil, loadErr } - defer taskStore.Close() + defer closeWithLog("task store", taskStore) data := agentRunStatusData(task) if errors.Is(err, context.DeadlineExceeded) { data["timed_out"] = true @@ -1816,7 +1696,7 @@ func (s *localMCPServer) waitForAgentRunTerminal(ctx context.Context, runID stri if err != nil { return err } - defer taskStore.Close() + defer closeWithLog("task store", taskStore) if agentRunTerminal(task.Status) { return nil } @@ -1860,7 +1740,7 @@ func (s *localMCPServer) toolAgentResult(arguments json.RawMessage) (map[string] if err != nil { return nil, err } - defer taskStore.Close() + defer closeWithLog("task store", taskStore) data := agentRunStatusData(task) data["ready"] = agentRunTerminal(task.Status) if task.Output != nil { @@ -1897,12 +1777,11 @@ func (s *localMCPServer) toolAgentEvents(arguments json.RawMessage) (map[string] if err != nil { return nil, err } - defer taskStore.Close() + defer closeWithLog("task store", taskStore) rows, err := taskStore.DB().Query(`SELECT timestamp, event, COALESCE(detail, '') FROM task_log WHERE task_id = ? ORDER BY id DESC LIMIT ?`, args.RunID, args.Limit) if err != nil { return nil, err } - defer rows.Close() var events []map[string]any for rows.Next() { var timestamp, event, detail string @@ -1915,7 +1794,13 @@ func (s *localMCPServer) toolAgentEvents(arguments json.RawMessage) (map[string] } events = append(events, entry) } - return map[string]any{"run_id": args.RunID, "events": events}, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + return map[string]any{"run_id": args.RunID, "events": events}, nil } func (s *localMCPServer) toolAgentSteer(arguments json.RawMessage) (map[string]any, error) { @@ -1934,7 +1819,9 @@ func (s *localMCPServer) toolAgentSteer(arguments json.RawMessage) (map[string]a if err != nil { return nil, err } - taskStore.Close() + if err := taskStore.Close(); err != nil { + return nil, fmt.Errorf("close task store: %w", err) + } model := args.Model if model == "" { model = parent.Model @@ -1944,7 +1831,15 @@ func (s *localMCPServer) toolAgentSteer(arguments json.RawMessage) (map[string]a Prompt: "Prior request:\n" + parent.What + "\n\nNew direction:\n" + args.Prompt, Agent: parent.Agent, Model: model, CWD: parent.CWD, Label: "followup-" + parent.ID, TimeoutSeconds: parent.TimeoutSeconds, - }, &parentID) + }, &agentRunFollowup{parentID: parentID, direction: args.Prompt}) +} + +func agentSteerPrompt(parentRequest, parentResult, direction string) string { + resultRunes := []rune(parentResult) + if len(resultRunes) > maxAgentSteerPriorResultChars { + parentResult = string(resultRunes[:maxAgentSteerPriorResultChars]) + "\n\n[Wingthing truncated the prior result for this follow-up.]" + } + return "Prior request:\n" + parentRequest + "\n\nPrior result:\n" + parentResult + "\n\nNew direction:\n" + direction } func (s *localMCPServer) toolAgentStop(arguments json.RawMessage) (map[string]any, error) { @@ -1958,13 +1853,23 @@ func (s *localMCPServer) toolAgentStop(arguments json.RawMessage) (map[string]an if err != nil { return nil, err } - defer taskStore.Close() + defer closeWithLog("task store", taskStore) if agentRunTerminal(task.Status) { return agentRunStatusData(task), nil } activeValue, ok := activeMCPAgentRuns.Load(s.agentRunKey(args.RunID)) active, valid := activeValue.(activeMCPAgentRun) if !ok || !valid || active.principal != s.clientPrincipal() { + // The runner can finish between the first database read and the active + // map lookup. Reload before reporting a detached run so an idempotent + // stop never turns a successful completion race into an error. + latest, reloadErr := taskStore.GetTask(args.RunID) + if reloadErr != nil { + return nil, reloadErr + } + if latest != nil && agentRunTerminal(latest.Status) { + return agentRunStatusData(latest), nil + } return nil, errors.New("run is no longer attached to this Wingthing process") } active.cancel() @@ -2033,7 +1938,7 @@ func (s *localMCPServer) toolTerminalStop(ctx context.Context, arguments json.Ra if err != nil { return nil, err } - defer ec.Close() + defer closeWithLog("egg client", ec) if err := ec.Kill(ctx, session.ID); err != nil { return nil, err } @@ -2165,7 +2070,7 @@ func (s *localMCPServer) toolTaskGet(arguments json.RawMessage) (map[string]any, if err != nil { return nil, err } - defer taskStore.Close() + defer closeWithLog("task store", taskStore) task, err := taskStore.GetTask(args.TaskID) if err != nil { return nil, err @@ -2201,7 +2106,7 @@ func (s *localMCPServer) executePrompt(ctx context.Context, prompt, agentName, c if err != nil { return nil, err } - defer taskStore.Close() + defer closeWithLog("task store", taskStore) task := &store.Task{ ID: genTaskID(), Type: "prompt", What: prompt, Agent: agentName, RunAt: time.Now().UTC(), ParentID: parentID, DependsOn: dependsOn, CWD: cwd, @@ -2259,7 +2164,9 @@ func (s *localMCPServer) toolPromptLoop(ctx context.Context, arguments json.RawM if err != nil { return nil, false, err } - rootStore.Close() + if err := rootStore.Close(); err != nil { + return nil, false, fmt.Errorf("close root task store: %w", err) + } results := make([]map[string]any, 0, args.MaxIterations) var previousTaskID string failed := false @@ -2360,7 +2267,7 @@ func (s *localMCPServer) toolSwarmRun(ctx context.Context, arguments json.RawMes if err != nil { return nil, false, err } - defer rootStore.Close() + defer closeWithLog("root task store", rootStore) taskIDs := make(map[string]string, len(args.Nodes)) byID := make(map[string]swarmNodeSpec, len(args.Nodes)) @@ -2427,8 +2334,13 @@ func (s *localMCPServer) toolSwarmRun(ctx context.Context, arguments json.RawMes } if dependencyFailed { message := "one or more dependencies failed" - _ = rootStore.SetTaskError(taskIDs[node.ID], message) - skipped, _ := rootStore.GetTask(taskIDs[node.ID]) + if err := rootStore.SetTaskError(taskIDs[node.ID], message); err != nil { + return nil, true, fmt.Errorf("mark blocked swarm node %s failed: %w", node.ID, err) + } + skipped, err := rootStore.GetTask(taskIDs[node.ID]) + if err != nil { + return nil, true, fmt.Errorf("reload blocked swarm node %s: %w", node.ID, err) + } results[node.ID] = skipped state[node.ID] = "blocked" continue @@ -2463,11 +2375,15 @@ func (s *localMCPServer) toolSwarmRun(ctx context.Context, arguments json.RawMes resultCh <- swarmNodeResult{logicalID: node.ID, err: openErr} return } - defer taskStore.Close() + defer closeWithLog("task store", taskStore) task, getErr := taskStore.GetTask(taskIDs[node.ID]) if getErr == nil && task != nil { - getErr = runTaskTo(ctx, s.cfg, taskStore, task, io.Discard) - task, _ = taskStore.GetTask(task.ID) + runErr := runTaskTo(ctx, s.cfg, taskStore, task, io.Discard) + refreshed, refreshErr := taskStore.GetTask(task.ID) + if refreshErr == nil { + task = refreshed + } + getErr = errors.Join(runErr, refreshErr) } resultCh <- swarmNodeResult{logicalID: node.ID, task: task, err: getErr} }() @@ -2584,12 +2500,10 @@ func (s *localMCPServer) createMetaTask(kind, what, agentName, cwd string) (*sto RunAt: time.Now().UTC(), Status: "pending", CWD: cwd, Principal: s.clientPrincipal(), } if err := taskStore.CreateTask(task); err != nil { - taskStore.Close() - return nil, nil, err + return nil, nil, closeAndJoin("task store", taskStore, err) } if err := taskStore.UpdateTaskStatus(task.ID, "running"); err != nil { - taskStore.Close() - return nil, nil, err + return nil, nil, closeAndJoin("task store", taskStore, err) } return task, taskStore, nil } @@ -2599,7 +2513,7 @@ func (s *localMCPServer) finishMetaTask(taskID, status string, data map[string]a if err != nil { return err } - defer taskStore.Close() + defer closeWithLog("task store", taskStore) encoded, err := json.Marshal(data) if err != nil { return err diff --git a/cmd/wt/mcp_local_test.go b/cmd/wt/mcp_local_test.go index c7792ff0..45614862 100644 --- a/cmd/wt/mcp_local_test.go +++ b/cmd/wt/mcp_local_test.go @@ -8,15 +8,18 @@ import ( "io" "os" "path/filepath" + "reflect" "strconv" "strings" "testing" "time" "github.com/ehrlich-b/wingthing/internal/config" + "github.com/ehrlich-b/wingthing/internal/control" "github.com/ehrlich-b/wingthing/internal/egg" mcppkg "github.com/ehrlich-b/wingthing/internal/mcp" "github.com/ehrlich-b/wingthing/internal/promptmgr" + "github.com/ehrlich-b/wingthing/internal/relay" "github.com/ehrlich-b/wingthing/internal/store" ) @@ -25,7 +28,7 @@ func TestLocalMCPStdioProtocolAndToolDiscovery(t *testing.T) { `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}`, `{"jsonrpc":"2.0","method":"notifications/initialized"}`, `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`, - `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"wingthing_capabilities","arguments":{}}}`, + `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"wingthing_capabilities","arguments":{},"_meta":{"progressToken":"claude-code"}}}`, }, "\n") + "\n" var output bytes.Buffer server := &localMCPServer{ @@ -59,8 +62,8 @@ func TestLocalMCPStdioProtocolAndToolDiscovery(t *testing.T) { if err := json.Unmarshal([]byte(lines[1]), &listed); err != nil { t.Fatal(err) } - if len(listed.Result.Tools) != 27 { - t.Fatalf("tools = %d, want 27", len(listed.Result.Tools)) + if want := len(control.Tools(control.SurfaceLocalMCP)); len(listed.Result.Tools) != want { + t.Fatalf("tools = %d, want %d from the control registry", len(listed.Result.Tools), want) } names := make(map[string]bool) for _, tool := range listed.Result.Tools { @@ -120,6 +123,164 @@ func TestLocalMCPStdioProtocolAndToolDiscovery(t *testing.T) { if len(capabilities.Result.StructuredContent["agents"].([]any)) != 7 { t.Fatalf("agents = %#v", capabilities.Result.StructuredContent["agents"]) } + contract := capabilities.Result.StructuredContent["control_contract"].(map[string]any) + if contract["surface"] != string(control.SurfaceLocalMCP) || contract["version"] != control.ContractVersion { + t.Fatalf("local control contract = %#v", contract) + } + if got := len(contract["operations"].([]any)); got != len(listed.Result.Tools) { + t.Fatalf("capability operations = %d, listed tools = %d", got, len(listed.Result.Tools)) + } +} + +func TestLocalMCPStdioEOFCancelsOutstandingWait(t *testing.T) { + inputReader, inputWriter := io.Pipe() + var output bytes.Buffer + server := &localMCPServer{ + cfg: &config.Config{Dir: t.TempDir(), DefaultAgent: "claude"}, + in: inputReader, out: &output, logs: &bytes.Buffer{}, principal: "owner", actor: "test", + } + done := make(chan error, 1) + go func() { done <- server.serve(context.Background()) }() + if _, err := io.WriteString(inputWriter, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"message_wait","arguments":{"timeout_seconds":3600}}}`+"\n"); err != nil { + t.Fatal(err) + } + // The request is dispatched asynchronously. EOF must cancel it whether it + // has entered its wait loop or is just about to do so. + if err := inputWriter.Close(); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("local MCP server remained stuck in a one-hour wait after stdin EOF") + } +} + +func TestLocalMCPCallAdmissionIsBounded(t *testing.T) { + slots := make(chan struct{}, maxConcurrentLocalMCPCalls) + for range maxConcurrentLocalMCPCalls { + if !acquireLocalMCPCallSlot(slots) { + t.Fatal("call slot rejected before reaching the limit") + } + } + if acquireLocalMCPCallSlot(slots) { + t.Fatal("call slot accepted beyond the concurrency limit") + } + <-slots + if !acquireLocalMCPCallSlot(slots) { + t.Fatal("released call slot was not reusable") + } +} + +func TestMCPToolCallParamsStillRejectUnknownEnvelopeFields(t *testing.T) { + server := &localMCPServer{} + response, _ := server.handle(context.Background(), localMCPRequest{ + JSONRPC: "2.0", ID: json.RawMessage(`1`), Method: "tools/call", + Params: json.RawMessage(`{"name":"wingthing_capabilities","arguments":{},"_meta":{"progressToken":"ok"},"surprise":true}`), + }) + if response.Error == nil || response.Error.Code != -32602 || !strings.Contains(response.Error.Message, `unknown field "surprise"`) { + t.Fatalf("unknown tool-call envelope field response = %#v", response) + } +} + +func TestUnknownMCPToolIsNotMisreportedAsMissingGrant(t *testing.T) { + server := &localMCPServer{grants: map[string]bool{}, logs: io.Discard} + _, _, protocolErr := server.callTool(context.Background(), "not_a_tool", json.RawMessage(`{}`)) + if protocolErr == nil || protocolErr.Code != -32602 || protocolErr.Message != "unknown tool: not_a_tool" { + t.Fatalf("unknown tool error = %#v", protocolErr) + } +} + +func TestLocalAndHTTPMCPShareControlRegistry(t *testing.T) { + local := make(map[string]localMCPTool) + for _, tool := range localMCPTools() { + local[tool.Name] = tool + } + cfg := &config.Config{WingID: "embedded-wing"} + native := roostMCPControlTools(relay.NewServer(nil, relay.ServerConfig{}), cfg, false) + httpDefinitions := control.Tools(control.SurfaceHTTPMCP) + if len(native) != len(httpDefinitions) { + t.Fatalf("HTTP native tools = %d, registry = %d", len(native), len(httpDefinitions)) + } + for index, want := range httpDefinitions { + got := native[index] + if got.Name != want.Name || got.Title != want.Title || got.Description != want.Description { + t.Errorf("HTTP tool %d metadata = %q/%q/%q, want %q/%q/%q", + index, got.Name, got.Title, got.Description, want.Name, want.Title, want.Description) + } + if !reflect.DeepEqual(got.InputSchema, want.InputSchema) { + t.Errorf("%s HTTP schema differs from registry", want.Name) + } + if !reflect.DeepEqual(got.Annotations, want.Annotations) { + t.Errorf("%s HTTP annotations differ from registry", want.Name) + } + if want.Authority == control.AuthorityWing && !reflect.DeepEqual(local[want.Name].InputSchema, want.InputSchema) { + t.Errorf("%s local schema differs from registry", want.Name) + } + } +} + +func TestRoostNativeMCPAuthorityIsExplicitBoundedAndShared(t *testing.T) { + admission := newMCPAdmissionState() + paths := []string{"/srv/alice"} + server := newRoostNativeMCPServer(&config.Config{Dir: t.TempDir()}, true, admission, mcppkg.Principal{ + UserID: "alice", Email: "alice@example.com", ClientID: "codex", + }, paths) + paths[0] = "/srv/mutated" + if server.admission != admission || server.maxSessions != defaultDirectMCPMaxSessions || server.maxSpawnsPerHour != defaultDirectMCPMaxSpawnsPerHour { + t.Fatalf("HTTP MCP bounds/admission = sessions %d spawns %d admission %p", server.maxSessions, server.maxSpawnsPerHour, server.admission) + } + if !server.enforcePathBounds || len(server.allowedPaths) != 1 || server.allowedPaths[0] != "/srv/alice" || !server.identity.SealedFS || !server.identity.SharedHost { + t.Fatalf("HTTP MCP identity boundary = paths %#v identity %#v", server.allowedPaths, server.identity) + } + for _, tool := range control.ToolsForAuthority(control.SurfaceHTTPMCP, control.AuthorityWing) { + if !server.grants[tool.Grant] { + t.Errorf("HTTP MCP default authority omitted explicit grant %q for %s", tool.Grant, tool.Name) + } + } + if portal, ok := control.Lookup("wing_list"); !ok || !server.grants[portal.Grant] { + t.Fatal("HTTP MCP capabilities omitted the composite portal wing_list grant") + } +} + +func TestRoostCapabilitiesReportHTTPContract(t *testing.T) { + dir := t.TempDir() + workspace := t.TempDir() + cfg := &config.Config{Dir: dir, DefaultAgent: "claude"} + if err := config.SaveWingConfig(dir, &config.WingConfig{ + Paths: config.PathList{{Path: workspace}}, + }); err != nil { + t.Fatal(err) + } + var capabilities mcppkg.NativeTool + for _, tool := range roostNativeMCPTools(cfg, true) { + if tool.Name == "wingthing_capabilities" { + capabilities = tool + break + } + } + if capabilities.Call == nil { + t.Fatal("roost capabilities tool is missing") + } + result, isError, err := capabilities.Call(context.Background(), mcppkg.Principal{ + UserID: "alice", Email: "alice@example.com", ClientID: "codex", + }, json.RawMessage(`{}`)) + if err != nil || isError { + t.Fatalf("capabilities = %#v isError=%v err=%v", result, isError, err) + } + contract := result["control_contract"].(map[string]any) + if contract["surface"] != string(control.SurfaceHTTPMCP) || contract["version"] != control.ContractVersion { + t.Fatalf("HTTP control contract = %#v", contract) + } + if got, want := contract["operations"], control.OperationNames(control.SurfaceHTTPMCP); !reflect.DeepEqual(got, want) { + t.Fatalf("HTTP operations = %#v, want %#v", got, want) + } + if got, want := result["objects"], control.ObjectKinds(control.SurfaceHTTPMCP); !reflect.DeepEqual(got, want) { + t.Fatalf("HTTP objects = %#v, want %#v", got, want) + } } func TestLocalMCPUnsandboxedModeIsExplicitAndAudited(t *testing.T) { @@ -138,6 +299,9 @@ func TestLocalMCPUnsandboxedModeIsExplicitAndAudited(t *testing.T) { if !strings.Contains(server.mcpInstructions(), "full authority") { t.Fatal("initialize instructions hide unsandboxed authority") } + if !strings.Contains(strings.ToLower(server.mcpInstructions()), "agent manager") { + t.Fatal("initialize instructions do not explain Wingthing's agent-manager role") + } explained, err := server.toolSandboxExplain(json.RawMessage(`{"agent":"claude"}`)) if err != nil { t.Fatal(err) @@ -146,6 +310,9 @@ func TestLocalMCPUnsandboxedModeIsExplicitAndAudited(t *testing.T) { if policy.ConfigSource != "MCP server --unsandboxed" || policy.Enforcement != "unrestricted" || policy.Isolation != "outer-boundary" { t.Fatalf("policy = %#v", policy) } + if _, err := server.toolSandboxExplain(json.RawMessage(`{"config":"egg.yaml"}`)); err == nil || !strings.Contains(err.Error(), "cannot be combined") { + t.Fatalf("unsandboxed server accepted a sandbox config it would not enforce: %v", err) + } if err := server.auditToolCall("wingthing_capabilities", json.RawMessage(`{}`), capabilities, "allowed"); err != nil { t.Fatal(err) } @@ -676,7 +843,7 @@ func TestLocalMCPTaskOwnership(t *testing.T) { if err := taskStore.CreateTask(&store.Task{ID: "task-beta", What: "secret", RunAt: time.Now(), Principal: "beta"}); err != nil { t.Fatal(err) } - taskStore.Close() + closeForTest(t, "task store", taskStore) server := &localMCPServer{cfg: cfg, logs: &bytes.Buffer{}, principal: "alpha"} result, isError, protocolErr := server.callTool(context.Background(), "task_get", json.RawMessage(`{"task_id":"task-beta"}`)) @@ -836,7 +1003,7 @@ func TestAgentStatusMarksOrphanedRunnerFailed(t *testing.T) { if err := taskStore.UpdateTaskStatus(task.ID, "running"); err != nil { t.Fatal(err) } - taskStore.Close() + closeForTest(t, "task store", taskStore) server := &localMCPServer{cfg: cfg, logs: &bytes.Buffer{}, principal: "alpha"} status, err := server.toolAgentStatus(json.RawMessage(`{"run_id":"orphaned-run"}`)) if err != nil { @@ -869,7 +1036,7 @@ func TestFailedParentDoesNotReleaseSteeredRun(t *testing.T) { if err := taskStore.SetTaskError(parent.ID, "review failed"); err != nil { t.Fatal(err) } - taskStore.Close() + closeForTest(t, "task store", taskStore) runnerCalled := make(chan struct{}, 1) server := &localMCPServer{ cfg: cfg, logs: &bytes.Buffer{}, principal: "alpha", @@ -896,12 +1063,84 @@ func TestFailedParentDoesNotReleaseSteeredRun(t *testing.T) { if err != nil { t.Fatal(err) } - defer childStore.Close() + defer closeForTest(t, "child task store", childStore) if !strings.Contains(child.What, "Prior request:\noriginal review") || !strings.Contains(child.What, "New direction:\nfocus on auth") { t.Fatalf("steered prompt = %q", child.What) } } +func TestAgentSteerPassesAndPersistsPriorResult(t *testing.T) { + dir := t.TempDir() + cwd := t.TempDir() + cfg := &config.Config{Dir: dir, DefaultAgent: "claude"} + taskStore, err := store.Open(cfg.DBPath()) + if err != nil { + t.Fatal(err) + } + parent := &store.Task{ + ID: "completed-parent", Type: "agent_run", What: "review this branch", Agent: "claude", Model: "opus", + RunAt: time.Now(), CWD: cwd, Principal: "alpha", RunnerPID: os.Getpid(), + } + if err := taskStore.CreateTask(parent); err != nil { + t.Fatal(err) + } + if err := taskStore.SetTaskOutput(parent.ID, "the auth boundary is sound"); err != nil { + t.Fatal(err) + } + if err := taskStore.UpdateTaskStatus(parent.ID, "done"); err != nil { + t.Fatal(err) + } + closeForTest(t, "task store", taskStore) + + wantPrompt := agentSteerPrompt(parent.What, "the auth boundary is sound", "now review the UI") + seenPrompt := make(chan string, 1) + server := &localMCPServer{ + cfg: cfg, logs: &bytes.Buffer{}, principal: "alpha", + runAgentTask: func(_ context.Context, _ *config.Config, taskStore *store.Store, task *store.Task, _ taskRunOptions) error { + seenPrompt <- task.What + return taskStore.UpdateTaskStatus(task.ID, "done") + }, + } + created, err := server.toolAgentSteer(json.RawMessage(`{"run_id":"completed-parent","prompt":"now review the UI"}`)) + if err != nil { + t.Fatal(err) + } + childID := created["run_id"].(string) + select { + case got := <-seenPrompt: + if got != wantPrompt { + t.Fatalf("runner prompt = %q, want %q", got, wantPrompt) + } + case <-time.After(2 * time.Second): + t.Fatal("steered agent runner did not start") + } + waited, err := server.toolAgentWait(context.Background(), json.RawMessage(`{"run_id":`+strconv.Quote(childID)+`,"timeout_seconds":2}`)) + if err != nil || waited["status"] != "done" { + t.Fatalf("child wait = %#v err=%v", waited, err) + } + child, childStore, err := server.ownedAgentRun(childID) + if err != nil { + t.Fatal(err) + } + defer closeForTest(t, "child task store", childStore) + if child.What != wantPrompt { + t.Fatalf("persisted prompt = %q, want %q", child.What, wantPrompt) + } +} + +func TestAgentSteerBoundsPriorResultWithoutSplittingUnicode(t *testing.T) { + prior := strings.Repeat("✓", maxAgentSteerPriorResultChars+1) + prompt := agentSteerPrompt("review", prior, "continue") + if strings.Contains(prompt, strings.Repeat("✓", maxAgentSteerPriorResultChars+1)) { + t.Fatal("follow-up retained the unbounded prior result") + } + if !strings.Contains(prompt, strings.Repeat("✓", maxAgentSteerPriorResultChars)) || + !strings.Contains(prompt, "[Wingthing truncated the prior result for this follow-up.]") || + !strings.HasSuffix(prompt, "New direction:\ncontinue") { + t.Fatalf("bounded follow-up prompt has the wrong shape: prefix=%q suffix=%q", prompt[:64], prompt[len(prompt)-96:]) + } +} + func TestStdioWaitDoesNotBlockStop(t *testing.T) { dir := t.TempDir() cwd := t.TempDir() diff --git a/cmd/wt/mcp_principals.go b/cmd/wt/mcp_principals.go index a7a33d2a..56783e32 100644 --- a/cmd/wt/mcp_principals.go +++ b/cmd/wt/mcp_principals.go @@ -45,36 +45,6 @@ func loadLocalMCPClientsConfig(cfg *config.Config) (localMCPClientsConfig, error return clients, nil } -var localMCPToolGrants = map[string]string{ - "wingthing_capabilities": "capabilities.read", - "sandbox_explain": "sandbox.read", - "terminal_list": "terminal.read", - "terminal_read": "terminal.read", - "terminal_wait": "terminal.read", - "terminal_send": "terminal.send", - "terminal_start": "terminal.start", - "agent_start": "terminal.start", - "agent_run": "agent.run", - "agent_status": "agent.read", - "agent_wait": "agent.read", - "agent_result": "agent.read", - "agent_events": "agent.read", - "agent_steer": "agent.run", - "agent_stop": "agent.stop", - "message_list": "message.read", - "message_wait": "message.read", - "message_send": "message.send", - "terminal_rename": "terminal.rename", - "terminal_stop": "terminal.stop", - "prompt_list": "prompt.read", - "prompt_get": "prompt.read", - "task_get": "prompt.read", - "prompt_save": "prompt.save", - "prompt_run": "prompt.run", - "prompt_loop": "prompt.run", - "swarm_run": "prompt.run", -} - func grantSet(grants []string) map[string]bool { set := make(map[string]bool, len(grants)) for _, grant := range grants { diff --git a/cmd/wt/network_audit_test.go b/cmd/wt/network_audit_test.go new file mode 100644 index 00000000..8ffc8652 --- /dev/null +++ b/cmd/wt/network_audit_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "path/filepath" + "testing" + "time" + + "github.com/ehrlich-b/wingthing/internal/sandbox" + "github.com/ehrlich-b/wingthing/internal/store" +) + +func TestUnconfinedEgressAuditIsDurable(t *testing.T) { + taskStore, err := store.Open(filepath.Join(t.TempDir(), "wingthing.db")) + if err != nil { + t.Fatal(err) + } + defer closeForTest(t, "task store", taskStore) + task := &store.Task{ID: "unconfined-audit", Type: "prompt", What: "test", RunAt: time.Now(), Agent: "claude"} + if err := taskStore.CreateTask(task); err != nil { + t.Fatal(err) + } + detail, err := appendNetworkEnforcementAudit(taskStore, task.ID, "unconfined_egress", "outer-boundary", sandbox.NetworkFull, []string{"*"}, []int{11434}) + if err != nil { + t.Fatal(err) + } + entries, err := taskStore.ListLogByTask(task.ID) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Event != "unconfined_egress" || entries[0].Detail == nil || *entries[0].Detail != detail { + t.Fatalf("unconfined audit entries = %#v, detail = %q", entries, detail) + } + if detail != "network=full enforcement=outer-boundary domains=1 local_ports=[11434]" { + t.Fatalf("unconfined audit detail = %q", detail) + } +} diff --git a/cmd/wt/process_argv_darwin.go b/cmd/wt/process_argv_darwin.go new file mode 100644 index 00000000..59bb5b54 --- /dev/null +++ b/cmd/wt/process_argv_darwin.go @@ -0,0 +1,61 @@ +//go:build darwin + +package main + +import ( + "encoding/binary" + "fmt" + + "golang.org/x/sys/unix" +) + +// processArgv uses KERN_PROCARGS2 instead of ps. ps flattens argv into display +// text and cannot distinguish a space in the executable path from an argument +// boundary, which made daemon lifecycle commands reject valid custom installs. +func processArgv(pid int) ([]string, error) { + data, err := unix.SysctlRaw("kern.procargs2", pid) + if err != nil { + return nil, err + } + return parseDarwinProcArgs(data) +} + +func parseDarwinProcArgs(data []byte) ([]string, error) { + if len(data) < 4 { + return nil, fmt.Errorf("kern.procargs2 response is truncated") + } + argc := int(binary.LittleEndian.Uint32(data[:4])) + if argc < 1 || argc > 1<<20 { + return nil, fmt.Errorf("kern.procargs2 returned invalid argc %d", argc) + } + + // The payload begins with argc, the executable path, alignment NULs, then + // exactly argc NUL-terminated argv entries followed by the environment. + position := 4 + for position < len(data) && data[position] != 0 { + position++ + } + if position == len(data) { + return nil, fmt.Errorf("kern.procargs2 response has no executable terminator") + } + for position < len(data) && data[position] == 0 { + position++ + } + + argv := make([]string, 0, argc) + for len(argv) < argc { + if position >= len(data) { + return nil, fmt.Errorf("kern.procargs2 response ended after %d of %d arguments", len(argv), argc) + } + start := position + for position < len(data) && data[position] != 0 { + position++ + } + if position == len(data) { + return nil, fmt.Errorf("kern.procargs2 argument %d is unterminated", len(argv)) + } + argv = append(argv, string(data[start:position])) + position++ + } + return argv, nil +} diff --git a/cmd/wt/process_argv_darwin_test.go b/cmd/wt/process_argv_darwin_test.go new file mode 100644 index 00000000..a3a90a66 --- /dev/null +++ b/cmd/wt/process_argv_darwin_test.go @@ -0,0 +1,53 @@ +//go:build darwin + +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +const daemonArgvHelperEnv = "WT_TEST_DAEMON_ARGV_HELPER" + +func init() { + if os.Getenv(daemonArgvHelperEnv) == "1" { + time.Sleep(30 * time.Second) + os.Exit(0) + } +} + +func TestInspectDaemonPidPreservesExecutablePathSpacesOnDarwin(t *testing.T) { + spacedDir := filepath.Join(t.TempDir(), "custom install path") + if err := os.Mkdir(spacedDir, 0o700); err != nil { + t.Fatal(err) + } + link := filepath.Join(spacedDir, "wt") + if err := os.Symlink(os.Args[0], link); err != nil { + t.Fatal(err) + } + + child := exec.Command(link, "wing", "start", "--foreground") + child.Env = append(os.Environ(), daemonArgvHelperEnv+"=1") + if err := child.Start(); err != nil { + t.Fatal(err) + } + defer func() { + _ = child.Process.Kill() + _ = child.Wait() + }() + + deadline := time.Now().Add(3 * time.Second) + for { + matches, err := inspectDaemonPid(child.Process.Pid, wingDaemon) + if err == nil && matches { + return + } + if time.Now().After(deadline) { + t.Fatalf("spaced-path daemon was not recognized: matches=%v err=%v", matches, err) + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/cmd/wt/process_argv_linux.go b/cmd/wt/process_argv_linux.go new file mode 100644 index 00000000..be1f8d69 --- /dev/null +++ b/cmd/wt/process_argv_linux.go @@ -0,0 +1,21 @@ +//go:build linux + +package main + +import ( + "fmt" + "os" + "strings" +) + +func processArgv(pid int) ([]string, error) { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) + if err != nil { + return nil, err + } + trimmed := strings.TrimRight(string(data), "\x00") + if trimmed == "" { + return nil, nil + } + return strings.Split(trimmed, "\x00"), nil +} diff --git a/cmd/wt/process_argv_other.go b/cmd/wt/process_argv_other.go new file mode 100644 index 00000000..b901a804 --- /dev/null +++ b/cmd/wt/process_argv_other.go @@ -0,0 +1,12 @@ +//go:build !darwin && !linux + +package main + +import ( + "fmt" + "runtime" +) + +func processArgv(pid int) ([]string, error) { + return nil, fmt.Errorf("process argv inspection is unsupported on %s", runtime.GOOS) +} diff --git a/cmd/wt/prompt.go b/cmd/wt/prompt.go index c2700192..8336b8e9 100644 --- a/cmd/wt/prompt.go +++ b/cmd/wt/prompt.go @@ -46,14 +46,17 @@ func promptListCmd() *cobra.Command { return writePromptJSON(cmd.OutOrStdout(), assets) } if len(assets) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "no saved prompts") - return nil + return writeln(cmd.OutOrStdout(), "no saved prompts") } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "NAME\tREVISION\tAGENT\tVARIABLES\tDESCRIPTION") + if err := writeln(w, "NAME\tREVISION\tAGENT\tVARIABLES\tDESCRIPTION"); err != nil { + return err + } for _, asset := range assets { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", asset.Name, asset.Revision, asset.Agent, - strings.Join(asset.Variables, ","), asset.Description) + if err := writef(w, "%s\t%s\t%s\t%s\t%s\n", asset.Name, asset.Revision, asset.Agent, + strings.Join(asset.Variables, ","), asset.Description); err != nil { + return err + } } return w.Flush() }, @@ -81,21 +84,31 @@ func promptShowCmd() *cobra.Command { if jsonOutput { return writePromptJSON(cmd.OutOrStdout(), asset) } - fmt.Fprintf(cmd.OutOrStdout(), "%s@%s\n", asset.Name, asset.Revision) + out := cmd.OutOrStdout() + if err := writef(out, "%s@%s\n", asset.Name, asset.Revision); err != nil { + return err + } if asset.Description != "" { - fmt.Fprintf(cmd.OutOrStdout(), "description: %s\n", asset.Description) + if err := writef(out, "description: %s\n", asset.Description); err != nil { + return err + } } if asset.Agent != "" { - fmt.Fprintf(cmd.OutOrStdout(), "agent: %s\n", asset.Agent) + if err := writef(out, "agent: %s\n", asset.Agent); err != nil { + return err + } } if asset.CWD != "" { - fmt.Fprintf(cmd.OutOrStdout(), "cwd: %s\n", asset.CWD) + if err := writef(out, "cwd: %s\n", asset.CWD); err != nil { + return err + } } if len(asset.Variables) > 0 { - fmt.Fprintf(cmd.OutOrStdout(), "variables: %s\n", strings.Join(asset.Variables, ", ")) + if err := writef(out, "variables: %s\n", strings.Join(asset.Variables, ", ")); err != nil { + return err + } } - fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", asset.Template) - return nil + return writef(out, "\n%s\n", asset.Template) }, } cmd.Flags().StringVar(&revision, "revision", "", "Read an immutable historical revision") @@ -154,8 +167,7 @@ func promptSaveCmd() *cobra.Command { if jsonOutput { return writePromptJSON(cmd.OutOrStdout(), asset) } - fmt.Fprintf(cmd.OutOrStdout(), "saved: %s@%s\n", asset.Name, asset.Revision) - return nil + return writef(cmd.OutOrStdout(), "saved: %s@%s\n", asset.Name, asset.Revision) }, } cmd.Flags().StringVar(&description, "description", "", "Human-readable purpose") @@ -207,7 +219,7 @@ func promptRunCmd() *cobra.Command { if err != nil { return fmt.Errorf("open db: %w", err) } - defer taskStore.Close() + defer closeWithLog("prompt task store", taskStore) task := &store.Task{ ID: genTaskID(), Type: "prompt", What: rendered, Agent: agentName, RunAt: time.Now().UTC(), CWD: cwd, PromptName: asset.Name, PromptRevision: asset.Revision, @@ -215,7 +227,9 @@ func promptRunCmd() *cobra.Command { if err := taskStore.CreateTask(task); err != nil { return err } - fmt.Fprintf(cmd.OutOrStdout(), "submitted: %s (%s@%s)\n", task.ID, asset.Name, asset.Revision) + if err := writef(cmd.OutOrStdout(), "submitted: %s (%s@%s)\n", task.ID, asset.Name, asset.Revision); err != nil { + return err + } return runTaskTo(cmd.Context(), cfg, taskStore, task, cmd.OutOrStdout()) }, } diff --git a/cmd/wt/roost.go b/cmd/wt/roost.go index 30b55cce..48450b60 100644 --- a/cmd/wt/roost.go +++ b/cmd/wt/roost.go @@ -2,9 +2,10 @@ package main import ( "context" + "errors" "fmt" + "io" "log" - "net/http" "os" "os/exec" "os/signal" @@ -16,16 +17,24 @@ import ( "github.com/ehrlich-b/wingthing/internal/auth" "github.com/ehrlich-b/wingthing/internal/config" "github.com/ehrlich-b/wingthing/internal/egg" + mcppkg "github.com/ehrlich-b/wingthing/internal/mcp" "github.com/ehrlich-b/wingthing/internal/relay" - "github.com/google/uuid" "github.com/spf13/cobra" ) +const ( + roostReadyFDEnv = "WT_ROOST_READY_FD" + roostReadyToken = "ready\n" + roostDaemonReadyTimeout = 10 * time.Second + roostWingReadyTimeout = 8 * time.Second + maxRoostReadyMessageSize = 32 +) + func roostCmd() *cobra.Command { cmd := &cobra.Command{ Use: "roost", Short: "Run relay + wing in a single process (self-hosted mode)", - Long: "Starts the relay server and a local wing together. One command, one process, one log stream.\nUse 'wt roost' to daemonize, 'wt roost --foreground' for systemd/debugging.", + Long: "Starts the relay server and a local wing together. One command, one process, one log stream.\nUse 'wt roost start' to daemonize, or 'wt roost start --foreground' for systemd/debugging.", } cmd.AddCommand(roostStartCmd()) @@ -35,10 +44,33 @@ func roostCmd() *cobra.Command { return cmd } +func roostAllowedEmailsFromEnv() ([]string, error) { + raw := strings.TrimSpace(os.Getenv("WT_ROOST_ALLOWED_EMAILS")) + if raw == "" { + return nil, nil + } + seen := map[string]bool{} + var emails []string + for _, value := range strings.Split(raw, ",") { + email := strings.ToLower(strings.TrimSpace(value)) + at := strings.IndexByte(email, '@') + if email == "" || strings.Count(email, "@") != 1 || at <= 0 || at == len(email)-1 || strings.ContainsAny(email, " \t\r\n") { + return nil, fmt.Errorf("WT_ROOST_ALLOWED_EMAILS contains invalid email %q", value) + } + if !seen[email] { + seen[email] = true + emails = append(emails, email) + } + } + return emails, nil +} + func roostStartCmd() *cobra.Command { // Relay flags var addrFlag string var devFlag bool + var httpsFlag bool + var httpsAddrFlag string // Wing flags var labelsFlag string var pathsFlag string @@ -54,16 +86,55 @@ func roostStartCmd() *cobra.Command { Short: "Start roost (relay + wing)", Long: "Start a roost — relay server and local wing in one process. Daemonizes by default. Use --foreground for debugging or systemd.", RunE: func(cmd *cobra.Command, args []string) error { - if foregroundFlag { - return runRoostForeground(addrFlag, devFlag, labelsFlag, pathsFlag, eggConfigFlag, orgFlag, auditFlag, debugFlag) + if err := validateAuthProviderEnvironment(); err != nil { + return err } - - // Daemon mode: check for existing daemon - if pid, err := readPidFrom(roostPidPath()); err == nil { - return fmt.Errorf("roost daemon already running (pid %d)", pid) + var lifecycleLock *os.File + if !foregroundFlag { + var err error + lifecycleLock, err = acquireDaemonLifecycleLock() + if err != nil { + return err + } + defer closeWithLog("daemon lifecycle lock", lifecycleLock) + } + localMode := !authProvidersConfigured() + if err := validateLocalHTTPSMode(httpsFlag, localMode, false); err != nil { + return err } - if pid, err := readPidFrom(wingPidPath()); err == nil { - return fmt.Errorf("wing daemon already running (pid %d) — stop it first with: wt stop", pid) + if localMode && !httpsFlag { + var err error + addrFlag, err = prepareLocalHTTPAddress(addrFlag, cmd.Flags().Changed("addr")) + if err != nil { + return err + } + } + if !foregroundFlag { + // Check before the trust ceremony so a failed duplicate start has + // no certificate or trust-store side effects. + if pid, kind, err := readDaemon(); err == nil { + if kind == roostDaemon { + return fmt.Errorf("roost daemon already running (pid %d)", pid) + } + return fmt.Errorf("wing daemon already running (pid %d) — stop it first with: wt stop", pid) + } else if !errors.Is(err, errNoDaemonRunning) { + return fmt.Errorf("inspect daemon state: %w", err) + } + } + var localHTTPS *localHTTPSConfig + if httpsFlag { + cfg, err := config.Load() + if err != nil { + return err + } + localHTTPS, err = prepareLocalHTTPS(cmd.Context(), cfg.Dir, addrFlag, httpsAddrFlag, cmd.Flags().Changed("addr")) + if err != nil { + return err + } + addrFlag = localHTTPS.HTTPAddr + } + if foregroundFlag { + return runRoostForeground(addrFlag, devFlag, labelsFlag, pathsFlag, eggConfigFlag, orgFlag, auditFlag, debugFlag, localHTTPS) } exe, err := os.Executable() @@ -80,6 +151,9 @@ func roostStartCmd() *cobra.Command { if devFlag { childArgs = append(childArgs, "--dev") } + if httpsFlag { + childArgs = append(childArgs, "--https", "--https-addr", httpsAddrFlag) + } if labelsFlag != "" { childArgs = append(childArgs, "--labels", labelsFlag) } @@ -99,36 +173,69 @@ func roostStartCmd() *cobra.Command { childArgs = append(childArgs, "--debug") } - rotateLog(roostLogPath()) + if err := rotateLog(roostLogPath()); err != nil { + return err + } logFile, err := os.OpenFile(roostLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { return fmt.Errorf("open log: %w", err) } - home, _ := os.UserHomeDir() + home, err := os.UserHomeDir() + if err != nil { + closeWithLog("roost log", logFile) + return fmt.Errorf("resolve user home: %w", err) + } child := exec.Command(exe, childArgs...) child.Dir = home child.Stdout = logFile child.Stderr = logFile child.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + readyReader, readyWriter, err := os.Pipe() + if err != nil { + closeWithLog("roost log", logFile) + return fmt.Errorf("create daemon readiness pipe: %w", err) + } + child.ExtraFiles = []*os.File{readyWriter} + child.Env = replaceEnvironmentValue(os.Environ(), roostReadyFDEnv, "3") if err := child.Start(); err != nil { - logFile.Close() + closeWithLog("roost readiness reader", readyReader) + closeWithLog("roost readiness writer", readyWriter) + closeWithLog("roost log", logFile) return fmt.Errorf("start daemon: %w", err) } - logFile.Close() - - if err := os.WriteFile(roostPidPath(), []byte(strconv.Itoa(child.Process.Pid)), 0644); err != nil { - log.Printf("warning: failed to write PID file: %v", err) + if err := readyWriter.Close(); err != nil { + abandonStartedDaemon(child) + closeWithLog("roost readiness reader", readyReader) + closeWithLog("roost log", logFile) + return fmt.Errorf("close parent readiness writer: %w", err) + } + if err := logFile.Close(); err != nil { + abandonStartedDaemon(child) + closeWithLog("roost readiness reader", readyReader) + return fmt.Errorf("close roost log: %w", err) + } + if err := awaitRoostReady(readyReader, roostDaemonReadyTimeout); err != nil { + abandonStartedDaemon(child) + return fmt.Errorf("roost daemon did not become ready: %w (see %s)", err, roostLogPath()) } - if err := os.WriteFile(roostArgsPath(), []byte(strings.Join(childArgs, "\n")), 0644); err != nil { - log.Printf("warning: failed to write args file: %v", err) + if err := writeDaemonMetadata(roostPidPath(), roostArgsPath(), child.Process.Pid, childArgs); err != nil { + abandonStartedDaemon(child) + return fmt.Errorf("start roost daemon: %w", err) + } + if err := child.Process.Release(); err != nil { + log.Printf("warning: failed to release daemon process handle: %v", err) } fmt.Printf("roost daemon started (pid %d)\n", child.Process.Pid) fmt.Printf(" log: %s\n", roostLogPath()) fmt.Println() - fmt.Printf("open http://localhost%s to start a terminal\n", addrFlag) + if localHTTPS != nil { + fmt.Printf("open %s to start a terminal\n", localHTTPS.URL) + } else { + fmt.Printf("open %s to start a terminal\n", localHTTPURL(addrFlag)) + } return nil }, } @@ -136,6 +243,8 @@ func roostStartCmd() *cobra.Command { // Relay flags cmd.Flags().StringVar(&addrFlag, "addr", ":8080", "listen address") cmd.Flags().BoolVar(&devFlag, "dev", false, "reload templates from disk on each request") + cmd.Flags().BoolVar(&httpsFlag, "https", false, "serve the local browser UI over HTTPS using an on-demand, device-local CA") + cmd.Flags().StringVar(&httpsAddrFlag, "https-addr", defaultLocalHTTPSAddr, "loopback HTTPS address for the local browser UI") // Wing flags cmd.Flags().StringVar(&labelsFlag, "labels", "", "comma-separated wing labels") cmd.Flags().StringVar(&pathsFlag, "paths", "", "comma-separated directories the wing can browse") @@ -149,7 +258,7 @@ func roostStartCmd() *cobra.Command { return cmd } -func runRoostForeground(addrFlag string, devFlag bool, labelsFlag, pathsFlag, eggConfigFlag, orgFlag string, auditFlag, debugFlag bool) error { +func runRoostForeground(addrFlag string, devFlag bool, labelsFlag, pathsFlag, eggConfigFlag, orgFlag string, auditFlag, debugFlag bool, localHTTPS *localHTTPSConfig) error { cfg, err := config.Load() if err != nil { return err @@ -157,11 +266,15 @@ func runRoostForeground(addrFlag string, devFlag bool, labelsFlag, pathsFlag, eg // --- Relay setup (local mode forced) --- - store, err := relay.OpenRelay(cfg.RelayDBPath()) + relayDBPath, err := cfg.RelayDBPath() + if err != nil { + return err + } + store, err := relay.OpenRelay(relayDBPath) if err != nil { return fmt.Errorf("open relay db: %w", err) } - defer store.Close() + defer closeWithLog("relay store", store) if err := store.BackfillProUsers(); err != nil { return fmt.Errorf("backfill pro users: %w", err) @@ -182,21 +295,27 @@ func runRoostForeground(addrFlag string, devFlag bool, labelsFlag, pathsFlag, eg log.Printf("using stable P-256 JWT signing key derived from WT_JWT_SECRET") } + roostAllowedEmails, err := roostAllowedEmailsFromEnv() + if err != nil { + return err + } srvCfg := relay.ServerConfig{ - BaseURL: envOr("WT_BASE_URL", "http://localhost:8080"), + BaseURL: defaultBaseURL(localHTTPS), AppHost: os.Getenv("WT_APP_HOST"), WSHost: os.Getenv("WT_WS_HOST"), JWTKey: jwtKey, - GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), + InternalSecret: os.Getenv("WT_INTERNAL_SECRET"), + GitHubClientID: strings.TrimSpace(os.Getenv("GITHUB_CLIENT_ID")), GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), - GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), + GoogleClientID: strings.TrimSpace(os.Getenv("GOOGLE_CLIENT_ID")), GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), - SMTPHost: os.Getenv("SMTP_HOST"), + SMTPHost: strings.TrimSpace(os.Getenv("SMTP_HOST")), SMTPPort: envOr("SMTP_PORT", "587"), SMTPUser: os.Getenv("SMTP_USER"), SMTPPass: os.Getenv("SMTP_PASS"), SMTPFrom: os.Getenv("SMTP_FROM"), HeroVideo: os.Getenv("WT_HERO_VIDEO"), + RoostAllowedEmails: roostAllowedEmails, } srv := relay.NewServer(store, srvCfg) @@ -224,7 +343,7 @@ func runRoostForeground(addrFlag string, devFlag bool, labelsFlag, pathsFlag, eg } // Auth mode detection: same pattern as serve.go - hasAuth := srvCfg.GoogleClientID != "" || srvCfg.GitHubClientID != "" || srvCfg.SMTPHost != "" + hasAuth := authProvidersConfigured() var wingToken string if !hasAuth { @@ -238,11 +357,8 @@ func runRoostForeground(addrFlag string, devFlag bool, labelsFlag, pathsFlag, eg wingToken = token // Grant pro tier — self-hosted has no bandwidth cap - if !store.IsUserPro(user.ID) { - subID := uuid.New().String() - store.CreateSubscription(&relay.Subscription{ID: subID, UserID: &user.ID, Plan: "local", Status: "active", Seats: 1}) - store.CreateEntitlement(&relay.Entitlement{ID: uuid.New().String(), UserID: user.ID, SubscriptionID: subID}) - store.UpdateUserTier(user.ID, "pro") + if err := ensureSelfHostedPro(store, user.ID, "local"); err != nil { + return err } fmt.Println("no auth providers configured — local mode") } else { @@ -255,11 +371,8 @@ func runRoostForeground(addrFlag string, devFlag bool, labelsFlag, pathsFlag, eg wingToken = token // Grant pro to service user - if !store.IsUserPro(user.ID) { - subID := uuid.New().String() - store.CreateSubscription(&relay.Subscription{ID: subID, UserID: &user.ID, Plan: "roost", Status: "active", Seats: 1}) - store.CreateEntitlement(&relay.Entitlement{ID: uuid.New().String(), UserID: user.ID, SubscriptionID: subID}) - store.UpdateUserTier(user.ID, "pro") + if err := ensureSelfHostedPro(store, user.ID, "roost"); err != nil { + return err } fmt.Println("auth providers configured — roost mode (OAuth enabled)") } @@ -270,7 +383,7 @@ func runRoostForeground(addrFlag string, devFlag bool, labelsFlag, pathsFlag, eg if err != nil { return err } - nativeTools := roostNativeMCPTools(cfg, hasAuth) + nativeTools := roostMCPControlTools(srv, cfg, hasAuth) if hasAuth || policy != nil { srv.EnableMCP(egg.NewToolRunner(tools), policy, nativeTools...) roleCount := 0 @@ -280,18 +393,15 @@ func runRoostForeground(addrFlag string, devFlag bool, labelsFlag, pathsFlag, eg log.Printf("mcp: enabled — %d control operation(s), %d executable tool(s), %d role(s) at POST /mcp", len(nativeTools), len(tools), roleCount) } - // Write device token so the wing goroutine can connect - ts := auth.NewTokenStore(cfg.Dir) - ts.Save(&auth.DeviceToken{ + // Keep the appliance service credential process-local. Persisting it in the + // ordinary token store would replace an operator's unrelated hosted login. + embeddedWingToken := &auth.DeviceToken{ Token: wingToken, DeviceID: "local", - }) - - httpSrv := &http.Server{ - Addr: addrFlag, - Handler: srv, } + listeners := newRelayListeners(srv, addrFlag, localHTTPS) + // --- Signal handling: single owner --- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -332,34 +442,172 @@ func runRoostForeground(addrFlag string, devFlag bool, labelsFlag, pathsFlag, eg // --- Start relay --- - relayErrCh := make(chan error, 1) - go func() { + if err := listeners.Start(localHTTPS); err != nil { + return err + } + if localHTTPS != nil { + fmt.Printf("wt roost wing endpoint (loopback HTTP): %s\n", localHTTPURL(addrFlag)) + fmt.Println() + fmt.Printf("open %s to start a terminal\n", localHTTPS.URL) + } else { fmt.Printf("wt roost listening on %s\n", addrFlag) fmt.Println() - fmt.Printf("open http://localhost%s to start a terminal\n", addrFlag) - relayErrCh <- httpSrv.ListenAndServe() - }() + fmt.Printf("open %s to start a terminal\n", localHTTPURL(addrFlag)) + } // --- Start wing (local=true, roost URL = localhost) --- + // A status file from an earlier standalone wing or roost must not satisfy + // this process's readiness check. The new embedded wing will recreate it as + // it moves through connecting to connected. + _ = os.Remove(wingStatusPath()) wingErrCh := make(chan error, 1) go func() { - wingErrCh <- runWingWithContext(ctx, sighupCh, "http://localhost"+addrFlag, labelsFlag, "auto", eggConfigFlag, orgFlag, nil, pathsFlag, debugFlag, auditFlag, true, false, hasAuth) + wingErrCh <- runWingWithContext(ctx, sighupCh, localHTTPURL(addrFlag), labelsFlag, "auto", eggConfigFlag, orgFlag, nil, pathsFlag, debugFlag, auditFlag, true, false, hasAuth, embeddedWingToken) }() + if err := awaitEmbeddedWingReady(ctx, wingErrCh, listeners.errCh, readWingStatus, roostWingReadyTimeout); err != nil { + _ = listeners.Shutdown(srv, 8*time.Second) + return fmt.Errorf("embedded wing did not become ready: %w", err) + } + if err := signalRoostReady(); err != nil { + _ = listeners.Shutdown(srv, 8*time.Second) + return err + } // --- Wait for shutdown --- select { case <-ctx.Done(): log.Println("roost shutting down...") - return srv.GracefulShutdown(httpSrv, 8*time.Second) - case err := <-relayErrCh: - return fmt.Errorf("relay: %w", err) + return listeners.Shutdown(srv, 8*time.Second) + case result := <-listeners.errCh: + err := listenerResult(result) + if err != nil { + _ = listeners.Shutdown(srv, 8*time.Second) + } + return err case err := <-wingErrCh: - return fmt.Errorf("wing: %w", err) + shutdownErr := listeners.Shutdown(srv, 8*time.Second) + return roostWingExitResult(ctx, err, shutdownErr) + } +} + +func roostWingExitResult(ctx context.Context, wingErr, shutdownErr error) error { + if wingErr != nil { + return errors.Join(fmt.Errorf("wing: %w", wingErr), shutdownErr) + } + if ctx.Err() != nil { + return shutdownErr + } + return errors.Join(errors.New("wing exited unexpectedly"), shutdownErr) +} + +func replaceEnvironmentValue(environment []string, key, value string) []string { + prefix := key + "=" + result := make([]string, 0, len(environment)+1) + for _, entry := range environment { + if !strings.HasPrefix(entry, prefix) { + result = append(result, entry) + } + } + return append(result, prefix+value) +} + +func awaitRoostReady(reader io.ReadCloser, timeout time.Duration) (resultErr error) { + result := make(chan error, 1) + go func() { + payload, err := io.ReadAll(io.LimitReader(reader, maxRoostReadyMessageSize)) + if err != nil { + result <- err + return + } + if string(payload) != roostReadyToken { + result <- fmt.Errorf("readiness pipe closed with %q", payload) + return + } + result <- nil + }() + defer func() { + if err := reader.Close(); err != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("close readiness pipe: %w", err)) + } + }() + select { + case err := <-result: + return err + case <-time.After(timeout): + return fmt.Errorf("timed out after %s", timeout) + } +} + +func signalRoostReady() (resultErr error) { + rawFD := strings.TrimSpace(os.Getenv(roostReadyFDEnv)) + if rawFD == "" { + return nil + } + fd, err := strconv.Atoi(rawFD) + if err != nil || fd < 3 { + return fmt.Errorf("invalid %s value %q", roostReadyFDEnv, rawFD) + } + readyWriter := os.NewFile(uintptr(fd), "roost-ready") + if readyWriter == nil { + return fmt.Errorf("open roost readiness descriptor %d", fd) + } + defer func() { + if err := readyWriter.Close(); err != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("close roost readiness descriptor: %w", err)) + } + }() + if _, err := io.WriteString(readyWriter, roostReadyToken); err != nil { + return fmt.Errorf("signal roost readiness: %w", err) + } + return nil +} + +func awaitEmbeddedWingReady(ctx context.Context, wingErrors <-chan error, relayErrors <-chan namedServerError, readStatus func() (*wingStatus, error), timeout time.Duration) error { + timer := time.NewTimer(timeout) + defer timer.Stop() + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-wingErrors: + if err == nil { + return errors.New("wing exited before connecting") + } + return err + case result := <-relayErrors: + if err := listenerResult(result); err != nil { + return err + } + return errors.New("relay listener closed before the wing connected") + case <-timer.C: + return fmt.Errorf("timed out after %s", timeout) + case <-ticker.C: + status, err := readStatus() + if err != nil { + continue + } + switch status.State { + case "connected": + return nil + case "auth_failed": + if status.Error != "" { + return fmt.Errorf("authentication failed: %s", status.Error) + } + return errors.New("authentication failed") + } + } } } +func roostMCPControlTools(srv *relay.Server, cfg *config.Config, sharedHost bool) []mcppkg.NativeTool { + tools := roostNativeMCPTools(cfg, sharedHost) + return append(tools, srv.PortalNativeMCPTools(cfg.WingID)...) +} + func loadRoostMCPConfig(configDir string) ([]*config.ToolConfig, *config.MCPConfig, error) { wingCfg, err := config.LoadWingConfig(configDir) if err != nil { @@ -392,16 +640,21 @@ func roostStopCmd() *cobra.Command { Use: "stop", Short: "Stop the roost daemon", RunE: func(cmd *cobra.Command, args []string) error { - pid, err := readPidFrom(roostPidPath()) + lifecycleLock, lockErr := acquireDaemonLifecycleLock() + if lockErr != nil { + return lockErr + } + defer closeWithLog("daemon lifecycle lock", lifecycleLock) + pid, err := readPidFrom(roostPidPath(), roostDaemon) if err != nil { return fmt.Errorf("no roost daemon running") } - proc, _ := os.FindProcess(pid) - if err := proc.Signal(syscall.SIGTERM); err != nil { - return fmt.Errorf("kill pid %d: %w", pid, err) + if err := stopDaemonAndWait(pid, roostDaemon, 5*time.Second); err != nil { + return err + } + if err := removeFiles(roostPidPath(), roostArgsPath()); err != nil { + return fmt.Errorf("remove roost daemon metadata: %w", err) } - os.Remove(roostPidPath()) - os.Remove(roostArgsPath()) fmt.Printf("roost daemon stopped (pid %d)\n", pid) return nil }, @@ -413,10 +666,13 @@ func roostStatusCmd() *cobra.Command { Use: "status", Short: "Check roost daemon status", RunE: func(cmd *cobra.Command, args []string) error { - pid, err := readPidFrom(roostPidPath()) + pid, err := readPidFrom(roostPidPath(), roostDaemon) if err != nil { - fmt.Println("roost daemon is not running") - return nil + if daemonAbsentError(err) { + fmt.Println("roost daemon is not running") + return nil + } + return fmt.Errorf("inspect roost daemon state: %w", err) } fmt.Printf("roost daemon is running (pid %d)\n", pid) fmt.Printf(" log: %s\n", roostLogPath()) diff --git a/cmd/wt/roost_lifecycle_test.go b/cmd/wt/roost_lifecycle_test.go new file mode 100644 index 00000000..7de6e526 --- /dev/null +++ b/cmd/wt/roost_lifecycle_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "context" + "errors" + "io" + "os" + "runtime" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +func TestAwaitRoostReadyRequiresExactToken(t *testing.T) { + for _, test := range []struct { + name string + payload string + wantErr bool + }{ + {name: "ready", payload: roostReadyToken}, + {name: "empty", wantErr: true}, + {name: "partial", payload: "read", wantErr: true}, + {name: "extra", payload: roostReadyToken + "surprise", wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + reader, writer := io.Pipe() + go func() { + _, _ = io.WriteString(writer, test.payload) + _ = writer.Close() + }() + err := awaitRoostReady(reader, time.Second) + if (err != nil) != test.wantErr { + t.Fatalf("awaitRoostReady error = %v, wantErr=%v", err, test.wantErr) + } + }) + } +} + +func TestAwaitRoostReadyTimesOutAndClosesReader(t *testing.T) { + reader, writer := io.Pipe() + defer closeForTest(t, "readiness writer", writer) + if err := awaitRoostReady(reader, 10*time.Millisecond); err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("timeout error = %v", err) + } + if _, err := writer.Write([]byte("late")); err == nil { + t.Fatal("readiness reader remained open after timeout") + } +} + +func TestSignalRoostReadyWritesOnlyToInheritedDescriptor(t *testing.T) { + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer closeForTest(t, "readiness reader", reader) + // signalRoostReady takes ownership of the inherited descriptor. Duplicate + // the pipe end to model exec.ExtraFiles, then close the original os.File so + // its finalizer cannot later close an unrelated descriptor that reused the + // same integer (for example SQLite's WAL file in a subsequent test). + inheritedFD, err := syscall.Dup(int(writer.Fd())) + if err != nil { + closeForTest(t, "readiness writer", writer) + t.Fatal(err) + } + owned := true + defer func() { + if owned { + _ = syscall.Close(inheritedFD) + } + }() + closeForTest(t, "readiness writer", writer) + t.Setenv(roostReadyFDEnv, strconv.Itoa(inheritedFD)) + if err := signalRoostReady(); err != nil { + t.Fatal(err) + } + owned = false + payload, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + if string(payload) != roostReadyToken { + t.Fatalf("readiness payload = %q", payload) + } + + // Exercise finalizers before later tests open security or database files. + // There must be no stale os.File left that can close a reused descriptor. + runtime.GC() + sentinel, err := os.CreateTemp(t.TempDir(), "fd-reuse-sentinel-") + if err != nil { + t.Fatal(err) + } + defer closeForTest(t, "descriptor reuse sentinel", sentinel) + if _, err := sentinel.WriteString("still open"); err != nil { + t.Fatalf("write after readiness descriptor handoff: %v", err) + } +} + +func TestReplaceEnvironmentValueRemovesInheritedSpoof(t *testing.T) { + got := replaceEnvironmentValue([]string{"A=1", roostReadyFDEnv + "=99", "B=2", roostReadyFDEnv + "=100"}, roostReadyFDEnv, "3") + want := []string{"A=1", "B=2", roostReadyFDEnv + "=3"} + if strings.Join(got, "\x00") != strings.Join(want, "\x00") { + t.Fatalf("environment = %#v, want %#v", got, want) + } +} + +func TestSignalRoostReadyRejectsInvalidDescriptor(t *testing.T) { + t.Setenv(roostReadyFDEnv, "stdout") + if err := signalRoostReady(); err == nil { + t.Fatal("invalid readiness descriptor accepted") + } +} + +func TestAwaitEmbeddedWingReadyRequiresConnectedStatus(t *testing.T) { + reads := 0 + err := awaitEmbeddedWingReady(context.Background(), make(chan error), make(chan namedServerError), func() (*wingStatus, error) { + reads++ + if reads == 1 { + return &wingStatus{State: "connecting"}, nil + } + return &wingStatus{State: "connected"}, nil + }, time.Second) + if err != nil { + t.Fatal(err) + } + if reads < 2 { + t.Fatalf("status reads = %d, want connecting followed by connected", reads) + } +} + +func TestAwaitEmbeddedWingReadyReportsEarlyFailures(t *testing.T) { + t.Run("wing", func(t *testing.T) { + wingErrors := make(chan error, 1) + wingErrors <- errors.New("authentication transport failed") + err := awaitEmbeddedWingReady(context.Background(), wingErrors, make(chan namedServerError), func() (*wingStatus, error) { + return nil, os.ErrNotExist + }, time.Second) + if err == nil || !strings.Contains(err.Error(), "authentication transport failed") { + t.Fatalf("error = %v", err) + } + }) + t.Run("relay", func(t *testing.T) { + relayErrors := make(chan namedServerError, 1) + relayErrors <- namedServerError{listener: "browser HTTPS", err: os.ErrPermission} + err := awaitEmbeddedWingReady(context.Background(), make(chan error), relayErrors, func() (*wingStatus, error) { + return nil, os.ErrNotExist + }, time.Second) + if err == nil || !strings.Contains(err.Error(), "browser HTTPS") { + t.Fatalf("error = %v", err) + } + }) + t.Run("auth status", func(t *testing.T) { + err := awaitEmbeddedWingReady(context.Background(), make(chan error), make(chan namedServerError), func() (*wingStatus, error) { + return &wingStatus{State: "auth_failed", Error: "token rejected"}, nil + }, time.Second) + if err == nil || !strings.Contains(err.Error(), "token rejected") { + t.Fatalf("error = %v", err) + } + }) +} + +func TestAwaitEmbeddedWingReadyTimesOut(t *testing.T) { + err := awaitEmbeddedWingReady(context.Background(), make(chan error), make(chan namedServerError), func() (*wingStatus, error) { + return &wingStatus{State: "connecting"}, nil + }, 10*time.Millisecond) + if err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("error = %v", err) + } +} + +func TestRoostWingExitResultHandlesCleanCancellationAndUnexpectedExit(t *testing.T) { + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if err := roostWingExitResult(canceled, nil, nil); err != nil { + t.Fatalf("clean canceled exit = %v", err) + } + if err := roostWingExitResult(context.Background(), nil, nil); err == nil || !strings.Contains(err.Error(), "wing exited unexpectedly") { + t.Fatalf("unexpected nil exit = %v", err) + } + wingErr := errors.New("wing transport failed") + shutdownErr := errors.New("relay shutdown failed") + err := roostWingExitResult(context.Background(), wingErr, shutdownErr) + if !errors.Is(err, wingErr) || !errors.Is(err, shutdownErr) { + t.Fatalf("joined exit error = %v", err) + } +} diff --git a/cmd/wt/roost_mcp_test.go b/cmd/wt/roost_mcp_test.go index 7e8d1fdb..57bea3b9 100644 --- a/cmd/wt/roost_mcp_test.go +++ b/cmd/wt/roost_mcp_test.go @@ -3,9 +3,29 @@ package main import ( "os" "path/filepath" + "reflect" "testing" ) +func TestRoostAllowedEmailsFromEnv(t *testing.T) { + t.Setenv("WT_ROOST_ALLOWED_EMAILS", " Alice@Example.com, bob@example.com,alice@example.com ") + got, err := roostAllowedEmailsFromEnv() + if err != nil { + t.Fatal(err) + } + want := []string{"alice@example.com", "bob@example.com"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("allowed emails = %#v, want %#v", got, want) + } + + for _, invalid := range []string{"not-an-email", "missing@", "@missing", "two@@example.com", "white space@example.com", "alice@example.com,"} { + t.Setenv("WT_ROOST_ALLOWED_EMAILS", invalid) + if _, err := roostAllowedEmailsFromEnv(); err == nil { + t.Fatalf("invalid enrollment email %q accepted", invalid) + } + } +} + func TestLoadRoostMCPConfigUsesWingYAMLAndConfiguredToolsDir(t *testing.T) { dir := t.TempDir() toolsDir := filepath.Join(dir, "custom-tools") diff --git a/cmd/wt/runtime_id.go b/cmd/wt/runtime_id.go new file mode 100644 index 00000000..73d664c0 --- /dev/null +++ b/cmd/wt/runtime_id.go @@ -0,0 +1,15 @@ +package main + +import ( + "encoding/hex" + + "github.com/google/uuid" +) + +// newRuntimeID returns a compact 64-bit identifier for new local routing and +// filesystem records. Older eight-character IDs remain valid and attachable; +// only newly-created records use the larger collision space. +func newRuntimeID() string { + id := uuid.New() + return hex.EncodeToString(id[:8]) +} diff --git a/cmd/wt/self_hosted_entitlement.go b/cmd/wt/self_hosted_entitlement.go new file mode 100644 index 00000000..1b33f19c --- /dev/null +++ b/cmd/wt/self_hosted_entitlement.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" + + "github.com/ehrlich-b/wingthing/internal/relay" + "github.com/google/uuid" +) + +func ensureSelfHostedPro(store *relay.RelayStore, userID, plan string) error { + subID := uuid.New().String() + sub := &relay.Subscription{ID: subID, UserID: &userID, Plan: plan, Status: "active", Seats: 1} + ent := &relay.Entitlement{ID: uuid.New().String(), UserID: userID, SubscriptionID: subID} + if _, _, err := store.EnsurePersonalSubscription(sub, ent); err != nil { + return fmt.Errorf("activate self-hosted subscription: %w", err) + } + return nil +} diff --git a/cmd/wt/self_hosted_entitlement_test.go b/cmd/wt/self_hosted_entitlement_test.go new file mode 100644 index 00000000..188c2e13 --- /dev/null +++ b/cmd/wt/self_hosted_entitlement_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "path/filepath" + "testing" + + "github.com/ehrlich-b/wingthing/internal/relay" +) + +func TestEnsureSelfHostedProIsAtomicAndIdempotent(t *testing.T) { + store, err := relay.OpenRelay(filepath.Join(t.TempDir(), "relay.db")) + if err != nil { + t.Fatal(err) + } + defer closeForTest(t, "relay store", store) + if err := store.CreateUser("local-user"); err != nil { + t.Fatal(err) + } + + if err := ensureSelfHostedPro(store, "local-user", "local"); err != nil { + t.Fatal(err) + } + if err := ensureSelfHostedPro(store, "local-user", "local"); err != nil { + t.Fatal(err) + } + if !store.IsUserPro("local-user") { + t.Fatal("self-hosted user was not granted an active entitlement") + } + var subscriptions, entitlements int + if err := store.DB().QueryRow("SELECT COUNT(*) FROM subscriptions WHERE user_id = ?", "local-user").Scan(&subscriptions); err != nil { + t.Fatal(err) + } + if err := store.DB().QueryRow("SELECT COUNT(*) FROM entitlements WHERE user_id = ?", "local-user").Scan(&entitlements); err != nil { + t.Fatal(err) + } + if subscriptions != 1 || entitlements != 1 { + t.Fatalf("idempotent grant created subscriptions=%d entitlements=%d, want 1/1", subscriptions, entitlements) + } +} + +func TestEnsureSelfHostedProRepairsExistingSubscriptionWithoutEntitlement(t *testing.T) { + store, err := relay.OpenRelay(filepath.Join(t.TempDir(), "relay.db")) + if err != nil { + t.Fatal(err) + } + defer closeForTest(t, "relay store", store) + if err := store.CreateUser("existing-user"); err != nil { + t.Fatal(err) + } + userID := "existing-user" + if err := store.CreateSubscription(&relay.Subscription{ + ID: "existing-sub", UserID: &userID, Plan: "local", Status: "active", Seats: 1, + }); err != nil { + t.Fatal(err) + } + + if err := ensureSelfHostedPro(store, userID, "local"); err != nil { + t.Fatal(err) + } + if !store.IsUserPro(userID) { + t.Fatal("existing self-hosted subscription was not repaired") + } + var subscriptions int + if err := store.DB().QueryRow("SELECT COUNT(*) FROM subscriptions WHERE user_id = ?", userID).Scan(&subscriptions); err != nil { + t.Fatal(err) + } + if subscriptions != 1 { + t.Fatalf("repair created %d subscriptions, want 1", subscriptions) + } +} diff --git a/cmd/wt/serve.go b/cmd/wt/serve.go index e8bbed6f..18fdc79a 100644 --- a/cmd/wt/serve.go +++ b/cmd/wt/serve.go @@ -3,16 +3,15 @@ package main import ( "context" "fmt" - "net/http" "os" "os/signal" + "strings" "syscall" "time" "github.com/ehrlich-b/wingthing/internal/auth" "github.com/ehrlich-b/wingthing/internal/config" "github.com/ehrlich-b/wingthing/internal/relay" - "github.com/google/uuid" "github.com/spf13/cobra" ) @@ -23,16 +22,57 @@ func envOr(key, fallback string) string { return fallback } +func relayPolicyFromEnv() (string, time.Time, error) { + policy := strings.TrimSpace(envOr("WT_RELAY_POLICY", relay.RelayPolicyLegacy)) + if policy != relay.RelayPolicyLegacy && policy != relay.RelayPolicyDirectFree { + return "", time.Time{}, fmt.Errorf("WT_RELAY_POLICY must be %q or %q", relay.RelayPolicyLegacy, relay.RelayPolicyDirectFree) + } + + // The migration boundary is deployment state, not a compile-time product + // default. Prefer the accurately named variable while retaining the old one + // as a compatibility alias for existing operators. + raw := strings.TrimSpace(os.Getenv("WT_RELAY_MIGRATION_BEFORE")) + legacyRaw := strings.TrimSpace(os.Getenv("WT_RELAY_GRANDFATHER_BEFORE")) + if raw != "" && legacyRaw != "" && raw != legacyRaw { + return "", time.Time{}, fmt.Errorf("WT_RELAY_MIGRATION_BEFORE and deprecated WT_RELAY_GRANDFATHER_BEFORE disagree") + } + if raw == "" { + raw = legacyRaw + } + if raw == "" { + return policy, time.Time{}, nil + } + cutoff, err := time.Parse(time.RFC3339, raw) + if err != nil { + return "", time.Time{}, fmt.Errorf("WT_RELAY_MIGRATION_BEFORE must be RFC3339: %w", err) + } + return policy, cutoff, nil +} + +func saveLocalServeToken(configDir, token string) error { + return auth.NewLocalTokenStore(configDir).Save(&auth.DeviceToken{ + Token: token, + DeviceID: "local", + }) +} + func serveCmd() *cobra.Command { var addrFlag string var devFlag bool var localFlag bool + var httpsFlag bool + var httpsAddrFlag string cmd := &cobra.Command{ Use: "relay", Aliases: []string{"serve"}, Short: "Start the relay server (web UI + WebSocket relay)", RunE: func(cmd *cobra.Command, args []string) error { + if !localFlag { + if err := validateAuthProviderEnvironment(); err != nil { + return err + } + } cfg, err := config.Load() if err != nil { return err @@ -65,11 +105,15 @@ func serveCmd() *cobra.Command { // Edge nodes skip SQLite and DB-dependent init var store *relay.RelayStore if !isEdge { - store, err = relay.OpenRelay(cfg.RelayDBPath()) + relayDBPath, pathErr := cfg.RelayDBPath() + if pathErr != nil { + return pathErr + } + store, err = relay.OpenRelay(relayDBPath) if err != nil { return fmt.Errorf("open relay db: %w", err) } - defer store.Close() + defer closeWithLog("relay store", store) if err := store.BackfillProUsers(); err != nil { return fmt.Errorf("backfill pro users: %w", err) @@ -78,38 +122,67 @@ func serveCmd() *cobra.Command { // Auto-enable local mode when no auth providers are configured. // Must happen before JWT key check — local mode uses wing.yaml, not env. - githubID := os.Getenv("GITHUB_CLIENT_ID") - googleID := os.Getenv("GOOGLE_CLIENT_ID") - smtpHost := os.Getenv("SMTP_HOST") - if !localFlag && !isEdge && githubID == "" && googleID == "" && smtpHost == "" { + githubID := strings.TrimSpace(os.Getenv("GITHUB_CLIENT_ID")) + googleID := strings.TrimSpace(os.Getenv("GOOGLE_CLIENT_ID")) + smtpHost := strings.TrimSpace(os.Getenv("SMTP_HOST")) + if !localFlag && !isEdge && !authProvidersConfigured() { localFlag = true fmt.Println("no auth providers configured — enabling local mode") } + if localFlag && !httpsFlag { + addrFlag, err = prepareLocalHTTPAddress(addrFlag, cmd.Flags().Changed("addr")) + if err != nil { + return err + } + } + if err := validateLocalHTTPSMode(httpsFlag, localFlag, isEdge); err != nil { + return err + } + var localHTTPS *localHTTPSConfig + if httpsFlag { + localHTTPS, err = prepareLocalHTTPS(cmd.Context(), cfg.Dir, addrFlag, httpsAddrFlag, cmd.Flags().Changed("addr")) + if err != nil { + return err + } + addrFlag = localHTTPS.HTTPAddr + } jwtKey, err := jwtKeyFromEnvironment() if err != nil { return fmt.Errorf("jwt key: %w", err) } + relayPolicy, relayMigrationBefore, err := relayPolicyFromEnv() + if err != nil { + return err + } + allowedEmails, err := roostAllowedEmailsFromEnv() + if err != nil { + return err + } srvCfg := relay.ServerConfig{ - BaseURL: envOr("WT_BASE_URL", "http://localhost:8080"), - AppHost: os.Getenv("WT_APP_HOST"), - WSHost: os.Getenv("WT_WS_HOST"), - JWTKey: jwtKey, - GitHubClientID: githubID, - GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), - GoogleClientID: googleID, - GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), - SMTPHost: smtpHost, - SMTPPort: envOr("SMTP_PORT", "587"), - SMTPUser: os.Getenv("SMTP_USER"), - SMTPPass: os.Getenv("SMTP_PASS"), - SMTPFrom: os.Getenv("SMTP_FROM"), - NodeRole: nodeRole, - LoginNodeAddr: loginAddr, - FlyMachineID: flyMachineID, - FlyRegion: flyRegion, - FlyAppName: flyApp, - HeroVideo: os.Getenv("WT_HERO_VIDEO"), + BaseURL: defaultBaseURL(localHTTPS), + AppHost: os.Getenv("WT_APP_HOST"), + WSHost: os.Getenv("WT_WS_HOST"), + JWTKey: jwtKey, + InternalSecret: os.Getenv("WT_INTERNAL_SECRET"), + GitHubClientID: githubID, + GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), + GoogleClientID: googleID, + GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), + SMTPHost: smtpHost, + SMTPPort: envOr("SMTP_PORT", "587"), + SMTPUser: os.Getenv("SMTP_USER"), + SMTPPass: os.Getenv("SMTP_PASS"), + SMTPFrom: os.Getenv("SMTP_FROM"), + NodeRole: nodeRole, + LoginNodeAddr: loginAddr, + FlyMachineID: flyMachineID, + FlyRegion: flyRegion, + FlyAppName: flyApp, + HeroVideo: os.Getenv("WT_HERO_VIDEO"), + RelayPolicy: relayPolicy, + RelayMigrationBefore: relayMigrationBefore, + RoostAllowedEmails: allowedEmails, } // JWT key: server mode requires WT_JWT_KEY env var. @@ -140,10 +213,10 @@ func serveCmd() *cobra.Command { return fmt.Errorf("WT_LOGIN_ADDR required for edge nodes") } srv.SetLoginProxy(relay.NewLoginProxy(loginAddr)) - srv.SetSessionCache(relay.NewSessionCache()) + srv.SetSessionCache(relay.NewSessionCache(srvCfg.InternalSecret)) // Bandwidth metering still works on edge, just with cached tiers srv.Bandwidth = relay.NewBandwidthMeter(relay.SustainedRate, 1*1024*1024, nil) - entCache := relay.NewEntitlementCache(loginAddr) + entCache := relay.NewEntitlementCache(loginAddr, srvCfg.InternalSecret) srv.Bandwidth.SetTierLookup(func(userID string) string { return entCache.GetTier(userID) }) @@ -185,26 +258,20 @@ func serveCmd() *cobra.Command { srv.SetLocalUser(user) // Grant pro tier — self-hosted has no bandwidth cap - if !store.IsUserPro(user.ID) { - subID := uuid.New().String() - store.CreateSubscription(&relay.Subscription{ID: subID, UserID: &user.ID, Plan: "local", Status: "active", Seats: 1}) - store.CreateEntitlement(&relay.Entitlement{ID: uuid.New().String(), UserID: user.ID, SubscriptionID: subID}) - store.UpdateUserTier(user.ID, "pro") + if err := ensureSelfHostedPro(store, user.ID, "local"); err != nil { + return err } - // Write device token so `wt wing` can connect without `wt login` - ts := auth.NewTokenStore(cfg.Dir) - ts.Save(&auth.DeviceToken{ - Token: token, - DeviceID: "local", - }) + // Keep the localhost credential separate from the ordinary portal + // login so starting a self-hosted UI cannot log this profile out of + // wingthing.ai or an operator's private roost. + if err := saveLocalServeToken(cfg.Dir, token); err != nil { + return fmt.Errorf("save local device token: %w", err) + } fmt.Println("local mode: single-user, no login required") } - httpSrv := &http.Server{ - Addr: addrFlag, - Handler: srv, - } + listeners := newRelayListeners(srv, addrFlag, localHTTPS) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -225,22 +292,34 @@ func serveCmd() *cobra.Command { srv.EntitlementCache.StartSync(ctx, 60*time.Second) } - errCh := make(chan error, 1) - go func() { + if err := listeners.Start(localHTTPS); err != nil { + return err + } + if localHTTPS != nil { + fmt.Printf("wt serve wing endpoint (loopback HTTP): %s\n", localHTTPURL(addrFlag)) + fmt.Printf("wt serve browser UI (local HTTPS): %s\n", localHTTPS.URL) + } else { fmt.Printf("wt serve listening on %s\n", addrFlag) - if localFlag { - fmt.Println() - fmt.Println("next: wt start --local") + } + if localFlag { + fmt.Println() + fmt.Println("next: wt start --local") + if localHTTPS != nil { + fmt.Printf("then: open %s\n", localHTTPS.URL) + } else { fmt.Println("then: open http://localhost:8080") } - errCh <- httpSrv.ListenAndServe() - }() + } select { case <-ctx.Done(): fmt.Println("graceful shutdown (sending relay.restart to all connections)...") - return srv.GracefulShutdown(httpSrv, 8*time.Second) - case err := <-errCh: + return listeners.Shutdown(srv, 8*time.Second) + case result := <-listeners.errCh: + err := listenerResult(result) + if err != nil { + _ = listeners.Shutdown(srv, 8*time.Second) + } return err } }, @@ -249,6 +328,8 @@ func serveCmd() *cobra.Command { cmd.Flags().StringVar(&addrFlag, "addr", ":8080", "listen address") cmd.Flags().BoolVar(&devFlag, "dev", false, "reload templates from disk on each request") cmd.Flags().BoolVar(&localFlag, "local", false, "single-user mode, no login required") + cmd.Flags().BoolVar(&httpsFlag, "https", false, "serve the local browser UI over HTTPS using an on-demand, device-local CA") + cmd.Flags().StringVar(&httpsAddrFlag, "https-addr", defaultLocalHTTPSAddr, "loopback HTTPS address for the local browser UI") return cmd } diff --git a/cmd/wt/serve_test.go b/cmd/wt/serve_test.go new file mode 100644 index 00000000..3addfab7 --- /dev/null +++ b/cmd/wt/serve_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "testing" + "time" + + "github.com/ehrlich-b/wingthing/internal/auth" + "github.com/ehrlich-b/wingthing/internal/relay" +) + +func TestSaveLocalServeTokenPreservesOrdinaryPortalLogin(t *testing.T) { + dir := t.TempDir() + ordinary := auth.NewTokenStore(dir) + if err := ordinary.Save(&auth.DeviceToken{Token: "hosted-login", DeviceID: "hosted"}); err != nil { + t.Fatal(err) + } + if err := saveLocalServeToken(dir, "localhost-login"); err != nil { + t.Fatal(err) + } + hosted, err := ordinary.Load() + if err != nil { + t.Fatal(err) + } + local, err := auth.NewLocalTokenStore(dir).Load() + if err != nil { + t.Fatal(err) + } + if hosted.Token != "hosted-login" || local.Token != "localhost-login" || local.DeviceID != "local" { + t.Fatalf("saved credentials: hosted=%#v local=%#v", hosted, local) + } +} + +func TestRelayPolicyFromEnvDefaultsPrivateGatewaysToLegacy(t *testing.T) { + t.Setenv("WT_RELAY_POLICY", "") + t.Setenv("WT_RELAY_MIGRATION_BEFORE", "") + t.Setenv("WT_RELAY_GRANDFATHER_BEFORE", "") + + policy, cutoff, err := relayPolicyFromEnv() + if err != nil { + t.Fatal(err) + } + if policy != relay.RelayPolicyLegacy || !cutoff.IsZero() { + t.Fatalf("default policy = %q cutoff=%s", policy, cutoff) + } +} + +func TestRelayPolicyFromEnvRequiresExplicitHostedMigrationState(t *testing.T) { + t.Setenv("WT_RELAY_POLICY", relay.RelayPolicyDirectFree) + t.Setenv("WT_RELAY_MIGRATION_BEFORE", "2026-08-26T00:00:00Z") + t.Setenv("WT_RELAY_GRANDFATHER_BEFORE", "") + + policy, cutoff, err := relayPolicyFromEnv() + if err != nil { + t.Fatal(err) + } + want := time.Date(2026, 8, 26, 0, 0, 0, 0, time.UTC) + if policy != relay.RelayPolicyDirectFree || !cutoff.Equal(want) { + t.Fatalf("hosted policy = %q cutoff=%s", policy, cutoff) + } +} + +func TestRelayPolicyFromEnvAcceptsCompatibleLegacyCutoffName(t *testing.T) { + t.Setenv("WT_RELAY_POLICY", relay.RelayPolicyDirectFree) + t.Setenv("WT_RELAY_MIGRATION_BEFORE", "") + t.Setenv("WT_RELAY_GRANDFATHER_BEFORE", "2026-08-26T00:00:00Z") + _, cutoff, err := relayPolicyFromEnv() + if err != nil || cutoff.IsZero() { + t.Fatalf("legacy cutoff: cutoff=%s err=%v", cutoff, err) + } +} + +func TestRelayPolicyFromEnvRejectsAmbiguousOrInvalidConfiguration(t *testing.T) { + t.Run("unknown policy", func(t *testing.T) { + t.Setenv("WT_RELAY_POLICY", "surprise") + t.Setenv("WT_RELAY_MIGRATION_BEFORE", "") + t.Setenv("WT_RELAY_GRANDFATHER_BEFORE", "") + if _, _, err := relayPolicyFromEnv(); err == nil { + t.Fatal("unknown policy accepted") + } + }) + t.Run("bad timestamp", func(t *testing.T) { + t.Setenv("WT_RELAY_POLICY", relay.RelayPolicyDirectFree) + t.Setenv("WT_RELAY_MIGRATION_BEFORE", "yesterday") + t.Setenv("WT_RELAY_GRANDFATHER_BEFORE", "") + if _, _, err := relayPolicyFromEnv(); err == nil { + t.Fatal("invalid timestamp accepted") + } + }) + t.Run("conflicting aliases", func(t *testing.T) { + t.Setenv("WT_RELAY_POLICY", relay.RelayPolicyDirectFree) + t.Setenv("WT_RELAY_MIGRATION_BEFORE", "2026-08-26T00:00:00Z") + t.Setenv("WT_RELAY_GRANDFATHER_BEFORE", "2026-08-25T00:00:00Z") + if _, _, err := relayPolicyFromEnv(); err == nil { + t.Fatal("conflicting migration timestamps accepted") + } + }) +} diff --git a/cmd/wt/session.go b/cmd/wt/session.go index 5a82b58a..d61a5d88 100644 --- a/cmd/wt/session.go +++ b/cmd/wt/session.go @@ -107,22 +107,22 @@ func sessionSendCmd() *cobra.Command { if len(input) == 0 && !enterFlag { return fmt.Errorf("provide text, --stdin, or --enter") } - if enterFlag { - input = append(input, '\r') - } - cfg, err := config.Load() if err != nil { return err } - session, err := sendSessionBytes(cmd.Context(), cfg, args[0], input) + session, err := sendSessionInput(cmd.Context(), cfg, args[0], input, enterFlag) if err != nil { return err } + bytesSent := len(input) + if enterFlag { + bytesSent++ + } if jsonFlag { - return writeSessionJSON(map[string]any{"session": session.ID, "bytes_sent": len(input)}) + return writeSessionJSON(map[string]any{"session": session.ID, "bytes_sent": bytesSent}) } - fmt.Printf("sent %d bytes to %s\n", len(input), session.ID) + fmt.Printf("sent %d bytes to %s\n", bytesSent, session.ID) return nil }, } @@ -177,7 +177,7 @@ func sessionWaitCmd() *cobra.Command { if err != nil { return err } - defer ec.Close() + defer closeWithLog("egg client", ec) ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { @@ -264,7 +264,7 @@ func sessionKillCmd() *cobra.Command { if err != nil { return err } - defer ec.Close() + defer closeWithLog("egg client", ec) if err := ec.Kill(cmd.Context(), session.ID); err != nil { return fmt.Errorf("kill session %s: %w", session.ID, err) } @@ -405,7 +405,9 @@ func sessionSyncCmd() *cobra.Command { } cmd.Flags().StringVar(&fromFlag, "from", "", "source wing ID") - cmd.MarkFlagRequired("from") + if err := cmd.MarkFlagRequired("from"); err != nil { + panic(err) + } return cmd } diff --git a/cmd/wt/shared_host_linux_integration_test.go b/cmd/wt/shared_host_linux_integration_test.go index 900e0707..4b75721b 100644 --- a/cmd/wt/shared_host_linux_integration_test.go +++ b/cmd/wt/shared_host_linux_integration_test.go @@ -30,6 +30,7 @@ func TestMain(m *testing.M) { } func TestSharedHostAgentRunUsesSealedJail(t *testing.T) { + const providerKey = "shared-provider-key-canary" root := t.TempDir() workspace := filepath.Join(root, "workspace") stateDir := filepath.Join(root, "wingthing-state") @@ -66,6 +67,7 @@ func TestSharedHostAgentRunUsesSealedJail(t *testing.T) { } t.Setenv("PATH", fixtureBinDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("WT_SHARED_HOST_SECRET", "must-not-cross-the-boundary") + t.Setenv("ANTHROPIC_API_KEY", providerKey) cfg := &config.Config{Dir: stateDir, DefaultAgent: "claude", WingID: "fixture-wing"} taskStore, err := store.Open(cfg.DBPath()) @@ -107,6 +109,10 @@ func TestSharedHostAgentRunUsesSealedJail(t *testing.T) { if string(marker) != "workspace-visible" { t.Fatalf("workspace marker = %q", marker) } + helper, err := os.ReadFile(filepath.Join(userHome, ".anthropic_key")) + if err != nil || string(helper) != providerKey { + t.Fatalf("shared provider helper = %q, err=%v", helper, err) + } stored, err := taskStore.GetTask(task.ID) if err != nil { t.Fatal(err) @@ -157,6 +163,7 @@ func TestPrepareSharedAgentHomeCreatesOwnerOnlyParentTree(t *testing.T) { } func runSharedHostFixtureAgent(args []string) int { + const providerKey = "shared-provider-key-canary" prompt := argumentValue(args, "-p") workspace := promptFixtureValue(prompt, "workspace") secretPath := promptFixtureValue(prompt, "secret") @@ -174,6 +181,21 @@ func runSharedHostFixtureAgent(args []string) int { if os.Getenv("WT_SHARED_HOST_SECRET") != "" { result = "environment-leaked" } + if os.Getenv("ANTHROPIC_API_KEY") != "" { + result = "provider-environment-leaked" + } + settingsData, settingsErr := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".claude", "settings.json")) + var settings map[string]any + if settingsErr != nil || json.Unmarshal(settingsData, &settings) != nil { + result = "provider-helper-settings-missing" + } else { + helper, _ := settings["apiKeyHelper"].(string) + wantHelper := "cat " + filepath.Join(os.Getenv("HOME"), ".anthropic_key") + key, keyErr := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".anthropic_key")) + if helper != wantHelper || keyErr != nil || string(key) != providerKey { + result = "provider-helper-unusable" + } + } if err := os.WriteFile(filepath.Join(workspace, "agent-wrote-here"), []byte("workspace-visible"), 0o600); err != nil { result = "workspace-read-only" } diff --git a/cmd/wt/terminal.go b/cmd/wt/terminal.go index 60a8bdb4..7eeb70cc 100644 --- a/cmd/wt/terminal.go +++ b/cmd/wt/terminal.go @@ -8,7 +8,6 @@ import ( "github.com/ehrlich-b/wingthing/internal/config" "github.com/ehrlich-b/wingthing/internal/egg" - "github.com/google/uuid" "github.com/spf13/cobra" "golang.org/x/term" ) @@ -97,7 +96,7 @@ func terminalSpawn(cmd *cobra.Command, command []string, name, configPath, cwd s } } - sessionID := uuid.New().String()[:8] + sessionID := newRuntimeID() ec, err := spawnEgg( cfg, sessionID, diff --git a/cmd/wt/test_helpers_test.go b/cmd/wt/test_helpers_test.go new file mode 100644 index 00000000..b25e5b02 --- /dev/null +++ b/cmd/wt/test_helpers_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "io" + "strings" + "testing" +) + +func closeForTest(t *testing.T, name string, closer io.Closer) { + t.Helper() + if err := closer.Close(); err != nil { + t.Errorf("close %s: %v", name, err) + } +} + +func TestDecodeCLIAPIResponseIsBounded(t *testing.T) { + var decoded struct { + Value string `json:"value"` + } + if err := decodeCLIAPIResponse(strings.NewReader(`{"value":"ok"}`), &decoded); err != nil { + t.Fatal(err) + } + if decoded.Value != "ok" { + t.Fatalf("decoded value = %q", decoded.Value) + } + oversized := `{"padding":"` + strings.Repeat("x", maxCLIAPIResponseBytes) + `"}` + if err := decodeCLIAPIResponse(strings.NewReader(oversized), &decoded); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("oversized response error = %v", err) + } +} diff --git a/cmd/wt/tool_call.go b/cmd/wt/tool_call.go index 9797fc18..cf34d230 100644 --- a/cmd/wt/tool_call.go +++ b/cmd/wt/tool_call.go @@ -20,40 +20,48 @@ func toolCallCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { sockPath := os.Getenv("WT_TOOL_SOCKET") if sockPath == "" { - fmt.Fprintln(os.Stderr, "WT_TOOL_SOCKET not set — tool-call must be run inside an egg session with tools configured") - os.Exit(126) + return exitError(126, "WT_TOOL_SOCKET not set — tool-call must be run inside an egg session with tools configured") } conn, err := net.Dial("unix", sockPath) if err != nil { - fmt.Fprintf(os.Stderr, "connect to tool socket: %v\n", err) - os.Exit(126) + return exitError(126, "connect to tool socket: %v", err) } - defer conn.Close() + defer closeWithLog("tool socket", conn) req := egg.ToolRequest{Tool: args[0], Args: args[1:]} - data, _ := json.Marshal(req) - conn.Write(data) - conn.(*net.UnixConn).CloseWrite() + data, err := json.Marshal(req) + if err != nil { + return exitError(127, "encode tool request: %v", err) + } + if _, err := conn.Write(data); err != nil { + return exitError(127, "write tool request: %v", err) + } + if err := conn.(*net.UnixConn).CloseWrite(); err != nil { + return exitError(127, "finish tool request: %v", err) + } respData, err := io.ReadAll(conn) if err != nil { - fmt.Fprintf(os.Stderr, "read tool response: %v\n", err) - os.Exit(127) + return exitError(127, "read tool response: %v", err) } var resp egg.ToolResponse if err := json.Unmarshal(respData, &resp); err != nil { - fmt.Fprintf(os.Stderr, "parse tool response: %v\n", err) - os.Exit(127) + return exitError(127, "parse tool response: %v", err) } if resp.Error != "" { - fmt.Fprintln(os.Stderr, resp.Error) - os.Exit(1) + return exitError(1, "%s", resp.Error) } if resp.Stdout != "" { - fmt.Fprint(os.Stdout, resp.Stdout) + if err := writef(os.Stdout, "%s", resp.Stdout); err != nil { + return exitError(127, "write tool stdout: %v", err) + } } if resp.Stderr != "" { - fmt.Fprint(os.Stderr, resp.Stderr) + if err := writef(os.Stderr, "%s", resp.Stderr); err != nil { + return exitError(127, "write tool stderr: %v", err) + } + } + if resp.ExitCode != 0 { + return exitError(resp.ExitCode, "") } - os.Exit(resp.ExitCode) return nil }, } @@ -66,28 +74,31 @@ func toolListCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { sockPath := os.Getenv("WT_TOOL_SOCKET") if sockPath == "" { - fmt.Fprintln(os.Stderr, "WT_TOOL_SOCKET not set — tool-list must be run inside an egg session with tools configured") - os.Exit(126) + return exitError(126, "WT_TOOL_SOCKET not set — tool-list must be run inside an egg session with tools configured") } conn, err := net.Dial("unix", sockPath) if err != nil { - fmt.Fprintf(os.Stderr, "connect to tool socket: %v\n", err) - os.Exit(126) + return exitError(126, "connect to tool socket: %v", err) } - defer conn.Close() + defer closeWithLog("tool socket", conn) req := egg.ToolRequest{Action: "list"} - data, _ := json.Marshal(req) - conn.Write(data) - conn.(*net.UnixConn).CloseWrite() + data, err := json.Marshal(req) + if err != nil { + return exitError(127, "encode tool list request: %v", err) + } + if _, err := conn.Write(data); err != nil { + return exitError(127, "write tool list request: %v", err) + } + if err := conn.(*net.UnixConn).CloseWrite(); err != nil { + return exitError(127, "finish tool list request: %v", err) + } respData, err := io.ReadAll(conn) if err != nil { - fmt.Fprintf(os.Stderr, "read tool response: %v\n", err) - os.Exit(127) + return exitError(127, "read tool response: %v", err) } var listResp egg.ToolListResponse if err := json.Unmarshal(respData, &listResp); err != nil { - fmt.Fprintf(os.Stderr, "parse tool response: %v\n", err) - os.Exit(127) + return exitError(127, "parse tool response: %v", err) } for _, t := range listResp.Tools { if t.Description != "" { diff --git a/cmd/wt/update.go b/cmd/wt/update.go index ad02447c..1cf1c097 100644 --- a/cmd/wt/update.go +++ b/cmd/wt/update.go @@ -1,16 +1,24 @@ package main import ( + "context" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" + "net/url" "os" "os/exec" + "path/filepath" "runtime" "strings" "syscall" + "time" + "github.com/ehrlich-b/wingthing/internal/fsutil" "github.com/spf13/cobra" ) @@ -26,21 +34,70 @@ type ghAsset struct { BrowserDownloadURL string `json:"browser_download_url"` } +type daemonUpdateState struct { + pid int + kind daemonKind + startArgs []string +} + +// daemonStateForUpdate snapshots and validates the restart command while the +// lifecycle lock is held. In particular, an update must not stop a live daemon +// and only then discover that its saved restart metadata is missing or corrupt. +func daemonStateForUpdate() (*daemonUpdateState, error) { + pid, kind, err := readDaemon() + if err != nil { + if errors.Is(err, errNoDaemonRunning) { + return nil, nil + } + return nil, err + } + argsPath := wingArgsPath() + if kind == roostDaemon { + argsPath = roostArgsPath() + } + saved, err := os.ReadFile(argsPath) + if err != nil { + return nil, fmt.Errorf("read saved %s daemon args: %w", kind, err) + } + startArgs, err := daemonRestartArgs(saved, kind) + if err != nil { + return nil, fmt.Errorf("validate saved %s daemon args: %w", kind, err) + } + return &daemonUpdateState{pid: pid, kind: kind, startArgs: startArgs}, nil +} + +func daemonRestartArgs(saved []byte, kind daemonKind) ([]string, error) { + foregroundArgs, err := parseSavedDaemonArgs(saved, kind) + if err != nil { + return nil, err + } + startArgs := make([]string, 0, len(foregroundArgs)-1) + for _, arg := range foregroundArgs { + if arg != "--foreground" { + startArgs = append(startArgs, arg) + } + } + return startArgs, nil +} + func updateCmd() *cobra.Command { return &cobra.Command{ Use: "update", Short: "Update wt to the latest release", - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) (runErr error) { fmt.Printf("current version: %s\n", version) - // Fetch latest release - req, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo), nil) + // Fetch latest release. + req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo), nil) + if err != nil { + return fmt.Errorf("create latest release request: %w", err) + } req.Header.Set("Accept", "application/vnd.github+json") - resp, err := http.DefaultClient.Do(req) + resp, err := releaseHTTPClient(30 * time.Second).Do(req) if err != nil { return fmt.Errorf("fetch latest release: %w", err) } - defer resp.Body.Close() + defer closeWithLog("GitHub release response", resp.Body) if resp.StatusCode == http.StatusNotFound { return fmt.Errorf("no releases found — tag a release first") @@ -49,8 +106,8 @@ func updateCmd() *cobra.Command { return fmt.Errorf("github API error: %s", resp.Status) } - var rel ghRelease - if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + rel, err := decodeGitHubRelease(resp.Body) + if err != nil { return fmt.Errorf("parse release: %w", err) } @@ -59,13 +116,16 @@ func updateCmd() *cobra.Command { return nil } - // Find matching binary + // Find the matching binary and its release checksum manifest. wantName := fmt.Sprintf("wt-%s-%s", runtime.GOOS, runtime.GOARCH) var downloadURL string + var sumsURL string for _, a := range rel.Assets { if a.Name == wantName { downloadURL = a.BrowserDownloadURL - break + } + if a.Name == "SHA256SUMS" { + sumsURL = a.BrowserDownloadURL } } if downloadURL == "" { @@ -76,101 +136,297 @@ func updateCmd() *cobra.Command { return fmt.Errorf("no binary for %s/%s in release %s (available: %s)", runtime.GOOS, runtime.GOARCH, rel.TagName, strings.Join(available, ", ")) } + if sumsURL == "" { + return fmt.Errorf("release %s has no SHA256SUMS manifest; refusing an unverified update", rel.TagName) + } + if err := validateReleaseAssetURL(downloadURL); err != nil { + return fmt.Errorf("binary asset URL: %w", err) + } + if err := validateReleaseAssetURL(sumsURL); err != nil { + return fmt.Errorf("checksum asset URL: %w", err) + } + + expected, err := fetchReleaseChecksum(cmd.Context(), sumsURL, wantName) + if err != nil { + return fmt.Errorf("verify release manifest: %w", err) + } fmt.Printf("downloading %s...\n", rel.TagName) // Download binary - dlResp, err := http.Get(downloadURL) + dlReq, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, downloadURL, nil) + if err != nil { + return fmt.Errorf("download request: %w", err) + } + dlResp, err := releaseHTTPClient(5 * time.Minute).Do(dlReq) if err != nil { return fmt.Errorf("download: %w", err) } - defer dlResp.Body.Close() + defer closeWithLog("release download response", dlResp.Body) if dlResp.StatusCode != http.StatusOK { return fmt.Errorf("download failed: %s", dlResp.Status) } - // Write to temp file next to current binary + // Write to a uniquely named file next to the current binary. Keeping it + // on the same filesystem makes the final rename atomic; CreateTemp also + // avoids following a predictable pre-created symlink. exe, err := os.Executable() if err != nil { return fmt.Errorf("find executable: %w", err) } - tmp := exe + ".tmp" - f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + f, err := os.CreateTemp(filepath.Dir(exe), ".wt-update-*") if err != nil { return fmt.Errorf("create temp file: %w", err) } + tmp := f.Name() + defer func() { + if f != nil { + if err := f.Close(); err != nil { + runErr = errors.Join(runErr, fmt.Errorf("close update temporary file: %w", err)) + } + } + if tmp != "" { + if err := removeIfExists(tmp); err != nil { + runErr = errors.Join(runErr, fmt.Errorf("remove update temporary file: %w", err)) + } + } + }() + if err := f.Chmod(0755); err != nil { + return fmt.Errorf("make downloaded binary executable: %w", err) + } - if _, err := io.Copy(f, dlResp.Body); err != nil { - f.Close() - os.Remove(tmp) - return fmt.Errorf("write binary: %w", err) + digest := sha256.New() + const maxBinaryBytes = 256 << 20 + written, copyErr := io.Copy(io.MultiWriter(f, digest), io.LimitReader(dlResp.Body, maxBinaryBytes+1)) + if copyErr != nil { + return fmt.Errorf("write binary: %w", copyErr) + } + if written > maxBinaryBytes { + return fmt.Errorf("downloaded binary exceeds %d bytes", maxBinaryBytes) + } + if err := f.Sync(); err != nil { + return fmt.Errorf("sync downloaded binary: %w", err) + } + if err := f.Close(); err != nil { + f = nil + return fmt.Errorf("close downloaded binary: %w", err) + } + f = nil + actual := fmt.Sprintf("%x", digest.Sum(nil)) + if !strings.EqualFold(actual, expected) { + return fmt.Errorf("checksum mismatch for %s", wantName) + } + if err := validateReleaseBinary(cmd.Context(), tmp); err != nil { + return fmt.Errorf("release contract: %w", err) + } + + // Serialize the atomic replacement with daemon start/stop. Without this + // lock, a concurrent start can race between daemon inspection and the + // rename, leaving an old process running with misleading new metadata. + lifecycleLock, err := acquireDaemonLifecycleLock() + if err != nil { + return err + } + lockHeld := true + defer func() { + if lockHeld { + if err := lifecycleLock.Close(); err != nil { + runErr = errors.Join(runErr, fmt.Errorf("release daemon lifecycle lock: %w", err)) + } + } + }() + daemonState, err := daemonStateForUpdate() + if err != nil { + return fmt.Errorf("inspect running daemon before update: %w", err) } - f.Close() // Atomic replace if err := os.Rename(tmp, exe); err != nil { - os.Remove(tmp) return fmt.Errorf("replace binary: %w", err) } + tmp = "" + if err := fsutil.SyncDirectory(filepath.Dir(exe)); err != nil { + return fmt.Errorf("persist binary replacement: %w", err) + } fmt.Printf("updated to %s\n", rel.TagName) - // Restart daemon if running (egg survives, daemon picks up new binary). - // Try wing.pid first, then roost.pid to determine which daemon to restart. - isRoost := false - daemonPid, pidErr := readPidFrom(wingPidPath()) - if pidErr != nil { - daemonPid, pidErr = readPidFrom(roostPidPath()) - if pidErr == nil { - isRoost = true - } - } - if pidErr == nil { - kind := "wing" - if isRoost { - kind = "roost" - } - fmt.Printf("restarting %s daemon (pid %d)...\n", kind, daemonPid) - proc, _ := os.FindProcess(daemonPid) - proc.Signal(syscall.SIGTERM) - - // Read saved args from last start. Strip --foreground since we want it to daemonize. - var startArgs []string - var argsPath string - if isRoost { - startArgs = []string{"roost", "start"} - argsPath = roostArgsPath() - os.Remove(roostPidPath()) - } else { - startArgs = []string{"wing", "start"} - argsPath = wingArgsPath() - os.Remove(wingPidPath()) + // Restart a running daemon. Keep the lifecycle lock until the old + // process is gone and its metadata has been removed, then release it + // before invoking the normal daemonizing start path. Any competing + // start after release wins the same lock and the loser sees a live PID; + // neither can create a duplicate listener. + if daemonState != nil { + kind := string(daemonState.kind) + fmt.Printf("restarting %s daemon (pid %d)...\n", kind, daemonState.pid) + if err := stopDaemonAndWait(daemonState.pid, daemonState.kind, 5*time.Second); err != nil { + return fmt.Errorf("updated to %s but could not restart daemon: %w; run 'wt %s stop' and 'wt %s start' manually", rel.TagName, err, kind, kind) } - if saved, err := os.ReadFile(argsPath); err == nil { - lines := strings.Split(strings.TrimSpace(string(saved)), "\n") - var filtered []string - for _, l := range lines { - if l != "--foreground" { - filtered = append(filtered, l) - } + if daemonState.kind == roostDaemon { + if err := removeFiles(roostPidPath(), roostArgsPath()); err != nil { + return fmt.Errorf("remove stopped roost metadata: %w", err) } - if len(filtered) > 0 { - startArgs = filtered + } else { + if err := removeFiles(wingPidPath(), wingArgsPath(), wingStatusPath()); err != nil { + return fmt.Errorf("remove stopped wing metadata: %w", err) } } + if err := lifecycleLock.Close(); err != nil { + return fmt.Errorf("release daemon lifecycle lock: %w", err) + } + lockHeld = false - child := exec.Command(exe, startArgs...) + child := exec.Command(exe, daemonState.startArgs...) child.Stdout = os.Stdout child.Stderr = os.Stderr if err := child.Run(); err != nil { fmt.Printf("warning: failed to restart %s: %v\n", kind, err) fmt.Printf("run 'wt %s start' manually to restart\n", kind) } + } else if err := lifecycleLock.Close(); err != nil { + return fmt.Errorf("release daemon lifecycle lock: %w", err) + } else { + lockHeld = false } return nil }, } } + +func waitForProcessExit(process *os.Process, timeout time.Duration) bool { + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + if err := process.Signal(syscall.Signal(0)); err != nil { + return true + } + select { + case <-deadline.C: + return false + case <-ticker.C: + } + } +} + +func releaseHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if !strings.EqualFold(req.URL.Scheme, "https") { + return fmt.Errorf("refusing release redirect to non-HTTPS URL") + } + if len(via) >= 10 { + return fmt.Errorf("too many release redirects") + } + return nil + }, + } +} + +func validateReleaseAssetURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return err + } + if !strings.EqualFold(u.Scheme, "https") || u.Host == "" { + return fmt.Errorf("must be an absolute HTTPS URL") + } + return nil +} + +func decodeGitHubRelease(body io.Reader) (ghRelease, error) { + const maxReleaseMetadataBytes = 1 << 20 + data, err := io.ReadAll(io.LimitReader(body, maxReleaseMetadataBytes+1)) + if err != nil { + return ghRelease{}, err + } + if len(data) > maxReleaseMetadataBytes { + return ghRelease{}, fmt.Errorf("release metadata exceeds %d bytes", maxReleaseMetadataBytes) + } + var release ghRelease + if err := json.Unmarshal(data, &release); err != nil { + return ghRelease{}, err + } + return release, nil +} + +func fetchReleaseChecksum(ctx context.Context, manifestURL, binaryName string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil) + if err != nil { + return "", err + } + resp, err := releaseHTTPClient(30 * time.Second).Do(req) + if err != nil { + return "", err + } + defer closeWithLog("release checksum response", resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download SHA256SUMS: %s", resp.Status) + } + const maxManifestBytes = 1 << 20 + data, err := io.ReadAll(io.LimitReader(resp.Body, maxManifestBytes+1)) + if err != nil { + return "", err + } + if len(data) > maxManifestBytes { + return "", fmt.Errorf("SHA256SUMS exceeds %d bytes", maxManifestBytes) + } + return releaseChecksum(data, binaryName) +} + +func releaseChecksum(manifest []byte, binaryName string) (string, error) { + found := "" + for _, line := range strings.Split(string(manifest), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == binaryName { + if len(fields[0]) != sha256.Size*2 { + return "", fmt.Errorf("invalid checksum length for %s", binaryName) + } + if _, err := hex.DecodeString(fields[0]); err != nil { + return "", fmt.Errorf("invalid checksum for %s", binaryName) + } + if found != "" { + return "", fmt.Errorf("SHA256SUMS contains duplicate entries for %s", binaryName) + } + found = strings.ToLower(fields[0]) + } + } + if found != "" { + return found, nil + } + return "", fmt.Errorf("SHA256SUMS does not contain %s", binaryName) +} + +func validateReleaseBinary(ctx context.Context, path string) error { + checks := []struct { + args []string + contains string + }{ + {args: []string{"--version"}, contains: "wt version"}, + {args: []string{"mcp", "connect", "--help"}, contains: "connect"}, + {args: []string{"serve", "--help"}, contains: "--https"}, + {args: []string{"roost", "start", "--help"}, contains: "--https"}, + {args: []string{"local-cert", "status", "--help"}, contains: "status"}, + } + for _, check := range checks { + checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + output, err := exec.CommandContext(checkCtx, path, check.args...).CombinedOutput() + contextErr := checkCtx.Err() + cancel() + if err != nil { + if contextErr != nil { + return fmt.Errorf("%s timed out or was canceled: %w", strings.Join(check.args, " "), contextErr) + } + return fmt.Errorf("%s failed: %w", strings.Join(check.args, " "), err) + } + if !strings.Contains(string(output), check.contains) { + return fmt.Errorf("%s output does not contain %q", strings.Join(check.args, " "), check.contains) + } + } + return nil +} diff --git a/cmd/wt/update_test.go b/cmd/wt/update_test.go new file mode 100644 index 00000000..f7051b9c --- /dev/null +++ b/cmd/wt/update_test.go @@ -0,0 +1,192 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func TestDecodeGitHubReleaseIsBounded(t *testing.T) { + release, err := decodeGitHubRelease(strings.NewReader(`{"tag_name":"v1","assets":[]}`)) + if err != nil || release.TagName != "v1" { + t.Fatalf("decode release = %#v, %v", release, err) + } + oversized := strings.NewReader(fmt.Sprintf(`{"tag_name":"v1","padding":"%s"}`, strings.Repeat("x", 1<<20))) + if _, err := decodeGitHubRelease(oversized); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("oversized release metadata error = %v", err) + } +} + +func TestReleaseAssetURLsAndRedirectsRequireHTTPS(t *testing.T) { + for _, valid := range []string{ + "https://github.com/ehrlich-b/wingthing/releases/download/v1/wt-linux-amd64", + "HTTPS://example.com/SHA256SUMS", + } { + if err := validateReleaseAssetURL(valid); err != nil { + t.Errorf("valid release URL %q: %v", valid, err) + } + } + for _, invalid := range []string{"", "/relative", "http://github.com/asset", "https:///missing-host"} { + if err := validateReleaseAssetURL(invalid); err == nil { + t.Errorf("insecure release URL %q was accepted", invalid) + } + } + + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("unexpected")) + })) + defer target.Close() + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer source.Close() + if _, err := releaseHTTPClient(time.Second).Get(source.URL); err == nil || !strings.Contains(err.Error(), "non-HTTPS") { + t.Fatalf("HTTPS downgrade redirect error = %v", err) + } +} + +func TestReleaseChecksumStrictlySelectsRequestedAsset(t *testing.T) { + want := strings.Repeat("a", 64) + manifest := []byte( + strings.Repeat("b", 64) + " wt-linux-arm64\n" + + want + " wt-linux-amd64\n", + ) + got, err := releaseChecksum(manifest, "wt-linux-amd64") + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("checksum = %q, want %q", got, want) + } + for _, invalid := range [][]byte{ + []byte(strings.Repeat("a", 63) + " wt-linux-amd64\n"), + []byte(strings.Repeat("z", 64) + " wt-linux-amd64\n"), + []byte(strings.Repeat("a", 64) + " other\n"), + []byte(strings.Repeat("a", 64) + " wt-linux-amd64\n" + strings.Repeat("a", 64) + " wt-linux-amd64\n"), + } { + if _, err := releaseChecksum(invalid, "wt-linux-amd64"); err == nil { + t.Fatalf("invalid manifest accepted: %q", invalid) + } + } +} + +func TestFetchReleaseChecksumBoundsAndHTTPStatus(t *testing.T) { + want := strings.Repeat("c", 64) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ok": + _, _ = w.Write([]byte(want + " *wt-darwin-arm64\n")) + case "/missing": + http.NotFound(w, r) + default: + _, _ = w.Write([]byte(strings.Repeat("x", (1<<20)+1))) + } + })) + defer server.Close() + + got, err := fetchReleaseChecksum(context.Background(), server.URL+"/ok", "wt-darwin-arm64") + if err != nil || got != want { + t.Fatalf("fetch checksum = %q err=%v", got, err) + } + if _, err := fetchReleaseChecksum(context.Background(), server.URL+"/missing", "wt-darwin-arm64"); err == nil { + t.Fatal("missing manifest returned success") + } + if _, err := fetchReleaseChecksum(context.Background(), server.URL+"/large", "wt-darwin-arm64"); err == nil { + t.Fatal("oversized/invalid manifest returned success") + } +} + +func TestValidateReleaseBinaryRequiresCurrentPublicCommandSurface(t *testing.T) { + if os.PathSeparator == '\\' { + t.Skip("shell fixture is Unix-only") + } + path := filepath.Join(t.TempDir(), "wt") + fixture := `#!/bin/sh +case "$*" in + "--version") echo "wt version v-test" ;; + "mcp connect --help") echo "connect directly" ;; + "serve --help") echo "--https" ;; + "roost start --help") echo "--https" ;; + "local-cert status --help") echo "status" ;; + *) exit 2 ;; +esac +` + if err := os.WriteFile(path, []byte(fixture), 0o755); err != nil { + t.Fatal(err) + } + if err := validateReleaseBinary(context.Background(), path); err != nil { + t.Fatal(err) + } + + broken := filepath.Join(t.TempDir(), "wt") + if err := os.WriteFile(broken, []byte("#!/bin/sh\necho old release\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := validateReleaseBinary(context.Background(), broken); err == nil { + t.Fatal("binary without current public command surface was accepted") + } + + hanging := filepath.Join(t.TempDir(), "wt") + if err := os.WriteFile(hanging, []byte("#!/bin/sh\nexec sleep 5\n"), 0o755); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if err := validateReleaseBinary(ctx, hanging); err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("hanging release validation error = %v", err) + } +} + +func TestWaitForProcessExitDoesNotConfuseRunningWithStopped(t *testing.T) { + running, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatal(err) + } + if waitForProcessExit(running, 20*time.Millisecond) { + t.Fatal("current process was reported stopped") + } + + child := exec.Command("sleep", "5") + if err := child.Start(); err != nil { + t.Fatal(err) + } + defer func() { + if err := child.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + t.Errorf("kill child process: %v", err) + } + }() + if err := child.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatal(err) + } + waited := make(chan struct{}) + go func() { + _ = child.Wait() + close(waited) + }() + if !waitForProcessExit(child.Process, 2*time.Second) { + t.Fatal("terminated process remained live") + } + <-waited +} + +func TestDaemonRestartArgsDropsOnlyForegroundAndValidatesKind(t *testing.T) { + args, err := daemonRestartArgs([]byte("roost\nstart\n--addr\n127.0.0.1:8080\n--foreground\n--https"), roostDaemon) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(args, "|"); got != "roost|start|--addr|127.0.0.1:8080|--https" { + t.Fatalf("restart args = %q", got) + } + if _, err := daemonRestartArgs([]byte("wing\nstart\n--foreground"), roostDaemon); err == nil { + t.Fatal("wing metadata was accepted as a roost restart command") + } +} diff --git a/cmd/wt/wing.go b/cmd/wt/wing.go index 06103f4e..5e004276 100644 --- a/cmd/wt/wing.go +++ b/cmd/wt/wing.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "compress/gzip" "context" @@ -8,12 +9,14 @@ import ( "crypto/ecdh" crand "crypto/rand" "encoding/base64" + "encoding/binary" "encoding/json" "errors" "fmt" "io" "log" "mime" + "net" "net/http" "net/url" "os" @@ -28,10 +31,12 @@ import ( "sync/atomic" "syscall" "time" + "unicode/utf8" agentpkg "github.com/ehrlich-b/wingthing/internal/agent" "github.com/ehrlich-b/wingthing/internal/auth" "github.com/ehrlich-b/wingthing/internal/config" + "github.com/ehrlich-b/wingthing/internal/control" directpkg "github.com/ehrlich-b/wingthing/internal/direct" "github.com/ehrlich-b/wingthing/internal/egg" pb "github.com/ehrlich-b/wingthing/internal/egg/pb" @@ -67,6 +72,12 @@ var sessionStates sync.Map // sessionID -> *sessionIdleState const attentionCooldown = 30 * time.Second +func writePTYMessage(write ws.PTYWriteFunc, message any) { + if err := write(message); err != nil { + log.Printf("send PTY message %T: %v", message, err) + } +} + // checkAndSendAttention fires session.attention if the cooldown has elapsed. // Returns true if the attention was sent. func checkAndSendAttention(sessionID, agent, cwd string, write ws.PTYWriteFunc) bool { @@ -76,11 +87,15 @@ func checkAndSendAttention(sessionID, agent, cwd string, write ws.PTYWriteFunc) return false } } - wingAttention.Store(sessionID, true) - wingAttentionCooldown.Store(sessionID, now) // Reuse nonce for the same attention episode; relay deduplicates by nonce. nonce, _ := wingAttentionNonce.LoadOrStore(sessionID, generateAttentionNonce()) - write(ws.SessionAttention{Type: ws.TypeSessionAttention, SessionID: sessionID, Agent: agent, CWD: cwd, Nonce: nonce.(string)}) + message := ws.SessionAttention{Type: ws.TypeSessionAttention, SessionID: sessionID, Agent: agent, CWD: cwd, Nonce: nonce.(string)} + if err := write(message); err != nil { + log.Printf("send attention for session %s: %v", sessionID, err) + return false + } + wingAttention.Store(sessionID, true) + wingAttentionCooldown.Store(sessionID, now) return true } @@ -97,6 +112,12 @@ func clearAttentionCooldown(sessionID string) { } } +func forgetAttentionState(sessionID string) { + wingAttention.Delete(sessionID) + wingAttentionCooldown.Delete(sessionID) + wingAttentionNonce.Delete(sessionID) +} + // generateAttentionNonce returns a random 8-byte hex nonce. func generateAttentionNonce() string { b := make([]byte, 8) @@ -212,6 +233,24 @@ func previewFilename(name string) string { return name } +// previewURL accepts only absolute HTTP(S) URLs without embedded credentials. +// The browser repeats this validation for compatibility with older wings, but +// rejecting unsafe schemes here keeps them out of the encrypted protocol too. +func previewURL(raw string) (string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" || len(raw) > 8192 { + return "", false + } + parsed, err := url.ParseRequestURI(raw) + if err != nil || parsed.Host == "" || parsed.User != nil { + return "", false + } + if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") { + return "", false + } + return parsed.String(), true +} + // parsePreviewFile parses a .wt-preview file into a mode/url/content map. // // First line "url:" → URL mode. @@ -231,7 +270,9 @@ func parsePreviewFile(data []byte) map[string]string { } firstLine = strings.TrimRight(firstLine, "\r") if strings.HasPrefix(firstLine, "url:") { - return map[string]string{"mode": "url", "url": strings.TrimSpace(firstLine[4:])} + if previewURL, ok := previewURL(firstLine[4:]); ok { + return map[string]string{"mode": "url", "url": previewURL} + } } // "file:" header: everything after the header line is content. if strings.HasPrefix(firstLine, "file:") { @@ -256,17 +297,90 @@ func parsePreviewFile(data []byte) map[string]string { } } +const ( + maxPreviewFileBytes = 1 << 20 + // Wing and relay WebSockets cap envelopes at 512 KiB. Leave room for GCM + // nonce/tag, base64 expansion, and the outer pty.preview JSON envelope. + maxPreviewJSONBytes = 350 << 10 +) + +var ( + errPreviewNotRegular = errors.New("preview path is not a regular file") + errPreviewTooLarge = errors.New("preview exceeds size limit") +) + +func readPreviewFileBounded(path string) ([]byte, error) { + return readPreviewFileBoundedWithOpen(path, os.Open) +} + +func readPreviewFileBoundedWithOpen(path string, open func(string) (*os.File, error)) ([]byte, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + // Reject symlinks, sockets, devices, and FIFOs. Besides preventing host-file + // reads through a symlink, this keeps a named pipe from blocking a watcher + // goroutine forever while it waits for a writer. + if !info.Mode().IsRegular() { + return nil, errPreviewNotRegular + } + file, err := open(path) + if err != nil { + return nil, err + } + defer closeWithLog("preview file", file) + openedInfo, err := file.Stat() + if err != nil { + return nil, err + } + // Bind the pathname check to the file descriptor we actually read. An + // agent can rename and replace files in its writable workspace between + // Lstat and Open; reject that swap rather than following a newly installed + // symlink with the host wing's broader filesystem authority. + if !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) { + return nil, errPreviewNotRegular + } + data, err := io.ReadAll(io.LimitReader(file, maxPreviewFileBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxPreviewFileBytes { + return nil, errPreviewTooLarge + } + return data, nil +} + +func marshalPreviewFile(data []byte) ([]byte, error) { + jsonBytes, err := json.Marshal(parsePreviewFile(data)) + if err != nil { + return nil, err + } + if len(jsonBytes) > maxPreviewJSONBytes { + return nil, errPreviewTooLarge + } + return jsonBytes, nil +} + // consumeAndSendPreview reads a .wt-preview file, deletes it, encrypts the content, and sends it. func consumeAndSendPreview(path, sessionID string, mu *sync.Mutex, gcm *cipher.AEAD, write ws.PTYWriteFunc) { - data, err := os.ReadFile(path) + data, err := readPreviewFileBounded(path) if err != nil { + if errors.Is(err, errPreviewNotRegular) || errors.Is(err, errPreviewTooLarge) { + log.Printf("pty session %s: discard preview: %v", sessionID, err) + if removeErr := removeIfExists(path); removeErr != nil { + log.Printf("pty session %s: remove rejected preview: %v", sessionID, removeErr) + } + } return } - os.Remove(path) - - parsed := parsePreviewFile(data) - jsonBytes, err := json.Marshal(parsed) + jsonBytes, err := marshalPreviewFile(data) if err != nil { + if errors.Is(err, errPreviewTooLarge) { + log.Printf("pty session %s: discard preview: %v", sessionID, err) + if removeErr := removeIfExists(path); removeErr != nil { + log.Printf("pty session %s: remove rejected preview: %v", sessionID, removeErr) + } + } return } @@ -282,7 +396,13 @@ func consumeAndSendPreview(path, sessionID string, mu *sync.Mutex, gcm *cipher.A log.Printf("pty session %s: preview encrypt error: %v", sessionID, err) return } - write(ws.PTYPreview{Type: ws.TypePTYPreview, SessionID: sessionID, Data: encrypted}) + if err := write(ws.PTYPreview{Type: ws.TypePTYPreview, SessionID: sessionID, Data: encrypted}); err != nil { + log.Printf("pty session %s: preview send error: %v", sessionID, err) + return + } + if err := removeIfExists(path); err != nil { + log.Printf("pty session %s: remove consumed preview: %v", sessionID, err) + } } // watchPreviewFile watches for the session-specific preview file in the given directory. @@ -293,12 +413,17 @@ func watchPreviewFile(ctx context.Context, cwd, sessionID string, mu *sync.Mutex // Try fsnotify first watcher, err := fsnotify.NewWatcher() if err == nil { - defer watcher.Close() + defer closeWithLog("preview watcher", watcher) if addErr := watcher.Add(cwd); addErr != nil { log.Printf("pty session %s: fsnotify add failed, falling back to polling: %v", sessionID, addErr) goto poll } var debounce *time.Timer + defer func() { + if debounce != nil { + debounce.Stop() + } + }() for { select { case ev, ok := <-watcher.Events: @@ -315,6 +440,11 @@ func watchPreviewFile(ctx context.Context, cwd, sessionID string, mu *sync.Mutex debounce.Stop() } debounce = time.AfterFunc(50*time.Millisecond, func() { + select { + case <-ctx.Done(): + return + default: + } consumeAndSendPreview(previewPath, sessionID, mu, gcm, write) }) case _, ok := <-watcher.Errors: @@ -342,9 +472,44 @@ poll: } } +const ( + maxBrowserRequestReadBytes = 64 << 10 + maxBrowserOpenURLBytes = 4096 +) + +// consumeBrowserRequestChunk extracts complete, bounded lines. An agent owns +// the request file, so a line without a newline must not grow host memory +// without bound. When a line crosses the cap, discard it through its newline. +func consumeBrowserRequestChunk(data []byte, pending *string, discarding *bool, emit func(string)) { + combined := *pending + string(data) + *pending = "" + parts := strings.Split(combined, "\n") + for _, raw := range parts[:len(parts)-1] { + if *discarding { + *discarding = false + continue + } + line := strings.TrimSpace(raw) + if line != "" && len(line) <= maxBrowserOpenURLBytes { + emit(line) + } + } + tail := parts[len(parts)-1] + if *discarding { + return + } + if len(tail) > maxBrowserOpenURLBytes { + *discarding = true + return + } + *pending = tail +} + // watchBrowserRequests polls for new lines in the browser-requests file and forwards them as PTYBrowserOpen messages. func watchBrowserRequests(ctx context.Context, path, sessionID string, write ws.PTYWriteFunc) { var lastOffset int64 + var pending string + var discarding bool ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for { @@ -355,31 +520,97 @@ func watchBrowserRequests(ctx context.Context, path, sessionID string, write ws. continue } info, err := f.Stat() - if err != nil || info.Size() <= lastOffset { - f.Close() + if err != nil { + closeWithLog("browser request file", f) + continue + } + if info.Size() < lastOffset { + lastOffset = 0 + pending = "" + discarding = false + } + if info.Size() == lastOffset { + closeWithLog("browser request file", f) + continue + } + if unread := info.Size() - lastOffset; unread > maxBrowserRequestReadBytes { + lastOffset = info.Size() - maxBrowserRequestReadBytes + pending = "" + discarding = true // the retained window may begin in the middle of a line + } + if _, err := f.Seek(lastOffset, io.SeekStart); err != nil { + closeWithLog("browser request file", f) + log.Printf("seek browser request file for session %s: %v", sessionID, err) continue } - f.Seek(lastOffset, io.SeekStart) - data, err := io.ReadAll(f) - f.Close() + data, err := io.ReadAll(io.LimitReader(f, maxBrowserRequestReadBytes)) + closeErr := f.Close() if err != nil || len(data) == 0 { continue } - lastOffset += int64(len(data)) - for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { - line = strings.TrimSpace(line) - if line != "" { - write(ws.PTYBrowserOpen{Type: ws.TypePTYBrowserOpen, SessionID: sessionID, URL: line}) - } + if closeErr != nil { + log.Printf("close browser request file for session %s: %v", sessionID, closeErr) + continue } + lastOffset += int64(len(data)) + consumeBrowserRequestChunk(data, &pending, &discarding, func(line string) { + writePTYMessage(write, ws.PTYBrowserOpen{Type: ws.TypePTYBrowserOpen, SessionID: sessionID, URL: line}) + }) case <-ctx.Done(): return } } } -// tunnelKeys caches derived AES-GCM keys per sender public key. -var tunnelKeys sync.Map // senderPub string → cipher.AEAD +const maxTunnelKeyCacheEntries = 1024 + +// tunnelKeyCache bounds derived AES-GCM keys per sender public key. Direct-free +// users may send coordination tunnels, so a process-lifetime sync.Map would let +// an authenticated caller grow the wing indefinitely by rotating X25519 keys. +type tunnelKeyCache struct { + mu sync.Mutex + max int + entries map[string]cipher.AEAD + order []string +} + +func newTunnelKeyCache(max int) *tunnelKeyCache { + return &tunnelKeyCache{max: max, entries: make(map[string]cipher.AEAD)} +} + +func (c *tunnelKeyCache) Get(senderPub string) (cipher.AEAD, bool) { + c.mu.Lock() + defer c.mu.Unlock() + key, ok := c.entries[senderPub] + return key, ok +} + +func (c *tunnelKeyCache) Put(senderPub string, key cipher.AEAD) { + c.mu.Lock() + defer c.mu.Unlock() + if _, exists := c.entries[senderPub]; exists { + c.entries[senderPub] = key + return + } + if c.max <= 0 { + return + } + for len(c.entries) >= c.max && len(c.order) > 0 { + oldest := c.order[0] + c.order = c.order[1:] + delete(c.entries, oldest) + } + c.entries[senderPub] = key + c.order = append(c.order, senderPub) +} + +func (c *tunnelKeyCache) Len() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.entries) +} + +var tunnelKeys = newTunnelKeyCache(maxTunnelKeyCacheEntries) // wingCfgMu serializes tunnel-driven wing.yaml mutations. Tunnel requests run // on concurrent goroutines; unsynchronized admin edits could race each other @@ -491,7 +722,7 @@ func sendPTYOutputTagged(sessionID, viewerID string, data []byte, gcm cipher.AEA log.Printf("pty session %s: encrypt error: %v", sessionID, err) return } - write(ws.PTYOutput{Type: ws.TypePTYOutput, SessionID: sessionID, Data: encrypted, ViewerID: viewerID}) + writePTYMessage(write, ws.PTYOutput{Type: ws.TypePTYOutput, SessionID: sessionID, Data: encrypted, ViewerID: viewerID}) return } for sent := 0; sent < len(data); { @@ -504,7 +735,7 @@ func sendPTYOutputTagged(sessionID, viewerID string, data []byte, gcm cipher.AEA log.Printf("pty session %s: chunk encrypt error: %v", sessionID, err) return } - write(ws.PTYOutput{Type: ws.TypePTYOutput, SessionID: sessionID, Data: encrypted, ViewerID: viewerID}) + writePTYMessage(write, ws.PTYOutput{Type: ws.TypePTYOutput, SessionID: sessionID, Data: encrypted, ViewerID: viewerID}) sent = end } } @@ -518,15 +749,27 @@ func sendPTYOutput(sessionID string, data []byte, gcm cipher.AEAD, write ws.PTYW // gzip stream so the browser can decompress them individually. const replayChunkSize = 128 * 1024 // 128KB raw → compresses well under WS limit +func replayChunkEnd(raw []byte, start int) int { + end := start + replayChunkSize + if end >= len(raw) { + return len(raw) + } + adjusted := end + for steps := 0; steps < utf8.UTFMax-1 && adjusted > start && !utf8.RuneStart(raw[adjusted]); steps++ { + adjusted-- + } + if adjusted > start && utf8.RuneStart(raw[adjusted]) { + return adjusted + } + return end +} + func sendReplayChunkedTagged(sessionID, viewerID string, raw []byte, gcm cipher.AEAD, write ws.PTYWriteFunc) { sent := 0 chunks := 0 totalCompressed := 0 for sent < len(raw) { - end := sent + replayChunkSize - if end > len(raw) { - end = len(raw) - } + end := replayChunkEnd(raw, sent) chunk := raw[sent:end] compressed, gzErr := gzipData(chunk) if gzErr != nil { @@ -538,7 +781,7 @@ func sendReplayChunkedTagged(sessionID, viewerID string, raw []byte, gcm cipher. log.Printf("pty session %s: replay chunk encrypt error: %v", sessionID, encErr) return } - write(ws.PTYOutput{Type: ws.TypePTYOutput, SessionID: sessionID, Data: encrypted, Compressed: isCompressed, ViewerID: viewerID}) + writePTYMessage(write, ws.PTYOutput{Type: ws.TypePTYOutput, SessionID: sessionID, Data: encrypted, Compressed: isCompressed, ViewerID: viewerID}) totalCompressed += len(compressed) sent = end chunks++ @@ -584,6 +827,34 @@ func filterProjectsByPaths(projects []ws.WingProject, resolvedPaths []string) [] return out } +// discoverWingProjects returns the project metadata a wing may advertise. +// Explicit path configuration is a disclosure boundary: never supplement it +// with projects found beneath the process cwd. +func discoverWingProjects(resolvedPaths []string, cwd string) []ws.WingProject { + scanPaths := resolvedPaths + maxDepth := 3 + if len(scanPaths) == 0 { + if cwd == "" { + return nil + } + scanPaths = []string{cwd} + maxDepth = 2 + } + + seen := make(map[string]bool) + var projects []ws.WingProject + for _, scanPath := range scanPaths { + for _, project := range discoverProjects(scanPath, maxDepth) { + if seen[project.Path] { + continue + } + seen[project.Path] = true + projects = append(projects, project) + } + } + return projects +} + // isUnderPaths returns true if path is equal to or under one of the resolved paths. func isUnderPaths(path string, resolvedPaths []string) bool { cleaned := filepath.Clean(path) @@ -617,22 +888,10 @@ func isExactPath(path string, paths []string) bool { return false } -// isMemberRole returns true if the org role is "member" or empty (not owner/admin). +// isMemberRole grants elevated behavior only to the two coordinator roles the +// wing understands. Empty, legacy, and unexpected values stay least-privilege. func isMemberRole(orgRole string) bool { - return orgRole == "member" || orgRole == "" -} - -// isPathMember returns true if email matches any member in any path entry. -func isPathMember(paths config.PathList, email string) bool { - emailLower := strings.ToLower(email) - for _, e := range paths { - for _, m := range e.Members { - if strings.ToLower(m) == emailLower { - return true - } - } - } - return false + return orgRole != "owner" && orgRole != "admin" } // discoverProjects scans dir for git repositories up to maxDepth levels deep. @@ -783,34 +1042,51 @@ const maxLogSize = 1 << 20 // 1MB // rotateLog rotates path when it exceeds maxLogSize. // Chain: .log -> .log.1 -> .log.2.gz -> deleted -func rotateLog(path string) { +func rotateLog(path string) error { info, err := os.Stat(path) - if err != nil || info.Size() < maxLogSize { - return + if errors.Is(err, os.ErrNotExist) || (err == nil && info.Size() < maxLogSize) { + return nil + } + if err != nil { + return fmt.Errorf("inspect log for rotation: %w", err) } // Delete oldest (.log.2.gz) - os.Remove(path + ".2.gz") + if err := removeIfExists(path + ".2.gz"); err != nil { + return fmt.Errorf("remove oldest rotated log: %w", err) + } // Compress .log.1 -> .log.2.gz if data, err := os.ReadFile(path + ".1"); err == nil { if gz, err := os.Create(path + ".2.gz"); err == nil { w := gzip.NewWriter(gz) if _, werr := w.Write(data); werr != nil { - log.Printf("rotateLog: gzip write failed: %v", werr) + closeWithLog("rotated gzip stream", w) + closeWithLog("rotated log", gz) + return fmt.Errorf("compress rotated log: %w", werr) } if err := w.Close(); err != nil { - log.Printf("rotateLog: gzip close failed: %v", err) + closeWithLog("rotated log", gz) + return fmt.Errorf("finish rotated log compression: %w", err) } if err := gz.Close(); err != nil { - log.Printf("rotateLog: file close failed: %v", err) + return fmt.Errorf("close rotated log: %w", err) + } + if err := removeIfExists(path + ".1"); err != nil { + return fmt.Errorf("remove compressed source log: %w", err) } - os.Remove(path + ".1") + } else { + return fmt.Errorf("create compressed rotated log: %w", err) } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read rotated log: %w", err) } // Rotate current -> .log.1 - os.Rename(path, path+".1") + if err := os.Rename(path, path+".1"); err != nil { + return fmt.Errorf("rotate current log: %w", err) + } + return nil } func wingArgsPath() string { @@ -842,19 +1118,33 @@ func wingStatusPath() string { // wingStatus is the JSON schema for wing.status. type wingStatus struct { - State string `json:"state"` // connecting, connected, auth_failed, disconnected - Error string `json:"error,omitempty"` - TS string `json:"ts"` + State string `json:"state"` // connecting, connected, auth_failed, disconnected + Error string `json:"error,omitempty"` + TS string `json:"ts"` + RoostURL string `json:"roost_url,omitempty"` } func writeWingStatus(state, lastErr string) { - s := wingStatus{State: state, Error: lastErr, TS: time.Now().UTC().Format(time.RFC3339)} - data, _ := json.Marshal(s) - tmp := wingStatusPath() + ".tmp" - if err := os.WriteFile(tmp, data, 0644); err != nil { + writeWingStatusForRoost(state, lastErr, "") +} + +func writeWingStatusForRoost(state, lastErr, roostURL string) { + s := wingStatus{ + State: state, + Error: lastErr, + TS: time.Now().UTC().Format(time.RFC3339), + RoostURL: relayMetadataURL(roostURL), + } + data, err := json.Marshal(s) + if err != nil { + log.Printf("encode wing status: %v", err) return } - os.Rename(tmp, wingStatusPath()) + // A custom coordinator URL can itself carry deployment metadata. Keep the + // status private just like the saved daemon arguments that selected it. + if err := writeAtomicMetadataFile(wingStatusPath(), data, 0600); err != nil { + log.Printf("write wing status: %v", err) + } } func readWingStatus() (*wingStatus, error) { @@ -920,29 +1210,275 @@ func roostLogPath() string { return filepath.Join(home, ".wingthing", "roost.log") } -// readPidFrom reads a PID from a specific file and checks the process is alive. -func readPidFrom(path string) (int, error) { +func writeDaemonMetadata(pidPath, argsPath string, pid int, args []string) error { + if err := writeAtomicMetadataFile(argsPath, []byte(strings.Join(args, "\n")), 0600); err != nil { + return fmt.Errorf("write daemon args: %w", err) + } + if err := writeAtomicMetadataFile(pidPath, []byte(strconv.Itoa(pid)), 0644); err != nil { + _ = os.Remove(argsPath) + return fmt.Errorf("write daemon pid: %w", err) + } + return nil +} + +func writeAtomicMetadataFile(path string, data []byte, mode os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".wt-daemon-*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer removeWithLog(tmpPath) + if err := tmp.Chmod(mode); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return err + } + dir, err := os.Open(filepath.Dir(path)) + if err != nil { + return err + } + defer closeWithLog("metadata directory", dir) + return dir.Sync() +} + +func acquireDaemonLifecycleLock() (*os.File, error) { + return acquireDaemonLifecycleLockAt(filepath.Join(filepath.Dir(wingPidPath()), "daemon.lock")) +} + +func acquireDaemonLifecycleLockAt(path string) (*os.File, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, fmt.Errorf("open daemon lifecycle lock: %w", err) + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = file.Close() + if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + return nil, fmt.Errorf("another daemon start/stop is already in progress") + } + return nil, fmt.Errorf("lock daemon lifecycle: %w", err) + } + return file, nil +} + +// abandonStartedDaemon terminates and reaps a child whose startup could not be +// committed to disk. This prevents a successful exec from becoming an +// invisible daemon when readiness or metadata persistence fails. +func abandonStartedDaemon(child *exec.Cmd) { + if child == nil || child.Process == nil { + return + } + _ = child.Process.Signal(syscall.SIGTERM) + done := make(chan struct{}) + go func() { + _ = child.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + _ = child.Process.Kill() + <-done + } +} + +type daemonKind string + +const ( + wingDaemon daemonKind = "wing" + roostDaemon daemonKind = "roost" +) + +var ( + errNoDaemonRunning = errors.New("no daemon running") + errStaleDaemonPID = errors.New("stale daemon pid") +) + +// readPidFrom reads a PID from a specific file and verifies that it still +// identifies the expected wt foreground daemon. PIDs are recycled, so merely +// finding a live same-UID process is not enough before callers send signals. +func readPidFrom(path string, kind daemonKind) (int, error) { data, err := os.ReadFile(path) if err != nil { return 0, err } pid, err := strconv.Atoi(strings.TrimSpace(string(data))) if err != nil { - return 0, err + _ = os.Remove(path) + return 0, fmt.Errorf("%w: invalid PID: %v", errStaleDaemonPID, err) } if !ownedProcessIsAlive(pid) { - os.Remove(path) - return 0, fmt.Errorf("stale pid") + _ = os.Remove(path) + return 0, errStaleDaemonPID + } + matches, inspectErr := inspectDaemonPid(pid, kind) + if inspectErr != nil { + return 0, inspectErr + } + if !matches { + _ = os.Remove(path) + return 0, errStaleDaemonPID } return pid, nil } +// daemonPidMatches confirms the command shape emitted by wingStartCmd or +// roostStartCmd. Failure to inspect argv fails closed: a status check may call +// a daemon stopped, but stop/update will never signal an unconfirmed process. +func inspectDaemonPid(pid int, kind daemonKind) (bool, error) { + argv, err := processArgv(pid) + if err != nil { + if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ESRCH) { + return false, nil + } + return false, fmt.Errorf("inspect %s daemon pid %d: %w", kind, pid, err) + } + return daemonArgvMatches(argv, kind), nil +} + +func daemonArgvMatches(argv []string, kind daemonKind) bool { + if len(argv) < 4 || argv[2] != "start" { + return false + } + switch kind { + case wingDaemon: + if argv[1] != "wing" && argv[1] != "daemon" { + return false + } + case roostDaemon: + if argv[1] != "roost" { + return false + } + default: + return false + } + for _, arg := range argv[3:] { + if arg == "--foreground" { + return true + } + } + return false +} + +func parseSavedDaemonArgs(data []byte, kind daemonKind) ([]string, error) { + trimmed := strings.TrimSpace(string(data)) + if trimmed == "" { + return nil, fmt.Errorf("empty daemon args") + } + args := strings.Split(trimmed, "\n") + argv := append([]string{"wt"}, args...) + if !daemonArgvMatches(argv, kind) { + return nil, fmt.Errorf("saved args do not describe a %s foreground daemon", kind) + } + return args, nil +} + +// readDaemon returns the one live daemon represented by local metadata. A +// healthy installation cannot run a standalone wing and a roost at once. If +// both metadata files identify live daemons, fail closed so lifecycle commands +// do not stop an arbitrary half of the conflicting installation. +func readDaemon() (int, daemonKind, error) { + wingPID, wingErr := readPidFrom(wingPidPath(), wingDaemon) + roostPID, roostErr := readPidFrom(roostPidPath(), roostDaemon) + if wingErr == nil && roostErr == nil { + return 0, "", fmt.Errorf("both wing (pid %d) and roost (pid %d) daemons are running", wingPID, roostPID) + } + if wingErr == nil { + if !daemonAbsentError(roostErr) { + return 0, "", roostErr + } + return wingPID, wingDaemon, nil + } + if roostErr == nil { + if !daemonAbsentError(wingErr) { + return 0, "", wingErr + } + return roostPID, roostDaemon, nil + } + if !daemonAbsentError(wingErr) { + return 0, "", wingErr + } + if !daemonAbsentError(roostErr) { + return 0, "", roostErr + } + return 0, "", errNoDaemonRunning +} + +func daemonAbsentError(err error) bool { + return os.IsNotExist(err) || errors.Is(err, errStaleDaemonPID) +} + // readPid tries wing.pid first, then roost.pid. Returns the first live daemon PID. func readPid() (int, error) { - if pid, err := readPidFrom(wingPidPath()); err == nil { - return pid, nil + pid, _, err := readDaemon() + return pid, err +} + +// stopDaemonAndWait revalidates the daemon command immediately before +// signaling it, then waits until that specific daemon identity is gone. This +// keeps the lifecycle lock meaningful: callers must not delete metadata and +// allow a replacement to start while the old listener is still shutting down. +func stopDaemonAndWait(pid int, kind daemonKind, timeout time.Duration) error { + if !ownedProcessIsAlive(pid) { + return nil + } + matches, inspectErr := inspectDaemonPid(pid, kind) + if inspectErr != nil { + return inspectErr + } + if !matches { + return nil + } + proc, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("find %s daemon pid %d: %w", kind, pid, err) + } + if err := proc.Signal(syscall.SIGTERM); err != nil { + if !ownedProcessIsAlive(pid) { + return nil + } + matches, inspectErr = inspectDaemonPid(pid, kind) + if inspectErr != nil { + return inspectErr + } + if !matches { + return nil + } + return fmt.Errorf("stop %s daemon pid %d: %w", kind, pid, err) + } + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + if !ownedProcessIsAlive(pid) { + return nil + } + matches, inspectErr = inspectDaemonPid(pid, kind) + if inspectErr != nil { + return inspectErr + } + if !matches { + return nil + } + select { + case <-deadline.C: + return fmt.Errorf("%s daemon pid %d did not stop within %s", kind, pid, timeout) + case <-ticker.C: + } } - return readPidFrom(roostPidPath()) } func wingCmd() *cobra.Command { @@ -988,10 +1524,17 @@ func wingStartCmd() *cobra.Command { if foregroundFlag { return runWingForeground(cmd, roostFlag, labelsFlag, convFlag, eggConfigFlag, orgFlag, allowFlags, pathsFlag, debugFlag, auditFlag, localFlag, !rawReplayFlag) } + lifecycleLock, err := acquireDaemonLifecycleLock() + if err != nil { + return err + } + defer closeWithLog("daemon lifecycle lock", lifecycleLock) // Daemon mode (default): re-exec detached, write PID file, return - if pid, err := readPid(); err == nil { + if pid, _, err := readDaemon(); err == nil { return fmt.Errorf("wing daemon already running (pid %d)", pid) + } else if !errors.Is(err, errNoDaemonRunning) { + return fmt.Errorf("inspect daemon state: %w", err) } // Pre-flight auth probe: catch expired tokens before spawning daemon @@ -1003,26 +1546,8 @@ func wingStartCmd() *cobra.Command { if tokErr != nil || !ts.IsValid(tok) { return fmt.Errorf("not logged in — run: wt login") } - // Mirror the daemon's relay URL resolution: flag → wing.yaml → config → default - relayURL := roostFlag - if relayURL == "" { - if wc, wcErr := config.LoadWingConfig(cfg.Dir); wcErr == nil && wc.Roost != "" { - relayURL = wc.Roost - } - } - if localFlag && relayURL == "" { - relayURL = "http://localhost:8080" - } - if relayURL == "" { - relayURL = cfg.RoostURL - } - if relayURL == "" { - relayURL = "https://ws.wingthing.ai" - } - // Ensure HTTP scheme for the auth probe (roost URLs may use wss://) - relayURL = strings.TrimRight(relayURL, "/") - relayURL = strings.Replace(relayURL, "wss://", "https://", 1) - relayURL = strings.Replace(relayURL, "ws://", "http://", 1) + // Use the same precedence and normalization as the child daemon. + relayURL := resolveWingRelayHTTPURL(cfg, roostFlag, localFlag) if err := auth.ValidateTokenRemote(relayURL, tok.Token); err != nil { if errors.Is(err, auth.ErrAuthFailed) { return fmt.Errorf("login expired — run: wt login") @@ -1076,15 +1601,23 @@ func wingStartCmd() *cobra.Command { } // Remove stale status from previous run - os.Remove(wingStatusPath()) + if err := removeIfExists(wingStatusPath()); err != nil { + return fmt.Errorf("remove stale wing status: %w", err) + } - rotateLog(wingLogPath()) + if err := rotateLog(wingLogPath()); err != nil { + return err + } logFile, err := os.OpenFile(wingLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { return fmt.Errorf("open log: %w", err) } - home, _ := os.UserHomeDir() + home, err := os.UserHomeDir() + if err != nil { + closeWithLog("wing log", logFile) + return fmt.Errorf("resolve user home: %w", err) + } child := exec.Command(exe, childArgs...) child.Dir = home @@ -1093,16 +1626,17 @@ func wingStartCmd() *cobra.Command { child.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := child.Start(); err != nil { - logFile.Close() + closeWithLog("wing log", logFile) return fmt.Errorf("start daemon: %w", err) } - logFile.Close() - - if err := os.WriteFile(wingPidPath(), []byte(strconv.Itoa(child.Process.Pid)), 0644); err != nil { - log.Printf("warning: failed to write PID file: %v", err) + if err := logFile.Close(); err != nil { + abandonStartedDaemon(child) + return fmt.Errorf("close wing log: %w", err) } - if err := os.WriteFile(wingArgsPath(), []byte(strings.Join(childArgs, "\n")), 0644); err != nil { - log.Printf("warning: failed to write args file: %v", err) + + if err := writeDaemonMetadata(wingPidPath(), wingArgsPath(), child.Process.Pid, childArgs); err != nil { + abandonStartedDaemon(child) + return fmt.Errorf("start daemon: %w", err) } // Wait for daemon to report initial connection state @@ -1110,12 +1644,10 @@ func wingStartCmd() *cobra.Command { switch startupResult { case "auth_failed": // Kill daemon, clean up - if proc, findErr := os.FindProcess(child.Process.Pid); findErr == nil { - proc.Signal(syscall.SIGTERM) + abandonStartedDaemon(child) + if err := removeFiles(wingPidPath(), wingArgsPath(), wingStatusPath()); err != nil { + return errors.Join(fmt.Errorf("login expired — run: wt login"), fmt.Errorf("remove failed daemon metadata: %w", err)) } - os.Remove(wingPidPath()) - os.Remove(wingArgsPath()) - os.Remove(wingStatusPath()) return fmt.Errorf("login expired — run: wt login") case "connected": fmt.Printf("wing daemon started (pid %d)\n", child.Process.Pid) @@ -1125,9 +1657,12 @@ func wingStartCmd() *cobra.Command { fmt.Printf("wing daemon started (pid %d)\n", child.Process.Pid) fmt.Printf(" relay: connecting...\n") } + if err := child.Process.Release(); err != nil { + log.Printf("warning: failed to release daemon process handle: %v", err) + } // Show account identity if cfgLoaded, cfgErr := config.Load(); cfgErr == nil { - relayURL := resolveRelayHTTPURL(cfgLoaded) + relayURL := resolveWingRelayHTTPURL(cfgLoaded, roostFlag, localFlag) if tok, tokErr := auth.NewTokenStore(cfgLoaded.Dir).Load(); tokErr == nil && tok != nil { if info, infoErr := auth.FetchUserInfo(relayURL, tok.Token); infoErr == nil { fmt.Printf(" account: %s\n", formatUserIdentity(info)) @@ -1136,14 +1671,16 @@ func wingStartCmd() *cobra.Command { } fmt.Printf(" log: %s\n", wingLogPath()) fmt.Println() - if localFlag { - localURL := roostFlag - if localURL == "" { - localURL = "http://localhost:8080" - } - fmt.Printf("open %s to start a terminal\n", localURL) + if cfgLoaded, cfgErr := config.Load(); cfgErr == nil { + browserURL := roostBrowserURL(resolveWingRelayHTTPURL(cfgLoaded, roostFlag, localFlag)) + fmt.Printf("open %s for wing status and direct-agent setup\n", browserURL) + } else if localFlag { + fmt.Println("open http://localhost:8080/app/ for wing status and direct-agent setup") } else { - fmt.Println("open https://app.wingthing.ai to start a terminal") + fmt.Println("open https://app.wingthing.ai/ for wing status and direct-agent setup") + } + if !localFlag { + fmt.Println("hosted browser terminals require relay access") } return nil }, @@ -1168,25 +1705,25 @@ func wingStartCmd() *cobra.Command { func runWingForeground(cmd *cobra.Command, roostFlag, labelsFlag, convFlag, eggConfigFlag, orgFlag string, allowFlags []string, pathsFlag string, debug, audit, local, vte bool) error { ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) defer cancel() - defer os.Remove(wingStatusPath()) + defer removeWithLog(wingStatusPath()) sighupCh := make(chan os.Signal, 1) signal.Notify(sighupCh, syscall.SIGHUP) + defer signal.Stop(sighupCh) - return runWingWithContext(ctx, sighupCh, roostFlag, labelsFlag, convFlag, eggConfigFlag, orgFlag, allowFlags, pathsFlag, debug, audit, local, vte, false) + return runWingWithContext(ctx, sighupCh, roostFlag, labelsFlag, convFlag, eggConfigFlag, orgFlag, allowFlags, pathsFlag, debug, audit, local, vte, false, nil) } -func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFlag, labelsFlag, convFlag, eggConfigFlag, orgFlag string, allowFlags []string, pathsFlag string, debug, audit, local, vte, sharedHost bool) error { +func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFlag, labelsFlag, convFlag, eggConfigFlag, orgFlag string, allowFlags []string, pathsFlag string, debug, audit, local, vte, sharedHost bool, tokenOverride *auth.DeviceToken) error { cfg, err := config.Load() if err != nil { return err } // Load wing.yaml - wingCfg, err := config.LoadWingConfig(cfg.Dir) + wingCfg, err := loadWingConfigForStart(cfg.Dir) if err != nil { - log.Printf("wing: load wing.yaml: %v (continuing with defaults)", err) - wingCfg = &config.WingConfig{} + return err } // Merge wing.yaml with CLI flags (CLI extends yaml) @@ -1291,16 +1828,21 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla if roostURL == "" { roostURL = "https://ws.wingthing.ai" } - passkeyPolicy := passkeyPolicyForRoost(passkeyRPURL(roostURL, os.Getenv("WT_BASE_URL"))) + var passkeyPolicyLive atomic.Value + passkeyPolicyLive.Store(passkeyPolicyForRoost(passkeyRPURL(roostURL, os.Getenv("WT_BASE_URL")))) + currentPasskeyPolicy := func() auth.PasskeyPolicy { + return passkeyPolicyLive.Load().(auth.PasskeyPolicy) + } // Convert HTTP URL to WebSocket URL wsURL := strings.Replace(roostURL, "https://", "wss://", 1) wsURL = strings.Replace(wsURL, "http://", "ws://", 1) wsURL = strings.TrimRight(wsURL, "/") + "/ws/wing" - // Load auth token - ts := auth.NewTokenStore(cfg.Dir) - tok, err := ts.Load() - if err != nil || !ts.IsValid(tok) { + // An all-in-one roost passes its embedded service credential in memory. It + // must not overwrite device_token.yaml: that file may hold the operator's + // independent wingthing.ai identity for standalone wings on this machine. + tok, err := wingConnectionToken(cfg.Dir, local, tokenOverride) + if err != nil { if local { return fmt.Errorf("no device token — run: wt serve --local") } @@ -1338,26 +1880,12 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla rootDir = resolvedPaths[0] } - // Scan for git projects in each path + // Scan only within explicitly configured paths. When no paths are configured, + // preserve the legacy cwd discovery behavior. A detached daemon starts in the + // user's home directory, so adding cwd to an explicit --paths scan would + // disclose unrelated project names and paths to the coordinator. cwd, _ := os.Getwd() - seen := make(map[string]bool) - var projects []ws.WingProject - for _, sp := range resolvedPaths { - for _, p := range discoverProjects(sp, 3) { - if !seen[p.Path] { - seen[p.Path] = true - projects = append(projects, p) - } - } - } - if cwd != "" { - for _, p := range discoverProjects(cwd, 2) { - if !seen[p.Path] { - seen[p.Path] = true - projects = append(projects, p) - } - } - } + projects := discoverWingProjects(resolvedPaths, cwd) fmt.Printf("connecting to %s\n", wsURL) fmt.Printf(" agents: %v\n", agents) @@ -1375,11 +1903,7 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla fmt.Printf(" access control enabled: %d pinned + %d ephemeral keys\n", pinnedCount, ephemeralCount) } fmt.Println() - if local || strings.Contains(roostURL, "localhost") { - fmt.Printf("open %s to start a terminal\n", strings.TrimRight(roostURL, "/")) - } else { - fmt.Println("open https://app.wingthing.ai to start a terminal") - } + fmt.Printf("open %s to start a terminal\n", roostBrowserURL(roostURL)) // Reap dead egg directories on startup reapDeadEggs(cfg) @@ -1394,10 +1918,12 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla return fmt.Errorf("load private key: %w", privKeyErr) } - // P2P: initialize PeerManager if connection mode supports it + // WebRTC backs both opt-in browser PTY migration and native direct MCP. + // Keep the manager available in ordinary relay mode for native control, but + // advertise browser P2P only for its existing p2p/p2p_only modes below. var peerMgr *webrtcpkg.PeerManager - p2pEnabled := wingCfg.ConnectionMode == "p2p" || wingCfg.ConnectionMode == "p2p_only" - if p2pEnabled { + peerManagerEnabled := wingCfg.ConnectionMode != "direct" + if peerManagerEnabled { var iceServers []pionwebrtc.ICEServer for _, s := range wingCfg.ICEServers { iceServers = append(iceServers, pionwebrtc.ICEServer{ @@ -1414,39 +1940,72 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla // P2P: track DataChannels and SwappableWriters per session var dcSessions sync.Map // sessionID → *pionwebrtc.DataChannel var swSessions sync.Map // sessionID → *webrtcpkg.SwappableWriter + directMCPAdmission := newMCPAdmissionState() var client *ws.Client // declared early so peerMgr.OnDC closure can capture it + var directSrv *directpkg.Server // P2P: wire up DataChannel message routing when DCs open if peerMgr != nil { - peerMgr.OnDC(func(senderPub, sessionID string, dc *pionwebrtc.DataChannel) { + peerMgr.OnDC(func(senderPub, sessionID string, ident webrtcpkg.PeerIdentity, dc *pionwebrtc.DataChannel) { + if strings.HasPrefix(dc.Label(), control.DirectChannelPrefix) { + if ident.UserID == "" { + log.Printf("[P2P] rejected direct MCP channel from %s: missing authenticated identity", shortLogValue(senderPub)) + if err := dc.Close(); err != nil { + log.Printf("[P2P] close rejected direct MCP channel: %v", err) + } + return + } + serveDirectMCPChannelWithPolicySource(cfg, home, sharedHost, directMCPAdmission, ident, dc, func() (*config.WingConfig, []config.AllowKey) { + wingCfgMu.Lock() + defer wingCfgMu.Unlock() + return wingCfg.Clone(), append([]config.AllowKey(nil), allowedKeys...) + }) + return + } if sessionID == "" { - log.Printf("[P2P] DC opened with no session ID from %s", senderPub[:8]) + log.Printf("[P2P] DC opened with no session ID from %s", shortLogValue(senderPub)) + return + } + if !ws.ValidSessionID(sessionID) { + log.Printf("[P2P] rejected DC with invalid session ID %q from %s", sessionID, shortLogValue(senderPub)) + if err := dc.Close(); err != nil { + log.Printf("[P2P] close invalid session channel: %v", err) + } return } // The label is client-controlled; a DataChannel feeds the session's // trusted input channel, so only the session owner's peer identity // may bind one. Anything else could inject input or kill a session // it does not own. - ident, ok := peerMgr.GetPeerIdentity(senderPub) owner := readEggOwner(filepath.Join(cfg.Dir, "eggs", sessionID)) - if !ok || owner == "" || ident.UserID != owner { - log.Printf("[P2P] rejected DC for session %s from %s: sender is not the session owner", sessionID, senderPub[:8]) - dc.Close() + if ident.UserID == "" || owner == "" || ident.UserID != owner { + log.Printf("[P2P] rejected DC for session %s from %s: sender is not the session owner", sessionID, shortLogValue(senderPub)) + if err := dc.Close(); err != nil { + log.Printf("[P2P] close rejected session channel: %v", err) + } return } dcSessions.Store(sessionID, dc) - log.Printf("[P2P] DC stored for session %s from %s", sessionID, senderPub[:8]) + log.Printf("[P2P] DC stored for session %s from %s", sessionID, shortLogValue(senderPub)) dc.OnMessage(func(msg pionwebrtc.DataChannelMessage) { + if !currentDataChannel(&dcSessions, sessionID, dc) { + return + } client.PushPTYInput(sessionID, msg.Data) }) dc.OnClose(func() { - dcSessions.Delete(sessionID) + if !dcSessions.CompareAndDelete(sessionID, dc) { + log.Printf("[P2P] stale DC closed for session %s", sessionID) + return + } // Trigger fallback to relay if session still active if swVal, ok := swSessions.Load(sessionID); ok { sessionSW := swVal.(*webrtcpkg.SwappableWriter) - sessionSW.FallbackToRelay(sessionID) + if err := sessionSW.FallbackToRelay(sessionID); err != nil { + log.Printf("[P2P] fall back session %s to relay: %v", sessionID, err) + } } log.Printf("[P2P] DC closed for session %s", sessionID) }) @@ -1469,6 +2028,28 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla RootDir: rootDir, Locked: wingCfg.Locked, AllowedCount: len(wingCfg.AllowKeys), + DirectMCP: directMCPEnabled(peerMgr != nil, wingCfg), + HostedRelay: wingCfg.EffectiveHostedRelay(), + } + client.OnRegistered = func(msg ws.RegisteredMsg) { + if policy, ok := passkeyPolicyFromRegistration(msg); ok { + passkeyPolicyLive.Store(policy) + log.Printf("passkey relying-party policy synchronized (rp_id=%s origins=%d)", policy.RPID, len(policy.Origins)) + } + if directSrv != nil && msg.RelayPubKey != "" { + pubKey, err := relaypkg.ParseECPublicKey(msg.RelayPubKey) + if err != nil { + log.Printf("[direct] reject relay public key: %v", err) + } else { + directSrv.SetRelayPublicKey(pubKey) + log.Printf("[direct] relay public key synchronized for JWT verification") + } + } + } + client.OnHostedRelayDenied = func(operation string) { + if err := appendHostedRelayPolicyAudit(cfg, operation); err != nil { + log.Printf("hosted relay policy audit: %v", err) + } } client.OnStateChange = func(state string, stateErr error) { @@ -1476,7 +2057,7 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla if stateErr != nil { errMsg = stateErr.Error() } - writeWingStatus(state, errMsg) + writeWingStatusForRoost(state, errMsg, roostURL) switch state { case "auth_failed": log.Printf("FATAL: relay rejected authentication — run: wt logout && wt login && wt start") @@ -1492,14 +2073,18 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla } client.OnPTY = func(ctx context.Context, start ws.PTYStart, write ws.PTYWriteFunc, input <-chan []byte) { + wingCfgMu.Lock() + sessionWingCfg := wingCfg.Clone() + sessionAllowedKeys := append([]config.AllowKey(nil), allowedKeys...) + wingCfgMu.Unlock() // Wing-level admin override: admins get full access regardless of org role - if wingCfg.IsAdmin(start.Email) && isMemberRole(start.OrgRole) { + if sessionWingCfg.IsAdmin(start.Email) && isMemberRole(start.OrgRole) { start.OrgRole = "admin" } // Per-user path ACLs: members only see their tagged folders - userPaths := pathsForRequest(wingCfg.Paths, start.Email, start.OrgRole, home) + userPaths := pathsForRequest(sessionWingCfg.Paths, start.Email, start.OrgRole, home) if isMemberRole(start.OrgRole) && len(userPaths) == 0 { - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "no accessible folders on this machine"}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "no accessible folders on this machine"}) return } // Clamp CWD to exact configured paths (not subdirectories). @@ -1511,9 +2096,9 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla } } // Members require egg.yaml in CWD (sandbox jail) - if isMemberRole(start.OrgRole) && len(wingCfg.Paths) > 0 { + if isMemberRole(start.OrgRole) && len(sessionWingCfg.Paths) > 0 { if _, err := os.Stat(filepath.Join(start.CWD, "egg.yaml")); os.IsNotExist(err) { - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "no egg.yaml in " + start.CWD + " — ask the wing owner to add a sandbox config"}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "no egg.yaml in " + start.CWD + " — ask the wing owner to add a sandbox config"}) return } } @@ -1525,14 +2110,14 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla eggCfg.Audit = true } var authTTL time.Duration // default 0 = boot-scoped, no expiry - if wingCfg.AuthTTL != "" { - if d, err := time.ParseDuration(wingCfg.AuthTTL); err == nil { + if sessionWingCfg.AuthTTL != "" { + if d, err := time.ParseDuration(sessionWingCfg.AuthTTL); err == nil { authTTL = d } } var idleTimeout time.Duration - if wingCfg.IdleTimeout != "" { - if d, err := time.ParseDuration(wingCfg.IdleTimeout); err == nil { + if sessionWingCfg.IdleTimeout != "" { + if d, err := time.ParseDuration(sessionWingCfg.IdleTimeout); err == nil { idleTimeout = d } } @@ -1546,14 +2131,14 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla sw = webrtcpkg.NewSwappableWriter(webrtcpkg.WriteFn(write)) swSessions.Store(start.SessionID, sw) defer swSessions.Delete(start.SessionID) - handlePTYSession(ctx, cfg, wingCfg, start, sw.Write, input, eggCfg, debugLive.Load(), vte, &allowedKeys, passkeyCache, passkeyPolicy, authTTL, idleTimeout, sw, &dcSessions, sessionTools, sharedHost) + handlePTYSession(ctx, cfg, sessionWingCfg, start, sw.Write, input, eggCfg, debugLive.Load(), vte, &sessionAllowedKeys, passkeyCache, currentPasskeyPolicy(), authTTL, idleTimeout, sw, &dcSessions, sessionTools, sharedHost) } else { - handlePTYSession(ctx, cfg, wingCfg, start, write, input, eggCfg, debugLive.Load(), vte, &allowedKeys, passkeyCache, passkeyPolicy, authTTL, idleTimeout, nil, nil, sessionTools, sharedHost) + handlePTYSession(ctx, cfg, sessionWingCfg, start, write, input, eggCfg, debugLive.Load(), vte, &sessionAllowedKeys, passkeyCache, currentPasskeyPolicy(), authTTL, idleTimeout, nil, nil, sessionTools, sharedHost) } } client.OnTunnel = func(ctx context.Context, req ws.TunnelRequest, write ws.PTYWriteFunc) { - handleTunnelRequest(ctx, cfg, wingCfg, req, write, &allowedKeys, passkeyCache, passkeyChallenges, passkeyPolicy, privKey, home, &wingEggMu, &wingEggCfg, auditLive.Load(), debugLive.Load(), client, peerMgr, &dcSessions) + handleTunnelRequest(ctx, cfg, wingCfg, req, write, &allowedKeys, passkeyCache, passkeyChallenges, currentPasskeyPolicy(), privKey, home, &wingEggMu, &wingEggCfg, auditLive.Load(), debugLive.Load(), client, peerMgr, &dcSessions) } client.OnOrphanKill = func(ctx context.Context, sessionID string) { @@ -1562,16 +2147,24 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla // Reclaim surviving egg sessions on every (re)connect client.OnReconnect = func(rctx context.Context) { + wingCfgMu.Lock() + reconnectWingCfg := wingCfg.Clone() + reconnectAllowedKeys := append([]config.AllowKey(nil), allowedKeys...) + wingCfgMu.Unlock() + if !reconnectWingCfg.HostedRelayAllowed() { + log.Printf("hosted relay payload transport disabled; skipping relay session reclaim") + return + } var authTTL time.Duration // default 0 = boot-scoped, no expiry - if wingCfg.AuthTTL != "" { - if d, err := time.ParseDuration(wingCfg.AuthTTL); err == nil { + if reconnectWingCfg.AuthTTL != "" { + if d, err := time.ParseDuration(reconnectWingCfg.AuthTTL); err == nil { authTTL = d } } wingToolsMu.Lock() reclaimTools := append([]*config.ToolConfig{}, wingTools...) wingToolsMu.Unlock() - reclaimEggSessions(rctx, cfg, client, wingCfg, allowedKeys, passkeyCache, passkeyPolicy, authTTL, reclaimTools) + reclaimEggSessions(rctx, cfg, client, reconnectWingCfg, reconnectAllowedKeys, passkeyCache, currentPasskeyPolicy(), authTTL, reclaimTools) } // SIGHUP reload goroutine — caller owns SIGTERM/SIGINT via ctx cancellation @@ -1591,13 +2184,17 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla log.Printf("reload failed: %v", err) continue } + if err := validateDirectMCPGrantConfig(newCfg); err != nil { + log.Printf("reload failed: %v", err) + continue + } + wingCfgMu.Lock() wingCfg.Locked = newCfg.Locked wingCfg.Spectate = newCfg.Spectate wingCfg.AllowKeys = newCfg.AllowKeys wingCfg.Admins = newCfg.Admins + wingCfg.DirectMCP = newCfg.DirectMCP allowedKeys = append([]config.AllowKey{}, newCfg.AllowKeys...) - client.Locked = newCfg.Locked - client.AllowedCount = len(newCfg.AllowKeys) // Hot-reload audit + debug (atomic, read at session start) auditLive.Store(newCfg.Audit) @@ -1610,15 +2207,14 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla // Hot-reload labels wingCfg.Labels = newCfg.Labels - client.Labels = newCfg.Labels // Hot-reload paths wingCfg.Paths = newCfg.Paths resolvedPaths = resolvePathStrings(newCfg.Paths.Strings(), home) if len(resolvedPaths) > 0 { - client.RootDir = resolvedPaths[0] + rootDir = resolvedPaths[0] } else { - client.RootDir = home + rootDir = home } // Hot-reload egg config (if path changed) @@ -1636,6 +2232,8 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla log.Printf("egg config reloaded from %s", eggPath) } } + client.UpdateRuntimeConfig(newCfg.Locked, len(newCfg.AllowKeys), directMCPEnabled(peerMgr != nil, newCfg), newCfg.Labels, rootDir) + wingCfgMu.Unlock() // Hot-reload tools newToolsDir := config.ResolveToolsDir(cfg.Dir, newCfg.ToolsDir) @@ -1648,7 +2246,9 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla log.Printf("tools reload failed: %v", tErr) } - client.SendConfig(ctx) + if err := client.SendConfig(ctx); err != nil { + log.Printf("send reloaded wing config: %v", err) + } log.Printf("config reloaded: locked=%v allowed=%d audit=%v debug=%v", newCfg.Locked, len(newCfg.AllowKeys), newCfg.Audit, newCfg.Debug) } } @@ -1666,9 +2266,12 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla return case <-ticker.C: } + wingCfgMu.Lock() + idleTimeoutValue := wingCfg.IdleTimeout + wingCfgMu.Unlock() var idleTimeout time.Duration - if wingCfg.IdleTimeout != "" { - if d, parseErr := time.ParseDuration(wingCfg.IdleTimeout); parseErr == nil { + if idleTimeoutValue != "" { + if d, parseErr := time.ParseDuration(idleTimeoutValue); parseErr == nil { idleTimeout = d } } @@ -1705,7 +2308,7 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla } } pollCancel() - ec.Close() + closeWithLog("idle-check egg client", ec) } } @@ -1714,8 +2317,10 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla sockPath := filepath.Join(eggDir, "egg.sock") tokenPath := filepath.Join(eggDir, "egg.token") if ec, dialErr := egg.Dial(sockPath, tokenPath); dialErr == nil { - ec.Kill(ctx, sid) - ec.Close() + if err := ec.Kill(ctx, sid); err != nil { + log.Printf("idle reaper: kill session %s: %v", sid, err) + } + closeWithLog("idle-reaper egg client", ec) } sessionStates.Delete(sid) } @@ -1723,35 +2328,23 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla }) } }() - if wingCfg.IdleTimeout != "" { - log.Printf("idle reaper enabled: timeout=%s", wingCfg.IdleTimeout) + wingCfgMu.Lock() + initialIdleTimeout := wingCfg.IdleTimeout + wingCfgMu.Unlock() + if initialIdleTimeout != "" { + log.Printf("idle reaper enabled: timeout=%s", initialIdleTimeout) } // Direct mode: start a local WebSocket server for direct browser connections if wingCfg.ConnectionMode == "direct" && wingCfg.DirectPort > 0 { - directSrv := &directpkg.Server{ + directSrv = &directpkg.Server{ OnPTY: client.OnPTY, } - go func() { - addr := fmt.Sprintf(":%d", wingCfg.DirectPort) - if err := directSrv.Start(addr); err != nil { - log.Printf("[direct] server error: %v", err) - } - }() - defer directSrv.Close() - - // Cache relay public key once available (set after registration) - go func() { - // Wait a bit for registration to complete - time.Sleep(3 * time.Second) - if client.RelayPubKey != "" { - pubKey, err := relaypkg.ParseECPublicKey(client.RelayPubKey) - if err == nil { - directSrv.RelayPubKey = pubKey - log.Printf("[direct] relay public key cached for JWT verification") - } - } - }() + addr := fmt.Sprintf(":%d", wingCfg.DirectPort) + if err := directSrv.StartAsync(addr); err != nil { + return fmt.Errorf("start direct server: %w", err) + } + defer closeWithLog("direct server", directSrv) } err = client.Run(ctx) @@ -1763,22 +2356,88 @@ func runWingWithContext(ctx context.Context, sighupCh <-chan os.Signal, roostFla return err } +func currentDataChannel(sessions *sync.Map, sessionID string, candidate *pionwebrtc.DataChannel) bool { + current, ok := sessions.Load(sessionID) + return ok && current == candidate +} + +func wingConnectionToken(configDir string, local bool, tokenOverride *auth.DeviceToken) (*auth.DeviceToken, error) { + store := auth.NewTokenStore(configDir) + if tokenOverride != nil { + copy := *tokenOverride + if !store.IsValid(©) { + return nil, fmt.Errorf("embedded wing token is expired") + } + return ©, nil + } + if local { + localStore := auth.NewLocalTokenStore(configDir) + token, err := localStore.Load() + if err != nil { + return nil, err + } + if localStore.IsValid(token) { + return token, nil + } + if token != nil { + return nil, fmt.Errorf("local device token is expired") + } + // Compatibility with releases that wrote the local credential into the + // ordinary token path. The next `wt serve --local` start writes the new + // dedicated file without replacing this fallback. + } + token, err := store.Load() + if err != nil { + return nil, err + } + if !store.IsValid(token) { + return nil, fmt.Errorf("device token is missing or expired") + } + return token, nil +} + +func loadWingConfigForStart(dir string) (*config.WingConfig, error) { + wingCfg, err := config.LoadWingConfig(dir) + if err != nil { + return nil, fmt.Errorf("load wing.yaml: %w", err) + } + if err := validateDirectMCPGrantConfig(wingCfg); err != nil { + return nil, fmt.Errorf("load wing.yaml: %w", err) + } + return wingCfg, nil +} + +func directMCPEnabled(hasPeerManager bool, wingCfg *config.WingConfig) bool { + return hasPeerManager && wingCfg != nil && (wingCfg.DirectMCP == nil || !wingCfg.DirectMCP.Disabled) +} + +func shortLogValue(value string) string { + if len(value) <= 8 { + return value + } + return value[:8] +} + func wingStopCmd() *cobra.Command { return &cobra.Command{ Use: "stop", Short: "Stop the wing daemon", RunE: func(cmd *cobra.Command, args []string) error { - pid, err := readPid() + lifecycleLock, lockErr := acquireDaemonLifecycleLock() + if lockErr != nil { + return lockErr + } + defer closeWithLog("daemon lifecycle lock", lifecycleLock) + pid, kind, err := readDaemon() if err != nil { return fmt.Errorf("no wing daemon running") } - proc, _ := os.FindProcess(pid) - if err := proc.Signal(syscall.SIGTERM); err != nil { - return fmt.Errorf("kill pid %d: %w", pid, err) + if err := stopDaemonAndWait(pid, kind, 5*time.Second); err != nil { + return err + } + if err := removeFiles(wingPidPath(), wingArgsPath(), wingStatusPath()); err != nil { + return fmt.Errorf("remove wing daemon metadata: %w", err) } - os.Remove(wingPidPath()) - os.Remove(wingArgsPath()) - os.Remove(wingStatusPath()) fmt.Printf("wing daemon stopped (pid %d)\n", pid) return nil }, @@ -1792,17 +2451,21 @@ func wingStatusCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { pid, err := readPid() if err != nil { - fmt.Println("wing daemon is not running") - return nil + if errors.Is(err, errNoDaemonRunning) { + fmt.Println("wing daemon is not running") + return nil + } + return fmt.Errorf("inspect daemon state: %w", err) } fmt.Printf("wing daemon is running (pid %d)\n", pid) cfg, _ := config.Load() + status, _ := readWingStatus() // Show account identity and relay verification var relayVerified bool if cfg != nil { - relayURL := resolveRelayHTTPURL(cfg) + relayURL := activeWingRelayHTTPURL(cfg, status) if tok, tokErr := auth.NewTokenStore(cfg.Dir).Load(); tokErr == nil && tok != nil { if info, infoErr := auth.FetchUserInfo(relayURL, tok.Token); infoErr == nil { fmt.Printf(" account: %s\n", formatUserIdentity(info)) @@ -1816,8 +2479,8 @@ func wingStatusCmd() *cobra.Command { } // Show relay connection state - if s, statusErr := readWingStatus(); statusErr == nil { - switch s.State { + if status != nil { + switch status.State { case "connected": if relayVerified { fmt.Println(" relay: connected (verified)") @@ -1829,13 +2492,13 @@ func wingStatusCmd() *cobra.Command { case "connecting": fmt.Println(" relay: connecting...") case "disconnected": - if s.Error != "" { - fmt.Printf(" relay: disconnected (%s)\n", s.Error) + if status.Error != "" { + fmt.Printf(" relay: disconnected (%s)\n", status.Error) } else { fmt.Println(" relay: disconnected") } default: - fmt.Printf(" relay: %s\n", s.State) + fmt.Printf(" relay: %s\n", status.State) } } @@ -1872,13 +2535,16 @@ func resolveEmail(cfg *config.Config, email string) (string, string, error) { if err != nil || !ts.IsValid(tok) { return "", "", fmt.Errorf("not logged in — run: wt login") } - req, _ := http.NewRequest("GET", strings.TrimRight(roostURL, "/")+"/api/app/resolve-email?email="+email, nil) + req, err := http.NewRequest(http.MethodGet, strings.TrimRight(roostURL, "/")+"/api/app/resolve-email?email="+url.QueryEscape(email), nil) + if err != nil { + return "", "", fmt.Errorf("build email lookup request: %w", err) + } req.Header.Set("Authorization", "Bearer "+tok.Token) - resp, err := http.DefaultClient.Do(req) + resp, err := cliHTTPClient.Do(req) if err != nil { return "", "", fmt.Errorf("resolve email: %w", err) } - defer resp.Body.Close() + defer closeWithLog("email lookup response", resp.Body) if resp.StatusCode != 200 { return "", "", fmt.Errorf("no user found with email: %s", email) } @@ -1886,7 +2552,9 @@ func resolveEmail(cfg *config.Config, email string) (string, string, error) { UserID string `json:"user_id"` DisplayName string `json:"display_name"` } - json.NewDecoder(resp.Body).Decode(&result) + if err := decodeCLIAPIResponse(resp.Body, &result); err != nil { + return "", "", fmt.Errorf("parse email lookup response: %w", err) + } return result.UserID, result.DisplayName, nil } @@ -1910,18 +2578,18 @@ func fetchCurrentPasskey(cfg *config.Config) (config.AllowKey, error) { return config.AllowKey{}, err } req.Header.Set("Authorization", "Bearer "+tok.Token) - resp, err := http.DefaultClient.Do(req) + resp, err := cliHTTPClient.Do(req) if err != nil { return config.AllowKey{}, fmt.Errorf("fetch passkeys: %w", err) } - defer resp.Body.Close() + defer closeWithLog("passkey response", resp.Body) if resp.StatusCode != http.StatusOK { return config.AllowKey{}, fmt.Errorf("fetch passkeys: HTTP %d", resp.StatusCode) } var credentials []struct { PublicKey string `json:"public_key"` } - if err := json.NewDecoder(resp.Body).Decode(&credentials); err != nil { + if err := decodeCLIAPIResponse(resp.Body, &credentials); err != nil { return config.AllowKey{}, fmt.Errorf("parse passkeys: %w", err) } for _, credential := range credentials { @@ -1996,13 +2664,16 @@ func wingAllowCmd() *cobra.Command { base := strings.TrimRight(roostURL, "/") // Resolve org slug to ID via GET /api/orgs - orgsReq, _ := http.NewRequest("GET", base+"/api/orgs", nil) + orgsReq, err := http.NewRequest(http.MethodGet, base+"/api/orgs", nil) + if err != nil { + return fmt.Errorf("build org lookup request: %w", err) + } orgsReq.Header.Set("Authorization", "Bearer "+tok.Token) - orgsResp, err := http.DefaultClient.Do(orgsReq) + orgsResp, err := cliHTTPClient.Do(orgsReq) if err != nil { return fmt.Errorf("fetch orgs: %w", err) } - defer orgsResp.Body.Close() + defer closeWithLog("org lookup response", orgsResp.Body) if orgsResp.StatusCode != 200 { return fmt.Errorf("fetch orgs: HTTP %d", orgsResp.StatusCode) } @@ -2010,7 +2681,7 @@ func wingAllowCmd() *cobra.Command { ID string `json:"id"` Slug string `json:"slug"` } - if err := json.NewDecoder(orgsResp.Body).Decode(&orgs); err != nil { + if err := decodeCLIAPIResponse(orgsResp.Body, &orgs); err != nil { return fmt.Errorf("parse orgs: %w", err) } var orgID string @@ -2025,13 +2696,16 @@ func wingAllowCmd() *cobra.Command { } // Fetch members via GET /api/orgs/{id}/members - req, _ := http.NewRequest("GET", base+"/api/orgs/"+orgID+"/members", nil) + req, err := http.NewRequest(http.MethodGet, base+"/api/orgs/"+url.PathEscape(orgID)+"/members", nil) + if err != nil { + return fmt.Errorf("build org member request: %w", err) + } req.Header.Set("Authorization", "Bearer "+tok.Token) - resp, err := http.DefaultClient.Do(req) + resp, err := cliHTTPClient.Do(req) if err != nil { return fmt.Errorf("fetch org members: %w", err) } - defer resp.Body.Close() + defer closeWithLog("org member response", resp.Body) if resp.StatusCode != 200 { return fmt.Errorf("fetch org members: HTTP %d", resp.StatusCode) } @@ -2043,7 +2717,7 @@ func wingAllowCmd() *cobra.Command { PasskeyPubKey string `json:"passkey_public_key"` } `json:"members"` } - if err := json.NewDecoder(resp.Body).Decode(&membersResp); err != nil { + if err := decodeCLIAPIResponse(resp.Body, &membersResp); err != nil { return fmt.Errorf("parse org members: %w", err) } members := membersResp.Members @@ -2087,7 +2761,9 @@ func wingAllowCmd() *cobra.Command { if err := config.SaveWingConfig(cfg.Dir, wingCfg); err != nil { return err } - signalDaemon(syscall.SIGHUP) + if err := signalDaemon(syscall.SIGHUP); err != nil { + return err + } } if skipped > 0 { fmt.Printf("skipped %d members without passkeys\n", skipped) @@ -2161,8 +2837,7 @@ func wingAllowCmd() *cobra.Command { display = keyB64[:12] + "..." } fmt.Printf("allowed %s\n", display) - signalDaemon(syscall.SIGHUP) - return nil + return signalDaemon(syscall.SIGHUP) }, } cmd.Flags().StringVar(&userIDFlag, "user-id", "", "relay user ID to allow") @@ -2199,8 +2874,7 @@ func wingRevokeCmd() *cobra.Command { return err } fmt.Printf("revoked all %d entries\n", count) - signalDaemon(syscall.SIGHUP) - return nil + return signalDaemon(syscall.SIGHUP) } if len(args) == 0 { @@ -2248,21 +2922,29 @@ func wingRevokeCmd() *cobra.Command { display = removed.Key[:12] + "..." } fmt.Printf("revoked: %s\n", display) - signalDaemon(syscall.SIGHUP) - return nil + return signalDaemon(syscall.SIGHUP) }, } cmd.Flags().Bool("all", false, "Revoke all entries from the allowlist") return cmd } -func signalDaemon(sig os.Signal) { +func signalDaemon(sig os.Signal) error { pid, err := readPid() if err != nil { - return + if daemonAbsentError(err) { + return nil + } + return fmt.Errorf("find daemon to signal: %w", err) + } + proc, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("find daemon process %d: %w", pid, err) + } + if err := proc.Signal(sig); err != nil { + return fmt.Errorf("signal daemon process %d: %w", pid, err) } - proc, _ := os.FindProcess(pid) - proc.Signal(sig) + return nil } func wingLockCmd() *cobra.Command { @@ -2312,7 +2994,9 @@ func wingLockCmd() *cobra.Command { if err := config.SaveWingConfig(cfg.Dir, wingCfg); err != nil { return err } - signalDaemon(syscall.SIGHUP) + if err := signalDaemon(syscall.SIGHUP); err != nil { + return err + } fmt.Println("wing locked") return nil }, @@ -2340,7 +3024,9 @@ func wingUnlockCmd() *cobra.Command { if err := config.SaveWingConfig(cfg.Dir, wingCfg); err != nil { return err } - signalDaemon(syscall.SIGHUP) + if err := signalDaemon(syscall.SIGHUP); err != nil { + return err + } fmt.Println("wing unlocked") return nil }, @@ -2381,6 +3067,7 @@ func wingConfigCmd() *cobra.Command { fmt.Printf("debug: %v\n", wingCfg.Debug) fmt.Printf("locked: %v\n", wingCfg.Locked) fmt.Printf("spectate: %v\n", wingCfg.Spectate) + fmt.Printf("hosted_relay: %s\n", wingCfg.EffectiveHostedRelay()) authTTL := wingCfg.AuthTTL if authTTL == "" { authTTL = "0" @@ -2411,7 +3098,7 @@ func wingConfigSetCmd() *cobra.Command { return err } - restartFields := map[string]bool{"org": true} + restartFields := map[string]bool{"org": true, "hosted_relay": true} immutableFields := map[string]bool{"wing_id": true, "roost": true, "allow_keys": true} var changedRestart []string @@ -2453,6 +3140,11 @@ func wingConfigSetCmd() *cobra.Command { return fmt.Errorf("spectate: expected true or false") } wingCfg.Spectate = b + case "hosted_relay": + if value != config.HostedRelayAllow && value != config.HostedRelayDeny { + return fmt.Errorf("hosted_relay: expected %q or %q", config.HostedRelayAllow, config.HostedRelayDeny) + } + wingCfg.HostedRelay = value case "labels": var labels []string for _, l := range strings.Split(value, ",") { @@ -2524,7 +3216,9 @@ func wingConfigSetCmd() *cobra.Command { return err } - signalDaemon(syscall.SIGHUP) + if err := signalDaemon(syscall.SIGHUP); err != nil { + return err + } for _, key := range changedRestart { fmt.Printf("%s: will take effect next restart\n", key) @@ -2630,24 +3324,29 @@ func reapDeadEggs(cfg *config.Config) { // cleanEggDir removes the files in an egg session directory, then the directory itself. // If audit files or chat history exist, preserves egg.meta, egg.owner, and data (only removes runtime files). func cleanEggDir(dir string) { - os.Remove(filepath.Join(dir, "egg.sock")) - os.Remove(filepath.Join(dir, "egg.token")) - os.Remove(filepath.Join(dir, "egg.pid")) + removeWithLog(filepath.Join(dir, "egg.sock")) + removeWithLog(filepath.Join(dir, "egg.token")) + removeWithLog(filepath.Join(dir, "egg.pid")) // Preserve egg.log — the parent process reads it via readEggCrashInfo // after this child exits. Deleting it here causes a race where the // crash message is lost ("egg process crashed (no log available)"). // The log is small and the parent's cleanEggDir call cleans it up later. // Keep egg.meta, egg.owner, and dir if audit recordings or chat history exist - _, hasPty := os.Stat(filepath.Join(dir, "audit.pty.gz")) - _, hasLog := os.Stat(filepath.Join(dir, "audit.log")) - _, hasChat := os.Stat(filepath.Join(dir, "chat.jsonl.gz")) - if hasPty == nil || hasLog == nil || hasChat == nil { - return + for _, name := range []string{"audit.pty.gz", "audit.log", "chat.jsonl.gz"} { + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + return + } else if !errors.Is(err, os.ErrNotExist) { + // An unreadable recording must never be mistaken for an absent one. + log.Printf("egg: preserve %s after stat failure: %v", dir, err) + return + } + } + // The egg copies its diagnostic log to the persistent log directory before + // normal shutdown. Remove the whole transient directory so crash logs and + // partially-created files do not leave an unreapable directory forever. + if err := os.RemoveAll(dir); err != nil { + log.Printf("egg: remove transient session directory %s: %v", dir, err) } - os.Remove(filepath.Join(dir, "egg.meta")) - os.Remove(filepath.Join(dir, "egg.owner")) - os.Remove(filepath.Join(dir, "session.name")) - os.Remove(dir) } // listAliveEggSessions scans ~/.wingthing/eggs/ for alive egg processes. @@ -2685,7 +3384,7 @@ func listAliveEggSessions(cfg *config.Config) []ws.SessionInfo { if dialErr != nil { continue } - ec.Close() + closeWithLog("egg health-check client", ec) agent, sessionCWD := readEggMeta(dir) info := ws.SessionInfo{ @@ -2735,6 +3434,10 @@ func eggPidMatchesSession(pid int, sessionID string) bool { // killOrphanEgg kills an egg session that has no active goroutine managing it. // This handles the case where a pty.kill arrives but the session was never reclaimed. func killOrphanEgg(cfg *config.Config, sessionID string) { + if err := validateSessionID(sessionID); err != nil { + log.Printf("refuse to kill invalid egg session: %v", err) + return + } dir := filepath.Join(cfg.Dir, "eggs", sessionID) sockPath := filepath.Join(dir, "egg.sock") tokenPath := filepath.Join(dir, "egg.token") @@ -2744,25 +3447,41 @@ func killOrphanEgg(cfg *config.Config, sessionID string) { // Can't reach egg — try to kill by PID, but only after confirming the // PID still belongs to this session's egg runner. pidPath := filepath.Join(dir, "egg.pid") + terminationRequested := false data, readErr := os.ReadFile(pidPath) if readErr == nil { if pid, parseErr := strconv.Atoi(strings.TrimSpace(string(data))); parseErr == nil && eggPidMatchesSession(pid, sessionID) { - if proc, findErr := os.FindProcess(pid); findErr == nil { - proc.Signal(syscall.SIGTERM) + if proc, findErr := os.FindProcess(pid); findErr != nil { + log.Printf("pty session %s: find orphan egg process %d: %v", sessionID, pid, findErr) + } else if signalErr := proc.Signal(syscall.SIGTERM); signalErr != nil { + log.Printf("pty session %s: terminate orphan egg process %d: %v", sessionID, pid, signalErr) + } else { + terminationRequested = true } } } - cleanEggDir(dir) - log.Printf("pty session %s: orphan killed (pid)", sessionID) + if terminationRequested { + // Leave runtime files in place until the egg exits and performs its + // own cleanup. Reaping them now can break an in-flight shutdown. + log.Printf("pty session %s: orphan termination requested (pid)", sessionID) + } else { + cleanEggDir(dir) + log.Printf("pty session %s: stale orphan metadata cleaned", sessionID) + } return } - ec.Kill(context.Background(), sessionID) - ec.Close() - cleanEggDir(dir) - log.Printf("pty session %s: orphan killed (grpc)", sessionID) + if killErr := ec.Kill(context.Background(), sessionID); killErr != nil { + log.Printf("pty session %s: terminate orphan over gRPC: %v", sessionID, killErr) + } else { + log.Printf("pty session %s: orphan termination requested (gRPC)", sessionID) + } + closeWithLog("orphan egg client", ec) } -func resizeEgg(cfg *config.Config, sessionID string, rows, cols uint32) error { +func resizeEgg(cfg *config.Config, sessionID string, rows, cols uint32) (result error) { + if err := validateSessionID(sessionID); err != nil { + return err + } if rows == 0 || cols == 0 || rows > 1000 || cols > 1000 { return fmt.Errorf("invalid terminal dimensions") } @@ -2771,7 +3490,7 @@ func resizeEgg(cfg *config.Config, sessionID string, rows, cols uint32) error { if err != nil { return fmt.Errorf("open session: %w", err) } - defer ec.Close() + defer func() { result = closeAndJoin("egg resize client", ec, result) }() resizeCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := ec.Resize(resizeCtx, sessionID, rows, cols); err != nil { @@ -2969,10 +3688,15 @@ func reclaimEggSessions(ctx context.Context, cfg *config.Config, wsClient *ws.Cl log.Printf("egg: reclaiming session %s (pid %d agent=%s)", sessionID, pid, agent) // Set up input routing for this session - write, input, cleanup := wsClient.RegisterPTYSession(ctx, sessionID) + write, input, cleanup, registered := wsClient.RegisterPTYSession(ctx, sessionID) + if !registered { + closeWithLog("duplicate reclaimed egg client", ec) + log.Printf("egg: session %s became active during reclaim, skipping", sessionID) + continue + } go func(sid string, ec *egg.Client, dir string) { defer cleanup() - defer ec.Close() + defer closeWithLog("reclaimed egg client", ec) handleReclaimedPTY(ctx, cfg, ec, sid, dir, write, input, wingCfg, allowedKeys, passkeyCache, passkeyPolicy, authTTL, tools) }(sessionID, ec, dir) } @@ -2988,7 +3712,7 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, privKey, privKeyErr := auth.LoadPrivateKey(cfg.Dir) if privKeyErr != nil { log.Printf("pty session %s: FATAL: load private key: %v (reclaim aborted)", sessionID, privKeyErr) - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: sessionID, ExitCode: 1, Error: "E2E encryption required but wing private key missing"}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: sessionID, ExitCode: 1, Error: "E2E encryption required but wing private key missing"}) return } wingPubKeyB64 := base64.StdEncoding.EncodeToString(privKey.PublicKey().Bytes()) @@ -3001,6 +3725,7 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, } sessionStates.Store(sessionID, reclaimIdleState) defer sessionStates.Delete(sessionID) + defer forgetAttentionState(sessionID) // Recreate the tool socket listener. It was owned by the previous daemon // process and died with it, but the surviving egg still points at this path @@ -3015,7 +3740,7 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, log.Printf("pty session %s: reclaim tool listener failed: %v", sessionID, tlErr) } else { log.Printf("pty session %s: reclaim tool listener restarted (%d tools)", sessionID, len(tools)) - defer tl.Close() + defer closeWithLog("reclaimed egg tool listener", tl) } } } @@ -3067,7 +3792,7 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, sendPTYOutput(sessionID, p.Output, currentGCM, write) case *pb.SessionMsg_ExitCode: log.Printf("pty session %s: exited with code %d", sessionID, p.ExitCode) - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: sessionID, ExitCode: int(p.ExitCode)}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: sessionID, ExitCode: int(p.ExitCode)}) clearAttentionCooldown(sessionID) sessionCancel() return @@ -3093,7 +3818,7 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, case <-pendingAuth.timeout(): for _, pending := range pendingAuth.expire(time.Now()) { log.Printf("pty session %s: reattach passkey timed out", sessionID) - write(ws.ErrorMsg{Type: ws.TypeError, Message: "passkey timed out", SessionID: sessionID, ViewerID: pending.attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "passkey timed out", SessionID: sessionID, ViewerID: pending.attach.ViewerID}) } continue case inputData, ok := <-input: @@ -3122,12 +3847,12 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, signature, _ := base64.StdEncoding.DecodeString(response.Signature) rawKey, verifyErr := verifySubjectPasskey(allowedKeys, pending.attach.UserID, pending.challenge, authData, clientData, signature, passkeyPolicy) if verifyErr != nil { - write(ws.ErrorMsg{Type: ws.TypeError, Message: "invalid passkey", SessionID: sessionID, ViewerID: pending.attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "invalid passkey", SessionID: sessionID, ViewerID: pending.attach.ViewerID}) continue } token, tokenErr := auth.GenerateAuthToken() if tokenErr != nil { - write(ws.ErrorMsg{Type: ws.TypeError, Message: "auth token generation failed", SessionID: sessionID, ViewerID: pending.attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "auth token generation failed", SessionID: sessionID, ViewerID: pending.attach.ViewerID}) continue } passkeyCache.Put(token, rawKey, pending.subject) @@ -3144,12 +3869,12 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, } } if !canAttachSession(attach.UserID, attach.OrgRole, readEggOwner(eggDir)) { - write(ws.ErrorMsg{Type: ws.TypeError, Message: "session not found or not owned by caller", SessionID: sessionID, ViewerID: attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "session not found or not owned by caller", SessionID: sessionID, ViewerID: attach.ViewerID}) continue } clearAttentionCooldown(sessionID) if attach.PublicKey == "" { - write(ws.ErrorMsg{Type: ws.TypeError, Message: "client encryption key required", SessionID: sessionID, ViewerID: attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "client encryption key required", SessionID: sessionID, ViewerID: attach.ViewerID}) continue } @@ -3158,7 +3883,7 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, attachSubject := passkeySubject(attach.UserID, attach.PublicKey) attachUserHasPasskey := len(passkeysForSubject(allowedKeys, attach.UserID)) > 0 if wingCfg.Locked && (attachSubject == "" || !attachUserHasPasskey) { - write(ws.ErrorMsg{Type: ws.TypeError, Message: "not allowed by wing", SessionID: sessionID, ViewerID: attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "not allowed by wing", SessionID: sessionID, ViewerID: attach.ViewerID}) continue } if attachUserHasPasskey { @@ -3176,7 +3901,7 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, log.Printf("pty session %s: reattach challenge generation failed: %v", sessionID, chalErr) continue } - write(ws.PasskeyChallenge{ + writePTYMessage(write, ws.PasskeyChallenge{ Type: ws.TypePasskeyChallenge, SessionID: sessionID, Challenge: base64.RawURLEncoding.EncodeToString(challenge), @@ -3193,22 +3918,22 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, // the controller, including after a wing daemon reclaim. if attach.Spectate { if !wingCfg.Spectate { - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: sessionID, ExitCode: 1, Error: "spectate not enabled", ViewerID: attach.ViewerID}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: sessionID, ExitCode: 1, Error: "spectate not enabled", ViewerID: attach.ViewerID}) continue } spectatorGCM, deriveErr := auth.DeriveSharedKey(privKey, attach.PublicKey, "wt-pty") if deriveErr != nil { - write(ws.ErrorMsg{Type: ws.TypeError, Message: "spectator encryption setup failed", SessionID: sessionID, ViewerID: attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "spectator encryption setup failed", SessionID: sessionID, ViewerID: attach.ViewerID}) continue } specCtx, specCancel := context.WithCancel(ctx) specStream, specErr := ec.AttachSession(specCtx, sessionID) if specErr != nil { specCancel() - write(ws.ErrorMsg{Type: ws.TypeError, Message: "spectator attach failed", SessionID: sessionID, ViewerID: attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "spectator attach failed", SessionID: sessionID, ViewerID: attach.ViewerID}) continue } - write(ws.PTYStarted{ + writePTYMessage(write, ws.PTYStarted{ Type: ws.TypePTYStarted, SessionID: sessionID, Agent: reclaimAgent, PublicKey: wingPubKeyB64, AuthToken: attachAuthToken, ViewerID: attach.ViewerID, }) @@ -3229,7 +3954,7 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, case *pb.SessionMsg_Output: sendPTYOutputTagged(sessionID, viewerID, payload.Output, g, write) case *pb.SessionMsg_ExitCode: - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: sessionID, ExitCode: int(payload.ExitCode), ViewerID: viewerID}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: sessionID, ExitCode: int(payload.ExitCode), ViewerID: viewerID}) return } } @@ -3243,7 +3968,7 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, newGCM, deriveErr := auth.DeriveSharedKey(privKey, attach.PublicKey, "wt-pty") if deriveErr != nil { log.Printf("pty session %s: reattach derive key failed: %v", sessionID, deriveErr) - write(ws.ErrorMsg{Type: ws.TypeError, Message: "client encryption setup failed", SessionID: sessionID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "client encryption setup failed", SessionID: sessionID}) continue } log.Printf("pty session %s: re-keyed E2E for reattach", sessionID) @@ -3252,7 +3977,7 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, if reErr != nil { newSCancel() log.Printf("pty session %s: reattach to egg failed: %v", sessionID, reErr) - write(ws.ErrorMsg{Type: ws.TypeError, Message: "reattach failed", SessionID: sessionID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "reattach failed", SessionID: sessionID}) continue } @@ -3274,12 +3999,14 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, if attachAuthToken != "" { started.AuthToken = attachAuthToken } - write(started) + writePTYMessage(write, started) } // Resize egg to browser dimensions before snapshot. if attach.Cols > 0 && attach.Rows > 0 { - ec.Resize(ctx, sessionID, attach.Rows, attach.Cols) + if err := ec.Resize(ctx, sessionID, attach.Rows, attach.Cols); err != nil { + log.Printf("pty session %s: resize reclaimed egg: %v", sessionID, err) + } time.Sleep(150 * time.Millisecond) // let agent repaint for new dimensions before VTE snapshot } @@ -3332,7 +4059,7 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, sendPTYOutput(sessionID, p.Output, currentGCM, write) case *pb.SessionMsg_ExitCode: log.Printf("pty session %s: exited with code %d", sessionID, p.ExitCode) - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: sessionID, ExitCode: int(p.ExitCode)}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: sessionID, ExitCode: int(p.ExitCode)}) clearAttentionCooldown(sessionID) sessionCancel() return @@ -3361,7 +4088,11 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, if decErr != nil { continue } - currentStream.Send(&pb.SessionMsg{SessionId: sessionID, Payload: &pb.SessionMsg_Input{Input: decoded}}) + if err := currentStream.Send(&pb.SessionMsg{SessionId: sessionID, Payload: &pb.SessionMsg_Input{Input: decoded}}); err != nil { + log.Printf("pty session %s: send input to reclaimed egg: %v", sessionID, err) + sessionCancel() + return + } case ws.TypePTYAttentionAck: clearAttentionCooldown(sessionID) @@ -3375,12 +4106,19 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, currentStream := activeStream mu.Unlock() if currentStream != nil { - currentStream.Send(&pb.SessionMsg{SessionId: sessionID, Payload: &pb.SessionMsg_Resize{Resize: &pb.Resize{Rows: uint32(msg.Rows), Cols: uint32(msg.Cols)}}}) + if err := currentStream.Send(&pb.SessionMsg{SessionId: sessionID, Payload: &pb.SessionMsg_Resize{Resize: &pb.Resize{Rows: uint32(msg.Rows), Cols: uint32(msg.Cols)}}}); err != nil { + log.Printf("pty session %s: send resize to reclaimed egg: %v", sessionID, err) + sessionCancel() + return + } } case ws.TypePTYKill: log.Printf("pty session %s: kill received", sessionID) - ec.Kill(ctx, sessionID) + if err := ec.Kill(ctx, sessionID); err != nil { + log.Printf("pty session %s: kill reclaimed egg: %v", sessionID, err) + } + sessionCancel() return } } @@ -3394,14 +4132,14 @@ func handleReclaimedPTY(ctx context.Context, cfg *config.Config, ec *egg.Client, func handlePTYSession(ctx context.Context, cfg *config.Config, wingCfg *config.WingConfig, start ws.PTYStart, write ws.PTYWriteFunc, input <-chan []byte, eggCfg *egg.EggConfig, debug, vte bool, allowedKeysPtr *[]config.AllowKey, passkeyCache *auth.AuthCache, passkeyPolicy auth.PasskeyPolicy, authTTL time.Duration, idleTimeout time.Duration, sw *webrtcpkg.SwappableWriter, dcSessions *sync.Map, tools []*config.ToolConfig, sharedHost bool) { allowedKeys := *allowedKeysPtr if start.PublicKey == "" { - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "E2E client key required"}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "E2E client key required"}) return } subject := passkeySubject(start.UserID, start.PublicKey) userHasPasskey := len(passkeysForSubject(allowedKeys, start.UserID)) > 0 if wingCfg.Locked && (subject == "" || !userHasPasskey) { log.Printf("pty session %s: locked wing rejected user without a locally approved passkey", start.SessionID) - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "not allowed by wing"}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "not allowed by wing"}) return } if userHasPasskey { @@ -3416,11 +4154,11 @@ func handlePTYSession(ctx context.Context, cfg *config.Config, wingCfg *config.W // Generate and send challenge challenge, chalErr := auth.GenerateChallenge() if chalErr != nil { - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "challenge generation failed"}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "challenge generation failed"}) return } - write(ws.PasskeyChallenge{ + writePTYMessage(write, ws.PasskeyChallenge{ Type: ws.TypePasskeyChallenge, SessionID: start.SessionID, Challenge: base64.RawURLEncoding.EncodeToString(challenge), @@ -3447,7 +4185,7 @@ func handlePTYSession(ctx context.Context, cfg *config.Config, wingCfg *config.W } var resp ws.PasskeyResponse if err := json.Unmarshal(data, &resp); err != nil { - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "invalid passkey response"}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "invalid passkey response"}) return } @@ -3458,7 +4196,7 @@ func handlePTYSession(ctx context.Context, cfg *config.Config, wingCfg *config.W matchedRawKey, verifyErr := verifySubjectPasskey(allowedKeys, start.UserID, challenge, authData, clientJSON, sig, passkeyPolicy) if verifyErr != nil { - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "invalid passkey signature"}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "invalid passkey signature"}) return } log.Printf("pty session %s: passkey verified", start.SessionID) @@ -3472,7 +4210,7 @@ func handlePTYSession(ctx context.Context, cfg *config.Config, wingCfg *config.W passkeyVerified = true case <-timer.C: - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "passkey authentication timed out"}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "passkey authentication timed out"}) return case <-ctx.Done(): @@ -3491,7 +4229,7 @@ authDone: privKey, privKeyErr := auth.LoadPrivateKey(cfg.Dir) if privKeyErr != nil { log.Printf("pty session %s: FATAL: load private key: %v", start.SessionID, privKeyErr) - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "E2E encryption required but wing private key missing"}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "E2E encryption required but wing private key missing"}) return } wingPubKeyB64 = base64.StdEncoding.EncodeToString(privKey.PublicKey().Bytes()) @@ -3499,7 +4237,7 @@ authDone: derived, deriveErr := auth.DeriveSharedKey(privKey, start.PublicKey, "wt-pty") if deriveErr != nil { log.Printf("pty session %s: FATAL: derive shared key: %v", start.SessionID, deriveErr) - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "E2E key exchange failed"}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "E2E key exchange failed"}) return } gcm = derived @@ -3513,7 +4251,11 @@ authDone: if len(tools) > 0 { eggDir := filepath.Join(cfg.Dir, "eggs", start.SessionID) toolsDir := filepath.Join(eggDir, ".tools") - os.MkdirAll(toolsDir, 0700) + if err := os.MkdirAll(toolsDir, 0700); err != nil { + log.Printf("pty session %s: create tool directory: %v", start.SessionID, err) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "create tool directory: " + err.Error()}) + return + } toolSocketPath = filepath.Join(toolsDir, "tool.sock") var tlErr error toolListener, tlErr = egg.NewToolListener(toolSocketPath, tools) @@ -3525,7 +4267,7 @@ authDone: } } if toolListener != nil { - defer toolListener.Close() + defer closeWithLog("egg tool listener", toolListener) } // Spawn a per-session egg @@ -3550,10 +4292,10 @@ authDone: if strings.Contains(crashInfo, "no log available") || strings.Contains(crashInfo, "empty log") { crashInfo = err.Error() } - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: crashInfo}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: crashInfo}) return } - defer ec.Close() + defer closeWithLog("PTY egg client", ec) log.Printf("pty session %s: spawned (user=%s agent=%s)", start.SessionID, start.UserID, start.Agent) @@ -3566,9 +4308,10 @@ authDone: } sessionStates.Store(start.SessionID, idleState) defer sessionStates.Delete(start.SessionID) + defer forgetAttentionState(start.SessionID) // Notify browser - write(ws.PTYStarted{ + writePTYMessage(write, ws.PTYStarted{ Type: ws.TypePTYStarted, SessionID: start.SessionID, Agent: start.Agent, @@ -3583,7 +4326,7 @@ authDone: if err != nil { sCancel() log.Printf("pty: egg attach failed: %v", err) - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1}) return } activeStream = stream @@ -3636,7 +4379,7 @@ authDone: case *pb.SessionMsg_ExitCode: log.Printf("pty session %s: exited with code %d", start.SessionID, p.ExitCode) - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: int(p.ExitCode)}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: int(p.ExitCode)}) clearAttentionCooldown(start.SessionID) sessionCancel() return @@ -3662,7 +4405,7 @@ authDone: case <-pendingAuth.timeout(): for _, pending := range pendingAuth.expire(time.Now()) { log.Printf("pty session %s: reattach passkey timed out", start.SessionID) - write(ws.ErrorMsg{Type: ws.TypeError, Message: "passkey timed out", SessionID: start.SessionID, ViewerID: pending.attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "passkey timed out", SessionID: start.SessionID, ViewerID: pending.attach.ViewerID}) } continue case inputData, ok := <-input: @@ -3691,12 +4434,12 @@ authDone: signature, _ := base64.StdEncoding.DecodeString(response.Signature) rawKey, verifyErr := verifySubjectPasskey(allowedKeys, pending.attach.UserID, pending.challenge, authData, clientData, signature, passkeyPolicy) if verifyErr != nil { - write(ws.ErrorMsg{Type: ws.TypeError, Message: "invalid passkey", SessionID: start.SessionID, ViewerID: pending.attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "invalid passkey", SessionID: start.SessionID, ViewerID: pending.attach.ViewerID}) continue } token, tokenErr := auth.GenerateAuthToken() if tokenErr != nil { - write(ws.ErrorMsg{Type: ws.TypeError, Message: "auth token generation failed", SessionID: start.SessionID, ViewerID: pending.attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "auth token generation failed", SessionID: start.SessionID, ViewerID: pending.attach.ViewerID}) continue } passkeyCache.Put(token, rawKey, pending.subject) @@ -3713,19 +4456,19 @@ authDone: } } if !canAttachSession(attach.UserID, attach.OrgRole, start.UserID) { - write(ws.ErrorMsg{Type: ws.TypeError, Message: "session not found or not owned by caller", SessionID: start.SessionID, ViewerID: attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "session not found or not owned by caller", SessionID: start.SessionID, ViewerID: attach.ViewerID}) continue } clearAttentionCooldown(start.SessionID) if attach.PublicKey == "" { - write(ws.ErrorMsg{Type: ws.TypeError, Message: "client encryption key required", SessionID: start.SessionID, ViewerID: attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "client encryption key required", SessionID: start.SessionID, ViewerID: attach.ViewerID}) continue } attachSubject := passkeySubject(attach.UserID, attach.PublicKey) attachUserHasPasskey := len(passkeysForSubject(allowedKeys, attach.UserID)) > 0 if wingCfg.Locked && (attachSubject == "" || !attachUserHasPasskey) { - write(ws.ErrorMsg{Type: ws.TypeError, Message: "not allowed by wing", SessionID: start.SessionID, ViewerID: attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "not allowed by wing", SessionID: start.SessionID, ViewerID: attach.ViewerID}) continue } var attachAuthToken string @@ -3738,10 +4481,10 @@ authDone: if attachAuthToken == "" { challenge, chalErr := auth.GenerateChallenge() if chalErr != nil { - write(ws.ErrorMsg{Type: ws.TypeError, Message: "challenge generation failed", SessionID: start.SessionID, ViewerID: attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "challenge generation failed", SessionID: start.SessionID, ViewerID: attach.ViewerID}) continue } - write(ws.PasskeyChallenge{ + writePTYMessage(write, ws.PasskeyChallenge{ Type: ws.TypePasskeyChallenge, SessionID: start.SessionID, Challenge: base64.RawURLEncoding.EncodeToString(challenge), @@ -3757,14 +4500,14 @@ authDone: if attach.Spectate { if !wingCfg.Spectate { log.Printf("pty session %s: spectate rejected (not enabled in wing config)", start.SessionID) - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "spectate not enabled", ViewerID: attach.ViewerID}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: 1, Error: "spectate not enabled", ViewerID: attach.ViewerID}) continue } // Derive spectator-specific E2E key (independent of controller) spectatorGCM, deriveErr := auth.DeriveSharedKey(privKey, attach.PublicKey, "wt-pty") if deriveErr != nil { log.Printf("pty session %s: spectator key derive failed: %v", start.SessionID, deriveErr) - write(ws.ErrorMsg{Type: ws.TypeError, Message: "spectator encryption setup failed", SessionID: start.SessionID, ViewerID: attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "spectator encryption setup failed", SessionID: start.SessionID, ViewerID: attach.ViewerID}) continue } log.Printf("pty session %s: spectator E2E enabled (viewer=%s)", start.SessionID, attach.ViewerID) @@ -3775,12 +4518,12 @@ authDone: if specErr != nil { specCancel() log.Printf("pty session %s: spectator attach to egg failed: %v", start.SessionID, specErr) - write(ws.ErrorMsg{Type: ws.TypeError, Message: "spectator attach failed", SessionID: start.SessionID, ViewerID: attach.ViewerID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "spectator attach failed", SessionID: start.SessionID, ViewerID: attach.ViewerID}) continue } // Send pty.started only after the independent stream exists. - write(ws.PTYStarted{ + writePTYMessage(write, ws.PTYStarted{ Type: ws.TypePTYStarted, SessionID: start.SessionID, Agent: start.Agent, @@ -3813,7 +4556,7 @@ authDone: sendPTYOutputTagged(start.SessionID, viewerID, p.Output, g, write) } case *pb.SessionMsg_ExitCode: - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: int(p.ExitCode), ViewerID: viewerID}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: int(p.ExitCode), ViewerID: viewerID}) return } } @@ -3827,7 +4570,7 @@ authDone: newGCM, deriveErr := auth.DeriveSharedKey(privKey, attach.PublicKey, "wt-pty") if deriveErr != nil { log.Printf("pty session %s: reattach derive key failed: %v", start.SessionID, deriveErr) - write(ws.ErrorMsg{Type: ws.TypeError, Message: "client encryption setup failed", SessionID: start.SessionID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "client encryption setup failed", SessionID: start.SessionID}) continue } log.Printf("pty session %s: re-keyed E2E for reattach", start.SessionID) @@ -3836,7 +4579,7 @@ authDone: if reErr != nil { newSCancel() log.Printf("pty session %s: reattach to egg failed: %v", start.SessionID, reErr) - write(ws.ErrorMsg{Type: ws.TypeError, Message: "reattach failed", SessionID: start.SessionID}) + writePTYMessage(write, ws.ErrorMsg{Type: ws.TypeError, Message: "reattach failed", SessionID: start.SessionID}) continue } @@ -3852,7 +4595,7 @@ authDone: idleState.mu.Lock() idleState.connected = true idleState.mu.Unlock() - write(ws.PTYStarted{ + writePTYMessage(write, ws.PTYStarted{ Type: ws.TypePTYStarted, SessionID: start.SessionID, Agent: start.Agent, @@ -3862,7 +4605,9 @@ authDone: // Resize egg to browser dimensions before snapshot. if attach.Cols > 0 && attach.Rows > 0 { - ec.Resize(ctx, start.SessionID, attach.Rows, attach.Cols) + if err := ec.Resize(ctx, start.SessionID, attach.Rows, attach.Cols); err != nil { + log.Printf("pty session %s: resize egg after reattach: %v", start.SessionID, err) + } time.Sleep(150 * time.Millisecond) // let agent repaint for new dimensions before VTE snapshot } @@ -3915,7 +4660,7 @@ authDone: sendPTYOutput(start.SessionID, p.Output, currentGCM, write) case *pb.SessionMsg_ExitCode: log.Printf("pty session %s: exited with code %d", start.SessionID, p.ExitCode) - write(ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: int(p.ExitCode)}) + writePTYMessage(write, ws.PTYExited{Type: ws.TypePTYExited, SessionID: start.SessionID, ExitCode: int(p.ExitCode)}) clearAttentionCooldown(start.SessionID) sessionCancel() return @@ -3945,10 +4690,14 @@ authDone: log.Printf("pty session %s: decrypt error: %v", start.SessionID, decErr) continue } - currentStream.Send(&pb.SessionMsg{ + if err := currentStream.Send(&pb.SessionMsg{ SessionId: start.SessionID, Payload: &pb.SessionMsg_Input{Input: decoded}, - }) + }); err != nil { + log.Printf("pty session %s: send input to egg: %v", start.SessionID, err) + sessionCancel() + return + } case ws.TypePTYAttentionAck: clearAttentionCooldown(start.SessionID) @@ -3962,13 +4711,17 @@ authDone: currentStream := activeStream mu.Unlock() if currentStream != nil { - currentStream.Send(&pb.SessionMsg{ + if err := currentStream.Send(&pb.SessionMsg{ SessionId: start.SessionID, Payload: &pb.SessionMsg_Resize{Resize: &pb.Resize{ Rows: uint32(msg.Rows), Cols: uint32(msg.Cols), }}, - }) + }); err != nil { + log.Printf("pty session %s: send resize to egg: %v", start.SessionID, err) + sessionCancel() + return + } } case ws.TypePTYMigrate: @@ -4007,7 +4760,10 @@ authDone: case ws.TypePTYKill: log.Printf("pty session %s: kill received", start.SessionID) - ec.Kill(ctx, start.SessionID) + if err := ec.Kill(ctx, start.SessionID); err != nil { + log.Printf("pty session %s: kill egg: %v", start.SessionID, err) + } + sessionCancel() return } } @@ -4102,6 +4858,47 @@ func passkeyPolicyForRoost(roostURL string) auth.PasskeyPolicy { } } +// passkeyPolicyFromRegistration accepts only a coherent RP policy delivered by +// the authenticated coordinator. Additive acknowledgement fields let new wings +// support custom AppHost and localhost HTTPS while retaining their URL-derived +// fallback when connected to an older relay. +func passkeyPolicyFromRegistration(msg ws.RegisteredMsg) (auth.PasskeyPolicy, bool) { + rpID := strings.ToLower(strings.TrimSpace(msg.PasskeyRPID)) + if rpID == "" || strings.ContainsAny(rpID, "/\\:@") || len(msg.PasskeyOrigins) == 0 || len(msg.PasskeyOrigins) > 8 { + return auth.PasskeyPolicy{}, false + } + origins := make([]string, 0, len(msg.PasskeyOrigins)) + seen := make(map[string]bool, len(msg.PasskeyOrigins)) + for _, rawOrigin := range msg.PasskeyOrigins { + parsed, err := url.Parse(rawOrigin) + if err != nil || parsed.User != nil || parsed.Hostname() == "" || parsed.Path != "" || + parsed.RawQuery != "" || parsed.Fragment != "" { + return auth.PasskeyPolicy{}, false + } + host := strings.ToLower(parsed.Hostname()) + if host != rpID && !strings.HasSuffix(host, "."+rpID) { + return auth.PasskeyPolicy{}, false + } + if parsed.Scheme != "https" { + ip := net.ParseIP(host) + loopback := strings.EqualFold(host, "localhost") || (ip != nil && ip.IsLoopback()) + if parsed.Scheme != "http" || !loopback { + return auth.PasskeyPolicy{}, false + } + } + origin := parsed.Scheme + "://" + parsed.Host + if !seen[origin] { + origins = append(origins, origin) + seen[origin] = true + } + } + return auth.PasskeyPolicy{ + RPID: rpID, + Origins: origins, + RequireUserVerification: true, + }, true +} + func passkeysForSubject(allowedKeys []config.AllowKey, userID string) []config.AllowKey { var matches []config.AllowKey for _, allowed := range allowedKeys { @@ -4117,6 +4914,19 @@ func passkeysForSubject(allowedKeys []config.AllowKey, userID string) []config.A return matches } +func visibleAllowKeys(req ws.TunnelRequest, allowedKeys []config.AllowKey) []config.AllowKey { + if !isMemberFiltered(req) { + return append([]config.AllowKey(nil), allowedKeys...) + } + visible := make([]config.AllowKey, 0, 1) + for _, allowed := range allowedKeys { + if allowed.UserID == req.SenderUserID { + visible = append(visible, allowed) + } + } + return visible +} + func verifySubjectPasskey(allowedKeys []config.AllowKey, userID string, challenge, authData, clientData, signature []byte, policy auth.PasskeyPolicy) ([]byte, error) { for _, allowed := range passkeysForSubject(allowedKeys, userID) { rawKey, err := base64.StdEncoding.DecodeString(allowed.Key) @@ -4148,16 +4958,16 @@ func tunnelRespond(gcm cipher.AEAD, requestID string, result any, write ws.PTYWr if err != nil { return } - write(ws.TunnelResponse{Type: ws.TypeTunnelResponse, RequestID: requestID, Payload: encrypted}) + writePTYMessage(write, ws.TunnelResponse{Type: ws.TypeTunnelResponse, RequestID: requestID, Payload: encrypted}) } // tunnelStreamChunk encrypts a streaming chunk and sends it as a tunnel.stream message. -func tunnelStreamChunk(gcm cipher.AEAD, requestID string, chunk []byte, done bool, write ws.PTYWriteFunc) { +func tunnelStreamChunk(gcm cipher.AEAD, requestID string, chunk []byte, done bool, write ws.PTYWriteFunc) error { encrypted, err := auth.Encrypt(gcm, chunk) if err != nil { - return + return err } - write(ws.TunnelStream{Type: ws.TypeTunnelStream, RequestID: requestID, Payload: encrypted, Done: done}) + return write(ws.TunnelStream{Type: ws.TypeTunnelStream, RequestID: requestID, Payload: encrypted, Done: done}) } // isMemberFiltered returns true if the tunnel request is from an org member (not owner/admin). @@ -4166,8 +4976,19 @@ func isMemberFiltered(req ws.TunnelRequest) bool { if req.SenderUserID == "" { return false } - role := req.SenderOrgRole - return role == "member" || role == "" + return req.SenderOrgRole != "owner" && req.SenderOrgRole != "admin" +} + +// requestAgainstWingConfig recomputes only the wing-local admin override from +// the relay-authenticated role. Mutation paths call this while holding +// wingCfgMu so a removed local admin cannot commit one last stale-snapshot edit +// after SIGHUP has revoked that override. +func requestAgainstWingConfig(req ws.TunnelRequest, authenticatedOrgRole string, wingCfg *config.WingConfig) ws.TunnelRequest { + req.SenderOrgRole = authenticatedOrgRole + if wingCfg != nil && wingCfg.IsAdmin(req.SenderEmail) && isMemberRole(req.SenderOrgRole) { + req.SenderOrgRole = "admin" + } + return req } // canSeeSession returns true if the request sender can view a session with the given owner. @@ -4192,6 +5013,18 @@ func canAccessSessionPath(req ws.TunnelRequest, sessionPath string, userPaths [] return len(userPaths) > 0 && isUnderPaths(sessionPath, userPaths) } +// canAccessSessionArtifact applies the same current owner-and-workspace policy +// used by session listings before exposing a persisted audit or chat artifact. +// Missing legacy metadata fails closed for members; owners and admins retain +// the historical oversight access. +func canAccessSessionArtifact(req ws.TunnelRequest, sessionDir string, userPaths []string) bool { + if !isMemberFiltered(req) { + return true + } + _, sessionPath := readEggMeta(sessionDir) + return canSeeSession(req, readEggOwner(sessionDir)) && canAccessSessionPath(req, sessionPath, userPaths) +} + func requestDirEntries(req ws.TunnelRequest, path string, userPaths []string) []ws.DirEntry { if isMemberFiltered(req) && len(userPaths) == 0 { return nil @@ -4209,6 +5042,29 @@ func requestProjects(req ws.TunnelRequest, projects []ws.WingProject, userPaths return projects } +func appendHostedRelayPolicyAudit(cfg *config.Config, operation string) (result error) { + if err := os.MkdirAll(cfg.Dir, 0o700); err != nil { + return err + } + path := filepath.Join(cfg.Dir, "policy-audit.log") + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer func() { result = closeAndJoin("hosted relay policy audit", file, result) }() + if err := file.Chmod(0o600); err != nil { + return err + } + record := map[string]string{ + "time": time.Now().UTC().Format(time.RFC3339Nano), + "event": "hosted_relay_denied", + "operation": operation, + "transport": "hosted-relay", + "policy": config.HostedRelayDeny, + } + return json.NewEncoder(file).Encode(record) +} + // handleTunnelRequest decrypts and dispatches an encrypted tunnel request from the browser. func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *config.WingConfig, req ws.TunnelRequest, write ws.PTYWriteFunc, allowedKeysPtr *[]config.AllowKey, passkeyCache *auth.AuthCache, passkeyChallenges *auth.ChallengeCache, @@ -4216,7 +5072,17 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi wingEggMu *sync.Mutex, wingEggCfg **egg.EggConfig, audit, debug bool, client *ws.Client, peerMgr *webrtcpkg.PeerManager, dcSessions *sync.Map) { - allowedKeys := *allowedKeysPtr + // Tunnel callbacks run concurrently and some operations stream or perform + // network/process work for an arbitrary amount of time. Admit each request + // against an immutable policy snapshot; hold wingCfgMu only while taking that + // snapshot or committing a serialized wing.yaml mutation. This keeps SIGHUP + // revocation and unrelated requests responsive to a slow peer. + liveWingCfg := wingCfg + authenticatedOrgRole := req.SenderOrgRole + wingCfgMu.Lock() + wingCfg = liveWingCfg.Clone() + allowedKeys := append([]config.AllowKey(nil), (*allowedKeysPtr)...) + wingCfgMu.Unlock() // Wing-level admin override if wingCfg.IsAdmin(req.SenderEmail) && isMemberRole(req.SenderOrgRole) { @@ -4225,8 +5091,8 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi // Derive or retrieve cached AES-GCM key for this sender var gcm cipher.AEAD - if cached, ok := tunnelKeys.Load(req.SenderPub); ok { - gcm, _ = cached.(cipher.AEAD) + if cached, ok := tunnelKeys.Get(req.SenderPub); ok { + gcm = cached } if gcm == nil { derived, err := auth.DeriveSharedKey(privKey, req.SenderPub, "wt-tunnel") @@ -4235,7 +5101,7 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi return } gcm = derived - tunnelKeys.Store(req.SenderPub, gcm) + tunnelKeys.Put(req.SenderPub, gcm) } // Decrypt the payload @@ -4251,6 +5117,23 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi log.Printf("tunnel %s: bad inner JSON: %v", req.RequestID, err) return } + if inner.SessionID != "" && !ws.ValidSessionID(inner.SessionID) { + log.Printf("tunnel %s: rejected invalid session ID %q", req.RequestID, inner.SessionID) + tunnelRespond(gcm, req.RequestID, map[string]string{"error": "invalid session ID"}, write) + return + } + if req.Purpose != "" && !ws.TunnelPurposeMatches(req.Purpose, inner.Type) { + log.Printf("tunnel %s: declared purpose %q does not match inner type %q", req.RequestID, req.Purpose, inner.Type) + tunnelRespond(gcm, req.RequestID, map[string]string{"error": "tunnel purpose mismatch"}, write) + return + } + // Every supported coordinator, including the N-1 org deployment, injects + // authenticated sender identity. Treat its absence as a protocol failure, + // not as legacy administrator authority. + if req.SenderUserID == "" { + tunnelRespond(gcm, req.RequestID, map[string]string{"error": "authenticated user identity required"}, write) + return + } isPasskeyCeremony := inner.Type == "passkey.auth.begin" || inner.Type == "passkey.auth.finish" subject := passkeySubject(req.SenderUserID, req.SenderPub) @@ -4347,17 +5230,19 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi "locked": wingCfg.Locked, "spectate": wingCfg.Spectate, "allowed_count": len(wingCfg.AllowKeys), + "hosted_relay": wingCfg.EffectiveHostedRelay(), } if wingCfg.Label != "" { resp["wing_label"] = wingCfg.Label } - // P2P: tell browser whether this wing supports P2P - if peerMgr != nil { + // Browser PTY migration remains opt-in even though the same PeerManager is + // available in relay mode for native direct MCP control. + if peerMgr != nil && (wingCfg.ConnectionMode == "p2p" || wingCfg.ConnectionMode == "p2p_only") { resp["p2p"] = true resp["connection_mode"] = wingCfg.ConnectionMode - if len(wingCfg.ICEServers) > 0 { - resp["ice_servers"] = wingCfg.ICEServers - } + } + if peerMgr != nil && len(wingCfg.ICEServers) > 0 { + resp["ice_servers"] = wingCfg.ICEServers } // Report which well-known API keys are set in the wing's environment var globalKeys []string @@ -4366,7 +5251,7 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi globalKeys = append(globalKeys, k) } } - if len(globalKeys) > 0 { + if len(globalKeys) > 0 && !isMemberFiltered(req) { resp["global_keys"] = globalKeys } if req.SenderUserID != "" { @@ -4398,7 +5283,7 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi tunnelRespond(gcm, req.RequestID, map[string]any{"error": fmt.Sprintf("webrtc offer: %v", err)}, write) return } - log.Printf("[P2P] webrtc.offer accepted from %s, answer SDP %d bytes", req.SenderPub[:8], len(answerSDP)) + log.Printf("[P2P] webrtc.offer accepted from %s, answer SDP %d bytes", shortLogValue(req.SenderPub), len(answerSDP)) tunnelRespond(gcm, req.RequestID, map[string]any{"sdp": answerSDP}, write) case "sessions.list": @@ -4416,25 +5301,20 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi tunnelRespond(gcm, req.RequestID, map[string]any{"sessions": sessions}, write) case "sessions.history": - sessions, total := getSessionsHistory(cfg, inner.Offset, inner.Limit) + sessions := collectSessionsHistory(cfg) if isMemberFiltered(req) { userPaths := pathsForRequest(wingCfg.Paths, req.SenderEmail, req.SenderOrgRole, home) - var filtered []pastSessionInfo - for _, s := range sessions { - if canSeeSession(req, s.UserID) && canAccessSessionPath(req, s.CWD, userPaths) { - filtered = append(filtered, s) - } - } - sessions = filtered - total = len(filtered) + sessions = filterSessionsHistoryForRequest(req, sessions, userPaths) } + sessions, total := paginateSessionsHistory(sessions, inner.Offset, inner.Limit) tunnelRespond(gcm, req.RequestID, map[string]any{"sessions": sessions, "total": total}, write) case "audit.request": if inner.SessionID != "" && isMemberFiltered(req) { - owner := readEggOwner(filepath.Join(cfg.Dir, "eggs", inner.SessionID)) - if !canSeeSession(req, owner) { - log.Printf("tunnel %s: denied audit (user=%s session_owner=%s)", req.RequestID, req.SenderUserID, owner) + sessionDir := filepath.Join(cfg.Dir, "eggs", inner.SessionID) + userPaths := pathsForRequest(wingCfg.Paths, req.SenderEmail, req.SenderOrgRole, home) + if !canAccessSessionArtifact(req, sessionDir, userPaths) { + log.Printf("tunnel %s: denied audit outside current owner/path policy (user=%s session=%s)", req.RequestID, req.SenderUserID, inner.SessionID) tunnelRespond(gcm, req.RequestID, map[string]string{"error": "access denied"}, write) return } @@ -4588,27 +5468,16 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi Email string `json:"email,omitempty"` } var allowed []allowInfo - for _, ak := range allowedKeys { + for _, ak := range visibleAllowKeys(req, allowedKeys) { allowed = append(allowed, allowInfo{Key: ak.Key, UserID: ak.UserID, Email: ak.Email}) } tunnelRespond(gcm, req.RequestID, map[string]any{"allowed": allowed}, write) case "allow.add": - if wingCfg.Locked { - tunnelRespond(gcm, req.RequestID, map[string]string{"error": "locked wings require local approval via wt wing allow"}, write) - return - } if req.SenderUserID == "" { tunnelRespond(gcm, req.RequestID, map[string]string{"error": "no user identity"}, write) return } - // Check duplicate by user_id - for _, ak := range allowedKeys { - if ak.UserID == req.SenderUserID { - tunnelRespond(gcm, req.RequestID, map[string]string{"error": "already allowed"}, write) - return - } - } // Validate key if provided if inner.Key != "" { keyBytes, decErr := base64.StdEncoding.DecodeString(inner.Key) @@ -4626,8 +5495,21 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi // clobbers shared wings (sets locked: true + allow_keys with only // the enrolling user, locking everyone else out). // Admins manage allow_keys explicitly via `wt wing allow`. - allowedKeys = append(allowedKeys, newEntry) - *allowedKeysPtr = allowedKeys + wingCfgMu.Lock() + if liveWingCfg.Locked { + wingCfgMu.Unlock() + tunnelRespond(gcm, req.RequestID, map[string]string{"error": "locked wings require local approval via wt wing allow"}, write) + return + } + for _, ak := range *allowedKeysPtr { + if ak.UserID == req.SenderUserID { + wingCfgMu.Unlock() + tunnelRespond(gcm, req.RequestID, map[string]string{"error": "already allowed"}, write) + return + } + } + *allowedKeysPtr = append(*allowedKeysPtr, newEntry) + wingCfgMu.Unlock() log.Printf("allowed: user=%s email=%s has_passkey=%v (session-scoped)", req.SenderUserID, req.SenderEmail, inner.Key != "") tunnelRespond(gcm, req.RequestID, map[string]any{ "ok": "true", "email": req.SenderEmail, "user_id": req.SenderUserID, @@ -4639,7 +5521,12 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi tunnelRespond(gcm, req.RequestID, map[string]string{"error": "no user identity"}, write) return } - // Find entry to remove: by key or user_id + wingCfgMu.Lock() + liveReq := requestAgainstWingConfig(req, authenticatedOrgRole, liveWingCfg) + allowedKeys = append([]config.AllowKey(nil), (*allowedKeysPtr)...) + // Find entry to remove: by key or user_id against the live ACL. A + // SIGHUP between admission and this mutation must not resurrect an old + // snapshot or authorize a removed local admin. target := inner.AllowUserID if target == "" && inner.Key != "" { for _, ak := range allowedKeys { @@ -4650,12 +5537,14 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi } } if target == "" { + wingCfgMu.Unlock() tunnelRespond(gcm, req.RequestID, map[string]string{"error": "missing allow_user_id or key"}, write) return } // Only wing owner or the entry's own user can remove - isOwner := req.SenderOrgRole == "owner" || req.SenderOrgRole == "admin" + isOwner := liveReq.SenderOrgRole == "owner" || liveReq.SenderOrgRole == "admin" if !isOwner && req.SenderUserID != target { + wingCfgMu.Unlock() tunnelRespond(gcm, req.RequestID, map[string]string{"error": "access denied"}, write) return } @@ -4664,24 +5553,22 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi // in-memory change back if persistence fails so a request reported as // failed cannot leave a live-but-unsaved authorization change. persistedRemoved := false - wingCfgMu.Lock() - oldAllowKeys := append([]config.AllowKey(nil), wingCfg.AllowKeys...) - for i, ak := range wingCfg.AllowKeys { + oldAllowKeys := append([]config.AllowKey(nil), liveWingCfg.AllowKeys...) + for i, ak := range liveWingCfg.AllowKeys { if ak.UserID == target || (inner.Key != "" && ak.Key == inner.Key) { - wingCfg.AllowKeys = append(wingCfg.AllowKeys[:i], wingCfg.AllowKeys[i+1:]...) + liveWingCfg.AllowKeys = append(liveWingCfg.AllowKeys[:i], liveWingCfg.AllowKeys[i+1:]...) persistedRemoved = true break } } if persistedRemoved { - if saveErr := config.SaveWingConfig(cfg.Dir, wingCfg); saveErr != nil { - wingCfg.AllowKeys = oldAllowKeys + if saveErr := config.SaveWingConfig(cfg.Dir, liveWingCfg); saveErr != nil { + liveWingCfg.AllowKeys = oldAllowKeys wingCfgMu.Unlock() tunnelRespond(gcm, req.RequestID, map[string]string{"error": "persist wing.yaml: " + saveErr.Error()}, write) return } } - wingCfgMu.Unlock() // Also remove from in-memory list (covers session-scoped entries) memRemoved := false for i, ak := range allowedKeys { @@ -4692,13 +5579,20 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi } } if !persistedRemoved && !memRemoved { + wingCfgMu.Unlock() tunnelRespond(gcm, req.RequestID, map[string]string{"error": "entry not found"}, write) return } *allowedKeysPtr = allowedKeys - client.Locked = wingCfg.Locked - client.AllowedCount = len(wingCfg.AllowKeys) - client.SendConfig(ctx) + locked, allowedCount := liveWingCfg.Locked, len(liveWingCfg.AllowKeys) + wingCfgMu.Unlock() + client.UpdateAccessConfig(locked, allowedCount) + if err := client.SendConfig(ctx); err != nil { + // The local policy mutation is already active and persisted. A later + // reconnect will advertise it again, so report the transient sync + // failure without rolling back the security decision. + log.Printf("advertise access config after revoke: %v", err) + } log.Printf("revoked: target=%s by=%s persisted=%v", target, req.SenderUserID, persistedRemoved) tunnelRespond(gcm, req.RequestID, map[string]string{"ok": "true"}, write) @@ -4713,45 +5607,48 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi } case "paths.set": - if isMemberFiltered(req) { - tunnelRespond(gcm, req.RequestID, map[string]string{"error": "admin required"}, write) - return - } if inner.Paths == nil { tunnelRespond(gcm, req.RequestID, map[string]string{"error": "missing paths"}, write) return } wingCfgMu.Lock() - oldPaths, oldRoot := wingCfg.Paths, wingCfg.Root - wingCfg.Paths = config.PathList(inner.Paths) - wingCfg.Root = "" - if saveErr := config.SaveWingConfig(cfg.Dir, wingCfg); saveErr != nil { + if isMemberFiltered(requestAgainstWingConfig(req, authenticatedOrgRole, liveWingCfg)) { + wingCfgMu.Unlock() + tunnelRespond(gcm, req.RequestID, map[string]string{"error": "admin required"}, write) + return + } + oldPaths, oldRoot := liveWingCfg.Paths, liveWingCfg.Root + liveWingCfg.Paths = clonePathList(config.PathList(inner.Paths)) + liveWingCfg.Root = "" + if saveErr := config.SaveWingConfig(cfg.Dir, liveWingCfg); saveErr != nil { // Roll back so a request reported as failed does not keep steering // live authorization until restart. - wingCfg.Paths, wingCfg.Root = oldPaths, oldRoot + liveWingCfg.Paths, liveWingCfg.Root = oldPaths, oldRoot wingCfgMu.Unlock() tunnelRespond(gcm, req.RequestID, map[string]string{"error": "persist wing.yaml: " + saveErr.Error()}, write) return } + paths := clonePathList(liveWingCfg.Paths) wingCfgMu.Unlock() - log.Printf("paths.set: %d entries by %s", len(wingCfg.Paths), req.SenderUserID) - go killSessionsViolatingACLs(cfg, wingCfg.Paths, home) + log.Printf("paths.set: %d entries by %s", len(paths), req.SenderUserID) + go killSessionsViolatingACLs(cfg, paths, home) tunnelRespond(gcm, req.RequestID, map[string]string{"ok": "true"}, write) case "paths.add_member": - if isMemberFiltered(req) { - tunnelRespond(gcm, req.RequestID, map[string]string{"error": "admin required"}, write) - return - } if inner.Path == "" || inner.Email == "" { tunnelRespond(gcm, req.RequestID, map[string]string{"error": "missing path or email"}, write) return } + wingCfgMu.Lock() + if isMemberFiltered(requestAgainstWingConfig(req, authenticatedOrgRole, liveWingCfg)) { + wingCfgMu.Unlock() + tunnelRespond(gcm, req.RequestID, map[string]string{"error": "admin required"}, write) + return + } found := false emailLower := strings.ToLower(inner.Email) - wingCfgMu.Lock() - oldPaths := clonePathList(wingCfg.Paths) - for i, e := range wingCfg.Paths { + oldPaths := clonePathList(liveWingCfg.Paths) + for i, e := range liveWingCfg.Paths { if e.Path == inner.Path { // Check duplicate dup := false @@ -4762,7 +5659,7 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi } } if !dup { - wingCfg.Paths[i].Members = append(wingCfg.Paths[i].Members, inner.Email) + liveWingCfg.Paths[i].Members = append(liveWingCfg.Paths[i].Members, inner.Email) } found = true break @@ -4773,8 +5670,8 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi tunnelRespond(gcm, req.RequestID, map[string]string{"error": "path not found"}, write) return } - if saveErr := config.SaveWingConfig(cfg.Dir, wingCfg); saveErr != nil { - wingCfg.Paths = oldPaths + if saveErr := config.SaveWingConfig(cfg.Dir, liveWingCfg); saveErr != nil { + liveWingCfg.Paths = oldPaths wingCfgMu.Unlock() tunnelRespond(gcm, req.RequestID, map[string]string{"error": "persist wing.yaml: " + saveErr.Error()}, write) return @@ -4784,19 +5681,20 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi tunnelRespond(gcm, req.RequestID, map[string]string{"ok": "true"}, write) case "paths.remove_member": - if isMemberFiltered(req) { - tunnelRespond(gcm, req.RequestID, map[string]string{"error": "admin required"}, write) - return - } if inner.Path == "" || inner.Email == "" { tunnelRespond(gcm, req.RequestID, map[string]string{"error": "missing path or email"}, write) return } + wingCfgMu.Lock() + if isMemberFiltered(requestAgainstWingConfig(req, authenticatedOrgRole, liveWingCfg)) { + wingCfgMu.Unlock() + tunnelRespond(gcm, req.RequestID, map[string]string{"error": "admin required"}, write) + return + } found := false emailLower := strings.ToLower(inner.Email) - wingCfgMu.Lock() - oldPaths := clonePathList(wingCfg.Paths) - for i, e := range wingCfg.Paths { + oldPaths := clonePathList(liveWingCfg.Paths) + for i, e := range liveWingCfg.Paths { if e.Path == inner.Path { // An empty member list means a legacy open entry visible to every // member, so removing the last member would silently make the @@ -4808,7 +5706,7 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi } for j, m := range e.Members { if strings.ToLower(m) == emailLower { - wingCfg.Paths[i].Members = append(e.Members[:j], e.Members[j+1:]...) + liveWingCfg.Paths[i].Members = append(e.Members[:j], e.Members[j+1:]...) found = true break } @@ -4821,15 +5719,16 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi tunnelRespond(gcm, req.RequestID, map[string]string{"error": "path or member not found"}, write) return } - if saveErr := config.SaveWingConfig(cfg.Dir, wingCfg); saveErr != nil { - wingCfg.Paths = oldPaths + if saveErr := config.SaveWingConfig(cfg.Dir, liveWingCfg); saveErr != nil { + liveWingCfg.Paths = oldPaths wingCfgMu.Unlock() tunnelRespond(gcm, req.RequestID, map[string]string{"error": "persist wing.yaml: " + saveErr.Error()}, write) return } + paths := clonePathList(liveWingCfg.Paths) wingCfgMu.Unlock() log.Printf("paths.remove_member: %s from %s by %s", inner.Email, inner.Path, req.SenderUserID) - go killSessionsViolatingACLs(cfg, wingCfg.Paths, home) + go killSessionsViolatingACLs(cfg, paths, home) tunnelRespond(gcm, req.RequestID, map[string]string{"ok": "true"}, write) default: @@ -4837,12 +5736,14 @@ func handleTunnelRequest(ctx context.Context, cfg *config.Config, wingCfg *confi } } -// getSessionsHistory returns dead egg sessions from disk, paginated. -func getSessionsHistory(cfg *config.Config, offset, limit int) ([]pastSessionInfo, int) { +// collectSessionsHistory returns all dead egg sessions from disk newest first. +// Authorization must be applied before pagination so member pages and totals do +// not depend on the positions of sessions they cannot see. +func collectSessionsHistory(cfg *config.Config) []pastSessionInfo { eggsDir := filepath.Join(cfg.Dir, "eggs") entries, err := os.ReadDir(eggsDir) if err != nil { - return nil, 0 + return nil } var dead []pastSessionInfo @@ -4895,194 +5796,267 @@ func getSessionsHistory(cfg *config.Config, offset, limit int) ([]pastSessionInf sort.Slice(dead, func(i, j int) bool { return dead[i].StartedAt > dead[j].StartedAt }) + return dead +} - total := len(dead) - if limit <= 0 { - limit = 20 - } - if offset > len(dead) { - offset = len(dead) +func filterSessionsHistoryForRequest(req ws.TunnelRequest, sessions []pastSessionInfo, userPaths []string) []pastSessionInfo { + if !isMemberFiltered(req) { + return sessions } - end := offset + limit - if end > len(dead) { - end = len(dead) + filtered := make([]pastSessionInfo, 0, len(sessions)) + for _, session := range sessions { + if canSeeSession(req, session.UserID) && canAccessSessionPath(req, session.CWD, userPaths) { + filtered = append(filtered, session) + } } - return dead[offset:end], total + return filtered } -// streamAuditData reads audit data from disk and streams encrypted chunks via tunnel.stream. -func streamAuditData(cfg *config.Config, sessionID, kind string, gcm cipher.AEAD, requestID string, write ws.PTYWriteFunc) { - dir := filepath.Join(cfg.Dir, "eggs", sessionID) +const ( + defaultSessionsHistoryLimit = 20 + maxSessionsHistoryLimit = 200 +) - var filePath string - switch kind { - case "keylog": - filePath = filepath.Join(dir, "audit.log") - case "chat": - filePath = filepath.Join(dir, "chat.jsonl.gz") - default: - filePath = filepath.Join(dir, "audit.pty.gz") +func paginateSessionsHistory(sessions []pastSessionInfo, offset, limit int) ([]pastSessionInfo, int) { + total := len(sessions) + if offset < 0 { + offset = 0 } - - data, err := os.ReadFile(filePath) - if err != nil { - tunnelRespond(gcm, requestID, map[string]string{"error": "file not found: " + kind}, write) - return + if offset > total { + offset = total } - - if kind == "chat" { - // Chat: stream raw gzip bytes as base64 chunks - const chunkSize = 32 * 1024 - for i := 0; i < len(data); i += chunkSize { - end := i + chunkSize - if end > len(data) { - end = len(data) - } - chunk := map[string]string{"data": base64.StdEncoding.EncodeToString(data[i:end])} - chunkJSON, _ := json.Marshal(chunk) - tunnelStreamChunk(gcm, requestID, chunkJSON, false, write) - } - tunnelStreamChunk(gcm, requestID, []byte(`{"done":true}`), true, write) - return + if limit <= 0 { + limit = defaultSessionsHistoryLimit } - - if kind != "pty" { - // Keylog: stream text wrapped in JSON chunks - text := string(data) - const chunkSize = 32 * 1024 - for i := 0; i < len(text); i += chunkSize { - end := i + chunkSize - if end > len(text) { - end = len(text) - } - chunk := map[string]string{"data": text[i:end]} - chunkJSON, _ := json.Marshal(chunk) - tunnelStreamChunk(gcm, requestID, chunkJSON, false, write) - } - tunnelStreamChunk(gcm, requestID, []byte(`{"done":true}`), true, write) - return + if limit > maxSessionsHistoryLimit { + limit = maxSessionsHistoryLimit } - - // Decompress gzip and stream as asciinema v2 NDJSON - // Tolerate incomplete gzip from live sessions (writer still open) - gr, gzErr := gzip.NewReader(bytes.NewReader(data)) - if gzErr != nil { - tunnelRespond(gcm, requestID, map[string]string{"error": "decompress: " + gzErr.Error()}, write) - return + remaining := total - offset + if limit > remaining { + limit = remaining } - raw, readErr := io.ReadAll(gr) - gr.Close() - if readErr != nil && len(raw) == 0 { - tunnelRespond(gcm, requestID, map[string]string{"error": "read: " + readErr.Error()}, write) - return + return sessions[offset : offset+limit], total +} + +const ( + auditStreamChunkBytes = 32 << 10 + maxAuditFrameBytes = 64 << 10 + maxAuditMetadataBytes = 64 << 10 +) + +func streamAuditFile(file io.Reader, base64Encode bool, emit func([]byte) error) error { + buffer := make([]byte, auditStreamChunkBytes) + for { + count, readErr := file.Read(buffer) + if count > 0 { + value := string(buffer[:count]) + if base64Encode { + value = base64.StdEncoding.EncodeToString(buffer[:count]) + } + chunk, marshalErr := json.Marshal(map[string]string{"data": value}) + if marshalErr != nil { + return marshalErr + } + if err := emit(chunk); err != nil { + return err + } + } + if errors.Is(readErr, io.EOF) { + return nil + } + if readErr != nil { + return readErr + } } +} - // Read terminal dimensions from egg.meta +func auditDimensions(dir string) (int, int) { cols, rows := 120, 40 - if meta, metaErr := os.ReadFile(filepath.Join(dir, "egg.meta")); metaErr == nil { - for _, line := range strings.Split(string(meta), "\n") { - if strings.HasPrefix(line, "cols=") { - if v, pErr := strconv.Atoi(strings.TrimPrefix(line, "cols=")); pErr == nil && v > 0 { - cols = v - } + file, err := os.Open(filepath.Join(dir, "egg.meta")) + if err != nil { + return cols, rows + } + defer closeWithLog("audit metadata", file) + meta, err := io.ReadAll(io.LimitReader(file, maxAuditMetadataBytes+1)) + if err != nil || len(meta) > maxAuditMetadataBytes { + return cols, rows + } + for _, line := range strings.Split(string(meta), "\n") { + if strings.HasPrefix(line, "cols=") { + if value, parseErr := strconv.Atoi(strings.TrimPrefix(line, "cols=")); parseErr == nil && value > 0 && value <= 65535 { + cols = value } - if strings.HasPrefix(line, "rows=") { - if v, pErr := strconv.Atoi(strings.TrimPrefix(line, "rows=")); pErr == nil && v > 0 { - rows = v - } + } + if strings.HasPrefix(line, "rows=") { + if value, parseErr := strconv.Atoi(strings.TrimPrefix(line, "rows=")); parseErr == nil && value > 0 && value <= 65535 { + rows = value } } } + return cols, rows +} + +func incompleteAuditRead(err error) bool { + return errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) +} + +func auditStreamErrorPayload(message string) []byte { + payload, err := json.Marshal(map[string]string{"error": message}) + if err != nil { + return []byte(`{"error":"audit stream failed"}`) + } + return payload +} - // Convert varint format to asciinema v2 NDJSON - isV2 := len(raw) >= 4 && string(raw[:4]) == "WTA2" - pos := 0 - if isV2 { - pos = 4 - if v, n := readVarint(raw[pos:]); n > 0 { - cols = int(v) - pos += n +// streamPTYAudit converts a decompressed V1/V2 recording incrementally. It +// never retains the whole recording or trusts a frame length before enforcing +// the per-frame cap. +func streamPTYAudit(reader io.Reader, fallbackCols, fallbackRows int, emit func([]byte) error) error { + buffered := bufio.NewReaderSize(reader, auditStreamChunkBytes) + isV2 := false + if header, err := buffered.Peek(4); err == nil && bytes.Equal(header, []byte("WTA2")) { + if _, err := buffered.Discard(4); err != nil { + return err + } + isV2 = true + cols, err := binary.ReadUvarint(buffered) + if err != nil { + return fmt.Errorf("read audit columns: %w", err) } - if v, n := readVarint(raw[pos:]); n > 0 { - rows = int(v) - pos += n + rows, err := binary.ReadUvarint(buffered) + if err != nil { + return fmt.Errorf("read audit rows: %w", err) } - } - var cumulativeMs int64 - var ndjson strings.Builder - fmt.Fprintf(&ndjson, `{"version":2,"width":%d,"height":%d}`, cols, rows) - ndjson.WriteByte('\n') - for pos < len(raw) { - deltaMs, n := readVarint(raw[pos:]) - if n <= 0 { - break + if cols == 0 || cols > 65535 || rows == 0 || rows > 65535 { + return fmt.Errorf("invalid audit dimensions %dx%d", cols, rows) } - pos += n + fallbackCols, fallbackRows = int(cols), int(rows) + } + header := []byte(fmt.Sprintf(`{"version":2,"width":%d,"height":%d}`, fallbackCols, fallbackRows)) + if err := emit(header); err != nil { + return err + } - var frameType int64 + var cumulativeMS uint64 + for { + deltaMS, err := binary.ReadUvarint(buffered) + if incompleteAuditRead(err) { + return nil + } + if err != nil { + return fmt.Errorf("read audit timestamp: %w", err) + } + frameType := uint64(0) if isV2 { - frameType, n = readVarint(raw[pos:]) - if n <= 0 { - break + frameType, err = binary.ReadUvarint(buffered) + if incompleteAuditRead(err) { + return nil + } + if err != nil { + return fmt.Errorf("read audit frame type: %w", err) } - pos += n } - - dataLen, n := readVarint(raw[pos:]) - if n <= 0 { - break + dataLen, err := binary.ReadUvarint(buffered) + if incompleteAuditRead(err) { + return nil } - pos += n - if pos+int(dataLen) > len(raw) { - break + if err != nil { + return fmt.Errorf("read audit frame length: %w", err) + } + if dataLen > maxAuditFrameBytes { + return fmt.Errorf("audit frame is %d bytes; maximum is %d", dataLen, maxAuditFrameBytes) + } + frame := make([]byte, int(dataLen)) + if _, err := io.ReadFull(buffered, frame); incompleteAuditRead(err) { + return nil + } else if err != nil { + return fmt.Errorf("read audit frame: %w", err) } - chunk := raw[pos : pos+int(dataLen)] - pos += int(dataLen) - cumulativeMs += deltaMs + if ^uint64(0)-cumulativeMS < deltaMS { + return errors.New("audit timestamp overflow") + } + cumulativeMS += deltaMS + var line []byte if frameType == 1 { - rCols, cn := readVarint(chunk) - if cn <= 0 { - continue - } - rRows, rn := readVarint(chunk[cn:]) - if rn <= 0 { + resize := bytes.NewReader(frame) + cols, colErr := binary.ReadUvarint(resize) + rows, rowErr := binary.ReadUvarint(resize) + if colErr != nil || rowErr != nil || cols == 0 || cols > 65535 || rows == 0 || rows > 65535 { continue } - fmt.Fprintf(&ndjson, "[%.3f,\"r\",\"%dx%d\"]\n", float64(cumulativeMs)/1000.0, rCols, rRows) + line = []byte(fmt.Sprintf("[%.3f,\"r\",\"%dx%d\"]", float64(cumulativeMS)/1000, cols, rows)) } else { - escaped := base64.StdEncoding.EncodeToString(chunk) - fmt.Fprintf(&ndjson, "[%.3f,\"o\",\"%s\"]\n", float64(cumulativeMs)/1000.0, escaped) + encoded := base64.StdEncoding.EncodeToString(frame) + line = []byte(fmt.Sprintf("[%.3f,\"o\",\"%s\"]", float64(cumulativeMS)/1000, encoded)) } - } - - // Stream NDJSON lines as JSON-wrapped chunks - text := ndjson.String() - lines := strings.Split(strings.TrimRight(text, "\n"), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue + if err := emit(line); err != nil { + return err } - // Parse each NDJSON line and send as a chunk the browser can JSON.parse - tunnelStreamChunk(gcm, requestID, []byte(line), false, write) } - tunnelStreamChunk(gcm, requestID, []byte(`{"done":true}`), true, write) } -// readVarint reads a varint from buf, returns (value, bytes consumed). -func readVarint(buf []byte) (int64, int) { - var x int64 - var s uint - for i, b := range buf { - if i >= 10 { - return 0, 0 +// streamAuditData reads audit data from disk and streams encrypted chunks via tunnel.stream. +func streamAuditData(cfg *config.Config, sessionID, kind string, gcm cipher.AEAD, requestID string, write ws.PTYWriteFunc) { + if !ws.ValidSessionID(sessionID) { + tunnelRespond(gcm, requestID, map[string]string{"error": "invalid session ID"}, write) + return + } + dir := filepath.Join(cfg.Dir, "eggs", sessionID) + + var filePath string + switch kind { + case "keylog": + filePath = filepath.Join(dir, "audit.log") + case "chat": + filePath = filepath.Join(dir, "chat.jsonl.gz") + default: + filePath = filepath.Join(dir, "audit.pty.gz") + } + + file, err := os.Open(filePath) + if err != nil { + tunnelRespond(gcm, requestID, map[string]string{"error": "file not found: " + kind}, write) + return + } + defer closeWithLog("audit stream", file) + emit := func(chunk []byte) error { + return tunnelStreamChunk(gcm, requestID, chunk, false, write) + } + + if kind == "chat" { + if err := streamAuditFile(file, true, emit); err != nil { + _ = tunnelStreamChunk(gcm, requestID, auditStreamErrorPayload("read chat audit: "+err.Error()), true, write) + return } - if b < 0x80 { - return x | int64(b)<>1)) + if total != len(sessions) || len(page) != maxSessionsHistoryLimit { + t.Fatalf("bounded history page length=%d total=%d", len(page), total) + } + page, total = paginateSessionsHistory(sessions, int(^uint(0)>>1), 1) + if total != len(sessions) || len(page) != 0 { + t.Fatalf("oversized history offset returned length=%d total=%d", len(page), total) + } +} + func TestWingStatusRoundTrip(t *testing.T) { // writeWingStatus/readWingStatus use wingStatusPath() which depends on config.Load(). // We test the JSON struct directly for unit isolation. dir := t.TempDir() statusPath := filepath.Join(dir, "wing.status") - s := wingStatus{State: "connected", Error: "", TS: "2026-02-21T00:00:00Z"} + s := wingStatus{State: "connected", Error: "", TS: "2026-02-21T00:00:00Z", RoostURL: "https://roost.example"} data, err := json.Marshal(s) if err != nil { t.Fatal(err) @@ -176,6 +764,30 @@ func TestWingStatusRoundTrip(t *testing.T) { if got.State != "connected" { t.Errorf("state = %q, want connected", got.State) } + if got.RoostURL != "https://roost.example" { + t.Errorf("roost URL = %q", got.RoostURL) + } +} + +func TestWriteWingStatusForRoostUsesPrivateMode(t *testing.T) { + dir := t.TempDir() + t.Setenv("WINGTHING_DIR", dir) + writeWingStatusForRoost("connected", "", "wss://user:secret@roost.example/?token=private#fragment") + + status, err := readWingStatus() + if err != nil { + t.Fatal(err) + } + if status.RoostURL != "https://roost.example" { + t.Fatalf("status roost = %q", status.RoostURL) + } + info, err := os.Stat(wingStatusPath()) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("status mode = %v, want 0600", info.Mode().Perm()) + } } func TestWingStatusAuthFailed(t *testing.T) { @@ -183,12 +795,22 @@ func TestWingStatusAuthFailed(t *testing.T) { statusPath := filepath.Join(dir, "wing.status") s := wingStatus{State: "auth_failed", Error: "relay rejected authentication (401)", TS: "2026-02-21T00:00:00Z"} - data, _ := json.Marshal(s) - os.WriteFile(statusPath, data, 0644) + data, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(statusPath, data, 0644); err != nil { + t.Fatal(err) + } - raw, _ := os.ReadFile(statusPath) + raw, err := os.ReadFile(statusPath) + if err != nil { + t.Fatal(err) + } var got wingStatus - json.Unmarshal(raw, &got) + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatal(err) + } if got.State != "auth_failed" { t.Errorf("state = %q, want auth_failed", got.State) } @@ -201,7 +823,9 @@ func TestScanDir_GitRepos(t *testing.T) { root := t.TempDir() mkProject(t, root, "alpha", true, false) mkProject(t, root, "beta", true, false) - os.MkdirAll(filepath.Join(root, "empty"), 0755) + if err := os.MkdirAll(filepath.Join(root, "empty"), 0755); err != nil { + t.Fatal(err) + } var projects []ws.WingProject scanDir(root, 0, 3, &projects) @@ -290,8 +914,9 @@ func TestScanDir_DepthLimit(t *testing.T) { root := t.TempDir() // Create a project 4 levels deep — should not be found with maxDepth=2. deep := filepath.Join(root, "a", "b", "c", "project") - os.MkdirAll(deep, 0755) - os.MkdirAll(filepath.Join(deep, ".git"), 0755) + if err := os.MkdirAll(filepath.Join(deep, ".git"), 0755); err != nil { + t.Fatal(err) + } var projects []ws.WingProject scanDir(root, 0, 2, &projects) @@ -304,7 +929,9 @@ func TestScanDir_DepthLimit(t *testing.T) { func TestScanDir_RootIsGitProject(t *testing.T) { // Configured path points directly at a git project. root := t.TempDir() - os.MkdirAll(filepath.Join(root, ".git"), 0755) + if err := os.MkdirAll(filepath.Join(root, ".git"), 0755); err != nil { + t.Fatal(err) + } var projects []ws.WingProject scanDir(root, 0, 3, &projects) @@ -317,7 +944,9 @@ func TestScanDir_RootIsGitProject(t *testing.T) { func TestScanDir_RootIsEggYamlWithChildren(t *testing.T) { // Configured path has egg.yaml but also contains git children. root := t.TempDir() - os.WriteFile(filepath.Join(root, "egg.yaml"), []byte("fs: []\n"), 0644) + if err := os.WriteFile(filepath.Join(root, "egg.yaml"), []byte("fs: []\n"), 0644); err != nil { + t.Fatal(err) + } mkProject(t, root, "child", true, false) var projects []ws.WingProject @@ -383,7 +1012,9 @@ func TestIsUnderPaths(t *testing.T) { func TestDiscoverProjects_GroupsParentsWithMultipleRepos(t *testing.T) { root := t.TempDir() container := filepath.Join(root, "repos") - os.MkdirAll(container, 0755) + if err := os.MkdirAll(container, 0755); err != nil { + t.Fatal(err) + } mkProject(t, container, "a", true, false) mkProject(t, container, "b", true, false) mkProject(t, container, "c", true, false) @@ -399,6 +1030,128 @@ func TestDiscoverProjects_GroupsParentsWithMultipleRepos(t *testing.T) { } } +func TestDiscoverWingProjectsExplicitPathsDoNotScanCWD(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + outside := filepath.Join(root, "outside") + if err := os.MkdirAll(allowed, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(outside, 0755); err != nil { + t.Fatal(err) + } + mkProject(t, allowed, "visible", true, false) + mkProject(t, outside, "private", true, false) + + projects := discoverWingProjects([]string{allowed}, outside) + if !hasName(projects, "visible") { + t.Fatalf("allowed project missing: %v", projectNames(projects)) + } + if hasName(projects, "private") { + t.Fatalf("cwd project escaped explicit path boundary: %v", projectNames(projects)) + } +} + +func TestDiscoverWingProjectsWithoutPathsPreservesCWDDiscovery(t *testing.T) { + cwd := t.TempDir() + mkProject(t, cwd, "legacy", true, false) + if projects := discoverWingProjects(nil, cwd); !hasName(projects, "legacy") { + t.Fatalf("legacy cwd project missing: %v", projectNames(projects)) + } +} + +func TestRoostBrowserURL(t *testing.T) { + tests := map[string]string{ + "wss://ws.wingthing.ai": "https://app.wingthing.ai/", + "https://wingthing.ai": "https://app.wingthing.ai/", + "https://bryan-wingthing.pants.taxi": "https://bryan-wingthing.pants.taxi/app/", + "ws://localhost:8080": "http://localhost:8080/app/", + "https://user:secret@roost.example": "https://roost.example/app/", + } + for input, want := range tests { + if got := roostBrowserURL(input); got != want { + t.Errorf("roostBrowserURL(%q) = %q, want %q", input, got, want) + } + } +} + +func TestResolveWingRelayHTTPURLPrecedence(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{Dir: dir, RoostURL: "https://config.example/"} + if err := config.SaveWingConfig(dir, &config.WingConfig{Roost: "wss://wing.example/"}); err != nil { + t.Fatal(err) + } + + if got := resolveWingRelayHTTPURL(cfg, "ws://explicit.example/", true); got != "http://explicit.example" { + t.Fatalf("explicit roost = %q", got) + } + if got := resolveWingRelayHTTPURL(cfg, "", true); got != "https://wing.example" { + t.Fatalf("wing.yaml roost = %q", got) + } + if err := os.Remove(filepath.Join(dir, "wing.yaml")); err != nil { + t.Fatal(err) + } + if got := resolveWingRelayHTTPURL(cfg, "", true); got != "http://localhost:8080" { + t.Fatalf("local roost = %q", got) + } + if got := resolveWingRelayHTTPURL(cfg, "", false); got != "https://config.example" { + t.Fatalf("config roost = %q", got) + } + if got := resolveWingRelayHTTPURL(nil, "", false); got != "https://ws.wingthing.ai" { + t.Fatalf("hosted default = %q", got) + } +} + +func TestRelayMetadataURL(t *testing.T) { + tests := map[string]string{ + "wss://user:secret@roost.example/base/?token=private#fragment": "https://roost.example/base", + "roost.example/": "https://roost.example", + "https://%": "", + } + for input, want := range tests { + if got := relayMetadataURL(input); got != want { + t.Errorf("relayMetadataURL(%q) = %q, want %q", input, got, want) + } + } +} + +func TestWingRoostFlags(t *testing.T) { + tests := []struct { + args []string + wantRoost string + wantLocal bool + }{ + {[]string{"wing", "start", "--foreground", "--roost", "https://one.example"}, "https://one.example", false}, + {[]string{"wing", "start", "--foreground", "--local"}, "", true}, + {[]string{"wing", "start", "--foreground", "--roost=https://two.example", "--local"}, "https://two.example", true}, + } + for _, test := range tests { + roost, local := wingRoostFlags(test.args) + if roost != test.wantRoost || local != test.wantLocal { + t.Errorf("wingRoostFlags(%v) = (%q, %v), want (%q, %v)", test.args, roost, local, test.wantRoost, test.wantLocal) + } + } +} + +func TestActiveWingRelayHTTPURLPrefersStatusAndSupportsOldDaemonArgs(t *testing.T) { + dir := t.TempDir() + t.Setenv("WINGTHING_DIR", dir) + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(wingArgsPath(), []byte("wing\nstart\n--foreground\n--roost\nhttps://saved.example\n"), 0600); err != nil { + t.Fatal(err) + } + + if got := activeWingRelayHTTPURL(cfg, &wingStatus{RoostURL: "wss://live.example/"}); got != "https://live.example" { + t.Fatalf("status roost = %q", got) + } + if got := activeWingRelayHTTPURL(cfg, &wingStatus{}); got != "https://saved.example" { + t.Fatalf("saved-args roost = %q", got) + } +} + func TestFormatUserIdentity(t *testing.T) { tests := []struct { name string @@ -446,10 +1199,17 @@ func TestFormatUserIdentity(t *testing.T) { } } +func requireSetupAPIKeyHelper(t *testing.T, agent string, envMap map[string]string, home string) { + t.Helper() + if err := setupAPIKeyHelper(agent, envMap, home); err != nil { + t.Fatalf("setupAPIKeyHelper: %v", err) + } +} + func TestSetupAPIKeyHelper_RemovesKeyFromEnv(t *testing.T) { home := t.TempDir() envMap := map[string]string{"ANTHROPIC_API_KEY": "sk-ant-test123", "OTHER": "keep"} - setupAPIKeyHelper("claude", envMap, home) + requireSetupAPIKeyHelper(t, "claude", envMap, home) if _, ok := envMap["ANTHROPIC_API_KEY"]; ok { t.Error("ANTHROPIC_API_KEY should be removed from envMap") } @@ -461,7 +1221,7 @@ func TestSetupAPIKeyHelper_RemovesKeyFromEnv(t *testing.T) { func TestSetupAPIKeyHelper_WritesKeyFile(t *testing.T) { home := t.TempDir() envMap := map[string]string{"ANTHROPIC_API_KEY": "sk-ant-secret"} - setupAPIKeyHelper("claude", envMap, home) + requireSetupAPIKeyHelper(t, "claude", envMap, home) keyFile := filepath.Join(home, ".anthropic_key") data, err := os.ReadFile(keyFile) if err != nil { @@ -479,14 +1239,16 @@ func TestSetupAPIKeyHelper_WritesKeyFile(t *testing.T) { func TestSetupAPIKeyHelper_SetsApiKeyHelperInSettings(t *testing.T) { home := t.TempDir() envMap := map[string]string{"ANTHROPIC_API_KEY": "sk-ant-test"} - setupAPIKeyHelper("claude", envMap, home) + requireSetupAPIKeyHelper(t, "claude", envMap, home) settingsPath := filepath.Join(home, ".claude", "settings.json") data, err := os.ReadFile(settingsPath) if err != nil { t.Fatalf("settings not written: %v", err) } var settings map[string]any - json.Unmarshal(data, &settings) + if err := json.Unmarshal(data, &settings); err != nil { + t.Fatal(err) + } want := "cat " + filepath.Join(home, ".anthropic_key") if got := settings["apiKeyHelper"]; got != want { t.Errorf("apiKeyHelper = %q, want %q", got, want) @@ -496,15 +1258,27 @@ func TestSetupAPIKeyHelper_SetsApiKeyHelperInSettings(t *testing.T) { func TestSetupAPIKeyHelper_PreservesExistingSettings(t *testing.T) { home := t.TempDir() settingsDir := filepath.Join(home, ".claude") - os.MkdirAll(settingsDir, 0700) + if err := os.MkdirAll(settingsDir, 0700); err != nil { + t.Fatal(err) + } existing := map[string]any{"theme": "dark", "permissions": map[string]any{"allow": true}} - data, _ := json.Marshal(existing) - os.WriteFile(filepath.Join(settingsDir, "settings.json"), data, 0644) + data, err := json.Marshal(existing) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(settingsDir, "settings.json"), data, 0644); err != nil { + t.Fatal(err) + } envMap := map[string]string{"ANTHROPIC_API_KEY": "sk-ant-test"} - setupAPIKeyHelper("claude", envMap, home) - raw, _ := os.ReadFile(filepath.Join(settingsDir, "settings.json")) + requireSetupAPIKeyHelper(t, "claude", envMap, home) + raw, err := os.ReadFile(filepath.Join(settingsDir, "settings.json")) + if err != nil { + t.Fatal(err) + } var settings map[string]any - json.Unmarshal(raw, &settings) + if err := json.Unmarshal(raw, &settings); err != nil { + t.Fatal(err) + } if settings["theme"] != "dark" { t.Errorf("existing theme setting clobbered, got %v", settings["theme"]) } @@ -519,12 +1293,17 @@ func TestSetupAPIKeyHelper_StablePath_NoSessionRace(t *testing.T) { home := t.TempDir() env1 := map[string]string{"ANTHROPIC_API_KEY": "key-session-1"} env2 := map[string]string{"ANTHROPIC_API_KEY": "key-session-2"} - setupAPIKeyHelper("claude", env1, home) - setupAPIKeyHelper("claude", env2, home) + requireSetupAPIKeyHelper(t, "claude", env1, home) + requireSetupAPIKeyHelper(t, "claude", env2, home) settingsPath := filepath.Join(home, ".claude", "settings.json") - raw, _ := os.ReadFile(settingsPath) + raw, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatal(err) + } var settings map[string]any - json.Unmarshal(raw, &settings) + if err := json.Unmarshal(raw, &settings); err != nil { + t.Fatal(err) + } helper := settings["apiKeyHelper"].(string) // Both sessions should point to the same stable path (no session ID in path) wantPath := filepath.Join(home, ".anthropic_key") @@ -533,7 +1312,10 @@ func TestSetupAPIKeyHelper_StablePath_NoSessionRace(t *testing.T) { } // The key file should contain the last writer's key (both are valid, // the point is the PATH is stable, not per-session) - data, _ := os.ReadFile(wantPath) + data, err := os.ReadFile(wantPath) + if err != nil { + t.Fatal(err) + } if string(data) != "key-session-2" { t.Errorf("key file = %q, want key-session-2", string(data)) } @@ -542,9 +1324,11 @@ func TestSetupAPIKeyHelper_StablePath_NoSessionRace(t *testing.T) { func TestSetupAPIKeyHelper_OverwritesOldReadOnlyKeyFile(t *testing.T) { home := t.TempDir() keyFile := filepath.Join(home, ".anthropic_key") - os.WriteFile(keyFile, []byte("old-key"), 0400) + if err := os.WriteFile(keyFile, []byte("old-key"), 0400); err != nil { + t.Fatal(err) + } envMap := map[string]string{"ANTHROPIC_API_KEY": "new-key"} - setupAPIKeyHelper("claude", envMap, home) + requireSetupAPIKeyHelper(t, "claude", envMap, home) data, err := os.ReadFile(keyFile) if err != nil { t.Fatalf("key file gone after overwrite: %v", err) @@ -557,7 +1341,7 @@ func TestSetupAPIKeyHelper_OverwritesOldReadOnlyKeyFile(t *testing.T) { func TestSetupAPIKeyHelper_NonClaudeAgent_Noop(t *testing.T) { home := t.TempDir() envMap := map[string]string{"ANTHROPIC_API_KEY": "sk-ant-test"} - setupAPIKeyHelper("codex", envMap, home) + requireSetupAPIKeyHelper(t, "codex", envMap, home) if _, ok := envMap["ANTHROPIC_API_KEY"]; !ok { t.Error("non-claude agent should not remove ANTHROPIC_API_KEY") } @@ -570,7 +1354,7 @@ func TestSetupAPIKeyHelper_NonClaudeAgent_Noop(t *testing.T) { func TestSetupAPIKeyHelper_NoKey_Noop(t *testing.T) { home := t.TempDir() envMap := map[string]string{"OTHER": "val"} - setupAPIKeyHelper("claude", envMap, home) + requireSetupAPIKeyHelper(t, "claude", envMap, home) settingsPath := filepath.Join(home, ".claude", "settings.json") if _, err := os.Stat(settingsPath); err == nil { t.Error("settings should not be created when no API key present") @@ -612,6 +1396,29 @@ func TestPasskeyPolicyForRoost(t *testing.T) { } } +func TestPasskeyPolicyFromRegistration(t *testing.T) { + policy, ok := passkeyPolicyFromRegistration(ws.RegisteredMsg{ + PasskeyRPID: "roost.example.test", + PasskeyOrigins: []string{ + "https://app.roost.example.test", + "https://roost.example.test", + }, + }) + if !ok || policy.RPID != "roost.example.test" || len(policy.Origins) != 2 || !policy.RequireUserVerification { + t.Fatalf("coordinator passkey policy = %#v, ok=%v", policy, ok) + } + for _, message := range []ws.RegisteredMsg{ + {PasskeyRPID: "example.test", PasskeyOrigins: []string{"https://attacker.test"}}, + {PasskeyRPID: "example.test", PasskeyOrigins: []string{"http://app.example.test"}}, + {PasskeyRPID: "example.test", PasskeyOrigins: []string{"https://app.example.test/path"}}, + {PasskeyRPID: "", PasskeyOrigins: []string{"https://app.example.test"}}, + } { + if policy, ok := passkeyPolicyFromRegistration(message); ok { + t.Errorf("unsafe coordinator passkey policy accepted: %#v", policy) + } + } +} + func TestPasskeyRPURLPrefersPublicBaseURL(t *testing.T) { // The embedded roost wing connects over loopback, but browsers reach the // roost at WT_BASE_URL; the RP ID must anchor on the browser-facing host. @@ -624,6 +1431,13 @@ func TestPasskeyRPURLPrefersPublicBaseURL(t *testing.T) { if standalone.RPID != "roost.example.test" { t.Fatalf("standalone policy = %#v", standalone) } + // Local HTTPS presents localhost to the browser while the embedded wing + // deliberately keeps using the separate loopback HTTP listener. Existing + // localhost development origins remain accepted for backward compatibility. + localHTTPS := passkeyPolicyForRoost(passkeyRPURL("http://127.0.0.1:8080", "https://localhost:8443")) + if localHTTPS.RPID != "localhost" || len(localHTTPS.Origins) != 3 || localHTTPS.Origins[0] != "https://localhost:8443" { + t.Fatalf("local HTTPS policy = %#v", localHTTPS) + } } func TestPasskeysForSubjectNeverTrustsAnotherUser(t *testing.T) { @@ -639,6 +1453,22 @@ func TestPasskeysForSubjectNeverTrustsAnotherUser(t *testing.T) { } } +func TestVisibleAllowKeysHidesOtherMembersFromOrdinaryUsers(t *testing.T) { + allowed := []config.AllowKey{ + {UserID: "alice", Email: "alice@example.com", Key: "alice-key"}, + {UserID: "bob", Email: "bob@example.com", Key: "bob-key"}, + {Key: "administrator-key-only"}, + } + member := visibleAllowKeys(ws.TunnelRequest{SenderUserID: "alice", SenderOrgRole: "member"}, allowed) + if len(member) != 1 || member[0].UserID != "alice" { + t.Fatalf("member-visible allow keys = %#v", member) + } + admin := visibleAllowKeys(ws.TunnelRequest{SenderUserID: "admin", SenderOrgRole: "admin"}, allowed) + if len(admin) != len(allowed) { + t.Fatalf("admin-visible allow keys = %#v, want all", admin) + } +} + func TestParsePreviewFile(t *testing.T) { tests := []struct { name string @@ -651,6 +1481,28 @@ func TestParsePreviewFile(t *testing.T) { name: "url mode", data: "url:https://example.com/app/", mode: "url", url: "https://example.com/app/", }, + { + name: "localhost url mode", data: "url:http://127.0.0.1:3000/", + mode: "url", url: "http://127.0.0.1:3000/", + }, + { + name: "javascript url becomes inert markdown", + data: "url:javascript:alert(1)", + mode: "markdown", content: "url:javascript:alert(1)", + file: "preview.md", mtype: "text/markdown", + }, + { + name: "credentialed url becomes inert markdown", + data: "url:https://user:secret@example.com/", + mode: "markdown", content: "url:https://user:secret@example.com/", + file: "preview.md", mtype: "text/markdown", + }, + { + name: "relative url becomes inert markdown", + data: "url:/local/path", + mode: "markdown", content: "url:/local/path", + file: "preview.md", mtype: "text/markdown", + }, { name: "bare markdown defaults to preview.md", data: "# Report\n\n| a | b |\n", @@ -716,6 +1568,66 @@ func TestParsePreviewFile(t *testing.T) { } } +func TestReadPreviewFileBoundedRejectsOversizedAndNonRegularInputs(t *testing.T) { + dir := t.TempDir() + regular := filepath.Join(dir, "preview") + if err := os.WriteFile(regular, bytes.Repeat([]byte("x"), maxPreviewFileBytes), 0o600); err != nil { + t.Fatal(err) + } + if data, err := readPreviewFileBounded(regular); err != nil || len(data) != maxPreviewFileBytes { + t.Fatalf("read maximum preview: len=%d err=%v", len(data), err) + } + if err := os.WriteFile(regular, bytes.Repeat([]byte("x"), maxPreviewFileBytes+1), 0o600); err != nil { + t.Fatal(err) + } + if _, err := readPreviewFileBounded(regular); !errors.Is(err, errPreviewTooLarge) { + t.Fatalf("oversized preview error = %v", err) + } + + target := filepath.Join(dir, "target") + if err := os.WriteFile(target, []byte("host data"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "preview-link") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := readPreviewFileBounded(link); !errors.Is(err, errPreviewNotRegular) { + t.Fatalf("symlink preview error = %v", err) + } + + swapped := filepath.Join(dir, "preview-swapped") + if err := os.WriteFile(swapped, []byte("safe preview"), 0o600); err != nil { + t.Fatal(err) + } + _, err := readPreviewFileBoundedWithOpen(swapped, func(path string) (*os.File, error) { + if err := os.Remove(path); err != nil { + return nil, err + } + if err := os.Symlink(target, path); err != nil { + return nil, err + } + return os.Open(path) + }) + if !errors.Is(err, errPreviewNotRegular) { + t.Fatalf("path-swap preview error = %v", err) + } +} + +func TestMarshalPreviewFileStaysInsideRelayEnvelopeBudget(t *testing.T) { + data := bytes.Repeat([]byte("\x00"), maxPreviewJSONBytes) + if _, err := marshalPreviewFile(data); !errors.Is(err, errPreviewTooLarge) { + t.Fatalf("expanded preview error = %v", err) + } + encoded, err := marshalPreviewFile(bytes.Repeat([]byte("x"), 64<<10)) + if err != nil { + t.Fatal(err) + } + if len(encoded) > maxPreviewJSONBytes { + t.Fatalf("encoded preview = %d bytes, limit %d", len(encoded), maxPreviewJSONBytes) + } +} + func TestPreviewMIME(t *testing.T) { tests := []struct{ name, want string }{ {"a.md", "text/markdown"}, @@ -738,3 +1650,22 @@ func TestPreviewMIME(t *testing.T) { }) } } + +func TestForgetAttentionStateRemovesAllSessionEntries(t *testing.T) { + sessionID := "attention-cleanup-test" + wingAttention.Store(sessionID, true) + wingAttentionCooldown.Store(sessionID, time.Now()) + wingAttentionNonce.Store(sessionID, "nonce") + t.Cleanup(func() { forgetAttentionState(sessionID) }) + + forgetAttentionState(sessionID) + if _, exists := wingAttention.Load(sessionID); exists { + t.Fatal("attention entry was retained") + } + if _, exists := wingAttentionCooldown.Load(sessionID); exists { + t.Fatal("attention cooldown was retained") + } + if _, exists := wingAttentionNonce.Load(sessionID); exists { + t.Fatal("attention nonce was retained") + } +} diff --git a/cmd/wt/wing_tunnel_lock_test.go b/cmd/wt/wing_tunnel_lock_test.go new file mode 100644 index 00000000..fc95450a --- /dev/null +++ b/cmd/wt/wing_tunnel_lock_test.go @@ -0,0 +1,196 @@ +package main + +import ( + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/ehrlich-b/wingthing/internal/auth" + "github.com/ehrlich-b/wingthing/internal/config" + "github.com/ehrlich-b/wingthing/internal/egg" + "github.com/ehrlich-b/wingthing/internal/ws" +) + +func TestTunnelResponseBackpressureDoesNotBlockWingConfigReload(t *testing.T) { + serverKey, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + senderKey, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + senderPublic := base64.StdEncoding.EncodeToString(senderKey.PublicKey().Bytes()) + senderGCM, err := auth.DeriveSharedKey(senderKey, + base64.StdEncoding.EncodeToString(serverKey.PublicKey().Bytes()), "wt-tunnel") + if err != nil { + t.Fatal(err) + } + payload, err := auth.Encrypt(senderGCM, []byte(`{"type":"wing.info"}`)) + if err != nil { + t.Fatal(err) + } + + enteredWrite := make(chan struct{}) + releaseWrite := make(chan struct{}) + done := make(chan struct{}) + wingCfg := &config.WingConfig{Locked: false, Labels: []string{"initial"}} + allowed := []config.AllowKey(nil) + wingEggCfg := &egg.EggConfig{} + var wingEggMu sync.Mutex + client := &ws.Client{Hostname: "test-wing", Platform: "test", Version: "test"} + + go func() { + defer close(done) + handleTunnelRequest(context.Background(), &config.Config{Dir: t.TempDir()}, wingCfg, ws.TunnelRequest{ + RequestID: "request-1", SenderPub: senderPublic, SenderUserID: "owner-1", + SenderEmail: "owner@example.com", SenderOrgRole: "owner", Payload: payload, + }, func(any) error { + close(enteredWrite) + <-releaseWrite + return nil + }, &allowed, auth.NewAuthCache(), auth.NewChallengeCache(), auth.PasskeyPolicy{}, + serverKey, t.TempDir(), &wingEggMu, &wingEggCfg, false, false, client, nil, &sync.Map{}) + }() + + select { + case <-enteredWrite: + case <-time.After(2 * time.Second): + close(releaseWrite) + t.Fatal("tunnel handler did not reach the blocked response") + } + + lockAcquired := make(chan struct{}) + go func() { + wingCfgMu.Lock() + wingCfg.Labels = []string{"reloaded"} + wingCfgMu.Unlock() + close(lockAcquired) + }() + select { + case <-lockAcquired: + case <-time.After(500 * time.Millisecond): + close(releaseWrite) + t.Fatal("a blocked tunnel response held wingCfgMu and prevented reload") + } + close(releaseWrite) + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("tunnel handler did not finish after response unblocked") + } +} + +func TestRequestAgainstWingConfigRevalidatesLocalAdmin(t *testing.T) { + req := ws.TunnelRequest{SenderUserID: "member-1", SenderEmail: "member@example.com", SenderOrgRole: "admin"} + withoutOverride := requestAgainstWingConfig(req, "member", &config.WingConfig{}) + if withoutOverride.SenderOrgRole != "member" { + t.Fatalf("removed override retained stale role %q", withoutOverride.SenderOrgRole) + } + withOverride := requestAgainstWingConfig(req, "member", &config.WingConfig{Admins: []string{"MEMBER@example.com"}}) + if withOverride.SenderOrgRole != "admin" { + t.Fatalf("live override role = %q, want admin", withOverride.SenderOrgRole) + } +} + +func TestTunnelRejectsPathShapedSessionIDBeforeFilesystemAccess(t *testing.T) { + serverKey, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + senderKey, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + senderPublic := base64.StdEncoding.EncodeToString(senderKey.PublicKey().Bytes()) + senderGCM, err := auth.DeriveSharedKey(senderKey, + base64.StdEncoding.EncodeToString(serverKey.PublicKey().Bytes()), "wt-tunnel") + if err != nil { + t.Fatal(err) + } + payload, err := auth.Encrypt(senderGCM, []byte(`{"type":"pty.kill","session_id":"../../victim"}`)) + if err != nil { + t.Fatal(err) + } + + root := t.TempDir() + cfg := &config.Config{Dir: filepath.Join(root, ".wingthing")} + victim := filepath.Join(root, "victim") + if err := os.MkdirAll(victim, 0o700); err != nil { + t.Fatal(err) + } + marker := filepath.Join(victim, "marker") + if err := os.WriteFile(marker, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + allowed := []config.AllowKey(nil) + wingCfg := &config.WingConfig{} + wingEggCfg := &egg.EggConfig{} + var wingEggMu sync.Mutex + var responses int + handleTunnelRequest(context.Background(), cfg, wingCfg, ws.TunnelRequest{ + RequestID: "traversal", SenderPub: senderPublic, SenderUserID: "owner-1", SenderOrgRole: "owner", Payload: payload, + }, func(any) error { + responses++ + return nil + }, &allowed, auth.NewAuthCache(), auth.NewChallengeCache(), auth.PasskeyPolicy{}, + serverKey, root, &wingEggMu, &wingEggCfg, false, false, &ws.Client{}, nil, &sync.Map{}) + + if responses != 1 { + t.Fatalf("responses = %d, want one rejection", responses) + } + if data, err := os.ReadFile(marker); err != nil || string(data) != "keep" { + t.Fatalf("path-shaped session ID touched victim: data=%q err=%v", data, err) + } +} + +func TestTunnelRejectsMissingCoordinatorIdentity(t *testing.T) { + serverKey, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + senderKey, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + serverPublic := base64.StdEncoding.EncodeToString(serverKey.PublicKey().Bytes()) + senderPublic := base64.StdEncoding.EncodeToString(senderKey.PublicKey().Bytes()) + senderGCM, err := auth.DeriveSharedKey(senderKey, serverPublic, "wt-tunnel") + if err != nil { + t.Fatal(err) + } + payload, err := auth.Encrypt(senderGCM, []byte(`{"type":"wing.info"}`)) + if err != nil { + t.Fatal(err) + } + var response ws.TunnelResponse + wingCfg := &config.WingConfig{} + allowed := []config.AllowKey(nil) + wingEggCfg := &egg.EggConfig{} + var wingEggMu sync.Mutex + handleTunnelRequest(context.Background(), &config.Config{Dir: t.TempDir()}, wingCfg, ws.TunnelRequest{ + RequestID: "anonymous", SenderPub: senderPublic, Payload: payload, + }, func(message any) error { + var ok bool + response, ok = message.(ws.TunnelResponse) + if !ok { + t.Fatalf("response type = %T", message) + } + return nil + }, &allowed, auth.NewAuthCache(), auth.NewChallengeCache(), auth.PasskeyPolicy{}, + serverKey, t.TempDir(), &wingEggMu, &wingEggCfg, false, false, &ws.Client{}, nil, &sync.Map{}) + plaintext, err := auth.Decrypt(senderGCM, response.Payload) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(plaintext), "authenticated user identity required") { + t.Fatalf("anonymous tunnel response = %s", plaintext) + } +} diff --git a/cmd/wt/wings.go b/cmd/wt/wings.go index a0a001b4..1cd2d2a1 100644 --- a/cmd/wt/wings.go +++ b/cmd/wt/wings.go @@ -141,8 +141,7 @@ func wingsCmd() *cobra.Command { if jsonFlag { return writeSessionJSON(entries) } - printWingFinderEntries(entries, noProbeFlag) - return nil + return printWingFinderEntries(entries, noProbeFlag) }, } @@ -162,13 +161,14 @@ func finderRelayURL(cfg *config.Config, override string) string { return resolveRelayHTTPURL(©) } -func printWingFinderEntries(entries []wingFinderEntry, noProbe bool) { +func printWingFinderEntries(entries []wingFinderEntry, noProbe bool) error { if len(entries) == 0 { - fmt.Println("no wings online") - return + return writeln(os.Stdout, "no wings online") } w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) - fmt.Fprintln(w, "WING\tNAME\tOWNER\tPLATFORM\tVERSION\tSTATUS") + if err := writeln(w, "WING\tNAME\tOWNER\tPLATFORM\tVERSION\tSTATUS"); err != nil { + return err + } for _, entry := range entries { name := entry.Label if name == "" { @@ -200,7 +200,9 @@ func printWingFinderEntries(entries []wingFinderEntry, noProbe bool) { } else if !noProbe { status = "online; unverified" } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", entry.WingID, name, owner, platform, version, status) + if err := writef(w, "%s\t%s\t%s\t%s\t%s\t%s\n", entry.WingID, name, owner, platform, version, status); err != nil { + return err + } } - w.Flush() + return w.Flush() } diff --git a/docs/agent-manager-product-brief.md b/docs/agent-manager-product-brief.md new file mode 100644 index 00000000..1763d505 --- /dev/null +++ b/docs/agent-manager-product-brief.md @@ -0,0 +1,651 @@ +# Agent Manager Product Brief and Gap Audit + +Status: working source of truth for `feature/direct-control-free-tier` + +Last reviewed: 2026-08-28 + +Related designs: + +- [Direct agent manager and coordination-only free tier](direct-agent-manager-design.md) +- [Bryan direct-control field report](bryan-wingthing-direct-control-field-report.md) +- [wingthing.ai production canary](wingthing-ai-production-canary-2026-08-25.md) +- [Roost deployment model](roost_design.md) +- [Local agent meta-layer](agent-meta-layer.md) +- [MCP service accounts and API credentials](mcp-service-accounts-design.md) +- [Sandbox enhancement design](sandbox-enhancement-design.md) + +## Why this document exists + +The direct-control design describes one transport and entitlement slice. It does +not by itself capture the product people are asking for or the work required to +make that product safe and coherent. This brief records the use cases gathered +from recent internal Wingthing conversations, compares them to the implementation, +and turns the discrepancies into an ordered engineering plan. + +This is the first document to read after context loss. It is deliberately candid +about incomplete or misleading behavior. Checked boxes and passing unit tests do +not override the end-to-end product gates below. + +## Product thesis + +Wingthing is an agent manager for agents, and also for people. + +An owner should be able to put wings on a home machine, an office VM, a private +lab, or a hosted worker; see one authorized inventory; and let either a person or +an agent create workspaces, launch durable agents, inspect semantic progress, +steer or stop work, and retrieve results. Claude, Codex, OpenCode, and other +adapters are interchangeable workers behind the same control contract. + +`wingthing.ai` is the hosted coordination plane, analogous to a tailnet control +plane. It provides identity, an access-filtered directory, signaling, and optional +paid encrypted relay. Direct clients should talk to wings without sending their +MCP or terminal payloads through the hosted service. A private roost provides the +same control-plane shape under the operator's trust boundary. + +The human browser, local CLI, local MCP, remote MCP, and future automation API are +clients of one resource and authorization model. None should invent a separate +notion of the current wing, owner, session, or workspace. + +## Canonical user stories + +The following stories recur in internal conversations and should drive scope. + +### Multiple machines, one inventory + +An owner runs wings on an office VM and a home VM. A browser and an LLM can list +both, distinguish their resources by `wing_id`, and operate either without logging +into each machine separately. Sessions remain alive when the controlling client +disconnects. + +### Prepare a workspace and launch an agent + +An owner or orchestrator can select a machine, create or reuse a directory or Git +worktree, run bounded setup commands, install or validate project prerequisites, +and launch a selected agent in that directory. The preparation result is typed, +audited, and composable; callers do not have to scrape a short-lived PTY to learn +whether setup succeeded. + +### Agents manage other agents + +An outer agent can discover available wings and workers, launch Claude or Codex, +wait without polling, inspect structured status and results, steer a run, exchange +owner-scoped messages, and stop it. Model choice is a parameter, not an architectural +fork. Concurrent work is bounded by policy. + +### Choose the trust boundary + +Wingthing can configure an agent, configure its nested sandbox, provide remote +access, or do any useful subset. A dedicated AI VM, container, or external sandbox +can be the outer boundary without pretending a nested sandbox is active. Conversely, +when Wingthing claims enforcement, filesystem and network policy must be real on +that platform and cannot silently disappear in an escape mode. + +### Private human access + +A small team can self-host one private roost behind a VPN/tailnet and register +several wings with it. The browser provides a unified view and terminal access. +HTTPS may be supplied by a real hostname and ACME or by a tailnet/VPN reverse proxy, +but the supported topology and trust consequences must be explicit. + +### Recurring unattended work + +An owner can schedule a prompt or agent run hourly or weekly, query configured +systems such as logs or issue trackers, and deliver a bounded result to a declared +destination such as Slack or email. The workload uses a revocable service identity, +not a human's indefinitely copied OAuth session. + +### Shared context and tools + +Employees and agents can use Wingthing's MCP endpoint to access configured tools, +role-specific instructions, databases, search, or other context under ACLs and +audit. Server-to-server consumers get first-class service accounts. Wingthing is +the policy and orchestration surface; generated web application hosting belongs to +a separate product. + +### Durable working context + +Agent memory and project instructions should not accidentally depend on the folder +from which a client was launched. Connecting to another wing can deliberately sync +or mount declared context without copying secrets or unapproved files. + +## Slack-derived workflow map + +This is an internal product-research map of the recurring requests. It deliberately +does not appear on the public `/patterns` page. The public page contains only +self-contained setup guides for behavior that ships today; gaps stay here until +they become usable product workflows. + +| Requested workflow | How to do it now | Pattern or gap | +| --- | --- | --- | +| One office VM and one home VM, durable Claude/Codex sessions, one parent-agent inventory | Register both wings with one coordinator; run `wt mcp connect`; call `wing_list`; qualify every operation with `wing_id`. | `remote-orchestration`; working direct MCP, including the 2026-08-28 Mac-plus-Bryan physical two-host canary. | +| The same multi-machine view for a person, without trusting public payload relay | Run one private roost behind a VPN/tailnet and valid HTTPS, enroll exact account emails, then register the wings with its gateway. The private roost supplies the browser relay inside the operator's trust boundary. | `shared-web-roost`; browser-direct hosted transport and roost federation remain gaps. | +| Run shell setup, make a directory or Git worktree, then launch the chosen agent there | Use an existing idempotent setup script through `terminal_start`, wait for its completion canary, then call `agent_run` or `agent_start` with the resulting `cwd`. | Compose now; typed `workspace_prepare`/worktree lifecycle is P0. | +| Let an outer agent supervise inner Claude, Codex, OpenCode, or another worker | Register local stdio MCP or the native direct connector; use semantic start/status/wait/result/steer/stop plus durable messages. | `local-subagents` and `remote-orchestration`; working. | +| Choose agent configuration, nested sandbox, remote access, or an outer VM/container independently | Use ordinary egg policy for the nested boundary, or explicitly declare trusted outer-boundary mode when the VM/container is the sandbox. | `local-sandbox`; remote outer-boundary policy parity remains partial. | +| Scheduled log/error review using Prometheus, Grafana, OpenSearch, or databases, followed by a Slack report | Put data access behind authenticated MCP tools. Local scheduled tasks exist, but remote schedule CRUD, revocable service identities, and typed delivery are not shipped. | P1 automation gap. | +| Let local agents use governed Jira/log/database/context connectors | Add the authenticated roost/context-service HTTP MCP endpoint to the local client; keep connector ACLs and audit at that service. | `shared-roost-agents`; working for configured tools. | +| Build and publish a dashboard or small internal app from the agent's result | Wingthing manages the agents, workspace, context, and evidence; hand the artifact to a separate internal hosting product. | Explicit non-goal for Wingthing itself. | +| Pair independently administered home and office roosts once, then browse one merged inventory | Add them as separately named MCP servers today. True peer directory/identity federation is not implemented. | Not a public pattern; federation gap. | + +## Supported topology today + +The shortest viable private multi-machine topology is one gateway, not peer +federation: + +```text +home wing ----\ + +-- one private roost/gateway -- browser and MCP clients +office wing --/ +``` + +Both wings register with the same access-filtered gateway. The browser can aggregate +their sessions, and native `wt mcp connect` can target an external wing directly. +This is the topology to document and dogfood now. + +Independent roosts do not discover or federate. The aspirational setup in which a +home roost and an office roost are paired once and thereafter expose one inventory +requires a peer directory, conflict rules, cross-roost authorization, revocation, +and routing that do not exist yet. + +## Implementation truth as of 2026-08-28 + +| User need | State | What is actually true | +| --- | --- | --- | +| Durable local Claude/Codex/OpenCode sessions | Working | Persistent PTYs and semantic agent runs survive client disconnects; the default idle timeout is disabled. | +| Local agent orchestrates agents | Working | `wt mcp stdio` exposes typed terminal, agent-run, message, sandbox, prompt, loop, and swarm controls. | +| Remote agent controls multiple wings | Field-proven across two physical wings | `wt mcp connect` listed an external macOS wing and Bryan simultaneously, reported `direct-webrtc` for both, started real Codex and Claude sessions on the qualified targets, received distinct exact responses, and stopped only the returned session IDs. A real Claude Sonnet orchestrator separately exercised the richer lifecycle on Bryan. | +| Mixed agent backends | Working | Claude, Codex, Cursor, Gemini, Hermes, Ollama, and OpenCode adapters exist. | +| Long-running semantic runs | Working | Start, status, events, wait, result, steer, and stop operations exist. | +| Unified human browser | Entitled/self-hosted only | Accounts with hosted relay access and enrolled private-roost users can use the relay browser. A hosted direct-only free account receives setup/readiness UI and no session inventory. | +| Direct browser terminal | Missing | Browser-direct transport is explicitly outside the current slice. | +| Secure direct access to a protected wing | Missing | Native direct MCP rejects locked and per-user-passkey-protected wings because it cannot complete the passkey ceremony. | +| User-selectable direct-only policy | Working on branch | `hosted_relay: deny` overrides hosted and private-roost relay entitlement at the honest gateway and again at the wing. Omitted policy remains `allow` for deployed wings. | +| Hosted relay for native MCP | Deliberate non-feature in this slice | The connector is direct-only and says so on failure. Hosted relay access applies to the browser/control-relay surface; the native connector never silently changes transports. | +| First-class workspace/worktree preparation | Partial | `terminal_start` accepts argv and `agent_start` accepts `cwd`; no typed one-shot execution, worktree object, setup hook, or atomic prepare-and-launch operation exists. | +| Prompt assets, loops, and swarms remotely | Missing | These registry tools remain local-MCP-only. | +| Recurring automation through MCP | Missing | Internal cron support and schedule parsing exist, but there are no schedule create/list/remove MCP tools or delivery targets. | +| Unattended service identity | Designed only | Human OAuth exists; the service-account design has not been implemented. | +| Bring-your-own outer sandbox | Partial | Local MCP exposes trusted outer-boundary mode. The remote direct surface does not expose an equally clear policy contract. | +| Linux network confinement | Working on branch | The route-less network namespace and inherited-FD relay enforce one egress path without root; the WSL2 battery passed. | +| Peer roost federation | Missing | A client selects a gateway URL; independent roosts are separate inventories. | +| Durable context/memory sync | Missing | Context sync remains backlog work. | +| Generated app hosting | Non-goal | Wingthing should give agents tools and context; a separate service should publish apps. | + +## Security and policy invariants + +These are release requirements, not aspirational documentation. + +1. **Coordinator identity is not local consent.** Compromise of a hosted account or + coordinator must not silently bypass a wing's lock or passkey policy. +2. **Direct-only must be enforceable.** A wing owner can disable hosted payload + relay even when an account has hosted relay access. +3. **One authorization model.** Direct MCP receives explicit grants, owner scoping, + path scoping, spawn/session bounds, and audit policy derived from authenticated + identity. Nil policy must never accidentally mean unrestricted remote access. +4. **Every resource is qualified.** The stable identity of a wing-owned resource is + `(wing_id, kind, id)`. Nested returned resources must not lose `wing_id`. +5. **Claims follow the effective boundary.** `--unsandboxed` or outer-boundary mode + must report that choice and must not imply nested filesystem or network enforcement. +6. **No theatrical egress policy.** Enforce mode either establishes a real kernel + boundary or refuses to launch. An agent that ignores `HTTPS_PROXY` must have no + alternate route to a denied host. +7. **Secrets and content stay out of audit.** Audit records identity, target, policy, + digest, and decision, not prompts, messages, terminal bytes, or credentials. +8. **Remote mutation has durable accountability.** Define which operations fail + closed when their audit record cannot be written; do not silently market a + best-effort log as a security guarantee. +9. **Bounds are enforced at the wing.** The client and coordinator are not trusted + to enforce maximum sessions, spawn rates, execution deadlines, or response sizes. +10. **Direct transport is not the entire security story.** Keeping payload bytes off + `wingthing.ai` reduces exposure but does not by itself solve authentication, + authorization, client supply-chain trust, endpoint compromise, or metadata. + +## Compatibility is a product invariant + +Wingthing has real users and an existing organization-mode shared-roost deployment. +Compatibility is therefore part of the architecture, not cleanup after a new path +works. The direct agent-manager work must be additive until a separately reviewed +migration deliberately removes an old behavior. + +The following deployed contracts remain supported while this branch rolls out: + +- existing browser terminal start, attach, reconnect, rename, stop, and encrypted + relay behavior for entitled users; +- existing HTTP MCP OAuth clients and their current tool schemas; +- existing personal wings and org-bound wings registered with the hosted gateway; +- organization owner/member/outsider visibility and role derivation; +- the shared roost's embedded service wing without exposing an external personal + wing to every enrolled roost user; +- folder-based ACLs and canonical-path enforcement for organization members; +- separate provider login homes and owner-scoped sessions on a shared host; +- existing passkeys, wing allow/revoke records, lock state, and browser ceremonies; +- durable sessions and database records created by the currently deployed binary; +- local self-hosted mode without OAuth, and private OAuth roost deployments that + intentionally retain relay behavior; and +- exact-email enrollment for private OAuth roosts, while an omitted enrollment + list preserves the prior accept-any-authenticated-account behavior; +- configuration files that omit newly introduced keys. + +Compatibility rules: + +1. **Schemas grow additively.** Existing fields retain meaning and optional new fields + get safe defaults. Renames require an alias/deprecation period. Unknown fields and + capability versions fail predictably rather than being reinterpreted. +2. **Old and new components coexist deliberately.** The supported matrix covers an + N-1 wing with an N gateway/browser and an N wing with an N-1 browser/gateway for + every protocol changed by the branch. Unsupported combinations return an upgrade + error before mutation. +3. **Database migrations are forward-only and transactional.** Test both fresh stores + and upgrades from a copy of the deployed relay and runtime schema. Do not drop or + rewrite user data in the feature rollout. A rollback uses a database backup when an + old binary cannot safely read the migrated schema. +4. **Defaults preserve private deployments.** A policy chosen for new public hosted + accounts must not silently change an existing shared/private roost. Public account + cohorts and private operator policy are separate inputs. +5. **Security tightening is explicit.** Fail-closed improvements may intentionally + reject an unsafe operation, but they require a clear error, upgrade/remediation + path, compatibility test, and release note. Never silently fall back to weaker + behavior to keep an old client green. +6. **Session continuity is tested.** Upgrade with live and detached sessions, then + reattach from both browser and MCP. Existing eggs either remain controllable or + receive a durable, truthful terminal state; they do not disappear or change owner. +7. **Rollback is rehearsed.** Promotion keeps the previous binary and a verified DB + backup, separates public hosted rollout from the organization-mode roost, and + records which protocol/database boundary prevents a binary-only rollback. + +The organization-mode deployment is the highest-value regression canary because it +combines OAuth, multiple users, role and path policy, shared host credentials, browser +relay, passkeys, durable sessions, and operator-managed configuration. It must not be +the first place a new binary or migration is tried. + +## Known contract discrepancies + +These are cases where behavior or prose currently says more than the system does. + +### Direct protection and authorization + +The nil-grants/unbounded direct-server discrepancy is closed on this branch. Every +direct request receives an explicit wing-resolved grant set, configured-path scope, +positive session/spawn bounds, and process-shared admission state. Already-open +channels re-resolve that policy after `SIGHUP`. Optional strict `direct_mcp` +configuration can narrow grants, change bounds, or disable the surface; malformed +policy fails startup/reload rather than falling back to full authority. +Deterministic, real-data-channel, repeat, race, integration, Linux-build, and +four-principal organization-mode browser gates cover the slice. + +The direct connector fails closed when a wing is locked or has a passkey for the +user. This is the correct temporary failure behavior, but it means secure enrollment +and the primary direct path cannot coexist. A native passkey-bound authorization +ceremony is required before the direct path can be the default for sensitive wings. + +### Human and agent views diverge + +The new free browser intentionally clears wing and session state and displays direct +agent setup instructions. It is not a unified human view. Headless semantic agent +runs also do not have a complete browser inventory. The product needs either a +browser-direct client or precise positioning that hosted free is agent-only. + +### Native transport positioning is now explicit + +Native `wt mcp connect` establishes a direct control client only. The command, +failure remediation, public website, patterns, and security docs now say that the +native connector does not silently use the hosted relay. Pro describes the hosted +browser-terminal/control relay surface, not an unimplemented native fallback. + +### Workspace setup is possible only through PTY composition + +An orchestrator can start an arbitrary command in a durable PTY and later read its +screen. That is useful escape-hatch behavior, not a typed setup API. It lacks a +structured exit code, stdout/stderr bounds, idempotency, workspace identity, and an +atomic handoff to agent launch. It also violates the design principle that callers +should not scrape terminal state when a typed fact can exist. + +### Remote automation is narrower than local automation + +Prompt assets, bounded loops, and swarms are local-only registry surfaces. Cron has +no MCP surface. Therefore “everything a person can do, an LLM can do remotely” is +not yet an accurate claim. + +### Public and private relay policy are separated + +The `wt serve` gateway defaults to backward-compatible `legacy` behavior for private +gateways. The checked-in Fly deployment explicitly selects `direct-free` and an +RFC3339 migration boundary. The older cutoff variable remains a compatibility alias, +conflicting values fail startup, and no account receives migration access from a +compile-time timestamp. + +### Private OAuth identity needed enrollment + +Completing OAuth historically enrolled any provider account in a private gateway or +all-in-one roost. Both deployment shapes can now set exact emails with +`WT_ROOST_ALLOWED_EMAILS`; the boundary is rechecked for login completion, existing +cookies, device tokens, inventory, relay, and MCP. Omitting the variable retains +existing deployment behavior, so current docs require the list unless the provider +or ingress already restricts membership. + +### Result qualification is explicit + +Every direct wing-owned result has a top-level `wing_id`. Resource objects returned +by the current list/send operations (sessions and messages) also carry their own +`wing_id`, so retaining one object outside its response does not create mutable +"current wing" state. Connector tests cover nested qualification and verify that the +wing-side source result is not mutated. + +## Ordered implementation plan + +### P0: make direct remote control safe + +1. **Done:** resolve explicit grants, configured paths, identity, role, and positive + process bounds at the wing; support additive local restriction/disable policy. +2. **Done:** require the resolved policy when constructing direct MCP and remove the + remote nil-grants/full-access sentinel. +3. **Done:** cover grant denial, owner/member/admin/outsider roles, member paths, + maximum sessions, reconnect-resistant spawn rate, lock rejection, real WebRTC, + race detection, and the existing shared-roost browser canary. +4. Extend the policy with an explicit outer-boundary permission and decide/test audit + failure behavior for remote mutations. +5. Specify and implement the native passkey challenge/response using the existing + one-time wing nonce and client-bound token semantics. A coordinator assertion alone + must not satisfy it. +6. **Done:** add a persistent per-wing `hosted_relay: allow|deny` control. `deny` + wins over account entitlement and is observable in capability metadata and + content-free gateway/wing audit records. + +### P0: prove the actual multi-machine path + +Build an end-to-end canary that starts or uses one coordinator and two distinct wings, +runs the actual `wt mcp connect` process, and drives it through the same JSON-RPC +stdio interface used by Claude and Codex. It must prove: + +- the authorized roster contains both wings; +- every wing-owned call requires `wing_id`; +- a command addressed to wing A cannot execute on wing B; +- returned resources remain qualified; +- direct MCP request and result bytes do not enter the hosted relay path; +- an unauthorized, locked, revoked, offline, or unreachable wing fails clearly; +- disconnecting the MCP client does not kill the durable terminal or agent; +- reconnecting can list and continue that work. + +Run the same canary on the WSL rig after coordinating around existing work there. The +recent WSL battery proves sandbox behavior only; it does not prove the remote +agent-manager path. + +The first deterministic boundary is now covered by +`TestConnectMCPStdioRoutesTwoWingsDirectlyAndPersistsAcrossReconnect`. It drives the +connector's JSON-RPC stdio surface, creates independent real WebRTC data channels to +`home` and `office` wing runtimes, requires `wing_id`, checks qualified results, +proves state written to one wing is absent from the other, closes the connector, and +discovers the durable state through a fresh connector. Its coordination spy observes +only `wing.info` and `webrtc.offer`. This deliberately does not satisfy the remaining +two-host, actual-binary/client, NAT, or WSL promotion gate. + +### P0: preserve existing users and organization mode + +Treat the current shared-roost browser canary and org authorization tests as the +floor, then extend them for this branch: + +1. Run the shared-roost browser suite with an org owner, two ordinary members, and + a non-enrolled outsider. Prove wing roster, project visibility, path denial, session launch, + encrypted attach, detach/reattach, rename/stop, account/org pages, and mobile. +2. For owner and member, exercise native direct MCP against the same org wing. Derive + role and paths at the wing; never accept them from tool arguments or signaling + payload supplied by the caller. +3. Prove an outsider cannot list, signal, guess, attach to, or infer the existence of + the org wing or its sessions. Prove roost mode does not make an external personal + wing globally visible. +4. Use distinct provider-home canary secrets for two users. Each user's agent can read + its own canary and cannot read the other's; terminal output and audit expose neither. +5. Cover unlocked, wing-locked, owner-passkey, and member-passkey rows through browser, + HTTP MCP, and native direct MCP. An unavailable native ceremony fails closed without + disturbing the working browser ceremony. +6. Seed detached sessions and semantic runs using the deployed/N-1 binary, upgrade + gateway and wing in both orders, and prove inventory, ownership, attach, wait, + result, and stop behavior. +7. Upgrade copies of both relay and runtime databases from every supported migration + baseline. Compare row counts and owner/org/resource relationships before and after. +8. Exercise new-free, temporary-migration, Pro, legacy private gateway, local roost, and + OAuth private roost policies. No cohort can acquire broader access from a missing + timestamp, cache miss, or default branch. +9. Canary a separate deployment with copied non-secret configuration and synthetic + users. Only after it passes may the organization-mode deployment be upgraded. + +### P0: make workspace preparation first-class + +Add a bounded semantic execution primitive before building elaborate project types: + +```text +exec_run( + wing_id?, argv[], cwd?, env_names?, timeout_seconds?, max_output_chars? +) -> { + execution_id, exit_code, stdout, stderr, truncated, started_at, finished_at +} +``` + +It accepts argv, never an implicit shell string. Environment values come from +operator-approved configuration or explicit non-secret inputs and are redacted from +audit. It enforces path, process, timeout, output, and sandbox policy at the wing. + +On top of that, add a Git-aware workspace operation: + +```text +workspace_prepare( + wing_id?, project_root, kind, name, base_ref?, reuse?, setup_steps[]? +) -> { + workspace_id, path, repository, branch, base_ref, reused, setup_results[] +} +``` + +`kind` initially supports `directory` and `git_worktree`. Preparation is idempotent +under an explicit `reuse` policy. A later `agent_start` or `agent_run` accepts +`workspace_id`; the wing resolves it to a bounded path. Provide a compound +prepare-and-launch operation only after the two underlying transactions are durable +and independently auditable. + +The browser project scanner must periodically rescan configured roots so a newly +prepared workspace appears without restarting the wing. + +### P0: make packaging and claims honest + +1. **Done on branch:** the connector error, pricing copy, and docs describe the native + direct-only path and only the relay paths that exist. +2. **Done on branch:** private `wt serve` defaults to `legacy`; the checked-in + `wingthing.ai` Fly configuration explicitly selects `direct-free`. +3. **Done on branch:** migration access has an explicit deploy-time cutoff with a + deprecated variable alias; no compile-time date grants access. +4. **Done on branch:** release artifacts contain checksums, the installer and updater + verify them and reject binaries missing the public command contract, and tag + publication waits for Linux, macOS, race, integration, and browser gates. +5. **Done on branch; production recheck required:** publish the agent-manager setup + page and new docs together. A production check must include `/`, `/docs`, + `/patterns`, capability metadata, a new free account, an old account, and a + self-hosted roost. +6. **Done on branch:** state plainly that hosted direct-only free accounts receive an + agent control path, not a human session UI. The readiness page does not call + itself a unified human view. + +### P1: complete the agent-manager product loop + +1. Expose schedules as typed create/list/get/pause/resume/remove operations. +2. Implement service accounts and revocable workload credentials before unattended + delivery or external server-to-server use is promoted. +3. Add declared delivery targets with bounded payloads, retry policy, and content-safe + audit; start with Slack webhook/app delivery and email only if ownership is clear. +4. Decide which prompt, loop, and swarm resources are safe remotely, then expose them + through the same registry and wing qualification contract. +5. Add headless agent-run inventory and steering to the human browser. +6. Add deliberate context and memory synchronization with allowlists and conflict + behavior; never infer that an arbitrary launch directory is durable memory. +7. Add a browser-direct terminal path if unified human access is part of hosted free. + +### P2: federated roosts and provider-managed environments + +Peer roosts require an explicit federation design: discovery, trust establishment, +identity mapping, ACL intersection, revocation propagation, resource qualification, +conflict behavior, offline state, and routing/fallback policy. Do not reuse the +replica peer directory and call it federation. + +Environment provisioning should remain provider-based. Wingthing can define typed, +bounded environment lifecycle controls and adapters for Proxmox, cloud VMs, or other +systems without making any one provider the core runtime. + +## Completed coding slice: remote policy propagation + +This slice is implemented and verified on 2026-08-25. + +1. Before production code, add characterization tests for the deployed behavior: + personal owner, org owner, org member, outsider, embedded shared-roost wing, + external personal wing, path ACLs, passkey/lock rejection, and existing relay + entitlements. Reuse the fixtures in `internal/relay/authz_test.go`, + `cmd/wt/mcp_direct_wing_test.go`, and `test/web/orgmode.mjs` where possible. +2. Introduce the smallest policy value type needed by direct MCP. +3. Resolve it in the wing-side authenticated direct-channel path. +4. Populate grants and bounds on `localMCPServer` without changing local stdio + compatibility. +5. Make a missing remote policy fail closed. +6. Add table-driven tests in `cmd/wt/mcp_direct_wing_test.go` for allowed grant, + denied grant, maximum sessions, spawn rate, member paths, owner identity, and the + existing lock/passkey behavior. +7. Add an integration row proving existing HTTP MCP and browser org behavior is + unchanged when direct MCP policy is enabled. +8. Run focused Go tests, the full unit and integration suites, static checks, Linux + cross-compilation, and then the two-host canary when it exists. + +Evidence: focused policy/config tests; real WebRTC grant and org-path tests; ten +repeat runs; race detector; `make test`; `make test-integ`; `make check`; Linux amd64 +cross-build; and the seeded organization-mode Docker browser canary. The canary also +uses the Docker daemon's architecture rather than assuming the client host matches it. + +## Completed coding slice: local hosted-relay opt-out + +`hosted_relay: allow|deny` is an additive wing-local policy. Default `allow` +preserves every deployed browser and organization-mode workflow. `deny` must be +enforced twice: advertised to an honest gateway so it refuses before routing, and +checked by the wing so a compromised or stale gateway cannot start/attach a relayed +PTY or send a general payload tunnel. Coordination-only signaling remains available +for native direct MCP. Capability metadata and content-free audit must expose the +effective decision. + +The branch now enforces that contract at both boundaries. Registration and config +update messages carry the additive policy; authorized portal/MCP roster entries and +encrypted `wing.info` report the effective value. The gateway refuses PTY start, +attach, input, passkey/session control, and general tunnel payloads before forwarding. +The wing client independently rejects those messages, limits the surviving discovery, +signaling, and passkey purposes to 256 KiB, skips relay session reclaim, and records a +private content-free local policy audit. Gateway denials append only actor, wing, +operation, and policy metadata. + +Compatibility is deliberate: an N-1 wing omits the field and remains `allow`; an N +wing talking to an N-1 gateway still enforces `deny` locally; unknown explicit wire +values fail closed. Real WebSocket integration covers org owner/member denial plus +continued discovery forwarding, while the organization browser +canary covers the omitted/default path. Explicit integration cohorts cover new-free +account denial, a Pro owner, a temporary-migration org member, and a private-roost owner; +the local `deny` wins for every otherwise-entitled cohort. + +## Scope of this merge candidate + +This branch's mergeable product slice is the direct multi-wing MCP connector, +coordination-only hosted free tier, wing-local hosted-relay opt-out, rootless Linux +egress enforcement, self-hosted localhost HTTPS, private-roost enrollment, and the +release/documentation contract around those features. + +Typed workspace preparation, native passkey ceremonies, browser-direct free terminal +transport, schedule MCP tools, service identities, delivery targets, and peer-roost +federation remain roadmap work. They are not advertised as shipped behavior and do +not become merge blockers for this bounded slice. The current connector fails closed +for passkey-protected wings, and the public patterns page contains only workflows that +can be run with the current binary. + +That de-scoping is not a claim that the full dream is complete. It distinguishes a +safe additive merge from a production/product acceptance test for later capabilities. + +### Test-first working rule + +Every implementation patch on this branch carries evidence at the lowest useful +layer and at the boundary it changes: + +- pure policy and schema changes get exhaustive table-driven allow/deny/default tests; +- parsers, bounds, and untrusted envelopes get malformed, oversized, unknown-version, + replay/duplicate, and fuzz or property coverage where useful; +- authorization changes always include owner, member, outsider, cross-owner, revoked, + and missing-policy negative controls; +- lifecycle changes cover success, failure, timeout, cancellation, disconnect, + restart, and idempotent retry; +- transport changes cross a real process boundary and include mixed-version behavior; +- database changes use fresh and upgrade fixtures and assert preserved relationships; +- browser-visible changes extend the four-principal organization-mode Playwright canary; +- sandbox claims are verified from inside the sandbox on the claimed operating system; + mocks alone cannot establish enforcement; and +- every bug found while dogfooding receives a regression test that would have caught + the original failure before the fix is considered complete. + +Do not optimize for a coverage percentage or raw test count. “Slathered in tests” +means every important claim has a positive control, a negative control, and evidence +at the boundary where the claim could fail. Tests must remain deterministic by +default; live providers and shared rigs are separate, explicit promotion gates. + +## Merge and production gates + +The bounded slice above is merge-ready only when: + +- the full Go suite, vet, race detector, web build, and diff/doc checks pass; +- integration, Debian and Ubuntu privileged sandbox, macOS Seatbelt, and + organization-mode browser suites pass; +- locked/passkey policy fails closed and remote grants, bounds, ownership, path ACLs, + provider homes, enrollment, detach/reattach, and stop have negative controls; +- current compatibility fixtures prove omitted new fields retain N-1 behavior and + old relay-cutoff configuration remains accepted; +- fresh and upgrade fixtures pass for both SQLite stores; +- release artifacts carry checksums and the exact binary passes the website command + contract before a tag can publish it; and +- public, operator, architecture, security, and testing docs agree about what is + shipped, direct-only, entitled, self-hosted, or still roadmap work. + +Production promotion is a separate gate. It additionally requires a matching GitHub +release before the website advertises its commands, the real WSL/physical-wing +canary, an enrolled OAuth private-roost user plus a denied outsider, a fresh +post-boundary hosted free account, a Pro account, a temporary-migration account, and +a private-roost account. Upgrade/reconnect and rollback checks run on a synthetic +canary before the organization deployment. These credentialed and physical-host +checks cannot be replaced by an in-process test and are recorded in the production +canary log rather than silently treated as repository unit coverage. + +No test is removed or weakened merely because a product path is being repositioned. +If an assertion represented obsolete behavior, replace it with a test of the explicit +migration or denial contract and record why the old behavior is no longer supported. + +## Branch and deployment snapshot + +At the time of this review: + +- branch: `feature/direct-control-free-tier`; +- Fly status was rechecked on 2026-08-27: release v306 is healthy on the single + `login` machine in `ewr`, with one of one checks passing. It runs image + `wingthing:deployment-01M0Z236DFZZGPMREMW4NH4P7R`; v305 and earlier releases and + the verified pre-feature database backup remain rollback inputs; +- the direct-control implementation and Linux egress fix have passed unit, + integration, static, cross-build, and WSL sandbox testing; +- a real `wt mcp connect` process controlled two physical wings through Bryan's + coordination plane without hosted payload relay, and real Codex and Claude + sessions on the two targets returned distinct canary responses; +- the branch is not merged to main, but its committed runtime is deployed as the + v306 public canary. The authorization, private-policy-default, release-contract, + and documentation changes made after `d1610e6` are not deployed; +- the public site now presents Wingthing as an agent manager and `/patterns` is live; +- all 25 accounts measured at the initial deployment were on the temporary-migration + side of `2026-08-26T00:00:00Z`; later direct-only accounts must be measured again + before promotion; and +- the public HTTP surfaces and anonymous native-MCP fail-closed path passed their + post-deploy canaries. Fresh authenticated enrollment remains outstanding. + +Recheck every item before relying on this snapshot. Git history, live deployment, +and account state can change independently. + +## Definition of the dream + +The feature is real when a new user can put one wing at home and one at the office, +open either an LLM or a browser, authenticate once, see the same authorized inventory, +prepare a worktree, launch different durable agents, leave, reconnect, inspect and +steer them, and schedule follow-up work—while the owner can prove which machine ran +what, which policy bounded it, whether payload bytes were relayed, and how to revoke +access without taking the machines apart. diff --git a/docs/agent-meta-layer.md b/docs/agent-meta-layer.md index 4022b0d8..58518bda 100644 --- a/docs/agent-meta-layer.md +++ b/docs/agent-meta-layer.md @@ -1,261 +1,214 @@ # Wingthing as an agent meta-access layer -Status: local implementation with principal guardrails -Reviewed: 2026-08-20 +Status: implemented local and self-hosted slices, with portal convergence in +design -## Thesis - -Wingthing should be the stable interface through which humans and models reach -agent runtimes. It should not become another agent, a universal prompt format, -or a cloud scheduler. +Reviewed: 2026-08-28 -The underlying agents remain opinionated products. Claude Code, Codex, Gemini, -Hermes, OpenCode, Cursor, and Ollama choose their own models, tools, context -strategies, and interaction styles. Wingthing gives those different runtimes a -small common control plane: +## Thesis -- discover what is installed and what it can do -- start or reattach to a durable terminal -- execute one bounded headless prompt -- inspect durable task state and output -- repeat a prompt with an explicit stop condition and hard bound -- run a dependency graph whose independent nodes may execute concurrently -- let either a human or another model read, wait for, and steer a terminal +Wingthing is the stable interface through which people and LLMs reach agent +runtimes. It is not another agent, a universal prompt format, or a cloud +scheduler. + +Claude Code, Codex, Gemini, Hermes, OpenCode, Cursor, and Ollama keep their own +models, tools, context, and interaction styles. Wingthing gives them a small +common control plane: + +- discover installed runtimes and their requirements; +- inspect the sandbox that will apply; +- start or reattach to a persistent terminal; +- submit a supervised headless run with semantic state; +- wait for, steer, stop, and read a run; +- repeat bounded work or execute a bounded dependency graph; and +- let a person and an LLM inspect the same owned resources. + +That is the useful meaning of agent meta-access layer: access to agents without +pretending the agents are interchangeable. + +## Object model + +| Object | Meaning | Authority | +| --- | --- | --- | +| Portal | Client-facing inventory and controls in a browser, CLI, or MCP client | Adapter over a gateway and one or more wings | +| Wing | One execution runtime with local process, workspace, agent-home, terminal, and task state | Wing | +| Session | Persistent interactive PTY for an agent, shell, or command | Wing egg store | +| Run | Supervised headless agent task with semantic status, events, output, and errors | Wing task store | +| Egg | Per-session process, PTY, sandbox policy, and local control socket | Wing | +| Prompt asset | Named, versioned prompt plus variables and runtime defaults | Prompt store | +| Loop | Bounded sequence of prompt tasks | Task orchestrator | +| Swarm | Bounded dependency DAG of prompt tasks | Task orchestrator | +| Roost | Self-hosted portal/gateway with an embedded wing | One deployment | + +A session is not a run. Terminal output is ANSI state and may contain a TUI, +shell, compiler, or model. A run has an explicit lifecycle and semantic result. +Use a session when a person may attach. Use a run when a caller needs reliable +task state. + +This distinction prevents three category errors: + +1. PTY activity does not mean working or done. +2. A group of people is not a swarm execution namespace. +3. A hosted route does not own the process or workspace. -That is the useful meaning of **agent meta-access layer**. It is access to -agents, not an attempt to erase their differences. +## Human and LLM parity -## The object model +The target is several thin clients over one control contract: -The product becomes much easier to reason about when byte streams and semantic -work are different objects: +```text +human CLI ---------\ +browser ------------+--> wing control contract --> sessions + runs +LLM through MCP ----/ +``` -| Object | Meaning | Lifetime | Authoritative owner | -|---|---|---|---| -| **Terminal** | A durable PTY, its process tree, snapshot, and input stream | Until explicitly stopped or process exit | egg/wing runtime | -| **Agent session** | A terminal occupied by a known agent adapter | Terminal lifetime | egg/wing runtime | -| **Prompt run** | One bounded, non-interactive invocation with structured status and working directory | Durable task record | local task store | -| **Loop** | A bounded sequence of prompt runs where result N is input to N+1 | Parent task plus child tasks | local orchestrator | -| **Swarm** | A bounded DAG of prompt runs; dependency results flow downstream | Parent task plus child tasks | local orchestrator | -| **Prompt asset** | A named, versioned prompt/template plus declared variables and runtime defaults | Persistent configuration | local prompt store | +No client should scrape another client's UI. A model should list sessions, read +a snapshot, wait for output, start an agent, or submit a graph through closed +schemas. A person should be able to inspect and interrupt the same resources. -A terminal is not a task. Terminal output is raw ANSI state and may contain a -TUI, shell, compiler, or model. A task has a semantic status and an output. An -agent session is the bridge between the two when interactive control matters. +The current implementation proves part of this: -This prevents three recurring category mistakes: +- local stdio MCP and the CLI operate local wing state; +- local MCP sessions appear in the browser when that wing is connected to the + selected portal; +- self-hosted HTTP MCP calls the roost's embedded wing with authenticated + owner and actor identity; and +- native direct MCP selects external wings explicitly and carries the shared + terminal, run, message, and sandbox operation subset over WebRTC; and +- the browser can aggregate sessions from several external wings registered to + one gateway. -1. PTY activity is not automatically `working` or `done`. -2. A group of people is not a swarm execution namespace. -3. A hosted route is not the owner of either terminal or task state. +Two gaps remain. Headless runs have no browser view. HTTP MCP cannot select an +external wing from the portal roster and controls only the roost's embedded +wing. -## Human and model parity +## MCP interfaces -The same primitives should be available through several thin clients: +Register the local stdio server: -```text -human CLI/TUI ─┐ -browser UI ────┼── local control semantics ──> wing/eggs + task store -LLM via MCP ───┤ -editor/plugin ─┘ +```bash +codex mcp add wingthing -- wt mcp stdio --client codex +claude mcp add --scope user wingthing -- wt mcp stdio --client claude ``` -No client should need to scrape another client's UI. A model should be able to -list terminals, read a snapshot, wait for output, start an agent, or submit a -swarm using an explicit schema. A human should be able to inspect and interrupt -the exact same objects from a terminal or browser. +It implements MCP JSON-RPC over stdin and stdout. Protocol messages use standard +output; diagnostics use standard error. Tools use closed JSON Schemas and +return structured content plus a serialized text fallback. -This is the deeper idea worth taking from Herdr: agent automation is not a -special dashboard feature. It is another client of the durable terminal -runtime. Wingthing can extend that idea with sandbox policy, structured prompt -runs, dependency flow, and collaboration controls. +The current local operation set is defined and tested in `internal/control`: -## First interface: local MCP over stdio +| Group | Tools | +| --- | --- | +| Discovery | `wingthing_capabilities`, `sandbox_explain` | +| Messages | `message_send`, `message_list`, `message_wait` | +| Sessions | `terminal_list`, `terminal_read`, `terminal_send`, `terminal_wait`, `terminal_start`, `agent_start`, `terminal_rename`, `terminal_stop` | +| Runs | `agent_run`, `agent_status`, `agent_wait`, `agent_result`, `agent_events`, `agent_steer`, `agent_stop` | +| Prompt workflows | `prompt_list`, `prompt_get`, `prompt_save`, `prompt_run`, `task_get`, `prompt_loop`, `swarm_run` | -This branch adds: +The local MCP process has the operating-system authority of the user that +launched it. The client name controls ownership and audit attribution inside +Wingthing; it is not independent OS authentication. A mode-0600 +`~/.wingthing/clients.yaml` can restrict client names, grants, and spawn +bounds. -```bash -wt mcp stdio --client CLIENT -``` +A pre-isolated VM or container can use: -It implements newline-delimited MCP JSON-RPC over stdin/stdout. Standard output -contains protocol messages only; diagnostics go to standard error. Every tool -has a closed JSON Schema and returns both structured content and a serialized -text fallback. - -Current tools: - -| Tool | Purpose | -|---|---| -| `wingthing_capabilities` | Supported/installed agents, storage/network requirements, transports, and object types | -| `terminal_list` | Discover live local terminals | -| `terminal_read` | Read the current ANSI snapshot | -| `terminal_send` | Send PTY input | -| `terminal_wait` | Wait for output text or I/O idleness without client polling | -| `terminal_start` | Start a persistent terminal for a generic command or shell | -| `agent_start` | Start a persistent sandboxed agent terminal, with verbatim passthrough of agent CLI arguments such as model selection | -| `terminal_rename` | Rename a terminal owned by the calling principal | -| `terminal_stop` | Stop a terminal and its process tree | -| `prompt_list` | List current named prompt assets and revisions | -| `prompt_get` | Read a current or immutable historical prompt revision | -| `prompt_save` | Atomically create/update a prompt with optimistic revision checking | -| `prompt_run` | Execute one sandboxed headless prompt in an explicit working directory and return its task record | -| `task_get` | Read durable task status, output, error, timing, and dependencies | -| `prompt_loop` | Execute a bounded sequential loop with optional text stop condition | -| `swarm_run` | Execute a bounded dependency DAG with output flow | - -A generic local MCP client can register it with the equivalent of: - -```json -{ - "mcpServers": { - "wingthing": { - "command": "wt", - "args": ["mcp", "stdio", "--client", "my-llm"] - } - } -} +```bash +wt mcp stdio --client CLIENT --unsandboxed ``` -The exact configuration file differs by client. No Wingthing account or network -service is involved; the MCP child process still has the OS authority of the -user who launched it. The client name is an attribution and Wingthing-policy -principal, not independent authentication from another local user. A mode-0600 -`~/.wingthing/clients.yaml` can require configured client names and give each -one explicit grants and spawn bounds. +This is a server-wide authority decision. Sessions and tasks then run with the +VM user's authority. Capabilities and audit rows report `outer-boundary`. -When the process already runs inside a dedicated sandbox VM or container, the -operator can register `wt mcp stdio --client CLIENT --unsandboxed`. This is a -server-wide authority choice, not a per-tool argument: persistent sessions and -headless prompt/loop/swarm tasks run with the full authority of the VM user. The -capability response and initialize instructions expose the mode, and audit rows -record `outer-boundary`. See [the VM recipe](sandboxed-ai-vm.md). +For a self-hosted roost with OAuth: -## Loop semantics +```bash +codex mcp add lab --url https://lab.example.com/mcp +codex mcp login lab +``` -A loop is deliberately less magical than an “autonomous mode”: +That HTTP endpoint currently controls the roost's embedded wing. Register +several independent roost URLs under distinct names if the parent LLM needs +several targets. There is no peer-roost discovery or federation. -1. Run the base prompt. -2. Store the task and output. -3. Add that output to the next iteration as a dependency result. -4. Stop if `until_contains` matches. -5. Otherwise stop at `max_iterations`, which cannot exceed 12. +## Loop and swarm semantics -The runtime guarantees dependency delivery and the hard bound. It cannot -guarantee that a weak model follows an instruction contained in the dependency. -Tests must report those separately. A model ignoring iteration context is a -model-compliance failure; missing dependency context would be an orchestrator -failure. +A loop: -Future loop conditions should be typed rather than becoming an expression -language immediately: exact match, JSON Schema validation, test command exit, -human approval, evaluator task, and time/token/cost budget. +1. runs the base prompt; +2. stores the task and output; +3. adds the output to the next iteration as a dependency result; +4. stops if `until_contains` matches; and +5. otherwise stops at `max_iterations`, capped at 12. -## Swarm semantics +The runtime guarantees dependency delivery and the hard bound. It does not +guarantee that a model follows instructions contained in a dependency result. -A swarm is a DAG, not a chat room and not an organization: +A swarm is a DAG: ```text -research-a ─┐ - ├── synthesis ──> review -research-b ─┘ +research-a --\ + +--> synthesis --> review +research-b --/ ``` -- Nodes with satisfied dependencies are eligible to run. -- Independent nodes may run concurrently. -- Completed dependency outputs are injected into each downstream prompt under a - clearly delimited `Dependency Results` section. +- Independent ready nodes may run concurrently. +- Dependency outputs are injected into downstream prompts. - Unknown dependencies, duplicate IDs, self-dependencies, and cycles are - rejected before any model call. + rejected before a model starts. - A failed node prevents dependent nodes from running. -- The whole graph is capped at 16 nodes and four concurrent workers. -- An agent-specific limit can be stricter. OpenCode is currently serialized - because its CLI shares one SQLite state database and concurrent processes can - fail with `database is locked`. -- Every parent and child is a durable task record, not an ephemeral goroutine. +- A graph is capped at 16 nodes and four workers. +- Every parent and child is a durable task record. -This is enough to express map/reduce, debate/review, independent investigation, -and staged implementation without inventing a general workflow language. +This is enough for map/reduce, independent investigation, synthesis, and staged +review without inventing a general workflow language. ## Safety and authority -LLM accessibility must not mean invisible ambient authority. - -- MCP tool annotations identify read-only, mutating, destructive, and - open-world operations. -- Agent invocations use the same sandbox/profile resolution as `wt run`. -- Timeouts cancel the whole agent process group, including grandchildren. -- Stderr is captured with a bound so failures are useful but cannot consume - unbounded memory. -- Loops, swarm size, and concurrency have server-side maximums that a caller - cannot raise. -- Task IDs include a UUID component; concurrent workers cannot collide within - one second. -- Each prompt task persists its absolute working directory and mounts that path - into the sandbox, so repo context is inspectable and retryable. -- SQLite writer contention has a bounded busy timeout. -- Terminal stop is explicit and separately marked destructive. - -The stdio server remains local-user authority, but named clients now get -principal-aware grants, spawn bounds, terminal/task ownership, and a mode-0600 -JSONL audit trail. These controls prevent accidental cross-client access inside -Wingthing; they are not an OS sandbox and cannot constrain a malicious process -that can read the same files or connect to the same local sockets. Any remote -version still needs cryptographically authenticated principals and path/profile -policy. Transport authentication alone is not authorization. - -## Relationship to collaboration - -There are two different kinds of collaboration: - -- **work coordination:** tasks, dependencies, loops, evaluators, and swarms -- **authority coordination:** viewers, controller lease, takeover, approvals, - grants, and audit attribution - -They compose but must not collapse into one concept. A swarm can run entirely -for one local user. Three humans can collaborate in one terminal without a -swarm. A hosted organization can help resolve identities and billing, but it is -neither the task graph nor the terminal namespace. - -This gives the old “roost” story a clean role. A shared roost is one deployment -of the runtime and policy plane. `wingthing.ai` may help a client find or reach -it. Neither one owns the abstract agent workflow. - -## What is implemented versus next - -Implemented on the vacation branch: - -- local MCP stdio server and strict tool schemas -- terminal list/read/send/wait/start/rename/stop -- named local principals with per-client grants and spawn bounds, terminal/task - ownership, and append-only JSONL audit records -- named prompt templates with declared variables, content-addressed revisions, - immutable local history, atomic writes, and conflict detection -- structured headless prompt runs and task inspection -- bounded sequential loops with dependency result injection -- bounded concurrent DAG swarms with failure gating -- one shared seven-agent catalog used by PTY launch, doctor, init, MCP, and - headless adapters -- process-tree cancellation, useful stderr propagation, runtime timeout, and - persisted resolved agent/isolation - -Important next steps: - -1. Put the control semantics behind one wing-owned local socket instead of - discovering per-egg sockets in each client. -2. Add asynchronous MCP task support so long runs can be polled/cancelled - without holding one tool call open. -3. Add structured agent events using native hooks first and terminal heuristics - only as a fallback. -4. Extend prompt assets with output schemas, model policy, composition, and - portable project selectors; named/versioned templates and run provenance are - already present. -5. Extend current session/spawn/tool bounds with wall-time, token, spend, retry, - path, and profile budgets. -6. Add pause/approve/resume nodes and human-visible control leases. -7. Run the same API over SSH/direct/P2P/relay transports with explicit grants. -8. Build a TUI/browser graph view only after the runtime semantics stabilize. - -The key product test is simple: can an LLM safely operate Wingthing without -pretending the web UI is an API, and can a human see and take over everything it -did? This branch establishes the first honest “yes.” +LLM access must not mean invisible ambient authority. + +- Tool annotations identify read-only, mutating, destructive, and open-world + operations. +- Agent invocations use the same sandbox resolution as `wt run`. +- Timeouts cancel the process group, including descendants. +- Stderr is bounded. +- Loops, graph size, and concurrency have server-side caps. +- Each task records its absolute working directory and resolved isolation. +- Principals receive grants, spawn bounds, ownership, and audit attribution. + +Local principals are useful protection against accidental cross-client access. +They do not constrain a malicious process that can read the same files or local +sockets. Remote transport authentication is also not sufficient by itself; +every operation still needs authorization and target policy. + +## Placement belongs in the contract + +An absolute `cwd` quietly binds execution to one workspace replica. Remote +workflows need to state five independent choices: + +1. execution wing; +2. workspace identity and replica; +3. display or preview destination; +4. credential source; and +5. durable memory source. + +Current Wingthing only routes execution. The working directory and untracked +files must already exist on the selected wing. The proposed workspace and +qualified resource model is in +[the LLM-first architecture review](llm-first-review.md). + +## Next work + +1. Continue extracting the handlers themselves into a transport-independent wing + control package. The shared registry already owns operation metadata and schemas. +2. Put the contract behind a wing-owned local socket. +3. Converge the native direct WebRTC subset and the browser's bespoke encrypted + tunnel onto one wing-owned request/response service. +4. Extend the direct connector's explicit `wing_id` qualification into one shared + portal/session/run resource model; add `portal_id` when several portals can be + aggregated by one client. +5. Give the browser a combined session and run inventory. +6. Add workspace, preview, credential, and durable-memory references without + turning Wingthing into a mandatory file-sync product. + +The product test is concrete: can an LLM operate Wingthing without scraping the +web UI, and can a person see, understand, and take over everything it did? diff --git a/docs/agent-support.md b/docs/agent-support.md index f05b97a4..806e37c7 100644 --- a/docs/agent-support.md +++ b/docs/agent-support.md @@ -1,9 +1,14 @@ # Supported agent evidence -Status: vacation branch verification snapshot +Status: historical live-verification snapshot, not a current release guarantee + Verified: 2026-08-08 -“Supported” is not a boolean. Wingthing tracks several independent contracts: +The versions and model results below are evidence captured on the verification +date. Current releases must produce a new machine-readable manifest. See +[Testing Wingthing](testing.md) for the proposed evidence and promotion policy. + +"Supported" is not a boolean. Wingthing tracks several independent contracts: 1. **Catalog:** discovery, interactive command, unattended/resume flags, sandbox storage, network, and credential requirements. @@ -16,7 +21,7 @@ Verified: 2026-08-08 6. **Orchestration:** the real adapter works through MCP, loops, and dependency swarms rather than only when invoked directly. -## Current matrix +## Captured matrix | Agent | Headless contract unit test | Synthetic PTY lifecycle | Real WSL sandbox startup | Live model completion on this branch | Notes | |---|---:|---:|---:|---:|---| diff --git a/docs/ai-api-surface.md b/docs/ai-api-surface.md index d27084d3..63421822 100644 --- a/docs/ai-api-surface.md +++ b/docs/ai-api-surface.md @@ -1,31 +1,33 @@ # The AI API surface Status: implemented local slice and target design -Reviewed: 2026-08-21 +Reviewed: 2026-08-28 The goal from `CLAUDE.md`: **an AI must be able to orchestrate wingthing as easily -as a human can.** This doc answers three questions honestly — what surfaces exist -today, why they do not add up to that goal, and what the target shape is. +as a human can.** This doc answers three questions: what surfaces exist today, +why they do not add up to that goal, and what the target shape is. ## What exists today -There are four surfaces. Local stdio MCP and authenticated shared-roost HTTP MCP -now share the typed terminal, agent-run, sandbox, and message vocabulary. The -REST and encrypted external-wing surfaces still use separate contracts. +There are five surfaces. Local stdio MCP, native direct MCP, and authenticated shared-roost HTTP MCP +now share a versioned operation registry for typed terminal, agent-run, +sandbox, message, and wing-inventory vocabulary. The REST and encrypted +browser-tunnel surfaces still use separate runtime contracts. | # | Surface | Transport | Auth | What it can do | |---|---------|-----------|------|----------------| | 1 | `wt mcp stdio` (`cmd/wt/mcp_local.go`) | stdio, local only | OS user plus optional owner, actor, grants, and bounds | Agent orchestration, terminals, messages, prompts, loops, swarms | -| 2 | `POST /mcp` (`internal/relay/mcp.go`) | HTTP | OAuth 2.0, dynamic client registration, owner-scoped native controls, role-scoped executable tools, audit observer | Shared-roost terminals, agent runs, messages, sandbox explanation, and configured privileged tools | +| 2 | `POST /mcp` (`internal/relay/mcp.go`) | HTTP | OAuth 2.0, dynamic client registration, owner-scoped native controls, role-scoped executable tools, audit observer | Authorized wing roster, shared-roost terminals, agent runs, messages, sandbox explanation, and configured privileged tools | | 3 | REST `/api/...` (`internal/relay/`) | HTTP | session cookie / bearer | Account, usage, passkeys, ntfy, orgs, and an authorized online-wing roster | | 4 | Encrypted tunnel (`internal/ws/`) | WebSocket, application-encrypted through relay | passkey + device token | `dir.list`, `sessions.list`, `sessions.history`, `pty.*`, `egg.config_update`, … | +| 5 | `wt mcp connect` (`cmd/wt/mcp_connect.go`) | stdio to the parent agent, authenticated WebRTC/DTLS to selected wings | device login, coordinator-filtered roster, wing-derived owner/role/grants/bounds | Qualified multi-wing terminal, run, message, and sandbox controls on unlocked wings | For pre-isolated VMs, the CLI and local MCP adapters share an explicit trusted outer-boundary mode. It is selected at CLI/MCP-server startup, reported through capabilities and session JSON, and included in the MCP audit trail; a model cannot toggle it per call. -### The 27 local MCP tools (surface 1) +### Local MCP operations (surface 1) `wingthing_capabilities`, `message_send`, `message_list`, `message_wait`, `sandbox_explain`, `terminal_list`, `terminal_read`, @@ -36,11 +38,12 @@ cannot toggle it per call. ### The problems -1. **Control semantics still live in the stdio adapter.** Surface 2 wraps the - same typed operations in-process and supplies authenticated owner/actor - identity, which proves shared-roost parity. Extracting `internal/control` - remains the maintainability step that gives CLI, stdio, HTTP, and future REST - one implementation. +1. **Control handlers still live in the stdio adapter.** `internal/control` + now owns names, schemas, grants, annotations, transport availability, + authority, and audit targeting. Surface 2 wraps the wing-owned handlers + in-process and supplies authenticated owner/actor identity. Moving those + handlers behind a transport-independent service remains the maintainability + step that gives CLI, stdio, HTTP, and future REST one implementation. 2. **There is no REST API for agent orchestration at all.** Surface 3 is account plumbing. `GET /api/app/wings` deliberately returns routing identity rather than host/project details, because the relay is a dumb pipe and knows nothing @@ -51,23 +54,29 @@ cannot toggle it per call. lifecycle, session history, and configuration are still bespoke encrypted messages rather than a supported general CLI/API surface. That is still a UI-shaped API. -4. **External wings still lack the typed control transport.** Shared roosts call - the embedded runtime directly. A hosted relay connected to a separate wing - still needs these operations carried through the encrypted tunnel. +4. **External wings now have a typed native control transport, but adapters still + diverge.** `wt mcp connect` requires `wing_id` and carries registry operations + directly over WebRTC. Shared-roost HTTP MCP still calls only its embedded runtime, + and the browser still uses bespoke encrypted tunnel messages. Locked/passkey wings + intentionally reject native direct control until that client has a ceremony. -The remaining parity gap is external-wing reachability plus extraction of the -shared semantics into a transport-independent package. +5. **The portal has one wing roster but two runtime inventories.** Browser and + HTTP MCP now share the access-filtered wing roster. Browser session lists + aggregate those wings, while HTTP MCP has no `wing_id` target and calls only + the embedded wing. Headless tasks have no browser inventory at all. + +The remaining parity gap is one qualified session/run inventory across browser, +HTTP MCP, and native MCP, plus extraction of the shared semantics into a +transport-independent package. ## Target shape One control plane, three adapters, one vocabulary. ```text - ┌──────────────────────────────────┐ - CLI (--json) ──▶│ │ - MCP (stdio+HTTP)│ wing control plane (semantics) │──▶ eggs / tasks / prompts - REST (/api/v1) ─▶│ principals · grants · bounds │ - └──────────────────────────────────┘ +CLI --json --------\ +MCP stdio or HTTP --+--> wing control plane --> sessions / runs / prompts +REST /api/v1 ------/ principal + grant + bound + audit ``` The rule: **a capability is defined once in the control plane and exposed by all @@ -88,10 +97,38 @@ They are not redundant; they serve different callers. ordinary status codes, pagination, and no session handshake. The web UI should migrate onto the same REST surface it exposes to everyone else. -That is the forcing function that keeps the two honest — surface 4's tunnel +That is the forcing function that keeps the two honest: surface 4's tunnel messages become REST resources carried over the encrypted transport, rather than a private protocol. +### Qualified resources and placement + +Every cross-wing object needs an immutable reference: + +```json +{ + "portal_id": "lab", + "wing_id": "01J...", + "kind": "session", + "id": "01K..." +} +``` + +Listing may aggregate across authorized wings. A mutating start operation must +require `wing_id` when a portal has more than one wing. Later operations should +accept the qualified reference rather than depend on a mutable current-wing +selection. + +Execution target is not enough for a remote run. The contract must eventually +name the workspace replica, preview destination, credential reference, and +durable-memory source. Until those objects exist, `cwd` means an existing path +on the selected wing; Wingthing does not copy it there. + +Several independent portals should remain explicit client targets in the first +version. Give each URL a local name, pin its identity, keep its OAuth and +capability cache separate, and fan out read-only lists in the client. Do not +merge identities by email or build peer-roost federation first. + ### One auth model Surface 2 already has the right ingredients: OAuth bearer, role-scoped policy, @@ -100,18 +137,27 @@ control plane rather than living only on the privileged-tool path. Every model-reachable action needs a principal, a grant, a bound (time, iterations, concurrency), and a log line. Local stdio keeps its -local-user-authority shortcut, but it must be the same *authorization* code path -with the principal pre-resolved — not a separate ungoverned door. + local-user-authority shortcut, but it must be the same *authorization* code path + with the principal pre-resolved, not a separate ungoverned door. ## Sequencing -1. Extract the control-plane semantics out of `cmd/wt/mcp_local.go` into a package - both the CLI and the servers call. -2. Put it behind the wing-owned local socket (P1 in `local-first-architecture.md`), - so clients stop doing per-egg filesystem discovery. -3. Add `/api/v1` over the same core; migrate one tunnel message at a time. -4. Serve MCP over HTTP from the same core, reusing surface 2's OAuth and policy. -5. Retire the bespoke tunnel inner-message vocabulary as REST covers it. +1. **In progress:** continue extracting the control handlers themselves from + `cmd/wt/mcp_local.go`; operation names, schemas, grants, annotations, authority, + transport availability, and audit targeting already live in `internal/control`. +2. Put the contract behind the wing-owned local socket (P1 in + `local-first-architecture.md`), so clients stop doing per-egg filesystem discovery. +3. **Done for the current MCP adapters:** define the operation registry once and + derive local, HTTP, and direct schemas from it. +4. **Done for the native remote subset:** carry the registry operations through an + authenticated direct WebRTC channel. The browser still uses its bespoke encrypted + tunnel contract. +5. **Partial:** direct MCP qualifies every wing-owned operation and result with + `wing_id`; a single qualified session/run inventory shared by every adapter is + still missing. +6. Migrate the browser to the shared session/run inventory. +7. Add `/api/v1` over the same core and retire bespoke tunnel messages as each + resource is covered. Nothing here requires new product surface. It is the same capabilities reachable by callers who are not a browser. diff --git a/docs/bryan-wingthing-direct-control-field-report.md b/docs/bryan-wingthing-direct-control-field-report.md new file mode 100644 index 00000000..8032a845 --- /dev/null +++ b/docs/bryan-wingthing-direct-control-field-report.md @@ -0,0 +1,304 @@ +# Bryan Wingthing Direct-Control Field Report + +Status: feature branch proven on a real shared roost and two physical wings; org/browser path revalidated + +Date: 2026-08-25 + +Last live validation: 2026-08-28 + +Branch: `feature/direct-control-free-tier` + +This is the durable handoff for the first real-host exercise of the direct agent +manager. It records what was actually tested, what dogfooding changed, what remains +unproven, and how to restore the test host. It supplements the product brief and +the direct-control design; it is not a claim that the feature is on the main site. + +## Outcome + +The core product loop works: + +1. A native MCP client logs into a coordination roost. +2. The roost returns an access-filtered wing roster and exchanges WebRTC signaling. +3. MCP and terminal payloads move directly between the client and the selected wing. +4. An outer Claude Sonnet can inspect the wing, create and recover durable terminals, + launch an inner Claude Sonnet in a sealed organization-mode workspace, wait for + its semantic result, exchange durable messages, and reconnect later. +5. With `hosted_relay: deny`, legacy hosted terminal and tunnel payload requests are + rejected even though direct control continues to work. + +After the Bryan exercise and full regression gates passed, the same committed tree +was deployed to `wingthing.ai` as Fly release v301. The production deployment has its +own evidence and rollback record in +[wingthing-ai-production-canary-2026-08-25.md](wingthing-ai-production-canary-2026-08-25.md). + +## Field topology + +The target was `bryan-wingthing.pants.taxi`, an Ubuntu 22.04 shared roost running as +the unprivileged `wingthing` service user behind nginx and a valid HTTPS certificate. +The feature binary replaced `/usr/local/bin/wt` reversibly; the systemd unit, nginx, +database location, and existing users were retained. + +The first direct MCP client ran as the roost service identity and connected through +the public HTTPS name. It discovered wing `e4d4904295254339899892e2` with transport +`direct-webrtc`. A later canary enrolled a separate macOS wing through the same +coordinator and exercised both physical machines in one native connector process. + +## End-to-end evidence + +| Behavior | Result | Evidence | +| --- | --- | --- | +| Direct wing discovery | Pass | `wing_list` returned Bryan with `mcp_transport: direct-webrtc`. | +| Direct terminal lifecycle | Pass | Started terminal `2505ba6b`, read two output canaries, listed and renamed it, then recovered it through fresh connectors. | +| Client disconnect persistence | Pass | The connector exited and reconnected without changing terminal identity or output. | +| Roost restart persistence | Pass | Repeated roost restarts preserved terminal PID `504140` and the two sessions that predated the feature deployment. | +| Direct-only policy | Pass | After changing to `hosted_relay: deny`, direct terminal reads continued to work. | +| Hosted payload denial | Pass | Authenticated `pty.start` and `tunnel.req` WebSocket canaries both returned `hosted relay payload transport is disabled by this wing`. | +| Content-free denial audit | Pass | Journal records contained operation and policy decision, not terminal bytes or tunnel payloads. | +| Org path boundary | Pass | Out-of-scope working directories were rejected; allowed shared-roost workspace execution succeeded. | +| Outer-agent orchestration | Pass | Claude Sonnet used the direct MCP tools to inspect, launch, wait, recover, message, rename, and read. | +| Inner semantic agent run | Pass | Run `t-20260825-222236-c9d5f8f2` completed as Claude Sonnet and returned `INNER_SONNET_OK_5bcaa8e`. | +| Sandboxed artifact write | Pass | The inner agent wrote `inner-sonnet-5bcaa8e.txt`; SHA-256 was `db13a3ab18d317662c5d14adb1cba918d90fdcba06a3da11598e4848b3739f54`. | +| Durable owner message | Pass | A fresh outer agent sent message `msg-77d372dd-3094-46b4-8bb4-db3fac8cf8ec` on channel `dogfood`. | +| HTTPS | Pass | Native MCP and WebSocket canaries used the public HTTPS name with the installed valid certificate. | +| Exact committed build | Pass | Final binary reports `feature-direct-db0dc78`; its SHA-256 matches the locally cross-compiled artifact, and a fresh direct connector reached `wingthing_capabilities` over WebRTC. | +| Two physical wings | Pass | One `wt mcp connect` process listed the external macOS wing and Bryan with distinct `wing_id` values and `mcp_transport: direct-webrtc`. | +| Qualified real-agent routing | Pass | The connector started Codex on macOS and Claude 2.1.243 on Bryan, observed `WINGTHING_PHYSICAL_MAC_OK` and `WINGTHING_PHYSICAL_BRYAN_OK` in rendered agent output, and stopped only the two returned session IDs. | + +The outer and inner model selection was explicitly `sonnet`; no Opus agent was used. + +## 2026-08-28 organization-mode revalidation + +Bryan is also the organization-mode compatibility canary, so the direct-only +exercise was not an appropriate steady-state policy for this host. The explicit +`hosted_relay: deny` left from the August 25 test produced a truthful but broken +browser experience: opening a terminal stopped at `hosted relay payload transport +is disabled by this wing`. The host now explicitly uses `hosted_relay: allow`. +Omission still defaults to `allow`, including the current Ansible template, so this +does not change the compatibility default. + +The deployed browser canary passed 17/17 checks against +`https://bryan-wingthing.pants.taxi` with zero console errors, page errors, or +failed requests. It proved browser-validated HTTPS (issuer `YR1`), admin and member +identity, per-role path filtering, mobile rendering, the legacy no-enrollment- +allowlist contract, and a real terminal open/identity-lock/resize/detach/reattach/ +end lifecycle. The temporary browser session was removed, all temporary login and +membership records were deleted by exact identity and creation timestamp, and +`PRAGMA integrity_check` returned `ok`. + +Two additional live defects were found and fixed during that run: + +- CLI and local-MCP `send` combined text and Enter in one PTY frame. Claude Code + treated that as a paste and left the prompt in its editor. Text and Enter are now + separate PTY frames with a short delay. A fresh sandboxed Claude 2.1.243 session + received, submitted, processed, and answered a prompt from one `send --enter` + call with no repair keystroke. +- `session kill` sent SIGTERM and immediately reported `stopped`. Interactive bash + ignores SIGTERM, so the session remained discoverable. Kill now waits for actual + exit, escalates after a three-second grace period, and waits for the normal reap + path. A focused ignored-SIGTERM regression and a fresh deployed interactive-shell + canary both pass. + +The current installed development candidate reports +`feature-direct-org-killfix-20260828`; SHA-256 is +`c1c7307005e414f3bafdcf5ef645c38b8517a01ed62c4c46203bc514fadf0a31`. +The roost service is active, public `/health` returns `{"ok":true}`, and the three +pre-existing sessions (`2505ba6b`, `81bc288e`, and `94093aee`) remain alive. No +test terminal or temporary browser identity remains. + +### Native WSL2 security and real-agent canary + +The same candidate and Linux test artifacts were also exercised directly on the +authorized Ubuntu 24.04 WSL2 rig (`6.6.87.2-microsoft-standard-WSL2`), outside the +Docker-only root path. The complete Linux agent battery passed. Real installed +Claude, Cursor, Gemini, Hermes, OpenCode, and Ollama binaries each produced PTY +output from inside the sandbox; Ollama completed three exact tool-call/dispatch +cases. The separate low-level sandbox and Linux CLI batteries also passed. + +Most importantly, the non-root sealed-jail regression ran as uid/gid 1000 and +proved an outer-to-inner PID namespace inode change, only two visible procfs PIDs, +an unreadable host-process secret, an unreadable denied-path canary, and blocked +mount syscalls. The proxy-bypass test independently proved that removing +`HTTPS_PROXY` leaves no route to a disallowed destination. + +This run found two test-harness defects and retained regressions for them: a real +agent helper could inherit stdout and make a synchronous scanner ignore its +deadline, and cross-user tests used a `t.TempDir` leaf beneath a root-only parent. +The observer now honors cancellation even while a writer remains open; non-root +fixtures are top-level, owner-scoped temp directories; and namespace detection +compares `/proc/self/ns/pid` inode identities instead of assuming a private procfs +must expose multiple `NSpid` values. Isolated `/tmp` staging also copies the mock +agent into the declared jail allowlist, so a portable test does not accidentally +depend on `/usr/local/bin`. + +## Defects found by real Sonnet dogfooding + +### Claude Code MCP metadata was rejected + +Claude Code adds the MCP-standard `_meta` member to `tools/call` parameters. Both +local and direct Wingthing MCP handlers used strict JSON decoding and rejected the +entire call as an unknown field, so every tool appeared broken to a real agent even +though the hand-written harnesses passed. + +The handlers now share a tool-call envelope that accepts `_meta` while preserving +strict rejection of every other unknown envelope field. Local protocol and direct +WebRTC harnesses send metadata, and a negative test pins strictness. + +### Shared-host semantic runs could not start or authenticate Claude + +The semantic `agent_run` path handed the relative command `claude` to a sealed +`deny:/` jail. PATH lookup cannot occur after the host root is replaced, so the +run failed with `lstat claude: no such file or directory`. After resolving that, +Claude still had no credential because shared-host mode correctly strips ambient +provider keys but the headless path did not install the file-backed API-key helper +used by interactive organization sessions. + +The headless path now resolves the catalog's real command before entering the jail, +atomically installs the native runtime under the owner's private home, and gives +Claude the existing 0400 file-backed helper without putting the provider key in the +agent environment. This also handles Cursor's executable name (`agent`) rather than +assuming every catalog name equals its command. + +The Linux integration now asserts all of the following in one run: the helper is +usable inside the jail, the key is absent from the environment, the agent can write +only its assigned workspace, and another user's secret remains invisible. + +### Sandbox diagnosis hid the real host failure + +Bryan's `/tmp` directory had mode 0755. The service user could create user +namespaces, but the capability probe could not create its temporary directory. +Wingthing discarded that cause and claimed the kernel security profile was the +problem. The Linux battery then skipped the most valuable non-root sealed-jail +assertion whenever preflight failed. + +The probe now returns the exact failing operation, child output, and OS error. Help +text distinguishes WSL2, AppArmor, disabled user namespaces, and a zero namespace +limit without guessing over the actual cause. Once a host demonstrates the required +namespace primitive, a later Wingthing preflight failure is a test failure rather +than a skip. Bryan's `/tmp` was restored to the standard sticky mode 1777; `wt +doctor` then reported `linux available (user namespaces + seccomp)`. + +### The Linux security Make target selected the client architecture + +The test client is an arm64 Mac, while its Colima Docker daemon is native amd64. +`make test-linux` built arm64 binaries and copied them into an amd64 image, producing +`Exec format error`. Security tests now select and validate the Docker daemon's +native architecture, as the existing browser battery already intended to do. + +The corrected battery also caught two portable-test assumptions: the runtime image +does not promise a Go toolchain, and an unsandboxed Linux policy must report +`unrestricted`, not claim proxy enforcement merely because the platform is Linux. + +## Regression and security gates + +The final tree passed: + +- focused package tests for MCP, agent orchestration, and sandboxing; +- the complete unit suite (`make test`); +- the complete integration suite (`make test-integ`); +- the repository-wide race detector (`go test -race ./...`); +- `go vet ./...`; +- Debian 12's privileged Linux sandbox battery; +- Ubuntu 24.04's Linux battery, including current real Node-based Claude Code in + the sandbox; and +- Ubuntu 24.04 on WSL2, including six real installed agent CLIs, exact local-model + tool dispatch, the low-level sandbox suite, the Linux CLI suite, and the non-root + sealed-jail boundary; and +- the shared-host sealed-jail integration cross-compiled and run directly on Bryan + as the relevant non-root Linux host. + +Both distro batteries passed `TestJail_LinuxProxyBypassHasNoRoute`: an agent with +the proxy environment removed has no alternate route. They also passed the full +direct MCP, organization policy, credential, persistence, audit, and shared-host +semantic-run test sets. Environment-dependent tests for unavailable external +agents or local Ollama skipped explicitly rather than weakening a required gate. + +## Compatibility observations + +- Two live sessions created by the prior v0.144.1 deployment survived every feature + roost restart with their original PIDs. +- The feature used the existing database in place; a pre-deploy copy was retained. +- `hosted_relay` remains additive. Omission preserves the existing `allow` behavior. + Bryan was deliberately changed to `deny` for the August 25 direct-only exercise + and restored to explicit `allow` during the August 28 org/browser revalidation. +- Existing entitled/private browser relay behavior remains covered in automated + compatibility tests. Bryan's live browser relay is enabled because it is the + organization-mode browser canary as well as a direct-control test host. +- Organization-mode members remain owner- and canonical-path-scoped. Shared-host + semantic runs refuse privileged isolation and do not inherit the roost account's + provider environment. + +## Operator state and rollback + +The current operator state is the August 28 candidate recorded above, with +`hosted_relay: allow`. After the final install, browser run, real Claude run, and +kill-path canary, all three tracked session PIDs (`128260`, `178503`, and `504140`) +were still alive. The long-running direct test terminal and its small dogfood +workspace remain available for inspection. They are test resources, not user data. + +Rollback artifacts on Bryan: + +- prior binary: `/usr/local/bin/wt.pre-direct-5bcaa8e-v0.144.1` +- pre-August-28 org canary: `/usr/local/bin/wt.pre-org-e2e-20260828` +- pre-Enter fix: `/usr/local/bin/wt.pre-enterfix-20260828` +- pre-kill fix: `/usr/local/bin/wt.pre-killfix-20260828` +- pre-feature state copy: `/opt/wingthing/.wingthing/backups/pre-direct-5bcaa8e/` + +The service unit uses `KillMode=process`, which is why detached agent/session +processes survive a roost service restart. A rollback should restore the saved +binary, restore the database copy only if schema compatibility requires it, and +restart `wingthing-roost` while verifying the detached PIDs before and after. + +## Remaining product friction + +The direct manager is credible but not yet a polished default: + +- A fresh human's `wt login` and first MCP enrollment were not exercised; field + testing reused the roost service identity. The free portal must make this path + copy-paste simple without exposing a durable bearer token. +- The roster still centers an opaque wing ID. Friendly stable labels and host + context are necessary before a person or LLM comfortably chooses between home + and office machines. +- An out-of-bounds workspace error is truthful but should return the caller's + allowed roots or an immediately actionable discovery operation. +- There is no typed prepare-workspace/worktree transaction. Today an orchestrator + composes terminal commands and `agent_run`, which works but is harder to make + idempotent and audit semantically. +- Browser-direct terminal transport is not implemented. With hosted relay denied, + the browser should present direct-MCP setup and policy status instead of looking + like an empty or broken session manager. +- One gateway can aggregate several wings, but separately administered roosts do + not yet federate. The desired home-roost/office-roost peer topology remains a + 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. + +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 +different configured coordinator, and the detached daemon supplemented explicit +`--paths` with a scan of its home-directory cwd. The branch now derives both status +and browser URLs from the active daemon's exact roost (with saved-argument fallback +for an older daemon), records that roost in a private `wing.status`, uses the same +selection for support diagnostics, strips URL credentials from displayed browser +links, and treats explicit paths as a project-metadata disclosure boundary. Focused +regressions cover coordinator precedence, public-host compatibility, self-hosted +`/app/` routing, old-daemon fallback, private status metadata, explicit-path +non-disclosure, and the legacy no-path scan. A rebuilt local canary reported +`projects: 0 found` for the empty canary workspace and the correct Bryan `/app/` +URL; its post-fix network reconnect was not repeated after the execution environment +declined that external connection. + +Canary cleanup revoked and deleted the exact temporary Bryan identity, token, +membership, and audit row, removed the stopped Mac/WSL/Bryan canary artifacts, and +terminated two detached Mac wing processes discovered by the cleanup sweep. Database +foreign-key checks remained clean, and Bryan's preserved sessions `2505ba6b`, +`81bc288e`, and `94093aee` remained present. + +## Release recommendation + +Keep this on the feature branch while adding the fresh-human enrollment and remaining +account-cohort canaries. The public v301 deployment is an explicit, reversible production +canary: existing accounts retain temporary relay parity, while newly created free +accounts receive the direct-control posture. Do not merge to main or broaden claims +until enrollment, upgrade, rollback, and browser-readiness paths are explicitly +exercised. diff --git a/docs/container-mode.md b/docs/container-mode.md index bcd2c018..1daa77c5 100644 --- a/docs/container-mode.md +++ b/docs/container-mode.md @@ -6,7 +6,7 @@ Thinking out loud about what it would look like if `egg.yaml` could define a Doc ## Why bother -The current sandbox is a deny list. Even with `base: none`, the host filesystem is visible - on macOS seatbelt starts with `(allow default)`, and on Linux the agent inherits the parent's mount namespace. You restrict access by denying specific paths. A container inverts this: start with an empty image, explicitly mount what you need. The agent can't see `~/.bash_history` because it doesn't exist in the container, not because we remembered to deny it. +The current sandbox is a deny list. Even with `base: none`, the host filesystem is visible - on macOS Seatbelt starts with `(allow default)`, and on Linux a fresh mount namespace starts with a cloned view of the host mounts. You restrict access by denying specific paths and narrow HOME writes with writable holes; OS-user-writable locations outside HOME are not a filesystem allowlist. A container inverts this: start with an empty image, explicitly mount what you need. The agent can't see `~/.bash_history` because it doesn't exist in the container, not because we remembered to deny it. Secondary: reproducible environments. A Dockerfile pins the exact toolchain. "Works on my machine" for agent sessions. diff --git a/docs/direct-agent-manager-design.md b/docs/direct-agent-manager-design.md new file mode 100644 index 00000000..c519dd6c --- /dev/null +++ b/docs/direct-agent-manager-design.md @@ -0,0 +1,171 @@ +# Direct Agent Manager and Coordination-Only Free Tier + +Status: implementation design for `feature/direct-control-free-tier` + +Reviewed: 2026-08-27 + +The broader product contract, Slack-derived use cases, gap audit, and ordered +roadmap live in [Agent Manager Product Brief and Gap Audit](agent-manager-product-brief.md). +This document remains the design for the direct transport and entitlement slice. + +## Product thesis + +Wingthing is an agent manager for agents. It gives an agent one inventory of durable agent runs and terminals across every machine its owner can access, then lets that agent start, inspect, message, wait for, and take over those sessions. A browser and a human terminal are clients of the same control plane, not the center of the product. + +`wingthing.ai` is the coordination service, analogous to a tailnet control plane. It authenticates identities, publishes an access-filtered wing directory, exchanges connection metadata, and helps peers establish encrypted direct connections. In this first slice it does not carry free-tier MCP payload bytes; the hosted browser terminal remains an entitled relay feature until browser-direct transport ships. + +The hosted browser-terminal/control relay is an optional paid transport. The +native connector in this slice is direct-only and does not silently fall back to +it. A self-hosted roost combines coordination, an operator-controlled relay, and +an optional local wing without making that embedded wing special. + +## Components and trust boundaries + +| Component | Responsibility | Sees payload bytes by default? | +| --- | --- | --- | +| `wt mcp stdio` | Control the wing on the same host through local state/socket APIs | Local only | +| `wt mcp connect` | Present one MCP server containing explicitly qualified resources from accessible remote wings | Yes, on the client | +| Wing | Own durable sessions and execute authorized control operations | Yes, for its own sessions | +| Coordinator (`wingthing.ai`) | Login, device identity, ACL-filtered wing directory, key exchange, and WebRTC signaling | No direct MCP payloads | +| Hosted relay | Entitled browser-terminal and control transport | Yes, encrypted transit bytes only | +| Roost | Self-hosted coordinator, optional relay, and optional embedded wing | Chosen by operator | + +The JavaScript and native clients distributed by a hosted coordinator remain a supply-chain trust boundary even when payloads travel directly. Encryption in transit does not remove the need to trust the client executable. + +## Resource model + +There is no mutable "current wing" in the remote MCP adapter. Every wing-owned call requires `wing_id`; every returned wing-owned object includes `wing_id`. Stable resource identity is therefore: + +```text +(wing_id, object_kind, object_id) +``` + +The portal owns only directory operations such as `wing_list`. A selected wing owns terminal, agent-run, message, and sandbox operations. Browser and MCP inventory must use the same access-filtered directory implementation. + +## Connection flows + +### Same-host agent + +```text +agent -> stdio MCP -> local Wingthing runtime +``` + +No coordinator login or network path is involved. + +### Remote agent, free/default + +```text +agent -> wt mcp connect -> encrypted WebRTC data channel -> selected wing + ^ + | + directory + signaling only + wingthing.ai +``` + +The connector logs in once, lists accessible wings, pins each wing's long-term key, and uses the coordinator to exchange a WebRTC offer and answer. MCP requests and results then travel on the peer-to-peer data channel. If a direct path cannot be established, the free connector returns an actionable error; it does not silently proxy the request. + +### Hosted relay access + +Accounts with relay access may use the hosted encrypted browser terminal and +control relay. Accounts on the temporary side of the deployment's explicit +migration boundary retain that path during rollout. `wt mcp connect` does not +switch to this transport after a direct failure. + +### Self-hosted roost + +A roost may allow relay traffic according to its operator policy. For one user on +one machine, `wt roost start --https` creates a localhost-only CA and leaf on demand, +installs only the public CA in that user's trust store, and keeps both private keys +on the host. A shared or remotely reachable roost still needs a real domain and +externally terminated HTTPS, such as ACME or a tailnet/VPN reverse proxy. Local MCP +needs neither public DNS nor HTTPS. + +## Direct control protocol + +The native connector exposes the shared Wingthing MCP contract plus `wing_list`. Its wing-owned tool schemas add a required `wing_id` field without changing the same-host or existing HTTP schemas. + +The WebRTC control channel label is versioned and identifies the authenticated client actor. Messages are bounded JSON envelopes: + +```json +{"version":"v1","id":"...","tool":"agent_start","arguments":{}} +{"version":"v1","id":"...","result":{"session":"..."},"is_error":false} +``` + +`wing_id` selects the transport and is removed before the operation reaches the wing handler. The wing derives the user, organization role, and passkey attestations from the authenticated signaling exchange; those fields are never accepted from the MCP request. It applies the same grant checks, owner scoping, filesystem scoping, argument redaction, and audit policy as its HTTP MCP adapter. + +The branch now resolves an explicit wing-local policy for every direct request, +including requests on already-open channels after a `SIGHUP` reload. The compatible +default operation set uses positive per-principal session/spawn bounds; `wing.yaml` +may narrow grants, change bounds, or disable direct MCP. The rolling spawn window is +shared across reconnecting data channels for the lifetime of the wing process. +Invalid identity, organization role, or local direct policy fails before a tool +handler runs. + +Coordinator-derived user and organization identity is leased for 15 minutes. The +wing closes the data channel at expiry, and the connector transparently performs a +new access-filtered discovery and signaling exchange on the next request. Wing-local +lock, passkey, path, and grant changes are stricter: they are checked on every +request and therefore do not wait for lease expiry. + +The first native transport targets host/LAN/tailnet candidates. Configured ICE servers can add broader NAT traversal. This first slice fails closed on locked or per-user passkey-protected wings; it returns an explicit error until the native connector can complete the same passkey-bound authorization ceremony used by the browser. + +## Entitlements + +| Capability | New free account | Pro | Existing account during migration | Self-hosted roost | +| --- | --- | --- | --- | --- | +| Login, directory, key exchange, signaling | Yes | Yes | Yes | Yes | +| Local MCP | Yes | Yes | Yes | Yes | +| Direct native MCP connection | Yes on unlocked wings | Yes on unlocked wings | Yes on unlocked wings | Yes on unlocked wings | +| Direct browser terminal | Not in this slice | Not in this slice | Not in this slice | Not in this slice | +| Hosted browser terminal/control relay | No | Yes | Temporary | Operator policy | + +Relay access is a server decision, returned as structured capability metadata and checked before a relayed terminal is started or attached. It is not inferred from client UI state. Temporary migration access uses an explicit deployment cutoff timestamp, is observable in `/api/app/me`, and can later be removed without changing account tiers. + +The wing has the final transport decision. `hosted_relay: deny` overrides every +account cohort, including Pro, temporary-migration, and self-hosted relay access. The +gateway rejects payload routing before forwarding and the wing independently rejects +relayed PTY and general control messages. Omitted policy remains `allow` for N-1 +wings; unknown explicit values fail closed. Coordination purposes remain bounded and +purpose-bound at the wing. Authorized roster and `wing.info` capability metadata show +the effective value, and denial audit records exclude command, path, and payload. + +Small, purpose-specific signaling messages remain available to free users. The outer tunnel envelope declares a bounded coordination purpose, and the wing verifies that declaration against the decrypted inner message before responding. New free accounts cannot use the general encrypted control tunnel. Wings advertise purpose-binding support at registration, and the coordinator rejects direct-only signaling to older wings. A later protocol version can split these declarations into physically separate endpoints. + +## Rollout + +1. Add the qualified direct MCP contract, native connector, and wing-side WebRTC control handler. +2. Add relay entitlement metadata and deny new free terminal relay starts/attaches while preserving explicit temporary migration access. +3. Put direct-agent setup at the center of the logged-in free page; preserve the current terminal UI for entitled users. +4. Restrict the opaque generic tunnel for new free users to purpose-bound discovery, + signaling, and passkey coordination. Browser-direct transport remains a separate + later slice. +5. Evaluate an explicit native relay transport separately, and design multi-roost peer directory exchange. + +Steps 1-4 are implemented on this branch. Step 5 is deliberately compatible with +the resource and entitlement model above but remains future work. + +## Acceptance criteria + +1. `wt mcp stdio` works with the network unavailable. +2. `wt mcp connect` lists two accessible wings and requires `wing_id` for every wing-owned call. +3. A call addressed to wing A cannot execute on wing B, and returned resources are qualified with A. +4. A successful direct MCP call sends no MCP request/result bytes through the relay. +5. A new free user is denied before a hosted relayed terminal starts or attaches, with direct/self-host/pro remediation. +6. Pro and explicit temporary-migration accounts retain the current relay behavior. +7. Roost mode keeps working without a hosted subscription and can choose its own relay policy. +8. Contract, connector, transport, authorization, and relay-policy behavior have unit/integration coverage and `make test` passes. +9. Locked and per-user passkey-protected wings reject native direct MCP calls until a passkey ceremony is implemented; coordinator identity alone never bypasses the local lock. +10. A wing with `hosted_relay: deny` rejects relayed payloads for owner and org member even when the account is otherwise entitled, while bounded discovery/signaling still works. + +## Compatibility and deployment + +The existing HTTP MCP endpoint remains available during migration, and the deployment can grant a measured existing-account cohort temporary access. No automatic Fly deployment is implied by a GitHub release: the production deployment must be performed and verified separately. Public docs and `/patterns` should be checked as part of the production rollout so the website does not advertise a contract older than the released binary. + +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. diff --git a/docs/egg-inheritance-design.md b/docs/egg-inheritance-design.md index 780bc941..c2859f7c 100644 --- a/docs/egg-inheritance-design.md +++ b/docs/egg-inheritance-design.md @@ -1,5 +1,10 @@ # Egg Config Inheritance +> **Status: implemented design record.** The `base` chain and additive merge are +> shipped. References below to “today” and “after” describe the implementation +> transition, not two selectable current behaviors. Use the +> [sandbox reference](sandbox.md) for the current user contract. + ## Problem Default egg.yaml blocks `~/.ssh`, port 22, and other sensitive paths. This is correct. But the only way to poke a hole today is to write a full replacement config that redeclares every deny rule, every mount, everything. You can't say "defaults + SSH." @@ -277,9 +282,15 @@ func mergeFS(parent, child []string) []string { ### Network port granularity -Current `NetworkNeed` is a coarse enum (None/Local/HTTPS/Full). The `*:22` syntax needs per-port support. +> **Deferred; this syntax is not implemented.** The current user contract is a +> host allowlist plus explicit host-loopback `network.local_ports`. CONNECT may +> carry TCP to any port on an allowed host; `domain:port` and `*:port` are not accepted policy forms. +> See the [sandbox reference](sandbox.md). + +The design considered replacing the coarse `NetworkNeed` enum +(None/Local/HTTPS/Full) with per-port support: -New network entry format: `domain`, `domain:port`, `*:port`, `*`, `none`, `localhost`. +Proposed network entry format: `domain`, `domain:port`, `*:port`, `*`, `none`, `localhost`. ```go type NetworkNeed struct { @@ -288,7 +299,10 @@ type NetworkNeed struct { } ``` -macOS seatbelt already filters by port (443/80 for HTTPS). Adding 22 is one more `(allow network-outbound (remote tcp "*:22"))` rule. Linux needs iptables in the namespace or an extension to the current approach. +macOS Seatbelt can filter by port. The Linux implementation instead keeps a +route-less namespace and exposes a host-policy CONNECT relay; implementing this +proposal would require port checks in that relay as well as the corresponding +macOS policy. ## Migration diff --git a/docs/egg-sandbox-design.md b/docs/egg-sandbox-design.md index 9de06c92..66142bb0 100644 --- a/docs/egg-sandbox-design.md +++ b/docs/egg-sandbox-design.md @@ -1,5 +1,11 @@ # Egg Sandbox Design: Auto-Drilled Agent Holes +> **Status: historical design and implementation record.** The proposed +> permissive default below did not ship as the current contract. Wingthing now +> defaults to a restrictive project-writable, home-read-only policy with common +> credential directories denied and only agent-required domains added. Use the +> [sandbox reference](sandbox.md) for current behavior. + ## Core Principle All sandbox rules are implicitly "AND what the tool needs to run." @@ -137,7 +143,7 @@ Egg says: Agent needs: env: ANTHROPIC_API_KEY env: ANTHROPIC_API_KEY + essentials Result: - network: HTTPS outbound (443/80) + DNS only (macOS); full network (Linux — see limitations) + network: declared hosts through an enforced CONNECT proxy writes: ~/scratch/jail + ~/.claude* + ~/.cache/claude — not all of HOME denies: ~/.ssh, ~/.gnupg, ~/.aws (takes precedence over everything) env: union of egg allowlist + agent profile + essentials (HOME, PATH, TERM, LANG) @@ -162,8 +168,10 @@ Uses CLONE_NEWUSER for unprivileged isolation. Architecture: ``` Parent process (wt egg run) - └─ CLONE_NEWUSER + CLONE_NEWNS [+ CLONE_NEWNET] + ├─ host DomainProxy + inherited socketpair endpoint + └─ CLONE_NEWUSER + CLONE_NEWNS + CLONE_NEWNET └─ _deny_init wrapper (runs as UID 0 in namespace) + ├─ raise lo + listen only on declared relay ports ├─ mount tmpfs over deny paths ├─ bind-mount HOME read-only, writable sub-mounts ├─ install seccomp BPF filter @@ -171,7 +179,7 @@ Parent process (wt egg run) └─ agent (runs as real UID, PID 1 in namespace) ``` -**Network:** CLONE_NEWNET isolates network completely. Stripped for agents that need network via NetworkNeed enum. **No port-level filtering** — Linux can't do iptables in unprivileged user namespaces (needs CAP_NET_ADMIN). Agents that need HTTPS get full network. +**Network:** `CLONE_NEWNET` is retained for every `NetworkNeed`. The namespace has no default route. Before clone, the parent creates a Unix socketpair beside `DomainProxy`; `_deny_init` raises `lo` and listens only on the proxy port and explicitly declared local ports. Accepted TCP sockets cross the socketpair by `SCM_RIGHTS`, and the host validates the requested listener before dialing. The bridge FD is close-on-exec before the agent starts, so the agent can use the declared listeners but cannot forge new targets. An agent that removes `HTTPS_PROXY` has no route. **Filesystem:** Deny paths via tmpfs overlays (empty, read-only). Write isolation via bind-mount HOME read-only, then bind-mount specific writable dirs/files. Prefix matching: for writable path `~/.claude`, automatically bind-mounts adjacent files like `~/.claude.json`. @@ -215,19 +223,17 @@ env: { allow: [ANTHROPIC_API_KEY, PATH, HOME, TERM] } These are architectural constraints of the platform, not bugs. Each has a clear fix path. -#### 1. Linux: Full network when agent needs HTTPS - -**What happens:** `isolation: standard` creates CLONE_NEWNET, but Claude's agent profile declares `NetworkHTTPS`. Linux strips CLONE_NEWNET entirely because unprivileged user namespaces can't do port-level filtering (no CAP_NET_ADMIN for iptables). - -**Result:** curl, wget, ping, ssh, raw sockets all work inside the sandbox on Linux. - -**macOS comparison:** macOS enforces port-level filtering — only TCP 443/80 + mDNSResponder allowed. Non-HTTPS traffic is blocked. - -**Risk:** Combined with the agent's own credentials (finding below), a malicious task could exfiltrate data. This is the highest-priority limitation. +#### 1. Linux egress protocol coverage -**Fix path:** Create a veth pair, move one end into the network namespace, add iptables rules to restrict to ports 443/80 + DNS 53. Requires a helper binary with CAP_NET_ADMIN or a running daemon. Tracked as a v1 goal. +**What happens:** Linux now retains `CLONE_NEWNET` and exposes only the inherited +CONNECT-proxy and declared host-loopback TCP listeners. This fixes the former raw +bypass: curl, wget, SSH, or custom sockets that ignore the proxy have no route. -**Mitigation until fixed:** For tasks that don't need network, use `isolation: strict` (or use macOS). For untrusted tasks on Linux, acknowledge that network isolation is incomplete. +**Remaining limit:** CONNECT can carry arbitrary TCP bytes to any port on an +allowed host, but software must honor the HTTP proxy variables or explicitly +speak CONNECT. The relay provides neither SOCKS nor a general routed interface, +and does not carry UDP, ICMP, or other non-TCP protocols. `network: "*"` means +any TCP target presented through CONNECT. #### 2. Agent credentials are accessible to the task @@ -237,9 +243,9 @@ These are architectural constraints of the platform, not bugs. Each has a clear **Why it's by design:** The agent IS Claude. It needs its credentials to make API calls. The sandbox runs the agent, and the agent uses its credentials. You can't hide the agent's credentials from the agent. -**Risk:** Combined with network access, a malicious task could exfiltrate these tokens. Without network (macOS strict, or Linux with CLONE_NEWNET), this is theoretical only. +**Risk:** A malicious task can use the agent's allowed provider destinations. Domain enforcement narrows egress but does not make readable credentials harmless. -**Mitigation:** Network isolation is the primary defense here. On macOS (port-filtered), only HTTPS exfil is possible, which is detectable. On Linux (full network), this is the motivation for fixing finding #1. +**Mitigation:** Network isolation is the primary defense here. Both platforms force declared HTTPS through the local CONNECT proxy; Linux raw-socket bypasses have no route. #### 3. HOME is readable (read-only, not denied) @@ -270,9 +276,9 @@ deny: **Result:** A sandboxed task can make outbound SSH connections — including `git` over SSH — using the user's SSH identity, despite `deny:~/.ssh`. If `StrictHostKeyChecking` triggers, the user sees an interactive host-key prompt they didn't expect. -**Fix (v0.10.4+):** `BuildEnv` strips `SSH_AUTH_SOCK` from the environment whenever any FS deny rule covers `~/.ssh`. Denying the key directory implies denying agent auth. +**Fix (v0.10.4+):** When an FS deny rule covers `~/.ssh`, `BuildEnv` strips an implicitly inherited `SSH_AUTH_SOCK` and the sandbox masks the live socket path itself. Stripping the variable alone is insufficient because common socket paths can be rediscovered under `/tmp` or the user runtime directory. Explicitly listing `SSH_AUTH_SOCK` is the opt-in for agent-backed SSH without raw key access; wildcard environment inheritance is not. -**Why not the reverse:** If users explicitly need git-over-SSH inside a sandbox (e.g., `network:*` + no deny on `~/.ssh`), `SSH_AUTH_SOCK` passes through normally. The stripping only happens when `deny:~/.ssh` is present. +**Shared hosts:** Host SSH agents are never forwarded to isolated shared-host users, even if a session policy tries to list the variable explicitly. #### 5. Agent config dir enables persistence attacks @@ -389,7 +395,7 @@ DNS resolution goes through `/private/var/run/mDNSResponder` (Unix domain socket ### High priority -1. **Linux network pinholes** - veth pair + iptables in namespace for port-level filtering. This closes the biggest gap between macOS and Linux security. Without it, Linux lockdown mode has full network for any agent that needs HTTPS. +1. **Additional Linux relay protocols** - add SOCKS support for TCP clients that cannot use CONNECT, and a protocol-aware path for explicitly declared UDP workloads, without creating a general route. The current CONNECT/local-port relay is enforced but intentionally TCP-only. 2. **CLONE_INTO_CGROUP** - eliminate the PostStart race by cloning the child directly into the cgroup (Linux 5.7+, requires CAP_SYS_ADMIN). Currently the child runs briefly before cgroup limits apply. diff --git a/docs/feature-local-first-merge-readiness.md b/docs/feature-local-first-merge-readiness.md index 558dbccf..c2d4acd3 100644 --- a/docs/feature-local-first-merge-readiness.md +++ b/docs/feature-local-first-merge-readiness.md @@ -2,10 +2,15 @@ Audit date: 2026-08-23. +Status: historical snapshot for the superseded local-first branch; not a current +merge verdict. Current scope and promotion gates live in the +[agent-manager product brief](agent-manager-product-brief.md) and the checked-in +CI/release workflows. + Branch: `feature-local-first-terminal-routing`, audited from the immutable `f59fe8a` snapshot based directly on `origin/main` at `3665624` (`v0.143.0`). -## Current evidence +## Snapshot evidence The committed 25-commit base and successive security-review snapshots were tested independently. The current local gates pass: diff --git a/docs/fly-ops.md b/docs/fly-ops.md index 461c9fa6..81fcfab5 100644 --- a/docs/fly-ops.md +++ b/docs/fly-ops.md @@ -10,6 +10,13 @@ Two process groups, one image: 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. ## One-time setup @@ -21,13 +28,79 @@ fly secrets set WT_JWT_KEY=$(wt keygen) ## Deploy -Build and push to all machines: +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 +commit being promoted: ``` +git tag vX.Y.Z +git push origin vX.Y.Z +# wait for the release workflow to pass and publish all five assets +gh release view vX.Y.Z +curl -fsSL https://wingthing.ai/install.sh | sh make deploy ``` -This runs `make check` first (tests + build), then `fly 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 +``` + +### Hosted relay policy + +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. + +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. + +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: + +```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 @@ -53,6 +126,8 @@ make status ## Middle-of-the-night playbook +Only use this after the matching release has passed the promotion sequence above: + ``` make deploy make deploy-edge REGIONS=nrt,lhr,cdg COUNT=1 @@ -101,4 +176,13 @@ make scale LOGIN=1 EDGE=0 ## Self-hosted -Self-hosted is single node. No `WT_NODE_ROLE`, no `FLY_MACHINE_ID`, no gossip, no fly-replay. Just `wt serve`. All multi-node code paths are gated on Fly env vars being present. +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. diff --git a/docs/group_transition.md b/docs/group_transition.md index 8400229f..24dbc75c 100644 --- a/docs/group_transition.md +++ b/docs/group_transition.md @@ -1,6 +1,13 @@ # Process Group Transition Plan -## Current State (prod) +Status: historical pre-v301 migration plan. Production crossed this transition on +2026-08-25 and now runs the `login` process group with the SQLite volume attached. +Use [fly-ops.md](fly-ops.md) for current operations and +[wingthing-ai-production-canary-2026-08-25.md](wingthing-ai-production-canary-2026-08-25.md) +for the deployment record. The commands and unchecked boxes below are retained only +as the original risk analysis; they are not a current deploy runbook. + +## Historical starting state - Single Fly machine in ewr, no process groups - Volume `wt_data` mounted at `/data` with no process group scope @@ -96,7 +103,10 @@ fly deploy --image # to remove the process group and go back to single-machine mode ``` -## TODO before deploying +## Historical pre-deploy checklist + +This checklist was written before the process-group deployment. Do not use it to +infer current production state. - [ ] Run staging test plan above - [ ] Verify Fly handles volume reassignment to process group diff --git a/docs/llm-first-review.md b/docs/llm-first-review.md new file mode 100644 index 00000000..10f4c16d --- /dev/null +++ b/docs/llm-first-review.md @@ -0,0 +1,379 @@ +# LLM-first architecture review + +Status: historical architecture snapshot at `c87a778`; later direct-control work is not reflected throughout + +Reviewed: 2026-08-24 +Repository snapshot: `c87a778` + +For current implementation state, gaps, and promotion gates, read the +[Agent Manager Product Brief](agent-manager-product-brief.md). This review is kept +to explain the decisions that led to the shared operation registry; statements +using “current” below refer to the recorded snapshot. + +## Verdict + +Wingthing already has most of the hard runtime pieces: persistent PTYs, +provider adapters, semantic headless runs, owner-scoped control, sandbox +enforcement, a browser, local and HTTP MCP, encrypted routing, and tests that +exercise real agent harnesses. + +Those pieces don't yet form one portal. The browser, local MCP server, roost MCP +server, and native CLI each expose a different subset of the same machine. They +share some storage. The first implementation slice adds a shared operation +registry and one access-filtered wing roster for the browser and roost HTTP MCP. +There is still no single session/run inventory, resource namespace, or event +stream. + +The next product boundary should be: + +> One inventory and control contract for every agent session and run, on every +> wing the caller can reach. A person and an LLM use different renderings of the +> same resources. + +"All your agents in one place" should mean one inventory, one authority model, +and one lifecycle contract. Code, credentials, and processes stay on their +chosen machines. + +## What exists now + +The current product has four partial views: + +| State | Authority | CLI | local MCP | roost HTTP MCP | web portal | +| --- | --- | ---: | ---: | ---: | ---: | +| Connected wing roster | gateway memory/database | `wt wings` | no | yes | yes | +| Live egg/PTY sessions | wing `eggs/` directory and egg sockets | yes | yes | embedded wing only | every accessible registered wing | +| Headless agent runs | wing `wt.db` task tables | limited | yes | embedded wing only | no | +| Prompt assets, loops, and swarms | wing `wt.db` and prompt store | yes | yes | no | no | +| Owner-scoped agent messages | wing `wt.db` | no | yes | embedded wing only | no | +| Sandbox explanation | wing policy resolver | CLI | yes | embedded wing only | partial editor | +| Session history and recordings | wing egg files | partial | no | no | yes | + +This explains the current behavior: + +- `terminal_start` and `agent_start` create ordinary eggs. If the same wing is + connected to a web portal, those sessions appear there. +- `agent_run` creates a task record and a supervised headless process. It + doesn't create an egg or PTY, so the browser never sees it. +- A self-hosted roost returns the same authorized wing roster through the + browser and HTTP MCP `wing_list`. Runtime tools still call the embedded + wing's local state directly and don't accept a `wing_id`. +- The hosted portal can show several personal or organization wings registered + with its gateway. It can't see independent self-hosted roosts. +- Registering several HTTP MCP servers gives an LLM several named targets, but + no Wingthing service aggregates their inventory. + +The existing `PeerDirectory`, edge map, and Fly replay logic coordinate +replicas inside one gateway deployment. WebRTC peers connect browsers to wings. +Neither mechanism discovers or federates independent roosts. + +## Use fewer public nouns + +The repository currently gives "roost" two incompatible definitions. The +original design and `wt roost` command define it as a relay plus an embedded +wing. The website calls the relay-only hosted service a roost. Both definitions +appear in current documentation. + +The public model should be: + +| Term | Definition | +| --- | --- | +| **Portal** | The client-facing inventory and control surface. It may be hosted or self-hosted, and it has a browser and MCP endpoint. | +| **Gateway** | The portal component that authenticates callers, keeps a wing roster, and routes traffic. Most users don't need this noun. | +| **Wing** | One execution runtime. The wing owns sessions, runs, workspaces, policy resolution, and provider credentials. | +| **Session** | An interactive persistent PTY. | +| **Run** | A semantic headless task with events and a result. | +| **Egg** | The execution boundary for one persistent session. Keep this term in sandbox and debugging documentation. | +| **Roost** | The self-hosted deployment bundle started by `wt roost start`: portal/gateway plus an embedded wing. | + +Under this definition, `app.wingthing.ai` is the hosted portal and +`wingthing.ai` supplies its gateway. A roost is one way to deploy the same +portal and runtime pieces. It stops being the root of the object model. + +Internal names such as `RoostURL` can remain during a compatibility window, +but new user-facing flags and schemas should say `portal` or `gateway` when +they select an endpoint. + +## One control contract + +The control service belongs beside the state it controls, on the wing. + +```text + portal + identity, roster, target routing + | + +--------------+---------------+ + | | | + web API HTTP MCP native client + | | | + +-------- versioned control RPC-+ + | + selected wing service + sessions, runs, policy, events + | + eggs + task store +``` + +For a roost's embedded wing, the portal can call the service in-process. For an +external wing, the same request travels through the application-encrypted +tunnel. Local CLI and stdio MCP call the service over a wing-owned local socket. +Transport changes don't create new semantics. + +The first implementation slice moves names, schemas, grants, annotations, +surface availability, authority, and audit targeting into `internal/control`. +The registry should ultimately define each capability once: + +- stable operation name and version; +- request and response schemas; +- resource and ownership rules; +- grant and server-side bounds; +- read-only, mutating, destructive, and open-world annotations; +- audit redaction; and +- supported transports. + +Adapters render that registry as CLI JSON, MCP tools, portal HTTP resources, and +browser actions. Contract tests compare every adapter with the registry. The +semantic handlers still live in `cmd/wt/mcp_local.go` and should move to a +transport-independent package before more runtime tools are added. + +## Resource identity and target selection + +Target selection must be explicit and safe under concurrent callers. A mutable +"current wing" on an MCP connection will route the wrong call eventually. + +Every resource reference should contain: + +```json +{ + "portal_id": "lab", + "wing_id": "1ae20a6b28854276b1514d14", + "kind": "session", + "id": "research" +} +``` + +The portal endpoint supplies `portal_id`; the caller doesn't get to impersonate +another portal. List and start operations accept a `wing_id`. Later operations +return and accept the full resource reference. Human labels remain local aliases, +not routing identity. + +The first read-only step is now present: `wing_list` returns the exact authorized +browser roster and says which entry the current endpoint can control. The next +multi-wing MCP slice needs a small set of additions: + +- `session_list` and `run_list` can filter by wing or return the whole portal; +- start calls require `wing_id` when more than one target is available; +- returned session and run references retain their wing; and +- read, wait, send, steer, and stop route by the returned reference. + +The web portal should use these same list and lifecycle operations. Its current +`sessions.list` tunnel message and localStorage merge can then become one +adapter instead of a second object model. + +## Several portals + +Portal federation is a later problem. The first useful version is a client-side +target registry: + +```text +personal portal --\ +team roost -------+--> local Wingthing target registry --> one combined inventory +GPU lab roost ----/ +``` + +Each entry has a name, URL, portal identity pin, independent OAuth session, and +capability cache. Read-only inventory can fan out. Mutations always carry the +selected portal and wing. Owner identities from two portals remain different +even when their email strings match. + +This gives a person and an LLM one view without designing roost peering, +cross-roost trust, replicated policy, or distributed ownership. A server-side +federation protocol should wait for a real workflow that client-side aggregation +can't handle. + +## Placement is part of every run + +The workflow discussion about laptops and development VMs exposes five separate +placement decisions: + +| Decision | Question | Current representation | Needed representation | +| --- | --- | --- | --- | +| Execution | Where does the agent or build run? | Implied by the MCP server or browser wing | Explicit `wing_id` and required capabilities | +| Workspace | Where is the authoritative code and untracked state? | Absolute host `cwd` | Logical workspace with per-wing replicas and sync state | +| Display | Where does a person or browser tool view the result? | PTY plus ad hoc preview file | Owned preview resource with source wing, route, TTL, and content type | +| Credentials | Which identity may the process use on that host? | Ambient env or per-user agent home | Owner-scoped credential reference resolved on the execution wing | +| Memory | Which durable project/user context follows the work? | Provider-specific home and Wingthing local files | Scoped memory asset with explicit replication and provenance | + +An execution target and a workspace are independent. The current API's host +`cwd` binds them together. A portable run should accept a logical workspace: + +```json +{ + "wing_id": "gpu-lab", + "workspace_id": "agentless", + "agent": "claude", + "model": "opus", + "prompt": "run the integration matrix" +} +``` + +The wing resolves `workspace_id` to a canonical local path, verifies that the +replica is ready, then records the resolved path and revision in run provenance. +`cwd` can remain as a local escape hatch, but it shouldn't be the portable +contract. + +### Workspace modes + +Wingthing shouldn't begin by implementing general bidirectional filesystem sync. +A workspace can declare one of four concrete modes: + +- **resident:** the authoritative files already live on one wing; +- **git materialized:** the target creates an isolated worktree from an exact + repository and revision, with an explicit patch or artifact for selected + untracked inputs; +- **externally replicated:** Syncthing, Mutagen, a shared filesystem, or another + operator-selected mechanism owns replication; Wingthing reports readiness, + revision, conflicts, and last sync time; or +- **artifact:** an immutable bundle is staged to the target and verified by + digest. + +A full `~/Work` tree has caches, sockets, secrets, large build products, and +files with no conflict semantics. Silent two-way sync is a poor default. +Workspace manifests should name included roots, excluded paths, maximum bytes, +the authority side, and conflict behavior. Non-git state then becomes visible +policy instead of an accidental omission. + +Offline work follows from this model. A local replica that is marked ready can +run on the local wing without a portal. Remote-only replicas remain unavailable +while offline, and the UI can say that directly. + +### Display and browser placement + +An agent running on a VM shouldn't need to launch a GUI on that VM so a person +can inspect a web app. A run should publish a preview resource: + +```text +remote build/run -> owned preview route -> local browser +``` + +The existing preview panel is the start of this contract. Promote it from a +magic file to typed operations with source wing, local or remote endpoint, +owner, TTL, readiness, and close state. The browser can render the preview on +the laptop. A browser agent can consume the same route later, whether its browser +runs on the laptop or another wing. + +### Credentials + +Provider and repository credentials belong on the execution wing under the +run owner's identity. Shared-host owner homes are useful isolation between +ordinary eggs. They don't protect secrets from host root, a hypervisor +administrator, or someone who can copy the VM disk. + +The control API should accept a credential class or reference, never a raw +token. The target reports `ready` or `auth_required` and supplies a bounded +login handoff. Short-lived delegated Git credentials or SSH-agent forwarding +are preferable when the workload permits them. A personal VM remains the right +boundary when the shared host operator isn't trusted. + +### Durable memory + +Agent-specific memory tied to a launch directory will fragment when work moves +between wings. Wingthing should model durable context as ordinary scoped assets: + +- owner memory; +- project/workspace memory; +- task or ticket memory; and +- agent-private cache that isn't promised to move. + +Each durable asset needs a source, revision, last writer, and replication mode. +An Obsidian or Git repository can be one storage adapter because it is just +Markdown with an existing sync story. Wingthing should expose explicit +`memory_read`, `memory_update`, and stale-state checks rather than relying on +an instruction that asks an agent to remember to edit notes. Automatic updates +need lifecycle hooks and reviewable diffs. + +## Make the usage patterns crisp + +The current six patterns mix actor, runtime, transport, and deployment. A recipe +should instead answer these fields: + +```text +driver: human | LLM +portal: none/local | hosted | self-hosted URL +wing: local | explicit wing_id +workspace: resident | git | replicated | artifact +display: terminal | browser | preview route +credentials: local owner | remote owner | delegated +memory: local | project | replicated +``` + +The current shipped matrix is: + +| Driver | Local wing | Personal wing through hosted portal | Shared roost wing | +| --- | --- | --- | --- | +| Human | CLI and `wt attach` | browser or SSH | browser | +| LLM | stdio MCP | no general external-wing MCP route | OAuth HTTP MCP | + +Multi-roost orchestration is composition over the matrix. It shouldn't be +presented as a sixth runtime type. + +## Test harness review + +The harness has a sound evidence ladder. Unit tests cover schemas and semantic +cores. Synthetic relay tests isolate routing. Privileged Linux tests exercise +real namespace and seccomp boundaries. The browser canary drives the complete +shared-roost UI. The provider-swap battery compares direct harness behavior with +the Wingthing path and checks an exact filesystem artifact. + +The non-root sealed-jail regression added in `2795bd3` is exactly the kind of +test this harness needs. It makes the mock agent report its UID and host-process +visibility, then proves that a non-root launch inside a private PID namespace +cannot see the host process or a denied secret. This closes a class of false +confidence created by root-running container tests. + +The gaps now line up with the product gap: + +1. The release smoke discovers 14 legacy MCP tools while the unconfigured local + server publishes 27. It doesn't exercise `agent_run`, wait, result, events, + steering, stop, messages, or HTTP MCP. +2. No test starts a session through MCP and then discovers and controls it in the + browser, or does the reverse. +3. An in-process contract test now connects accessible and inaccessible wings, + compares MCP `wing_list` with the browser roster, and checks control + metadata. No black-box test targets and isolates runtime work on two wings. +4. No test exposes headless runs in the browser because that product surface + doesn't exist. +5. The web E2E tier isn't part of `make test-e2e` or required CI. +6. The tag release workflow builds artifacts without running the documented + promotion gates. +7. The HTTP MCP tests exercise OAuth and owner propagation in-process, but no + real Codex or Claude client logs into a roost and completes a semantic run. +8. Manual host names, run IDs, dates, versions, and artifact hashes in evidence + documents will go stale. Machines should emit a signed or hashed manifest + that the docs link to. + +[Testing](testing.md) turns these findings into a proposed command and +acceptance matrix. + +## Recommended implementation branch + +Create a separate work branch after the current test work and Claude's stack +have a stable base. The docs/review branch should remain small enough to merge or +hand back independently. + +The implementation stack should preserve this order: + +1. settle public terminology and add persistent portal identity; +2. continue extracting semantic handlers behind the new `internal/control` + registry; +3. add qualified resource references and cross-adapter conformance tests; +4. put runs, messages, history, and sessions in one portal inventory; +5. carry the control RPC through the encrypted tunnel to external wings; +6. add explicit multi-wing selection to HTTP MCP and the browser; +7. add logical workspaces and run placement; +8. promote previews, credential readiness, and durable memory to typed + resources; and +9. add a client-side multi-portal target registry if the prior slices are solid. + +The branch should not begin with roost federation, a filesystem sync engine, or +a new dashboard. Those would harden the current split semantics. The control +contract and resource identity come first. diff --git a/docs/local-first-architecture.md b/docs/local-first-architecture.md index 57e5da18..6523e698 100644 --- a/docs/local-first-architecture.md +++ b/docs/local-first-architecture.md @@ -1,16 +1,27 @@ # Local-first runtime architecture -Status: direction proposal and first implementation slice -Reviewed: 2026-08-08 +Status: adopted direction, with control-plane convergence in progress + +Reviewed: 2026-08-28 + +This document establishes the wing as the runtime authority. The current +LLM-first follow-up defines the portal, qualified resource identity, and remote +workspace placement in +[the LLM-first architecture review](llm-first-review.md). ## Decision in one sentence **A wing is the durable runtime; everything else is a client, transport, access policy, or deployment bundle around it.** -Wingthing should start locally, require no account, keep real terminal processes alive, and be useful from the terminal where the user already works. `wingthing.ai` is an optional browser client, rendezvous service, and relay fallback. It is not the place the work conceptually lives. +Wingthing should start locally, require no account, keep real terminal processes alive, and be useful from the terminal where the user already works. `wingthing.ai` is an optional browser client, rendezvous service, and entitled browser/control relay. The native MCP connector is direct-only. The hosted service is not the place the work conceptually lives. That framing resolves the apparent conflict between “everyone goes to the roost” and “wingthing.ai routes to every wing.” They are different deployment stories built from the same layers, not competing definitions of the product. +The next question is not simply local versus remote. Every workflow places five +things: execution, workspace, display, credentials, and durable memory. The +wing owns execution. The portal presents inventory and controls. Neither term +implies that code or memory has been copied between machines. + ## What Herdr got right This review used Herdr v0.8.0 (`3a76fea`), its public documentation, and its @@ -101,7 +112,8 @@ Wingthing did not miss the runtime. It buried it under the network and organizat - A wing discovers, starts, reclaims, and routes persistent eggs. - Browser ↔ wing terminal content is application-encrypted through the relay; see `docs/security.md` for metadata, TOFU, web-code, and forward-secrecy limits. -- WebRTC can migrate browser traffic to a direct data channel with relay fallback. +- WebRTC can migrate eligible browser traffic to a direct data channel while an + entitled session retains its relay transport. - A direct WebSocket server exists for browser clients on reachable networks. - Sandboxing, auditing, per-user homes, path policy, and privileged tools already sit next to the PTY runtime. @@ -137,9 +149,9 @@ Clients should be peers, not tiers: The browser is valuable because it is universal. It is not the definition of remote access. -### Transports +### Target transport ordering -Choose the simplest reachable transport, then fall back: +The converged session protocol should choose the simplest reachable transport: 1. local Unix socket 2. SSH stdio using the user's SSH config, agent, VPN, or tailnet @@ -147,6 +159,11 @@ Choose the simplest reachable transport, then fall back: 4. peer-to-peer negotiated path 5. encrypted relay through `wingthing.ai` or another gateway +This is an architecture target, not an automatic fallback claim. The shipped +native `wt mcp connect` adapter is direct-only and fails closed when WebRTC cannot +connect; it never changes to the hosted relay. Browser terminals with relay access +use their gateway transport today, while CLI remote attach uses explicit SSH. + The session protocol above these transports should converge. A client should attach to a wing and session; it should not need separate behavior because a relay happens to carry the bytes. “Local-first” does not mean “local-only.” It means the runtime is complete without the cloud and the cloud does not become the source of truth when enabled. @@ -188,7 +205,11 @@ local/SSH/native/web client ──transport──> wing ──local socket── └── authoritative state ``` -The user installs `wt`, starts agents on their own machine, detaches, and reattaches. They may enable `wingthing.ai` for browser access, discovery, and NAT/firewall fallback. Collaboration is explicit sharing of a wing or session. +The user installs `wt`, starts agents on their own machine, detaches, and reattaches. +They may enable `wingthing.ai` for discovery and direct connection coordination; +accounts with hosted relay access may also use its browser and NAT/firewall relay. +The native MCP connector remains direct-only. Collaboration is explicit sharing of +a wing or session. This should be the default onboarding and README story. @@ -324,7 +345,7 @@ global routing. - [x] Add stable human-readable session labels alongside immutable IDs. - [x] Add persistent shells and arbitrary commands alongside agent sessions. - [x] Let the native CLI list and encrypted-probe wings available through a roost. -- [ ] Dogfood before post-vacation promotion. +- [x] Dogfood local and SSH detach/reattach before promotion. - [ ] Make `wt attach` one client protocol across every transport. ### P1: one local control surface @@ -335,7 +356,9 @@ global routing. - [x] Expose local terminal and agent orchestration to LLM clients through MCP stdio. - [x] Add structured one-shot prompt, bounded loop, and dependency-DAG swarm primitives. - [x] Add named prompt templates with immutable revisions and task provenance. -- Expose the same control semantics through browser tunnel and other transports. +- Converge the direct and browser transports on the same wing-owned handlers; native + direct MCP already uses the shared operation registry, while the browser tunnel + remains bespoke. - Separate raw terminal operations from agent-aware operations. ### P2: agent awareness @@ -358,7 +381,9 @@ global routing. ### P4: transport convergence - Put local, SSH, direct, P2P, and relay paths behind one attach protocol. -- Prefer direct reachability and make relay fallback observable. +- Prefer direct reachability for every transport; where the browser/control path is + entitled to relay, make that fallback explicit and observable. Do not add silent + relay fallback to the direct-only native MCP connector. - [x] Let `wingthing.ai` provide optional discovery/rendezvous without owning session metadata. - Keep the browser as a first-class client on the same protocol. - Emit a versioned control-protocol schema from the installed binary. diff --git a/docs/local_https.md b/docs/local_https.md new file mode 100644 index 00000000..edff345f --- /dev/null +++ b/docs/local_https.md @@ -0,0 +1,172 @@ +# Local HTTPS design + +Status: implemented + +Reviewed: 2026-08-27 + +## Outcome + +A person can run a single-user self-hosted portal with: + +```bash +wt roost start --https +open https://localhost:8443 +``` + +or run the gateway separately for a remote wing: + +```bash +wt serve --local --https +``` + +The first invocation creates a localhost-only certificate authority on demand +and asks the operating system to trust its public certificate for the current +user. WT says what it is doing before the trust command runs. The CA private key +never leaves the Wingthing profile. + +## Why there are two listeners + +```text +browser ===== HTTPS :8443 ===== local gateway + | +wing ===== loopback HTTP :8080 =====+ +``` + +The browser needs a trusted secure origin. A local wing, embedded wing, or remote +wing arriving through an SSH reverse forward needs an endpoint it can reach +without trusting the browser computer's private CA. + +Both listeners use the same relay handler and bind only to loopback in local +HTTPS mode. The ordinary HTTP endpoint is therefore a host-local transport, not +a LAN service. For a remote wing, SSH authenticates and encrypts the segment +between hosts before it reaches that loopback endpoint. + +The two-listener design avoids copying the CA certificate or any private key to a +remote Linux or WSL machine. It also preserves `wt start --local` and the existing +reverse-forward recipe. + +## Certificate material + +WT creates these files under `WINGTHING_DIR/local-tls`: + +| File | Contents | Mode | +| --- | --- | --- | +| `ca-key.pem` | ECDSA P-256 CA private key | `0600` | +| `ca.pem` | public self-signed CA certificate | `0644` | +| `localhost-key.pem` | ECDSA P-256 server private key | `0600` | +| `localhost.pem` | public server certificate | `0644` | +| `trusted` | successful, platform-verified user trust-store marker | `0600` | + +The directory is mode `0700`. Writes use a temporary file and atomic rename. +Existing CA material is never silently replaced: incomplete, corrupt, mismatched, +not-yet-valid, or expired CA state fails with an explicit error. A corrupt or +near-expiry leaf may be regenerated under the same CA. + +The CA has a zero-length intermediate path and critical name constraints for: + +- `localhost`; +- `127.0.0.0/8`; and +- `::1`. + +The leaf contains only `localhost`, `127.0.0.1`, and `::1` SANs and server-auth +usage. The root is valid for ten years; the leaf rotates when fewer than thirty +days remain. + +## Trust ceremony + +The explicit `--https` flag is consent to create and install this local material. +Before installation WT prints: + +- the CA private-key path and mode; +- the public certificate path; +- the localhost-only constraint; +- that only the public certificate enters the trust store; and +- on macOS, that a native Certificate Trust Settings dialog may appear. + +Platform destinations are: + +| Platform | Current-user destination | +| --- | --- | +| macOS | explicitly selected `~/Library/Keychains/login.keychain-db` user trust settings | +| Windows | current-user Root certificate store | +| Linux | Chromium NSS database at `~/.pki/nssdb` | + +Linux needs `certutil` from `libnss3-tools` or `nss-tools`. WT initializes a +missing Chromium NSS database without a password and without root. + +WT checks the platform destination before writing the successful marker. On +macOS it evaluates the generated leaf with the SSL policy, the `localhost` name, +and the explicit login keychain. Windows and Linux confirm the precise root in +their current-user browser stores. A failed check is never recorded as trusted. +Markers made by older builds are reinstalled and upgraded once. The verified +marker then prevents a daemon or temporarily locked macOS login keychain from +reopening the ceremony on every start. `wt local-cert remove` removes this +precise public root and clears either marker version. On macOS removal clears +both the trust setting and the public certificate. It leaves the keys on the box +so a running listener is not broken and WT cannot silently replace an authority +that was previously trusted. + +## Address and mode safety + +When `--https` is selected: + +- the implicit `:8080` default becomes `127.0.0.1:8080`; +- both supplied addresses must be explicit loopback addresses with nonzero ports; +- wildcard, LAN, DNS, and public hosts are rejected before any key is created; +- HTTP and HTTPS may not resolve to the same loopback socket; +- the browser-facing base URL becomes `https://localhost:8443`; and +- a stale public `WT_BASE_URL` cannot change that local origin. + +The certificate ceremony is deliberately opt-in. Local listener hardening also +applies to the HTTP-only form of the same no-login mode: + +| Deployment | Result | +| --- | --- | +| Existing `wt serve` / Fly edge or login node | unchanged | +| Existing single-user local HTTP serve/roost | stays HTTP; implicit `:8080` becomes `127.0.0.1:8080`, explicit non-loopback binds are rejected | +| OAuth, organization, or public shared roost | keeps external HTTPS; local CA mode is rejected | +| Single-user local serve/roost with `--https` | dual loopback listeners and local trust ceremony | + +Hosted `WT_BASE_URL=https://...` remains authoritative whenever local HTTPS is +not selected. + +The relay independently rejects non-loopback Host headers in local mode, so a DNS +rebinding hostname cannot turn the loopback service into its own origin. Browser +WebSocket upgrades must be same-origin. Unsafe browser requests must carry the +same exact scheme, host, and port when they include Origin, and requests marked +cross-site by `Sec-Fetch-Site` are rejected. Origin-less native wing and CLI +requests remain compatible. Authenticated hosted and organization topologies keep +their existing cross-host WebSocket behavior. + +## Security boundary + +HTTPS protects browser-to-local-gateway traffic and supplies a conventional +secure browser origin. It does not replace Wingthing's browser-to-wing +application encryption, wing authentication, SSH authentication, or the egg +sandbox. + +The CA private key has the power to issue another localhost certificate on this +one profile. Protecting the owning OS account and `WINGTHING_DIR` remains part of +the trust boundary. A compromised gateway still serves the browser JavaScript +and is therefore inside the client trust boundary even when terminal payloads +are application-encrypted. + +## Regression gates + +The automated battery covers certificate constraints, SANs, chain verification, +root reuse, leaf rotation, corrupt-state behavior, expiry behavior, permissions, +symlink refusal, atomic trust markers, failed install and verification commands, +legacy-marker migration, idempotence, macOS trust-rule and certificate removal, +all platform command arguments, Linux NSS initialization, and the invariant that +no trust command receives a private-key path. + +Listener tests cover unsafe-address refusal before key creation, default address +rewriting in both HTTP and HTTPS local modes, loopback alias collisions, ordinary +HTTP and HTTPS handler parity, trusted and untrusted TLS clients, local passkey +origins, Host-header/DNS-rebinding refusal, same-origin mutation and WebSocket +rules, hosted base URL preservation, opt-in flags, and mode rejection. + +The ordinary full repository gate and Docker-backed shared-roost browser battery +remain required. The shared-roost battery exercises multiple users, role paths, +ACL denial, persistent terminal replay, mobile views, and existing organization +API behavior without selecting local HTTPS. diff --git a/docs/native-sandbox-landscape.md b/docs/native-sandbox-landscape.md index f2c8fbf7..a2d7358c 100644 --- a/docs/native-sandbox-landscape.md +++ b/docs/native-sandbox-landscape.md @@ -1,5 +1,12 @@ # Native Agent Sandbox Landscape & Translation Strategy +Status: historical vendor landscape with an active translation roadmap + +Reviewed: 2026-08-27 + +Vendor details below are a dated comparison, not a current support guarantee. +Wingthing's current enforcement contract is in [the sandbox reference](sandbox.md). + ## Context Every major AI coding agent now ships built-in OS-level sandboxing. They're all converging on the same primitives wingthing already uses (seatbelt on macOS, landlock/seccomp/bwrap on Linux). This doc catalogs what each agent can and can't do natively, compares against egg.yaml's capabilities, and outlines the path toward wingthing as a sandbox orchestrator rather than a standalone sandbox. @@ -289,7 +296,8 @@ Capabilities that will likely ALWAYS need wingthing enforcement: Capabilities trending toward native: - **Filesystem isolation** — all agents handle this, with varying granularity -- **Network domain filtering** — Claude Code and Cursor are ahead of wingthing here +- **Network domain filtering** — Wingthing now enforces the same core domain-list + boundary on macOS and Linux; vendor-specific protocol coverage still varies - **Env var filtering** — Codex is ahead, others will likely follow ### Implementation Phases @@ -307,7 +315,7 @@ Capabilities trending toward native: The AI agent is a configuration, not the product. The product is: - **Sandboxed terminal sessions accessible from anywhere.** egg.yaml defines the sandbox. The thing running inside can be Claude, Cursor, Codex, bash, python, node, anything. -- **Remote access via wing/relay.** Application-encrypted payloads, wing-verified passkeys, P2P with relay fallback. See `security.md` for the exact trust boundary. +- **Remote access via wing/relay.** Application-encrypted payloads, wing-verified passkeys, direct transport where available, and an entitled relay transport. See `security.md` for the exact trust boundary. - **Audit.** Full session recording. Already built. - **Multi-user path ACLs.** Already built (v0.113.0: strict whitelist). - **Privilege broker pattern.** Give sandboxed processes access to specific APIs without exposing credentials. Already built (Slide shim pattern). diff --git a/docs/p2p_design.md b/docs/p2p_design.md index e13b289a..02e18089 100644 --- a/docs/p2p_design.md +++ b/docs/p2p_design.md @@ -1,8 +1,16 @@ # WebRTC P2P Data Channels for PTY Sessions +Status: historical implementation design for the browser PTY migration path. +Current product and trust claims live in +[the agent-manager product brief](agent-manager-product-brief.md) and +[security model](security.md). + ## Context -All PTY traffic currently round-trips through the Fly relay, even when browser and wing are on the same LAN. WebRTC data channels enable direct browser-to-wing communication, eliminating relay latency entirely for same-LAN users. The relay becomes a signaling server and fallback transport. +When this design was written, all PTY traffic round-tripped through the Fly +relay even when browser and wing were on the same LAN. The design introduced +WebRTC data channels for eligible browser sessions while retaining signaling and +an entitled relay transport. ## Architecture diff --git a/docs/preview-panel-design.md b/docs/preview-panel-design.md index e15cff85..0f6d3a17 100644 --- a/docs/preview-panel-design.md +++ b/docs/preview-panel-design.md @@ -1,15 +1,21 @@ # Preview Panel: Live App Preview in Terminal View -**Status:** Idea / design sketch -**Date:** 2026-02-18 +**Status:** Implemented; this document records the shipped contract and remaining follow-up work +**Reviewed:** 2026-08-28 --- ## The Idea -When a sandboxed agent builds a web app (or any URL-accessible artifact), it drops a well-known file inside the egg session directory. The wingthing frontend detects this file via the encrypted tunnel and renders the URL in an iframe panel alongside the terminal --- like Claude Desktop's "preview" tab, but for any agent, any app, running on your own machine. +When a sandboxed agent builds a web app (or any URL-accessible artifact), it +writes the per-session file named by `WT_PREVIEW_FILE` in its working directory. +The wing session supervisor consumes this file and sends the preview through the +encrypted terminal channel. Markdown renders immediately; a URL appears for review +and requires the user to choose **load preview** before the browser fetches it. -The user never copies a URL. The user never opens a new tab. The dashboard appears next to the conversation that created it. +The user does not have to copy a URL or open a new tab. The address and an inert +disclosure appear next to the conversation that created it; the user decides +whether the browser should load the page. ## Why This Matters @@ -28,10 +34,13 @@ With preview panel: 1. Sales rep: "track my partner's orders" 2. Sonnet builds the app, deploys it 3. Sonnet writes the well-known file -4. Dashboard appears RIGHT THERE in a split panel next to the terminal +4. A split panel shows the URL; the user reviews it and chooses **load preview** ``` -Step 4 happens automatically. No copy-paste. No context switch. The user sees their app come to life in real time as the conversation produces it. +The panel opening happens automatically. The network request does not: the user +reviews the address and chooses **load preview** or **open**. This preserves the +no-copy-paste workflow without letting a network-confined agent silently use the +attached browser as an egress path. ## The Well-Known File @@ -43,7 +52,7 @@ Step 4 happens automatically. No copy-paste. No context switch. The user sees th The agent writes this in its normal writable start directory (e.g. `~/sales/` in the Slide deployment). No sandbox changes needed --- it's already writable. The per-session suffix prevents collisions when multiple sessions share a working directory. -### Format: Two modes +### Format: Three forms The file contents determine what the preview panel shows: @@ -53,9 +62,17 @@ The file contents determine what the preview panel shows: url:https://wingthing.slide.tech/apps/sarah/order-tracker/ ``` -Frontend loads this in an iframe. The URL is displayed prominently with a copy button. This is the "here's your live dashboard" mode. +The frontend displays the URL with load, copy, and open controls. It does not load +the iframe until the user chooses **load preview**. This is the "here's your live +dashboard" mode. -**Mode 2: Markdown preview** --- anything else +**Mode 2: named text content** --- file starts with `file:` + +The remainder of the file is displayed as Markdown when the sanitized filename +has a Markdown extension, or as escaped source otherwise. A download button uses +that filename and its derived content type. + +**Mode 3: Markdown preview** --- anything else ```markdown # Backup Health Report @@ -64,25 +81,36 @@ Frontend loads this in an iframe. The URL is displayed prominently with a copy b |---------|-------------|--------| | Acme Corp | 2 hours ago | OK | | Initech | 14 hours ago | WARNING | - -![chart](https://wingthing.slide.tech/apps/sarah/chart.png) ``` -If the contents don't start with `url:`, they're treated as markdown. The frontend renders this in a sandboxed iframe --- markdown only, images allowed, **no raw HTML**. This lets agents show quick reports, tables, status summaries without deploying a whole web app. +If the contents don't start with `url:`, they're treated as markdown. The frontend +renders this in a sandboxed, network-inert iframe: raw HTML is escaped, links are +shown as text, and image syntax becomes a text label instead of a browser request. +This lets agents show quick reports, tables, and status summaries without deploying +a web app or turning the user's browser into an egress path. -**Why not JSON?** Because the agent can just `cat > .wt-preview` a markdown blob without worrying about escaping quotes in JSON. The `url:` prefix is unambiguous and trivial to detect. +**Why not JSON?** Because the agent can write a markdown blob to +`$WT_PREVIEW_DIR/$WT_PREVIEW_FILE` without escaping quotes in JSON. The `url:` +and `file:` prefixes are unambiguous and trivial to detect. ### The consume-on-read trick -**The egg process watches for `.wt-preview` and consumes it (deletes the file) as soon as it reads it.** This is the key design choice: +**The wing session supervisor watches the file named by `WT_PREVIEW_FILE` and +consumes it (deletes it) after it forwards the preview.** This is the key design +choice: -1. Agent writes `.wt-preview` in its working directory -2. Egg detects the file (fsnotify or polling the working dir) -3. Egg reads the contents, deletes the file, forwards the preview data up to the wing -4. Wing sends it through the encrypted tunnel to the frontend -5. Frontend opens the preview panel (iframe for URLs, rendered markdown otherwise) +1. Agent writes `$WT_PREVIEW_FILE` in its working directory. +2. The wing's session watcher detects the file using filesystem notifications + with bounded polling as a fallback. +3. The watcher reads and validates the file. +4. The wing encrypts the bounded preview payload, sends `pty.preview` through + the session transport. +5. After the send succeeds, the wing deletes the file. The frontend opens the + preview panel. Markdown renders without network access; + URLs wait for explicit user activation. -**The file disappearing IS the signal to the agent that the preview is showing.** The CLAUDE.md instructions say: "After you write `.wt-preview`, it will disappear --- that means the frontend picked it up and is displaying it." +**The file disappearing tells the agent that Wingthing accepted the preview.** It +does not mean the user loaded an agent-authored URL. This solves three problems at once: - **No sandbox hole needed** --- agent writes in its normal writable directory @@ -91,28 +119,35 @@ This solves three problems at once: ### Updating the preview -Agent writes `.wt-preview` again. Egg consumes it again. Frontend updates the panel. Same flow every time. Can switch between URL and markdown modes freely. +The agent writes `$WT_PREVIEW_FILE` again. The watcher consumes it and the frontend +updates the panel. Every new URL returns to the explicit-load state. ### Clearing the preview -Agent writes `.wt-preview` with just a blank line or empty content. Egg consumes it, sends null upstream, frontend closes the panel. +The agent writes `$WT_PREVIEW_FILE` with just a blank line or empty content. The +watcher consumes it and the frontend closes the panel. -## Detection: Egg Watches the Working Directory +## Detection: the wing watches the working directory -The egg process already manages the agent's working directory and PTY. Adding a file watch is natural: +The wing's session controller already manages the agent's working directory and PTY: -1. **Egg watches** the agent's working directory for `.wt-preview` creation (fsnotify or tight poll --- the egg is local, this is cheap) -2. **Egg reads + deletes** the file atomically -3. **Egg sends** the preview data to the wing process via the existing egg<->wing channel (gRPC or direct, depending on session type) -4. **Wing forwards** through the encrypted tunnel to the frontend as a new inner message type: +1. **Wing watches** the working directory for the session-specific filename. +2. **Wing reads a bounded regular file and deletes it.** The read rejects symlinks, + non-regular files, oversized content, and file swaps between inspection and open. +3. **Wing encrypts and forwards** the preview to the frontend as `pty.preview`: | Inner type | Direction | Payload | |-----------|-----------|---------| -| `preview.update` | wing -> browser | `{session_id, mode: "url", url: "..."}` or `{session_id, mode: "markdown", content: "..."}` or `{session_id, mode: null}` (close) | +| `pty.preview` | wing -> browser | encrypted `{mode: "url", url: "..."}`, `{mode: "markdown", content: "..."}`, or `{mode: null}` associated with `session_id` | -Egg parses the file: starts with `url:` → mode "url" with the URL. Otherwise → mode "markdown" with the raw content. Empty/blank → mode null (close panel). +The wing parses the file: `url:` selects validated URL mode; `file:` sends +the remaining text with a sanitized download filename and derived content type; +anything else is Markdown. Empty or blank content closes the panel. -This is a push model, not polling. The egg watches locally (fast, no tunnel overhead), and only sends a message when something changes. The frontend never polls for previews --- it just listens for `preview.update` messages on the tunnel stream. +This is a push model. The wing watches locally with filesystem notifications and +bounded polling as a fallback, and sends only when the session-specific file +changes. The frontend does not poll for previews; it listens for encrypted +`pty.preview` messages on the terminal transport. ## Frontend UI @@ -124,9 +159,9 @@ When a preview URL is active, the terminal view splits: +-------------------------------------------+ | Terminal (xterm.js) | Preview panel | | | | -| $ sonnet is typing | [Order Tracker] X | +| $ sonnet is typing | [Preview] X | | ... | https://wingth... | -| | [copy] [open] | +| | [load][copy][open] | | | +--------------+ | | | | Dashboard | | | | | content | | @@ -140,11 +175,13 @@ When a preview URL is active, the terminal view splits: The header bar above the iframe is the **main thing the user interacts with**. It must make the URL obvious and copyable --- this is a preview of a real, permanent, shareable URL. -- **Title** (from `.wt-preview`): e.g. "Order Tracker" +- **Title:** "Preview" in URL mode, or the sanitized filename in content mode. - **Full URL displayed prominently**: `https://wingthing.slide.tech/apps/sarah/order-tracker/` --- not truncated, not hidden behind a tooltip. This is a real link they can share. - **Copy button** right next to the URL. One click, URL in clipboard, brief "Copied!" confirmation. This is how they grab the link to send to a coworker, paste in Slack, bookmark, etc. -- **Open in new tab button** (external link icon). Opens the URL in a real browser tab. -- **Refresh button** to reload the iframe. +- **Load preview button.** The iframe remains network-inert until the user reviews + the address and chooses this button. Every replacement URL requires a fresh click. +- **Open in new tab button** (external link icon). Opens the URL in a real browser tab + only when the user clicks it. - **Collapse/close button** to dismiss the panel and go full-width terminal. The copy button is the star. The whole point is: agent builds it, user sees it live, user copies the URL and runs off to show someone. The preview panel is a launchpad, not a cage. @@ -160,17 +197,21 @@ Full-width terminal. No empty panel. No placeholder. The preview panel only appe ### iframe considerations (URL mode) -- Same-origin if the app is on the same domain (wingthing.slide.tech) --- works perfectly -- Cross-origin apps need appropriate CORS/X-Frame-Options headers -- The agent-built apps (Node.js behind nginx) are same-origin by default --- this just works -- For localhost URLs during development, the wing could proxy through the tunnel +- Receiving the agent message does not navigate or fetch. The user must explicitly + load each URL. +- Loaded pages run in an opaque sandbox origin. Cross-origin pages must permit iframe + embedding through their own response headers. The iframe and open link use a + no-referrer policy. +- For localhost URLs during development, the browser's localhost is not the remote + wing. A future wing-side HTTP tunnel could bridge that gap. ### Markdown rendering - Use a markdown library (marked, markdown-it, etc.) to render to HTML -- Render into a sandboxed iframe: ` +