From 2dfcc5de4715962c5571e6d0e57e8fb858db5f4c Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Tue, 20 Oct 2026 13:32:56 -0700 Subject: [PATCH] Run byre and the app on their own embedded Tailscale nodes --- .gitignore | 1 + AGENTS.md | 4 +- README.md | 8 +- TODO.md | 3 - api/openapi.yaml | 28 +- cmd/byre/check.go | 18 +- cmd/byre/install.go | 9 +- cmd/byre/serve.go | 101 +++- docs/macos.md | 17 +- docs/operations.md | 39 +- go.mod | 51 +- go.sum | 258 +++++++++- internal/agents/manager.go | 80 +-- internal/agents/preview.go | 136 ++++- internal/api/api.gen.go | 618 ++++++++++++----------- internal/server/server.go | 52 +- internal/store/preview.go | 14 +- internal/tailscale/tailscale.go | 94 ---- mac/Package.swift | 2 + mac/Sources/Byre/API.swift | 1 + mac/Sources/Byre/Chat.swift | 16 +- mac/Sources/Byre/Forwards.swift | 301 +++++++++++ mac/Sources/Byre/Panes.swift | 4 +- mac/Sources/Byre/Server.swift | 99 +++- mac/Sources/Byre/Startup.swift | 63 ++- mac/Sources/Byre/Store.swift | 13 +- mac/Sources/Byre/Tailnet.swift | 149 ++++++ mac/Sources/Byre/TranscriptBuilder.swift | 4 +- mac/bundle.sh | 21 +- 29 files changed, 1602 insertions(+), 602 deletions(-) delete mode 100644 internal/tailscale/tailscale.go create mode 100644 mac/Sources/Byre/Forwards.swift create mode 100644 mac/Sources/Byre/Tailnet.swift diff --git a/.gitignore b/.gitignore index 21d9b7d..27aeb5e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /internal/codex/testdata/transcripts/ /.preview/ /mac/.build/ +/mac/Frameworks/ /hands/.build/ diff --git a/AGENTS.md b/AGENTS.md index c8e6f36..bc9ebd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ These are requirements, not preferences. Changing one needs the owner's agreemen A deploy is a release that isn't uploaded: the same notarized disk image, installed on the owner's Macs instead of published. When asked to deploy, do exactly this, from the worktree root: 1. Run `mac/deploy.sh`, always as the last action of the turn. It runs `mac/package.sh` (the build, both notarizations and the disk image, as for a release, into `mac/.build/release//`), and installs the image's app as `/Applications/Byre.app` with `mac/install.sh`. The service runs `/Applications/Byre.app/Contents/Helpers/byre`; within 5 seconds it sees that file replaced and exits, and launchd starts the new server and `byre-hands`. Everything is signed with Developer ID, so the macOS privacy grants (Screen Recording, Accessibility, Automation) survive. -2. When you resume, confirm it came up: read the tail of `~/Library/Logs/byre.log`, and `GET /api/admin/status` through the `tailscale serve` URL (`tailscale serve status` shows it). Report what you deployed and what you checked. +2. When you resume, confirm it came up: read the tail of `~/Library/Logs/byre.log`, and `GET /api/tailnet` at the loopback address (`byre check` prints it as `loopback`), whose `state` must be `Running`. Report what you deployed and what you checked. 3. Once the service is confirmed healthy, update each client Mac whose app is running, through the client tools and without asking first: upload the disk image (`mac/.build/release//byre-.dmg`) and `mac/install.sh`, quit the app, run `install.sh `, and `open /Applications/Byre.app`. The app reads its server address from its iCloud-synced SwiftData store, so it reconnects to prod with no setup. Report each Mac's app commit (`install.sh` prints it). 4. End the report with release notes for everything on main since the last release tag (`git describe --tags --abbrev=0` names it), written for a byre user in the "Releasing" shape (`## New` and `## Fixed`, each a bullet with a bold short name and what it does or what now works; no `## Install`). Read `git diff ..HEAD`, not just the commit subjects, and leave out changes no user sees. @@ -21,7 +21,7 @@ What to expect: - The notarizations take a minute or two each; `notarytool` prints "In Progress" until "Accepted". A rejection stops the script before anything is installed; `xcrun notarytool log --keychain-profile byre-notary` says why. - You run inside byre, so the restart kills your own turn just after the install. That is expected, not a reason to skip it. You resume with a "byre restarted while you were mid-turn" message; do the checks then, and confirm the service started after your install (the log shows `executable replaced, exiting` just before it). -- The restart can cut off the record of the last tool call, so you may resume with no memory of having deployed. Before concluding that another agent restarted byre, compare the service's start time (`started_at` in `/api/admin/status`, or the last `listening` line in the log) with when you ran `mac/deploy.sh`: a restart a few minutes after it, once the notarizations are done, is most likely your install. If byre restarts without your install, another agent deployed its own branch, and the running build is theirs. +- The restart can cut off the record of the last tool call, so you may resume with no memory of having deployed. Before concluding that another agent restarted byre, compare the service's start time (the last `listening` line in the log) with when you ran `mac/deploy.sh`: a restart a few minutes after it, once the notarizations are done, is most likely your install. If byre restarts without your install, another agent deployed its own branch, and the running build is theirs. ## Releasing diff --git a/README.md b/README.md index e53aabf..8494642 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,18 @@ A server and macOS app for running many coding agents (Claude Code, Codex and An - **Workflows.** Named graphs of stages, each run as its own task, with fan-out and fan-in. - **TODO.md.** A project's `TODO.md` items show on its dashboard; start or discuss one in a click. - **Changes, Files and Terminal.** Per-task diff view with line comments sent to the agent, a read-only file browser, and a shell in the worktree. -- **Previews.** Agents expose dev servers over HTTPS through `tailscale serve`. +- **Previews.** Agents expose dev servers, and the app opens each at a local `*.localhost` address forwarded over the tailnet. - **Screen and app control.** Agents can see and drive the Mac byre runs on (screen capture, accessibility, Orion, Chrome DevTools) and the Macs you have the app open on (shell, files, the same screen tools). - **Status.** Running turns, stuck or zombie agent processes, rate-limit usage and connected clients. ## Requirements -macOS, [Tailscale](https://tailscale.com) on every Mac, and on the Mac that runs the server, `git`, the `claude` and `codex` CLIs (and for Gemini models the Antigravity CLI, `brew install --cask antigravity-cli`, signed in once by running `agy`), Node.js (agents' Playwright browser tools run through `npx`) and Google Chrome (`brew install git node --cask google-chrome`). Identity comes from Tailscale: byre only serves requests that arrive through `tailscale serve`. +macOS, a [Tailscale](https://tailscale.com) tailnet with MagicDNS and HTTPS certificates turned on (byre and the app each join it as their own node, so no Mac needs the Tailscale app), and on the Mac that runs the server, `git`, the `claude` and `codex` CLIs (and for Gemini models the Antigravity CLI, `brew install --cask antigravity-cli`, signed in once by running `agy`), Node.js (agents' Playwright browser tools run through `npx`) and Google Chrome (`brew install git node --cask google-chrome`). Identity comes from Tailscale: byre serves only the tailnet's users, as its node identifies them. ## Installing -1. Download [byre](https://dl.byre.run/byre.dmg) from [byre.run](https://byre.run) and drag `Byre.app` to Applications, on every Mac you'll use byre from. -2. On the Mac that runs the server, open byre and click **Run byre on this Mac**. It first checks for `git`, Tailscale (signed in, with HTTPS certificates turned on) and at least one of `claude`, `codex` and `agy`, and lists anything missing with how to install it. Then it installs the server as a launchd service, using your login shell's `PATH` to find `claude`, `codex`, `agy`, `git` and `npx`, serves it through `tailscale serve`, and connects to it. The Server tab shows its URL, turns it off and on, and lists the tools it found and how to install the missing ones. +1. Download [byre](https://dl.byre.run/byre.dmg) from [byre.run](https://byre.run) and drag `Byre.app` to Applications, on every Mac you'll use byre from. Open it and click **Sign in to Tailscale** to put that Mac's byre on your tailnet. +2. On the Mac that runs the server, click **Run byre on this Mac**. It first checks for `git` and at least one of `claude`, `codex` and `agy`, and lists anything missing with how to install it. Then it installs the server as a launchd service, using your login shell's `PATH` to find `claude`, `codex`, `agy`, `git` and `npx`, asks you to sign the server's own Tailscale node in, and connects to it. The Server tab shows its URL, turns it off and on, and lists the tools it found and how to install the missing ones. 3. Open byre on each other Mac; it picks up the server's URL through iCloud, or enter that URL. The app updates itself, and the server along with it on the server's Mac. To build from source instead, see "Running as a service" and "macOS app" in [docs/operations.md](docs/operations.md); that needs Go and Xcode. diff --git a/TODO.md b/TODO.md index 00089fc..3a6a8ca 100644 --- a/TODO.md +++ b/TODO.md @@ -23,8 +23,5 @@ # M8 — macOS client -- Embed teamscale - Embed the teamscale library in the server and the client. Ask ian for the detailed plan before starting. - - Phone app A native client for the phone over the same API, sharing the macOS client's SwiftUI code where it can. Every surveyed tool has one (Claude, Codex, Cursor and Conductor apps, GitHub Mobile, Superset for iPhone); it needs its own layout, since two half-width panes don't fit a phone. diff --git a/api/openapi.yaml b/api/openapi.yaml index 5b1a929..d15b25c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -720,8 +720,8 @@ paths: get: operationId: listPreviews description: > - The ports the task's agent exposed with expose_port, by name. Each is served by tailscale serve on the same - port number of byre's host, over HTTPS, until the task is merged or discarded. + The ports the task's agent exposed with expose_port, by name. Each is served on the same port number of + byre's tailnet node, over plain HTTP, until the task is merged or discarded. responses: "200": description: The previews. @@ -1139,6 +1139,19 @@ paths: application/json: schema: { $ref: "#/components/schemas/Status" } + /api/tailnet: + get: + operationId: getTailnet + description: > + The state of byre's own tailnet node. It is the only request byre answers on its loopback address, where it + needs no identity, so the app on the same Mac can sign the node in before it is reachable on the tailnet. + responses: + "200": + description: The node's state. + content: + application/json: + schema: { $ref: "#/components/schemas/Tailnet" } + /api/admin/usage-commands: get: operationId: getUsageCommands @@ -1642,9 +1655,18 @@ components: properties: task_id: { type: string } name: { type: string } - port: { type: integer, description: "A port in the task's block, served at https://:/." } + port: { type: integer, description: "A port in the task's block, served at http://:/." } created_at: { type: string, format: date-time } + Tailnet: + type: object + required: [state, auth_url, hostname, origin] + properties: + state: { type: string, description: "The node's backend state: NoState, NeedsLogin, NeedsMachineAuth, Stopped, Starting or Running." } + auth_url: { type: string, description: "The URL that signs the node in while it needs a login; empty otherwise." } + hostname: { type: string, description: The hostname the node asks the tailnet for. } + origin: { type: string, description: "byre's https origin on the tailnet, empty until the node has a certificate domain." } + Review: type: object description: An agent session on the vendor the task's agent doesn't use, reviewing one snapshot's change. diff --git a/cmd/byre/check.go b/cmd/byre/check.go index 6d09df3..b44125b 100644 --- a/cmd/byre/check.go +++ b/cmd/byre/check.go @@ -1,14 +1,11 @@ package main import ( - "context" "encoding/json" "fmt" "os" "os/exec" "strings" - - "g.fc.run/ai/byre/internal/tailscale" ) const ( @@ -30,7 +27,6 @@ type check struct { func checks() []check { return []check{ gitCheck(), - tailscaleCheck(), command("claude", needAgent, "Claude models", "curl -fsSL https://claude.ai/install.sh | bash", "Then run claude once to sign in."), command("codex", needAgent, "GPT models", "brew install --cask codex", "Then run codex once to sign in."), command("agy", needAgent, "Gemini models", "brew install --cask antigravity-cli", "Then run agy once to sign in."), @@ -47,11 +43,6 @@ func gitCheck() check { return c } -func tailscaleCheck() check { - problem := tailscale.Problem(context.Background()) - return check{Name: "Tailscale", Need: needRequired, For: "the server's HTTPS address and identity", OK: problem == "", Fix: problem} -} - func command(name, need, purpose, install, fix string) check { _, err := exec.LookPath(name) return check{Name: name, Need: need, For: purpose, OK: err == nil, Command: install, Fix: fix} @@ -79,9 +70,10 @@ func checkCmd() { cs := checks() ready := markBlocking(cs) out, err := json.Marshal(struct { - Ready bool `json:"ready"` - Checks []check `json:"checks"` - }{ready, cs}) + Ready bool `json:"ready"` + Loopback string `json:"loopback"` + Checks []check `json:"checks"` + }{ready, loopbackURL, cs}) if err != nil { fatal("encode checks", "err", err) } @@ -93,7 +85,7 @@ func requireChecks() { if markBlocking(cs) { return } - fmt.Println("byre can't run on this Mac yet. It needs git, Tailscale, and at least one of claude, codex and agy:") + fmt.Println("byre can't run on this Mac yet. It needs git and at least one of claude, codex and agy:") for _, c := range cs { if c.Blocking { fmt.Printf(" %s: %s\n", c.Name, strings.TrimSpace(c.Command+" "+c.Fix)) diff --git a/cmd/byre/install.go b/cmd/byre/install.go index c9b83a0..63d121d 100644 --- a/cmd/byre/install.go +++ b/cmd/byre/install.go @@ -1,7 +1,6 @@ package main import ( - "context" "encoding/xml" "fmt" "os" @@ -9,8 +8,6 @@ import ( "path/filepath" "strings" "syscall" - - "g.fc.run/ai/byre/internal/tailscale" ) const serviceLabel = "run.fc.byre" @@ -71,11 +68,7 @@ func installCmd() { } fmt.Println("wrote", plistPath) - url, err := tailscale.ServeRoot(context.Background(), "http://"+defaultListen) - if err != nil { - fatal("tailscale serve", "err", err) - } - fmt.Println("byre is at", url) + fmt.Println("byre's loopback address is", loopbackURL) domain := fmt.Sprintf("gui/%d", os.Getuid()) service := domain + "/" + serviceLabel diff --git a/cmd/byre/serve.go b/cmd/byre/serve.go index 300c5df..956167a 100644 --- a/cmd/byre/serve.go +++ b/cmd/byre/serve.go @@ -2,8 +2,10 @@ package main import ( "context" + "crypto/tls" "errors" "flag" + "fmt" "log/slog" "net" "net/http" @@ -17,9 +19,13 @@ import ( "g.fc.run/ai/byre/internal/server" "g.fc.run/ai/byre/internal/store" + "tailscale.com/tsnet" ) -const defaultListen = "127.0.0.1:8270" +const ( + defaultListen = "127.0.0.1:8270" + loopbackURL = "http://" + defaultListen +) func serveCmd(args []string) { configDir, err := os.UserConfigDir() @@ -29,11 +35,34 @@ func serveCmd(args []string) { flags := flag.NewFlagSet("serve", flag.ExitOnError) dataDir := flags.String("data", filepath.Join(configDir, "byre"), "directory holding byre's database, worktrees, leads and blobs") - listenAddr := flags.String("listen", defaultListen, "address to listen on") + listenAddr := flags.String("listen", defaultListen, "loopback address serving GET /api/tailnet") ports := flags.Int("ports", store.DefaultPortFirst, "first port of the tasks' port blocks") + hostname := flags.String("ts-hostname", "byre-"+macSlug(), "hostname of byre's tailnet node") flags.Parse(args) - srv, err := server.New(*dataDir, *ports) + host, _, err := net.SplitHostPort(*listenAddr) + if err != nil { + fatal("parse --listen", "err", err) + } + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + fatal("--listen must be a loopback address", "addr", *listenAddr) + } + + ts := &tsnet.Server{ + Dir: filepath.Join(*dataDir, "tsnet"), + Hostname: *hostname, + UserLogf: func(format string, args ...any) { + slog.Info("tsnet", "msg", fmt.Sprintf(format, args...)) + }, + } + err = ts.Start() + if err != nil { + fatal("start tsnet", "err", err) + } + defer ts.Close() + + srv, err := server.New(*dataDir, *ports, ts) if err != nil { fatal("start server", "err", err) } @@ -46,26 +75,76 @@ func serveCmd(args []string) { go watchExecutable(ctx, replaced) go updateDaily(ctx) - hs := &http.Server{ - Addr: *listenAddr, - Handler: srv.Handler(), - BaseContext: func(net.Listener) context.Context { return ctx }, - } + base := func(net.Listener) context.Context { return ctx } + loopback := &http.Server{Addr: *listenAddr, Handler: srv.LoopbackHandler(), BaseContext: base} + tailnet := &http.Server{Handler: srv.Handler(), BaseContext: base} go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - hs.Shutdown(shutdownCtx) + loopback.Shutdown(shutdownCtx) + tailnet.Shutdown(shutdownCtx) }() - slog.Info("listening", "addr", *listenAddr, "data", *dataDir, "ports", *ports) - err = hs.ListenAndServe() + go serveTailnet(ctx, ts, tailnet) + + slog.Info("listening", "addr", *listenAddr, "hostname", *hostname, "data", *dataDir, "ports", *ports) + err = loopback.ListenAndServe() if !errors.Is(err, http.ErrServerClosed) { fatal("serve", "err", err) } } +func serveTailnet(ctx context.Context, ts *tsnet.Server, hs *http.Server) { + st, err := ts.Up(ctx) + if ctx.Err() != nil { + return + } + if err != nil { + fatal("tsnet up", "err", err) + } + if len(st.CertDomains) == 0 { + slog.Error("tailnet has no https certificates; turn on HTTPS on the DNS page of the tailscale admin console, then restart byre") + return + } + lc, err := ts.LocalClient() + if err != nil { + fatal("tsnet local client", "err", err) + } + ln, err := ts.Listen("tcp", ":443") + if err != nil { + fatal("tsnet listen", "err", err) + } + tlsLn := tls.NewListener(ln, &tls.Config{GetCertificate: lc.GetCertificate, NextProtos: []string{"h2", "http/1.1"}}) + slog.Info("listening on tailnet", "origin", "https://"+st.CertDomains[0]) + err = hs.Serve(tlsLn) + if !errors.Is(err, http.ErrServerClosed) { + fatal("serve tailnet", "err", err) + } +} + +func macSlug() string { + out, err := exec.Command("scutil", "--get", "ComputerName").Output() + if err != nil { + fatal("computer name", "err", err) + } + slug := strings.Builder{} + dash := false + for _, r := range strings.ToLower(strings.TrimSpace(string(out))) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + if dash && slug.Len() > 0 { + slug.WriteByte('-') + } + slug.WriteRune(r) + dash = false + continue + } + dash = true + } + return slug.String() +} + func watchExecutable(ctx context.Context, replaced context.CancelFunc) { exe, err := os.Executable() if err != nil { diff --git a/docs/macos.md b/docs/macos.md index 82d5831..8091b2b 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -6,7 +6,13 @@ The macOS app under `mac/` is byre's client. The UI's behaviour, shared with the The app is a native SwiftUI client of byre's API. **No web view, under any circumstances**: no `WKWebView`, no `WebView`, no HTML rendering of any kind, in the window, in a dialog, for Markdown, for diagrams, for the terminal or for previews. A link to a web page opens in the default browser. -The app connects to byre's `tailscale serve` HTTPS URL, so its requests get the Tailscale identity headers (see "Identity" in `docs/operations.md`). It never sends identity headers itself, and never shows a login. +The app joins the tailnet as its own node and reaches byre's HTTPS origin on byre's node (see "Tailnet" below), so byre knows who is calling from the node itself (see "Identity" in `docs/operations.md`). It never sends identity headers, and its only login is signing its node in to Tailscale. + +## Tailnet + +`Tailnet.swift` runs one node per app install with libtailscale, through the C API of TailscaleKit (`tailscale_new` and the rest, from the umbrella header): hostname `byre-app-` (the slug as the server's, see "Tailnet" in `docs/operations.md`), not ephemeral, its state in `tsnet` in `run.fc.byre.mac` under Application Support. It starts at launch (`tailnetNode`, which exits the app if libtailscale can't start) and opens the node's loopback SOCKS5 proxy (`tailscale_loopback`). `API` sets that proxy, with user `tsnet` and the proxy credential, as the `proxyConfigurations` of its `URLSession`, so REST, the SSE streams (`EventStream`), the command channel (`Remote`) and terminals (`Terminal`) all go over the tailnet; only the Server tab's `GET /api/tailnet` to the local service's loopback address uses `URLSession.shared`. `Tailnet` polls the node's status (`tailscale_status_json`) every second, and until its `BackendState` is `Running` the window shows only **Sign in to Tailscale**, which opens the node's `AuthURL` in the browser. Preview forwards dial through the node too (see "Previews" in `docs/operations.md`). + +`mac/bundle.sh` builds TailscaleKit from `github.com/tailscale/libtailscale` at the commit pinned in the script (`libtailscale=`): it clones it into `~/Library/Caches/byre/build/libtailscale-`, runs its `make -C swift macos` (Go's `c-archive` build, then `xcodebuild` of the framework, unsigned) and wraps the framework with `xcodebuild -create-xcframework`, once per commit, under the build lock. It copies that into the shared build directory's `mac/Frameworks/TailscaleKit.xcframework`, which `mac/Package.swift` names as a binary target, and bundles and signs the framework in `Contents/Frameworks` like Sparkle's. To move to a newer libtailscale, change the pinned commit. ## Stack @@ -14,9 +20,9 @@ The app connects to byre's `tailscale serve` HTTPS URL, so its requests get the - AppKit only through `NSViewRepresentable`, and only where the "Gaps in SwiftUI" table below says so: the terminal. - Foundation's `URLSession` for REST, the SSE streams and the terminal WebSocket. - `UserNotifications` for notifications. -- Third-party code: Apple's `swift-openapi-generator` (with `swift-openapi-runtime` and `swift-openapi-urlsession`) for the API types and client, SwiftTerm for the terminal, and Sparkle for updates (its framework is a binary target, loaded from `Contents/Frameworks` through the `@executable_path/../Frameworks` rpath set in `mac/Package.swift`). +- Third-party code: Apple's `swift-openapi-generator` (with `swift-openapi-runtime` and `swift-openapi-urlsession`) for the API types and client, SwiftTerm for the terminal, Sparkle for updates and TailscaleKit for the tailnet (both frameworks are binary targets, loaded from `Contents/Frameworks` through the `@executable_path/../Frameworks` rpath set in `mac/Package.swift`). -The project is a Swift package under `mac/`, built with `swift build`, not an Xcode project. It builds with Xcode's toolchain: `xcode-select --print-path` must print `/Applications/Xcode.app/Contents/Developer`. With the Command Line Tools selected while Xcode is installed, Swift Build finds two toolchains for the Command Line Tools path and picks one at random on each run; the pick is part of the build request, so every change of pick rebuilds every target. `mac/bundle.sh` builds the release configuration in one directory shared by every worktree, `~/Library/Caches/byre/build`: SwiftPM keys a build on the package's absolute path, so a build in a fresh worktree, or one sharing a `--scratch-path`, recompiles every dependency. Under a `lockf` lock on `~/Library/Caches/byre/build.lock`, so builds from different worktrees take turns, it copies the worktree's `mac/`, `api/` and `hands/` there with `rsync --checksum`, which rewrites only files whose contents differ and leaves the rest's modification times alone, builds both packages, and copies the executables and Sparkle's framework out to `mac/.build/Byre`, `mac/.build/byre-update`, `mac/.build/byre-hands` and `mac/.build/Sparkle.framework`. The server is built outside the lock with `go build -o mac/.build/server/byre`, since Go's own cache is shared and safe to use concurrently. It passes `--no-color-diagnostics` because `COLOR_DIAGNOSTICS` otherwise follows whether output is a terminal and is part of the build request, so agents whose shells differ would each rebuild every target in turn; `build-request.json` under `.build/out/Intermediates.noindex/XCBuildData/` shows what a build was keyed on. It then assembles `mac/.build/Byre.app` (`Byre` in `Contents/MacOS`, the server, `byre-hands` and `byre-update` in `Contents/Helpers`, Sparkle in `Contents/Frameworks`, `mac/Info.plist` with the fixed bundle identifier `run.fc.byre.mac` and a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, which `API` sends as the `Byre-Commit` header on every request beside `Byre-Host`, the Mac's name, `CFBundleShortVersionString` from `git describe --tags --always` and `CFBundleVersion` from `git rev-list --count HEAD`, the `mac/fonts/` files and an `.icns` rendered from `mac/icon.svg`, cached as `mac/.build/byre.icns` until the SVG changes) and signs it; notification permission needs the bundle identifier. `docs/operations.md`, "macOS app", lists the signing order. +The project is a Swift package under `mac/`, built with `swift build`, not an Xcode project. It builds with Xcode's toolchain: `xcode-select --print-path` must print `/Applications/Xcode.app/Contents/Developer`. With the Command Line Tools selected while Xcode is installed, Swift Build finds two toolchains for the Command Line Tools path and picks one at random on each run; the pick is part of the build request, so every change of pick rebuilds every target. `mac/bundle.sh` builds the release configuration in one directory shared by every worktree, `~/Library/Caches/byre/build`: SwiftPM keys a build on the package's absolute path, so a build in a fresh worktree, or one sharing a `--scratch-path`, recompiles every dependency. Under a `lockf` lock on `~/Library/Caches/byre/build.lock`, so builds from different worktrees take turns, it copies the worktree's `mac/`, `api/` and `hands/` there with `rsync --checksum`, which rewrites only files whose contents differ and leaves the rest's modification times alone, builds both packages, and copies the executables and the Sparkle and TailscaleKit frameworks out to `mac/.build/Byre`, `mac/.build/byre-update`, `mac/.build/byre-hands`, `mac/.build/Sparkle.framework` and `mac/.build/TailscaleKit.framework`. The server is built outside the lock with `go build -o mac/.build/server/byre`, since Go's own cache is shared and safe to use concurrently. It passes `--no-color-diagnostics` because `COLOR_DIAGNOSTICS` otherwise follows whether output is a terminal and is part of the build request, so agents whose shells differ would each rebuild every target in turn; `build-request.json` under `.build/out/Intermediates.noindex/XCBuildData/` shows what a build was keyed on. It then assembles `mac/.build/Byre.app` (`Byre` in `Contents/MacOS`, the server, `byre-hands` and `byre-update` in `Contents/Helpers`, Sparkle and TailscaleKit in `Contents/Frameworks`, `mac/Info.plist` with the fixed bundle identifier `run.fc.byre.mac` and a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, which `API` sends as the `Byre-Commit` header on every request beside `Byre-Host`, the Mac's name, `CFBundleShortVersionString` from `git describe --tags --always` and `CFBundleVersion` from `git rev-list --count HEAD`, the `mac/fonts/` files and an `.icns` rendered from `mac/icon.svg`, cached as `mac/.build/byre.icns` until the SVG changes) and signs it; notification permission needs the bundle identifier. `docs/operations.md`, "macOS app", lists the signing order. ## Signing @@ -40,6 +46,7 @@ SwiftUI's own macros don't build here: the SDK declares `@State`, `@Entry`, `@An | `mac/Sources/Byre/` | What it is | | --- | --- | | `API.swift`, `EventStream.swift` | The generated client over `URLSession`; the SSE client | +| `Tailnet.swift`, `Forwards.swift` | The app's tailnet node and its sign-in screen; the local ports that forward previews | | `Store.swift`, `Feed.swift` | The one `@Observable` store, `handleEvent`, per-task feeds, focus | | `ByreApp.swift`, `Startup.swift` | The `App`, menus, the saved servers and their connections (`Servers`, `Connection`), the window and its server picker, the connection screen, startup sequence | | `Panes.swift`, `PaneFocus.swift` | The two panes, tab strips, More menu, pane-focus reporting | @@ -153,7 +160,7 @@ The app menu has About, Check for Updates…, Forget Server (for the server the ## Connection -On first launch, and from Add Server…, the window shows one field, Server URL (an `https` origin with no path), Connect, and Run byre on this Mac. Run byre on this Mac runs the bundle's `Contents/Helpers/byre install` through the user's login shell (`pw_shell -l -c 'exec "$BYRE_HELPER" install'`, which sh, zsh, bash and fish all accept), since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`; it takes the address from the `byre is at` line, polls `GET /api/admin/status` for up to 10 s while the service starts, then connects to it as Connect does. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is Tailscale connected?"), 401 (no Tailscale identity: use the `ts.net` HTTPS address), 403 (the request didn't come through `tailscale serve`); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is saved as a `ServerRecord` (see "Persistence") with the first task colour no other saved server uses, startup runs, and the window switches to that server; later launches start against every saved server directly. Connecting to a server whose startup failed replaces its failed connection with a new one, keeping its record and colour. Connecting to a saved server switches to it, so each server has one `Store`; worktree images (`ImageCache`) and review patches (keyed by origin) are cached per server. Each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) reloads the saved servers: it starts a connection for each new one and stops the connection of each one forgotten on another Mac, and when the window shows the connection screen with an empty field it takes the first new one, so on a new Mac the address another Mac saved connects without typing. Forget Server deletes the current server's `ServerRecord` (on every Mac, through iCloud), stops its connection and shows the connection screen with the old origin in the field. +Once the app's node runs (see "Tailnet"), on first launch and from Add Server…, the window shows one field, Server URL (an `https` origin with no path, a byre node's `ts.net` origin), Connect, and Run byre on this Mac. Run byre on this Mac runs the bundle's `byre check`, then, unless the service is already loaded, `Contents/Helpers/byre install`, both through the user's login shell (`pw_shell -l -c 'exec "$BYRE_HELPER" install'`, which sh, zsh, bash and fish all accept), since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`. It then polls `GET /api/tailnet` at the check's `loopback` address every 500 ms, failing if nothing answers for 10 s, and while the service's node isn't `Running` shows **Sign in to Tailscale** for it (opening its `auth_url`), waiting as long as that takes; a node that runs with no `origin` (the tailnet has no HTTPS certificates) stops it with how to turn them on and restart the service, which the Server tab shows too; once the node runs it takes its `origin`, polls `GET /api/admin/status` there for up to 15 s while the app's node learns of the new one, then connects to it as Connect does. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is byre running there?"), 401 (byre couldn't identify the app's node), 403 (a tagged node); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is saved as a `ServerRecord` (see "Persistence") with the first task colour no other saved server uses, startup runs, and the window switches to that server; later launches start against every saved server directly. Connecting to a server whose startup failed replaces its failed connection with a new one, keeping its record and colour. Connecting to a saved server switches to it, so each server has one `Store`; worktree images (`ImageCache`) and review patches (keyed by origin) are cached per server. Each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) reloads the saved servers: it starts a connection for each new one and stops the connection of each one forgotten on another Mac, and when the window shows the connection screen with an empty field it takes the first new one, so on a new Mac the address another Mac saved connects without typing. Forget Server deletes the current server's `ServerRecord` (on every Mac, through iCloud), stops its connection and shows the connection screen with the old origin in the field. ## Updates @@ -163,7 +170,7 @@ A Mac that runs the server but not the app is updated by `byre-update` (`mac/Sou ## Persistence -`Persistence.swift` holds the app's state in SwiftData, synced through CloudKit to the private database of container `iCloud.run.fc.byre.mac`, so it follows the iCloud account to every Mac: `ServerRecord` (one per saved server: its origin and `color`, an index into the task colours), `DraftRecord` (drafts, see "Drafts") and `VisitRecord` (one per task: when it was last selected, read by the task switcher). CloudKit has no unique constraints, so two Macs can each create a record for the same thing; readers take the oldest `ServerRecord` per origin, the newest `DraftRecord` and the latest `VisitRecord` per task; the next draft or visit write keeps one and deletes the rest, and Forget Server deletes every record for its origin. The store is `byre.store` in `run.fc.byre.mac` under Application Support (the app isn't sandboxed, so SwiftData's default `default.store` would be shared with every other unsandboxed app). It opens at launch and the app exits if it can't, which is what an unsigned build without the iCloud entitlement does. `Servers.sync` is the iCloud sync state, from the latest finished CloudKit setup, import and export, which Settings shows under iCloud sync and every connection reports to its server (`docs/operations.md`, "Storage"). Forget Server (in the app menu) flushes that server's drafts, reports pane focus false, drops its store and streams, and shows the connection screen again, its field holding the forgotten origin. Quitting does the same flush and report for every server at once, waiting up to 2 s for the reports before exiting (`applicationShouldTerminate` in `ByreApp.swift`). +`Persistence.swift` holds the app's state in SwiftData, synced through CloudKit to the private database of container `iCloud.run.fc.byre.mac`, so it follows the iCloud account to every Mac: `ServerRecord` (one per saved server: its origin and `color`, an index into the task colours), `DraftRecord` (drafts, see "Drafts") and `VisitRecord` (one per task: when it was last selected, read by the task switcher). Each load of the saved servers deletes every `ServerRecord` saved before `tailnetServersSince` in `Startup.swift` (on every Mac, through iCloud): those are `tailscale serve` origins from before byre had its own node, and servers are added again by their node's origin. Records saved later, on any Mac, are kept. CloudKit has no unique constraints, so two Macs can each create a record for the same thing; readers take the oldest `ServerRecord` per origin, the newest `DraftRecord` and the latest `VisitRecord` per task; the next draft or visit write keeps one and deletes the rest, and Forget Server deletes every record for its origin. The store is `byre.store` in `run.fc.byre.mac` under Application Support (the app isn't sandboxed, so SwiftData's default `default.store` would be shared with every other unsandboxed app). It opens at launch and the app exits if it can't, which is what an unsigned build without the iCloud entitlement does. `Servers.sync` is the iCloud sync state, from the latest finished CloudKit setup, import and export, which Settings shows under iCloud sync and every connection reports to its server (`docs/operations.md`, "Storage"). Forget Server (in the app menu) flushes that server's drafts, reports pane focus false, drops its store and streams, and shows the connection screen again, its field holding the forgotten origin. Quitting does the same flush and report for every server at once, waiting up to 2 s for the reports before exiting (`applicationShouldTerminate` in `ByreApp.swift`). ## Gaps in SwiftUI diff --git a/docs/operations.md b/docs/operations.md index b8a3f50..a77e1d5 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -12,11 +12,10 @@ - `internal/claude`: the Claude Code stream-json client (see `docs/claude-protocol.md`). - `internal/codex`: the `codex app-server` JSON-RPC client, with types generated from the CLI's schema by `go generate ./internal/codex` (see `docs/codex-protocol.md`). One goroutine reads the CLI's stdout and blocks on the bounded `Events` channel when its consumer falls behind, so responses queued behind those notifications wait too: the goroutine draining `Events` (`codexProcess.drive`) therefore never waits on a reply itself. It writes `turn/steer` in order and waits for the reply on another goroutine, and starts the prose-question nudge and the turn-end `Deliver` on their own goroutines. Every request gives up after `rpcTimeout`, or the caller's earlier deadline, and that deadline covers waiting for the write lock, writing the request to stdin and waiting for the reply. Stdin is an `os.Pipe`, so writes take a deadline. A request whose deadline passes before any of it is written fails and leaves the CLI running. One that times out after part of its line is written has broken the framing, so byre kills the CLI, logging `codex stopped reading its stdin mid-line`, and the session ends as any exited process does. - `internal/antigravity`: the Antigravity CLI (`agy`) stream-json client (see `docs/antigravity-protocol.md`). Each session has its own plugin directory and a `127.0.0.1` listener that serves byre's MCP tools and hooks to the CLI, and restarts the CLI on the same conversation after an interrupt or a model switch. -- `internal/tailscale`: `tailscale serve` for task previews (see "Previews"). - `internal/workflow`: validating workflow definitions and reading and writing their YAML files (see "Workflows"). - `hands/` is `byre-hands`, the Swift MCP server that lets task agents see and drive the Mac byre runs on (see "Screen and app control") and, bundled into the macOS app, the human's Macs (see "Client Macs"). - `internal/clients`: the WebSocket connections of the macOS apps that task agents use (see "Client Macs"). -- `mac/` is the macOS app, byre's client; `docs/macos.md` describes how it is built, and "macOS app" below covers building, installing and its storage. It connects to byre's `tailscale serve` `https` origin, typed into its connection screen. The server serves only the API; it has no web pages. +- `mac/` is the macOS app, byre's client; `docs/macos.md` describes how it is built, and "macOS app" below covers building, installing and its storage. It joins the tailnet as its own node and connects to byre's `https` origin on byre's own node (see "Tailnet"), typed into its connection screen. The server serves only the API; it has no web pages. ## Data @@ -125,11 +124,17 @@ A team task has several agents working at once in its one worktree, branch and p ## Running as a service -The listen address is `byre serve --listen`, whose default is `defaultListen` in `cmd/byre/serve.go` (`go run ./cmd/byre serve --help` prints it); keep it on loopback, since identity relies on requests arriving from `tailscale serve`. +The API is served only on byre's tailnet node (see "Tailnet"). `byre serve --listen` is a loopback address, refused otherwise, whose default is `defaultListen` in `cmd/byre/serve.go` (`go run ./cmd/byre serve --help` prints it); it answers only `GET /api/tailnet`, with no identity, and nothing else. -`byre check` (`cmd/byre/check.go`) prints, as one line of JSON, what the server needs from the Mac it runs on, found through the `PATH` it was run with: each check's name, need (`required`: `git`, counted only when `/usr/bin/git`'s Command Line Tools are installed (`xcode-select -p`), and Tailscale, which must be installed at `/Applications/Tailscale.app`, signed in (`BackendState` `Running` in `tailscale status --json`) and have HTTPS certificates (non-empty `CertDomains`); `agent`: `claude`, `codex` and `agy`, at least one of which is required; `optional`: `npx` and Google Chrome in `/Applications`), what it is for, whether it was found, the shell command that installs it (`command`, empty for Tailscale) and any further step (`fix`, such as signing in, or what is wrong with Tailscale), and whether its absence blocks the server, with `ready` true when nothing blocks. `byre install` (`cmd/byre/install.go`) runs the same checks first and, when one blocks, prints each blocking one with how to install it and exits 1 without changing anything. Otherwise it sets the service up for the binary it is run as, with `byre-hands` beside it: it writes `~/Library/LaunchAgents/run.fc.byre.plist` running ` serve`, with a `PATH` of that binary's directory (so `byre-hands` beside it is found) followed by the `PATH` of the shell it was run from (which must find `claude`, `codex`, `agy`, `curl`, `git` and `npx`, `curl` for Antigravity's hooks and `npx` for the Playwright MCP server, which also needs Google Chrome) and logs to `~/Library/Logs/byre.log`; runs `tailscale serve --bg --https=443 http://` through the Tailscale app's CLI (`tailscale.ServeRoot`, see "Previews"), printing `byre is at https://..ts.net`; and bootstraps the service. When the service is already loaded, it hands `launchctl bootout` and `bootstrap` to a detached `sh` in its own session and exits, so the new plist is read: `bootout` kills every process in the service's process groups, which includes `byre install` itself when an agent byre runs is the one running it. The service always runs from the app, `/Applications/Byre.app/Contents/Helpers/byre install`, which the app's Server tab and connection screen run for you (see "UI"). +`byre check` (`cmd/byre/check.go`) prints, as one line of JSON, the loopback URL (`loopback`, `http://`) and what the server needs from the Mac it runs on, found through the `PATH` it was run with: each check's name, need (`required`: `git`, counted only when `/usr/bin/git`'s Command Line Tools are installed (`xcode-select -p`); `agent`: `claude`, `codex` and `agy`, at least one of which is required; `optional`: `npx` and Google Chrome in `/Applications`), what it is for, whether it was found, the shell command that installs it (`command`) and any further step (`fix`, such as signing in), and whether its absence blocks the server, with `ready` true when nothing blocks. `byre install` (`cmd/byre/install.go`) runs the same checks first and, when one blocks, prints each blocking one with how to install it and exits 1 without changing anything. Otherwise it sets the service up for the binary it is run as, with `byre-hands` beside it: it writes `~/Library/LaunchAgents/run.fc.byre.plist` running ` serve`, with a `PATH` of that binary's directory (so `byre-hands` beside it is found) followed by the `PATH` of the shell it was run from (which must find `claude`, `codex`, `agy`, `curl`, `git` and `npx`, `curl` for Antigravity's hooks and `npx` for the Playwright MCP server, which also needs Google Chrome) and logs to `~/Library/Logs/byre.log`; prints `byre's loopback address is `; and bootstraps the service. When the service is already loaded, it hands `launchctl bootout` and `bootstrap` to a detached `sh` in its own session and exits, so the new plist is read: `bootout` kills every process in the service's process groups, which includes `byre install` itself when an agent byre runs is the one running it. The service always runs from the app, `/Applications/Byre.app/Contents/Helpers/byre install`, which the app's Server tab and connection screen run for you (see "UI"). -byre is served at the root of the machine's `ts.net` name on 443 (`https://..ts.net`), not under a path: the API uses absolute paths, and the macOS app takes an origin with no path. `tailscale serve status` shows the URL and what else the machine serves. `launchctl print gui/$(id -u)/run.fc.byre` shows the service and the program it runs. +byre is served at the root of its node's `ts.net` name on 443 (`https://..ts.net`), not under a path: the API uses absolute paths, and the macOS app takes an origin with no path. `curl /api/tailnet` prints the origin. `launchctl print gui/$(id -u)/run.fc.byre` shows the service and the program it runs. + +## Tailnet + +byre doesn't use the Tailscale app. `byre serve` runs its own node with `tailscale.com/tsnet` (`cmd/byre/serve.go`), its state in `/tsnet`, its hostname `--ts-hostname`, by default `byre-` (`scutil --get ComputerName`, lower-cased, each run of other characters a `-`), and its logs in byre's log as `tsnet` lines. A new node needs signing in: until it is, the log repeats its sign-in URL every few seconds, and `GET /api/tailnet` on the loopback address reports `state` `NeedsLogin` and the URL as `auth_url` (`hostname`, and `origin` once the node has a certificate domain, are the rest). The macOS app's Server tab and connection screen show it as **Sign in to Tailscale** (see "UI"). Once the node is up (`tsnet.Server.Up`), byre listens on its port 443 with TLS from the node's certificate (`local.Client.GetCertificate`) offering HTTP/2, and serves the API there. The tailnet must have MagicDNS and HTTPS certificates turned on (the DNS page of Tailscale's admin console); without certificates byre logs `tailnet has no https certificates` and serves only the loopback address until it is restarted. + +The macOS app joins as a node of its own (see "Tailnet" in `docs/macos.md`), and every byre server it uses is on the same tailnet. A new build reaches the service by replacing `/Applications/Byre.app`: `mac/deploy.sh` (see "Releases"), the app's updater, or `byre-update`. `byre serve` runs `byre-update` (found on its `PATH`, beside it in `Contents/Helpers`) when it starts and every 24 hours after, logging its output; it installs the newest release with Sparkle and no prompt unless the app is running from that bundle, which then updates itself (see "Updates" in `docs/macos.md`). `byre serve` stats its own executable (`os.Executable`) every 5 seconds (`watchExecutable` in `cmd/byre/serve.go`); once that path holds a different file, it logs `executable replaced, exiting` and shuts down as on SIGTERM, and launchd (`KeepAlive`) starts the new binary. A missing path counts as not yet replaced, so the moment in `install.sh` between removing the old bundle and moving the new one in doesn't trigger it. Logs go to `~/Library/Logs/byre.log`. @@ -163,7 +168,7 @@ byre.run and byre.team are registered at Porkbun, with Cloudflare as their DNS, `site/deploy.sh` and `mac/release.sh` read a Cloudflare API token from the generic password with service `byre-cloudflare` (`security add-generic-password -s byre-cloudflare -a cloudflare -w`, which prompts for it); it needs Account → Cloudflare Pages: Edit and Workers R2 Storage: Edit, and Zone → DNS: Edit, Single Redirect: Edit, Zone Settings: Edit and Cache Purge: Purge on both zones. byre.run's Browser Cache TTL is "Respect Existing Headers", so browsers keep the 5-minute cache on `byre.dmg` and `appcast.xml`. The account ID is in both scripts. -Clients reach `tailscale serve` over HTTP/2; it proxies to byre over HTTP/1.1 and passes SSE through unbuffered. +Clients reach byre over HTTP/2 on its tailnet node. ## macOS app @@ -198,29 +203,23 @@ Each implementer session gets a media folder, `-media` (`MediaDir` in ## Previews -An agent shows the human a server it runs with the `expose_port` MCP tool (`internal/agents/preview.go`): `name` labels it, `port` is where it listens. The agent starts the server itself, usually as a background command; byre doesn't supervise it, so it dies with the agent's `claude` process (a byre restart, compaction, the task closing) and the agent starts it again when asked. The call is refused when `port` is outside the task's block or nothing accepts a connection on `127.0.0.1:`. Otherwise byre runs `tailscale serve --bg --https= http://127.0.0.1:` (`internal/tailscale`, which runs the macOS CLI at `/Applications/Tailscale.app/Contents/MacOS/Tailscale`, since byre's environment has no `tailscale` on its `PATH`, with `TAILSCALE_BE_CLI=1`, without which the app executable launched from the service exits 0 without running the subcommand), then reads `tailscale serve status --json` and fails the call if no `Web` host for that port is listed, so the server is at `https://:/` with Tailscale's identity headers, and records a row in the `previews` table (task, name, port, time) with a `preview.exposed` event, which the transcript shows as a note linking the URL. Each port gets its own HTTPS port rather than a path under byre's, since path-prefix proxying breaks apps that use absolute paths. Exposing a name or port the task already has replaces that row, and a port it no longer uses is turned off with `tailscale serve --https= off`. A server must listen on `127.0.0.1`: while `tailscale serve` holds a port, binding `0.0.0.0` on it fails with "address already in use". When the task is merged or discarded, byre turns off each of its ports (`Manager.Unexpose`); the rows stay. `GET /api/tasks/{taskId}/previews` lists a task's rows by name, and `tailscale serve status` shows what is served. +An agent shows the human a server it runs with the `expose_port` MCP tool (`internal/agents/preview.go`): `name` labels it, `port` is where it listens. The agent starts the server itself, usually as a background command; byre doesn't supervise it, so it dies with the agent's `claude` process (a byre restart, compaction, the task closing) and the agent starts it again when asked. The call is refused when `port` is outside the task's block or nothing accepts a connection on `127.0.0.1:`. Otherwise byre listens on that port of its tailnet node (`servePreview`, a plain `tsnet.Server.Listen`, no TLS) with a reverse proxy to `127.0.0.1:` that streams responses unbuffered, passes WebSocket upgrades and the client's `Host` through, and sets `Tailscale-User-Login` and `Tailscale-User-Name` from the node's `WhoIs` of the caller, and records a row in the `previews` table (task, name, port, time) with a `preview.exposed` event, which the transcript shows as a note linking the app's local URL for it. Each port gets its own port on the node rather than a path under byre's, since path-prefix proxying breaks apps that use absolute paths. Exposing a name or port the task already has replaces that row, closing the listener of a port it no longer uses. When the task is merged or discarded, byre closes each of its listeners (`Manager.Unexpose`); the rows stay. On startup byre listens again for every active task's rows (`recoverPreviews`), since the listeners live in the process. `GET /api/tasks/{taskId}/previews` lists a task's rows by name. -The right pane's top row shows the task's previews at its right end as links, one per name, each opening its URL in the default browser (`PreviewLink` in `mac/Sources/Byre/Panes.swift`). It fetches the list when the selected task changes and on each `preview.exposed` in the task's stream. Previews are never framed inside byre. +The macOS app forwards each preview of each active task to a local port (`Forwards` in `mac/Sources/Byre/Forwards.swift`): a listener on `127.0.0.1` at the preview's port number, or any free port when that one is taken on the Mac, whose every connection is dialed to `:` through the app's node (`tailscale_dial`) and copied raw both ways, 64 KiB at a time: each direction reads more only once what it read has been written, and a failed write closes the connection. The link is `http://-.localhost:/`, a host per preview so their cookies don't mix; browsers resolve `*.localhost` to the Mac itself. The app opens a server's forwards from each active task's previews when it connects, refetches a task's list on each `preview.exposed` (opening new ones and closing those whose name or port is gone), and closes a task's forwards on `task.merged` and `task.discarded`, and all of a server's on Forget Server. The right pane's top row shows the task's previews at its right end as links, one per name, each opening its local URL in the default browser (`PreviewLink` in `mac/Sources/Byre/Panes.swift`). It fetches the list when the selected task changes and on each `preview.exposed` in the task's stream. Previews are never framed inside byre. ## Previewing byre -A task's build of byre runs alongside the service with its own empty state, from the worktree root, on the task's port, and the agent exposes it with `expose_port`: +A task's build of byre runs alongside the service with its own empty state and its own tailnet node, from the worktree root, with its loopback address on the task's port: ``` -go run ./cmd/byre serve --data "$PWD/.preview" --listen 127.0.0.1:$PORT --ports 51000 +go run ./cmd/byre serve --data "$PWD/.preview" --listen 127.0.0.1:$PORT --ports 51000 --ts-hostname byre-preview- ``` -It must never share the service's data directory: nothing locks the database, and startup resumes every session marked `running` in its recorded worktree. `--ports` puts the preview's own tasks' port blocks in a range the service's tasks don't use; each instance only knows its own tasks, so with the default both would hand out blocks from the same first port, starting with the one the preview listens on. `.preview/` is gitignored, so the end-of-turn commit leaves it out. The macOS app's Add Server… connects to its exposed URL, beside the service in the server picker. The preview starts with no users, projects or sessions; set a git identity with `PUT /api/me` and add a project to try things. A project's Merge pushes to its checkout's `origin`, so a preview project should be a clone whose `origin` is a local bare repository (`git clone --bare .preview/origin.git`, then clone that), never the real checkout. +It must never share the service's data directory: nothing locks the database, and startup resumes every session marked `running` in its recorded worktree. `--ports` puts the preview's own tasks' port blocks in a range the service's tasks don't use; each instance only knows its own tasks, so with the default both would hand out blocks from the same first port, starting with the one the preview listens on. `.preview/` is gitignored, so the end-of-turn commit leaves it out. Its node needs signing in once: `curl http://127.0.0.1:$PORT/api/tailnet` prints the sign-in URL, and then its `origin`. The macOS app's Add Server… connects to that origin, beside the service in the server picker. The preview starts with no users, projects or sessions; set a git identity with `PUT /api/me` and add a project to try things. A project's Merge pushes to its checkout's `origin`, so a preview project should be a clone whose `origin` is a local bare repository (`git clone --bare .preview/origin.git`, then clone that), never the real checkout. ## Identity -`tailscale serve` adds `Tailscale-User-Login` and `Tailscale-User-Name` to proxied requests. The server accepts API requests only from loopback and only with both headers; anything else gets 401 or 403. Users are created on first request; each sets their commit identity with `PUT /api/me`. - -To call the API locally without Tailscale, send the headers yourself: - -``` -curl -H 'Tailscale-User-Login: you@example.com' -H 'Tailscale-User-Name: You' http:///api/me -``` +Every API request arrives over byre's tailnet node, and the `identify` middleware (`internal/server/server.go`) asks the node who the caller is (`local.Client.WhoIs` of the request's remote address): the login is the owner's `UserProfile.LoginName` and the name its `DisplayName`. A caller the node can't identify gets 401, and a tagged node, which belongs to no user, gets 403. Users are created on first request; each sets their commit identity with `PUT /api/me`. There is no way to call the API other than as a tailnet user. ## Agent sessions @@ -309,7 +308,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie Task agents get notes too. An implementer session's instructions end with `taskNotesGuidance` and `notesText` for the task's creator, limited to the every-project notes and the task's project's (`startLive` in `internal/agents/manager.go`), fixed when the agent process starts like the lead's. Reviewers get none. Implementers have a `suggest_note` tool (`text`, `evidence`) that queues a lead signal for the task's creator naming the task, the suggested text, the evidence and the project id, and refuses when the creator has no live lead session. The lead's guidance tells it to save a suggestion with `remember`, choosing its project, only when it holds and isn't already covered, and to tell the human nothing either way. A suggestion leaves no record of its own beyond the signal; a saved one shows as the lead's `lead.note_saved`. - **Restarts.** On shutdown, byre marks each running lead session idle with "byre stopped mid-turn" and holds a restart notice for it, so a lead is never left `running` for a build without lead support to find. After a crash, startup resumes a running lead like a task session. Startup then delivers any held input. -The UI is two panes filling the window, with no navbar. The left has six tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Notes, the lead's notes (see "Notes" under the lead), Workflows, Status, Server and Settings, and at the end of its tab strip the connection dot and the user's name. Workflows (`mac/Sources/Byre/Workflows.swift`) lists `GET /api/workflows` by name with each description, refetching on `workflow.updated` and `workflow.deleted`; a workflow's run button doesn't start anything itself, since a run needs a project and a brief: it appends `Run the "" workflow: ` to the lead's composer draft, selects the Lead tab and focuses the composer, for the human to finish and send. Server is about the byre service on the Mac the app runs on, whatever server the app is connected to (`mac/Sources/Byre/Server.swift`), so its headings name the Mac (`Host.current().localizedName`, as in `Byre-Host`): Server on is On when launchd has the service loaded (`launchctl print gui//run.fc.byre` succeeds), showing the address from `tailscale serve status --json` and Turn off (`launchctl bootout`), and Off otherwise, showing Turn on, which runs the bundle's `byre install` as the connection screen's Run byre on this Mac does. Below that, Tools on lists every check from the bundle's `byre check` (see "Running as a service"), each Found or Missing, with how to install a missing one: its command in a box with Copy and Run in Terminal, which writes the command to a `.command` file in the temporary directory, run by the user's login shell, and opens it in Terminal, so prompts like sudo's work and the list updates when the app becomes active again; Missing is in the danger colour only when it blocks the server. Run byre on this Mac runs `byre check` first, and when something blocks it lists the missing checks in the same form instead of installing. Both Turn on and Run byre on this Mac use the running service's address instead of reinstalling when it is already on, since a reinstall restarts it and the old service could answer the first status check. The app runs both helpers through the user's login shell, so they see the `PATH` the service is given. It reads the state and checks again each time it shows and when the app becomes active. While the service is on, Agents' permissions has Check permissions, which calls `POST /api/admin/permissions` on the service at that address (see "Permissions" under "Screen and app control") and lists each grant as Granted, Missing (with Open System Settings, which opens the grant's pane) or Not checked. Below it, Usage commands has one field per vendor, empty meaning the vendor's own reports, loaded from and saved to that service's `/api/admin/usage-commands` (see "Usage" under "Status"). Settings holds the user's and this client's preferences; its This Mac's permissions has the same Check permissions for the app's own grants, the ones `client_hands` uses, running the bundle's `byre-hands check` from a copy in the temporary directory (`checkLocalPermissions` in `Server.swift`). Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Notes, Workflows, Status, Server or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Workflow and Project, always in that order. Board shows only on a team task (see "Teams") and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there. Questions and reviews have no tabs: both live in the transcript (see "Inbox" and "Reviews"), and the status in the transcript's composer bar points at what needs the user there (see "Attention" under "Inbox"). The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled; while the task is `discard_suggested` it opens the Discard confirmation instead. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open. +The UI is two panes filling the window, with no navbar. The left has six tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Notes, the lead's notes (see "Notes" under the lead), Workflows, Status, Server and Settings, and at the end of its tab strip the connection dot and the user's name. Workflows (`mac/Sources/Byre/Workflows.swift`) lists `GET /api/workflows` by name with each description, refetching on `workflow.updated` and `workflow.deleted`; a workflow's run button doesn't start anything itself, since a run needs a project and a brief: it appends `Run the "" workflow: ` to the lead's composer draft, selects the Lead tab and focuses the composer, for the human to finish and send. Server is about the byre service on the Mac the app runs on, whatever server the app is connected to (`mac/Sources/Byre/Server.swift`), so its headings name the Mac (`Host.current().localizedName`, as in `Byre-Host`): Server on is On when launchd has the service loaded (`launchctl print gui//run.fc.byre` succeeds), showing Turn off (`launchctl bootout`) and, from `GET /api/tailnet` at the `loopback` address `byre check` prints, the service's `origin` once its node is `Running`, or else **Sign in to Tailscale** for the service's node, which opens its `auth_url`, polling every 2 s until the node runs; Off otherwise, showing Turn on, which runs the bundle's `byre install` as the connection screen's Run byre on this Mac does. Below that, Tools on lists every check from the bundle's `byre check` (see "Running as a service"), each Found or Missing, with how to install a missing one: its command in a box with Copy and Run in Terminal, which writes the command to a `.command` file in the temporary directory, run by the user's login shell, and opens it in Terminal, so prompts like sudo's work and the list updates when the app becomes active again; Missing is in the danger colour only when it blocks the server. Run byre on this Mac runs `byre check` first, and when something blocks it lists the missing checks in the same form instead of installing. Both Turn on and Run byre on this Mac leave the service running instead of reinstalling when it is already on, since a reinstall restarts it and the old service could answer the first status check. The app runs both helpers through the user's login shell, so they see the `PATH` the service is given. It reads the state and checks again each time it shows and when the app becomes active. While the service is on, Agents' permissions has Check permissions, which calls `POST /api/admin/permissions` on the service at that address (see "Permissions" under "Screen and app control") and lists each grant as Granted, Missing (with Open System Settings, which opens the grant's pane) or Not checked. Below it, Usage commands has one field per vendor, empty meaning the vendor's own reports, loaded from and saved to that service's `/api/admin/usage-commands` (see "Usage" under "Status"). Settings holds the user's and this client's preferences; its This Mac's permissions has the same Check permissions for the app's own grants, the ones `client_hands` uses, running the bundle's `byre-hands check` from a copy in the temporary directory (`checkLocalPermissions` in `Server.swift`). Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Notes, Workflows, Status, Server or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Workflow and Project, always in that order. Board shows only on a team task (see "Teams") and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there. Questions and reviews have no tabs: both live in the transcript (see "Inbox" and "Reviews"), and the status in the transcript's composer bar points at what needs the user there (see "Attention" under "Inbox"). The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled; while the task is `discard_suggested` it opens the Discard confirmation instead. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open. ## TODO @@ -492,4 +491,4 @@ A workflow is a named graph of stages, each a stage name, the stages it comes af The macOS app keeps two streams (`EventStream` in `mac/Sources/Byre/EventStream.swift`). The global one (`startGlobalStream` in `mac/Sources/Byre/Store.swift`), opened after the `last_event_seq` of `GET /api/admin/status`, carries every event the user may see from the app's startup onward; `handleEvent` (`Store.swift`) drives the task, session, inbox and lead session lists, the right pane's focus and the Status tab from it, and hands each event with a `task_id` to that task's feed. The lead's (`startLeadStream`) first fetches the last 50 turns from `GET /api/me/lead/events`, retrying every 2s until it succeeds, then opens with `?lead=true` and `after` the last seq of that page, replays what arrived since and stays live. There is no per-task stream: selecting a task opens nothing. Only the global stream drives the connection dot. -The app keeps every active task's events in memory, one `Feed` (`mac/Sources/Byre/Feed.swift`) per task (`taskFeeds` in `Store.swift`), so switching tasks makes no request. Each `loadTasks` creates a feed for any active task without one, which pages through `GET /api/tasks/{taskId}/events` (1000 at a time) for its history; selecting a task that has no feed (a closed one) does the same. Live events reach every feed through the global stream; a feed holds back what arrives while its history loads and drops the duplicates by `seq`. A task feed keeps unstored events (`agent.delta`) only while its history has loaded and its task is selected, and passes them straight to the transcript's streaming view without storing them; a stored event is drawn only from the feed, so a reconnect after a byre restart fills in what was missed through the global stream's replay. Feeds of closed tasks are dropped once they aren't selected. A failed history load is logged and leaves the feed waiting for its history; the next `loadTasks` or selection of that task loads it again into the same feed. A feed takes its history, then the live events held back during the load, skipping each event whose `seq` is at or below the last one it kept, so an event in both is kept once. The lead's feed holds back what its `?lead=true` stream sends until that stream's first `ready`, then takes the fetched page and those events together. Either way the history reaches the transcript at once, so it draws in one pass, already scrolled to the bottom; until then the transcript shows "loading…". `EventStream` tracks the last seq it saw and opens a new stream from it when nothing (event or ping) has arrived for 40s, 2s after one fails (as it does when `tailscale serve` answers 502 while byre restarts), when the network comes back, and on the app becoming active if the stream is stale. While disconnected the left pane's tab strip shows a red dot and "reconnecting…"; partial output (`agent.delta`) sent while disconnected is lost, and the finished `agent.message` replaces it. A feed collects live events and flushes them on the next main-actor turn, so events arriving together are drawn in one batch. +The app keeps every active task's events in memory, one `Feed` (`mac/Sources/Byre/Feed.swift`) per task (`taskFeeds` in `Store.swift`), so switching tasks makes no request. Each `loadTasks` creates a feed for any active task without one, which pages through `GET /api/tasks/{taskId}/events` (1000 at a time) for its history; selecting a task that has no feed (a closed one) does the same. Live events reach every feed through the global stream; a feed holds back what arrives while its history loads and drops the duplicates by `seq`. A task feed keeps unstored events (`agent.delta`) only while its history has loaded and its task is selected, and passes them straight to the transcript's streaming view without storing them; a stored event is drawn only from the feed, so a reconnect after a byre restart fills in what was missed through the global stream's replay. Feeds of closed tasks are dropped once they aren't selected. A failed history load is logged and leaves the feed waiting for its history; the next `loadTasks` or selection of that task loads it again into the same feed. A feed takes its history, then the live events held back during the load, skipping each event whose `seq` is at or below the last one it kept, so an event in both is kept once. The lead's feed holds back what its `?lead=true` stream sends until that stream's first `ready`, then takes the fetched page and those events together. Either way the history reaches the transcript at once, so it draws in one pass, already scrolled to the bottom; until then the transcript shows "loading…". `EventStream` tracks the last seq it saw and opens a new stream from it when nothing (event or ping) has arrived for 40s, 2s after one fails (as it does while byre restarts), when the network comes back, and on the app becoming active if the stream is stale. While disconnected the left pane's tab strip shows a red dot and "reconnecting…"; partial output (`agent.delta`) sent while disconnected is lost, and the finished `agent.message` replaces it. A feed collects live events and flushes them on the next main-actor turn, so events arriving together are drawn in one batch. diff --git a/go.mod b/go.mod index fc5e5e1..ddfa650 100644 --- a/go.mod +++ b/go.mod @@ -1,39 +1,78 @@ module g.fc.run/ai/byre -go 1.26.0 +go 1.27.3 require ( github.com/coder/websocket v1.8.15 github.com/creack/pty v1.1.24 github.com/getkin/kin-openapi v0.142.0 github.com/oapi-codegen/runtime v1.7.0 - go.yaml.in/yaml/v3 v3.0.4 + go.yaml.in/yaml/v3 v3.0.5 modernc.org/sqlite v1.60.1 + tailscale.com v1.104.2 ) require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/creachadair/msync v0.10.1 // indirect + github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-openapi/jsonpointer v0.23.1 // indirect - github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.3 // indirect + github.com/gaissmai/bart v0.29.0 // indirect + github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/jsimonetti/rtnetlink v1.4.2 // indirect + github.com/klauspost/compress v1.20.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mdlayher/netlink v1.11.2 // indirect + github.com/mdlayher/socket v0.7.0 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect github.com/oasdiff/yaml v0.1.1 // indirect github.com/oasdiff/yaml3 v0.0.14 // indirect + github.com/pires/go-proxyproto v0.15.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/safchain/ethtool v0.7.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/speakeasy-api/jsonpath v0.6.3 // indirect github.com/speakeasy-api/openapi v1.24.0 // indirect + github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20260917222731-e0ed2d0d0fea // indirect + github.com/tailscale/wireguard-go v0.0.0-20260928213032-417aef361226 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + github.com/x448/float16 v0.8.4 // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/oauth2 v0.37.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/term v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.50.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v1.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gvisor.dev/gvisor v0.0.0-20260915211658-a6f909f08a72 // indirect modernc.org/libc v1.77.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect diff --git a/go.sum b/go.sum index 420959d..1833add 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,84 @@ +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= +filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/aws/aws-sdk-go-v2 v1.46.0 h1:1kt7m/EKcEHt5mlyyxx9cSlMddRPIKbjb6DIQsu4HPk= +github.com/aws/aws-sdk-go-v2 v1.46.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2/config v1.33.3 h1:h090b3O5S17bF87/0ysHZuIT/7DCb4EBRFQX2PMVPCw= +github.com/aws/aws-sdk-go-v2/config v1.33.3/go.mod h1:YYDB1kTejxbfAbEVUqgCtkVp26xvNCHev9cLKABMGAk= +github.com/aws/aws-sdk-go-v2/credentials v1.20.3 h1:tToOYM/LXev4NpfWlIYGDvBvjHmJ3HXpRU9ppl+pM6k= +github.com/aws/aws-sdk-go-v2/credentials v1.20.3/go.mod h1:wfGneWyncO7p67wqXV2IQhPk14JqIc25woKlaArT3WI= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.2 h1:Ldv7RPHs7qwwTscRjAl3YBud32f3BvdAGRmSvAx5L38= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.2/go.mod h1:XyK6UV8xbo66ysVqLd2783C09pBYHOm8aKTRV5DVJ30= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.2 h1:q/PSLGuRWCChWg+dLnb9dWOnrCxJtnboXbBtFoqqRrI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.2/go.mod h1:TD1jvU2LvXkJexct5vBqcd8QlNXh5EmRUeL/Z32p0n4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.2 h1:6fl86IPqKEXoySqiOWdfgbEp9OVbn44zTfEICNEBDhY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.2/go.mod h1:63HDfhFkdzBpI8WGXTSKUHPKS6mqldj4u3LJW7RZtSU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.2 h1:XMgIRS+uW9F3yFKnXGRrI9pkHi99CXTmoz2kz2/TGBA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.2/go.mod h1:vorxDzK+n3jiv9a5ST/LG0Eu9cSv1CRdKTpG6pDMs+M= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.2 h1:ZtHYnumr6QyxhzEzNZwzQTFJEXOswrZqTTkRxthwvr4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.2/go.mod h1:a1NXrYpBd311gBzn1UI5UzJyyvXktM4xNh/ydPiPpqY= +github.com/aws/aws-sdk-go-v2/service/signin v1.9.0 h1:c3k+k/CS4L+sAIH6fxikL+g5g2LpeNczaoyjjw1iMKI= +github.com/aws/aws-sdk-go-v2/service/signin v1.9.0/go.mod h1:AGIoQg99fBrOIQnF78TLx4lj18mc4gZ0hJx1UaLIFM4= +github.com/aws/aws-sdk-go-v2/service/ssm v1.77.0 h1:JqKSBJlS68F4nxoPLwD7tB3lJ3gWEK9p+7eOiBE/lsM= +github.com/aws/aws-sdk-go-v2/service/ssm v1.77.0/go.mod h1:5TMMHmQGjCODMeIopSNfxkan11jG0SmFpyjcMvCLzHA= +github.com/aws/aws-sdk-go-v2/service/sso v1.37.0 h1:+rqBaOq7jzInjY8M12hr+zEe85JpRll9BjMx38r33Ok= +github.com/aws/aws-sdk-go-v2/service/sso v1.37.0/go.mod h1:XFlVwUsw3sYh8Hw37umYVJnrcWrwXWRxbNw/JY0bblw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.42.0 h1:hzM3GslEAOBcLn3DHH6ENToFi+vXP+n02W+x6zejAIM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.42.0/go.mod h1:588e7skMkYIYkSUseT8E3WKFfbAfrA1bj3Zf+qxJtJY= +github.com/aws/aws-sdk-go-v2/service/sts v1.49.0 h1:N7Ey8obY3uSui+cxl0OUzFlFmkxSucoJnrniFhw+cLc= +github.com/aws/aws-sdk-go-v2/service/sts v1.49.0/go.mod h1:zMBwjSf4Pt8a1OHYiZ5rPD0PJRK1kQrUaxhS/Dbld8E= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/axiomhq/hyperloglog v0.2.6 h1:sRhvvF3RIXWQgAXaTphLp4yJiX4S0IN3MWTaAgZoRJw= +github.com/axiomhq/hyperloglog v0.2.6/go.mod h1:YjX/dQqCR/7QYX0g8mu8UZAjpIenz1FKM71UEsjFoTo= +github.com/benbjohnson/immutable v0.4.3 h1:GYHcksoJ9K6HyAUpGxwZURrbTkXA0Dh4otXGqbhdrjA= +github.com/benbjohnson/immutable v0.4.3/go.mod h1:qJIKKSmdqz1tVzNtst1DZzvaqOU1onk1rc03IeM3Owk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/bradfitz/reco v0.0.0-20260929154613-b883fbd17e3f h1:jdXGSp5Ge5/kzikuxGOw9+6HSStUjkNBJZjGNtpItek= +github.com/bradfitz/reco v0.0.0-20260929154613-b883fbd17e3f/go.mod h1:oiCPXxBoDKSkb/RGEZwAZk8y/fwDlQ1vYlBbNRxPysw= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY= +github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc= +github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/creachadair/mds v0.31.0 h1:h4SXXgallvZfD8CFQnQNAOtBkUGpZjPwNBRQpHpHM/g= +github.com/creachadair/mds v0.31.0/go.mod h1:IZ3oLffgVNZ4O5WXTPxx7EX5ilSl1ypdv5FekYyynyI= +github.com/creachadair/msync v0.10.1 h1:14jT5Ujob64Zp2D3ZcUidayDMoXikhmYN8xBxEuPKPk= +github.com/creachadair/msync v0.10.1/go.mod h1:dFf4Z0cxE2nibFmAdq5OqTuGn++GgEqA9JuKH+7ceSA= +github.com/creachadair/taskgroup v0.14.4 h1:ttR8StLWmYA1O6x96YlTpQLXjKyRpO4RBo+OcO6W6C0= +github.com/creachadair/taskgroup v0.14.4/go.mod h1:uhCtIEsa7zpeMAFixddhCYjbJi7e4JMyU7OXUygkSsg= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca h1:h1Awca4lQOspNR/2eeo04Ricn5NixDX9mb17WSAgLhQ= +github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca/go.mod h1:2TGl1jRJrRpbzykmg7asHm3h08TqutUgQqY5v9k/g3c= +github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM= +github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= @@ -20,18 +86,37 @@ github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aT github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= +github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gaissmai/bart v0.29.0 h1:wO6HGE8g9YE0Wm0bCpYxwRzfQ4+fbJKOhL64e5ACGCI= +github.com/gaissmai/bart v0.29.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w= github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= -github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= -github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= -github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= -github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= -github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= -github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go4org/hashtriemap v0.0.0-20260925222741-44e5305f85d9 h1:J8S+Ms89nvHX1F9Ma2m3KhdTl41ZhbFKMyAJqvDA+iE= +github.com/go4org/hashtriemap v0.0.0-20260925222741-44e5305f85d9/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -41,22 +126,49 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg= +github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= +github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= +github.com/insomniacslk/dhcp v0.0.0-20260901064844-234b97448fae h1:nXGg65fXsylSUTNNWwvHuQsXev7mhIzQTnZREPTbWzs= +github.com/insomniacslk/dhcp v0.0.0-20260901064844-234b97448fae/go.mod h1:tGfUTcnFYGYvVNCaZZhwlJySU/fQQxh9TmpsFzWXnnY= +github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= +github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= +github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90= +github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kamstrup/intmap v0.5.2 h1:qnwBm1mh4XAnW9W9Ue9tZtTff8pS6+s6iKF6JRIV2Dk= +github.com/kamstrup/intmap v0.5.2/go.mod h1:gWUVWHKzWj8xpJVFf5GC0O26bWmv3GqdnIX/LMT6Aq4= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -66,8 +178,24 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o= +github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA= +github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI= +github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA= +github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= +github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= +github.com/mdlayher/socket v0.7.0 h1:qVREPVwtUMg17pwvveQxpurq0PVisMxi3FGgpsorMYQ= +github.com/mdlayher/socket v0.7.0/go.mod h1:f7iKql2EK/rfsWYDKofKjk2Ig7I+6HQWdMIdn1tO4A4= +github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= +github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= @@ -93,17 +221,28 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= +github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= +github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= +github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8= +github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/safchain/ethtool v0.7.0 h1:rlJzfDetsVvT61uz8x1YIcFn12akMfuPulHtZjtb7Is= +github.com/safchain/ethtool v0.7.0/go.mod h1:MenQKEjXdfkjD3mp2QdCk8B/hwvkrlOTm/FD4gTpFxQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04= @@ -113,16 +252,62 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ= +github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28 h1:Azz5ILxxVsHN/KjIu3wkJPAmmtiijucZw4Ax5Ye8n+s= +github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/golang-x-crypto v0.0.0-20260720160339-c86ca1284b96 h1:m9EAsCu/afUkg9YkknXaRqMPl/omkUNRol5yB7hbG0Y= +github.com/tailscale/golang-x-crypto v0.0.0-20260720160339-c86ca1284b96/go.mod h1:NC3xRCu4UR+m4n6ix8b6oLLbHa820Y0StbOQEdWTDo0= +github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f h1:9hiVElpCmKzsBKQHkBqZ8LGzt82iLfM8egxr4sew+Ys= +github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f/go.mod h1:8/zr1Tv0+cKpVtGCEB/7YfRXr2TszsMxMXLaT8YuBgU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= +github.com/tailscale/web-client-prebuilt v0.0.0-20260917222731-e0ed2d0d0fea h1:ah5jt6ZnxMvNclt6kBBoLcMpLLjMA44PTsnov2y7gpU= +github.com/tailscale/web-client-prebuilt v0.0.0-20260917222731-e0ed2d0d0fea/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= +github.com/tailscale/wireguard-go v0.0.0-20260928213032-417aef361226 h1:v3Lpj2iHPWQDqeCwemQPz4fWweIEMLqBkwJqCjRyJQc= +github.com/tailscale/wireguard-go v0.0.0-20260928213032-417aef361226/go.mod h1:rUelGmuK4UnSJYM5gl5Mknp6YbwwcL8+VAPMhNYe+jg= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= +github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4= +github.com/tc-hib/winres v0.3.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/u-root/u-root v0.16.0 h1:wY40O83MBVks97+Is0WlFlOPSwKQMIrWP9R1IsrExg8= +github.com/u-root/u-root v0.16.0/go.mod h1:yL/XdSSW27PdGLgUh4MNRBy54mKM+TBLzpwiB4nwj90= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs= +go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= +golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f h1:+lI8cDJ4uceLipg2f1ODay7fEuLkk0BIHXd6PB8icxo= +golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo= +golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= +golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= @@ -135,9 +320,12 @@ golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -158,12 +346,16 @@ golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= @@ -173,6 +365,12 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8= +golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -181,6 +379,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -198,6 +398,12 @@ gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gvisor.dev/gvisor v0.0.0-20260915211658-a6f909f08a72 h1:EytKYr5WrVs+Aah/0xDO8ZAZZzS/iTEVC8ln4ipDbL0= +gvisor.dev/gvisor v0.0.0-20260915211658-a6f909f08a72/go.mod h1:8aLQqUBHDH8fY5y60lzmwDpMMbQCcT3EBfoSwhfaGCY= +honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w= +honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= @@ -226,3 +432,7 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU= +software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +tailscale.com v1.104.2 h1:zBbSW0tI4Xi2PhrZEl9yt6oWay3l8A+M8scsTnZpyAk= +tailscale.com v1.104.2/go.mod h1:D+gqEyJjFnwIaojBLaKxUbOfEO3cRXOaFaOZ5BIvYqs= diff --git a/internal/agents/manager.go b/internal/agents/manager.go index 1d5dfb0..2d89d77 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -22,6 +22,7 @@ import ( "g.fc.run/ai/byre/internal/store" "g.fc.run/ai/byre/internal/terminal" "g.fc.run/ai/byre/internal/workflow" + "tailscale.com/tsnet" ) var ( @@ -45,6 +46,7 @@ type Manager struct { addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error) clients *clients.Registry terminals *terminal.Terminals + tailnet *tsnet.Server mu sync.Mutex live map[string]*live @@ -82,6 +84,9 @@ type Manager struct { workflowMu sync.Mutex + previewMu sync.Mutex + previewListeners map[int]*previewListener + bad *badLines watchStop chan struct{} } @@ -130,41 +135,43 @@ func (l *live) implementer() bool { return l.owner == "" && l.reviewID == "" } -func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome, antigravityRoot string, status func(context.Context) (api.Status, error), addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error), clientRegistry *clients.Registry, terminals *terminal.Terminals) *Manager { +func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome, antigravityRoot string, status func(context.Context) (api.Status, error), addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error), clientRegistry *clients.Registry, terminals *terminal.Terminals, tailnet *tsnet.Server) *Manager { m := &Manager{ - store: st, - worktreeRoot: worktreeRoot, - leadRoot: leadRoot, - handsRoot: handsRoot, - codexHome: codexHome, - antigravityRoot: antigravityRoot, - status: status, - addProject: addProject, - clients: clientRegistry, - terminals: terminals, - live: map[string]*live{}, - starting: map[string]*start{}, - deliverLocks: map[string]*sessionLock{}, - rateLimits: map[api.AgentSessionVendor]api.Usage{}, - usageCommands: map[api.AgentSessionVendor]string{}, - usageMu: map[api.AgentSessionVendor]*sync.Mutex{api.Claude: {}, api.Codex: {}, api.Antigravity: {}}, - leadAnswered: map[string]bool{}, - paneFocus: map[string]api.PaneFocus{}, - paneReported: map[string]time.Time{}, - paneMoved: map[string]time.Time{}, - humanMoved: map[string]time.Time{}, - moveRefused: map[string]bool{}, - startedTodo: map[string]time.Time{}, - startRefused: map[string]bool{}, - finishing: map[string]bool{}, - handoffModel: map[string]string{}, - mergeTurns: map[string]mergeTurn{}, - landing: map[string]landing{}, - signals: make(chan []api.Event, 1024), - snaps: map[string]*snapState{}, - merges: map[string]*projectMerge{}, - bad: newBadLines(), - watchStop: make(chan struct{}), + store: st, + worktreeRoot: worktreeRoot, + leadRoot: leadRoot, + handsRoot: handsRoot, + codexHome: codexHome, + antigravityRoot: antigravityRoot, + status: status, + addProject: addProject, + clients: clientRegistry, + terminals: terminals, + tailnet: tailnet, + live: map[string]*live{}, + starting: map[string]*start{}, + deliverLocks: map[string]*sessionLock{}, + rateLimits: map[api.AgentSessionVendor]api.Usage{}, + usageCommands: map[api.AgentSessionVendor]string{}, + usageMu: map[api.AgentSessionVendor]*sync.Mutex{api.Claude: {}, api.Codex: {}, api.Antigravity: {}}, + leadAnswered: map[string]bool{}, + paneFocus: map[string]api.PaneFocus{}, + paneReported: map[string]time.Time{}, + paneMoved: map[string]time.Time{}, + humanMoved: map[string]time.Time{}, + moveRefused: map[string]bool{}, + startedTodo: map[string]time.Time{}, + startRefused: map[string]bool{}, + finishing: map[string]bool{}, + handoffModel: map[string]string{}, + mergeTurns: map[string]mergeTurn{}, + landing: map[string]landing{}, + signals: make(chan []api.Event, 1024), + snaps: map[string]*snapState{}, + merges: map[string]*projectMerge{}, + previewListeners: map[int]*previewListener{}, + bad: newBadLines(), + watchStop: make(chan struct{}), } go m.signalLoop() go m.loadUsage() @@ -349,6 +356,11 @@ func (m *Manager) Recover(ctx context.Context) error { return err } + err = m.recoverPreviews(ctx) + if err != nil { + return err + } + stale, err := m.store.SessionsWithSubagents(ctx) if err != nil { return err diff --git a/internal/agents/preview.go b/internal/agents/preview.go index 5a0adfa..97636c6 100644 --- a/internal/agents/preview.go +++ b/internal/agents/preview.go @@ -7,15 +7,17 @@ import ( "fmt" "log/slog" "net" + "net/http" + "net/http/httputil" "strconv" "strings" + "sync" "time" "unicode/utf8" "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/mcp" "g.fc.run/ai/byre/internal/store" - "g.fc.run/ai/byre/internal/tailscale" ) const maxPreviewName = 40 @@ -34,7 +36,7 @@ func (m *Manager) exposePortTool(l *live) mcp.Tool { Name: "expose_port", Description: "Show the human a server you are running, such as a dev server, as a link above the task's tabs that opens it in a new window. " + "Start the server yourself first (in the background), listening on 127.0.0.1 (not 0.0.0.0 or localhost) on a port in this task's block. " + - "byre then serves it over HTTPS on the same port of byre's host through tailscale serve, and returns the URL. " + + "byre then serves it on the same port of byre's tailnet node, and the human's app opens it through a local link. " + "name labels it in the tab (\"web\", \"storybook\"); exposing a name or port again replaces the earlier one. " + "The server stops when your process does (a byre restart, compaction, the task closing); the human sees it as unreachable until you start it again.", InputSchema: json.RawMessage(`{ @@ -79,26 +81,138 @@ func (m *Manager) exposePort(ctx context.Context, l *live, raw json.RawMessage) } conn.Close() - err = tailscale.Serve(ctx, args.Port) + err = m.servePreview(args.Port) if err != nil { return "", err } dropped, err := m.store.SetPreview(ctx, api.Preview{TaskId: task.Id, Name: name, Port: args.Port, CreatedAt: time.Now().UTC()}, agentActor) if errors.Is(err, store.ErrNotFound) { + m.stopPreview(args.Port) return "", errors.New("this task is no longer active") } if err != nil { return "", err } for _, port := range dropped { - err = tailscale.Off(ctx, port) - if err != nil { - slog.Error("stop serving replaced preview", "task", task.Id, "port", port, "err", err) - } + m.stopPreview(port) } - return fmt.Sprintf("Exposed %q: https://:%d/ proxies to %s, and the human sees a link to it above the task's tabs.", name, args.Port, addr), nil + return fmt.Sprintf("Exposed %q: port %d of byre's tailnet node proxies to %s, and the human sees a link to it above the task's tabs.", name, args.Port, addr), nil +} + +func (m *Manager) servePreview(port int) error { + m.stopPreview(port) + ln, err := m.tailnet.Listen("tcp", ":"+strconv.Itoa(port)) + if err != nil { + return fmt.Errorf("listen on the tailnet on port %d: %w", port, err) + } + lc, err := m.tailnet.LocalClient() + if err != nil { + ln.Close() + return err + } + tracked := &previewListener{Listener: ln, conns: map[net.Conn]bool{}} + m.previewMu.Lock() + m.previewListeners[port] = tracked + m.previewMu.Unlock() + + target := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + proxy := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.Out.URL.Scheme = "http" + pr.Out.URL.Host = target + }, + FlushInterval: -1, + } + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + who, err := lc.WhoIs(r.Context(), r.RemoteAddr) + if err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + r.Header.Set("Tailscale-User-Login", who.UserProfile.LoginName) + r.Header.Set("Tailscale-User-Name", who.UserProfile.DisplayName) + proxy.ServeHTTP(w, r) + }) + go func() { + err := (&http.Server{Handler: handler}).Serve(tracked) + if !errors.Is(err, net.ErrClosed) { + slog.Error("serve preview", "port", port, "err", err) + } + }() + return nil +} + +func (m *Manager) stopPreview(port int) { + m.previewMu.Lock() + ln := m.previewListeners[port] + delete(m.previewListeners, port) + m.previewMu.Unlock() + if ln != nil { + ln.closeAll() + } +} + +type previewListener struct { + net.Listener + mu sync.Mutex + conns map[net.Conn]bool + closed bool +} + +func (l *previewListener) Accept() (net.Conn, error) { + c, err := l.Listener.Accept() + if err != nil { + return nil, err + } + l.mu.Lock() + defer l.mu.Unlock() + if l.closed { + c.Close() + return nil, net.ErrClosed + } + tc := &previewConn{Conn: c, l: l} + l.conns[tc] = true + return tc, nil +} + +func (l *previewListener) closeAll() { + l.Listener.Close() + l.mu.Lock() + l.closed = true + conns := l.conns + l.conns = map[net.Conn]bool{} + l.mu.Unlock() + for c := range conns { + c.Close() + } +} + +type previewConn struct { + net.Conn + l *previewListener +} + +func (c *previewConn) Close() error { + c.l.mu.Lock() + delete(c.l.conns, c) + c.l.mu.Unlock() + return c.Conn.Close() +} + +func (m *Manager) recoverPreviews(ctx context.Context) error { + previews, err := m.store.ActivePreviews(ctx) + if err != nil { + return err + } + for _, p := range previews { + err = m.servePreview(p.Port) + if err != nil { + slog.Error("serve preview", "task", p.TaskId, "port", p.Port, "err", err) + } + } + return nil } func (m *Manager) Unexpose(ctx context.Context, taskID string) { @@ -108,11 +222,7 @@ func (m *Manager) Unexpose(ctx context.Context, taskID string) { return } for _, p := range previews { - err = tailscale.Off(ctx, p.Port) - if err != nil { - slog.Error("stop serving preview", "task", taskID, "port", p.Port, "err", err) - continue - } + m.stopPreview(p.Port) slog.Info("stopped serving preview", "task", taskID, "name", p.Name, "port", p.Port) } } diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index b2055b4..b6a788b 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -1087,7 +1087,7 @@ type Preview struct { CreatedAt time.Time `json:"created_at"` Name string `json:"name"` - // Port A port in the task's block, served at https://:/. + // Port A port in the task's block, served at http://:/. Port int `json:"port"` TaskId string `json:"task_id"` } @@ -1302,6 +1302,21 @@ type SummaryRead struct { Seq int64 `json:"seq"` } +// Tailnet defines model for Tailnet. +type Tailnet struct { + // AuthUrl The URL that signs the node in while it needs a login; empty otherwise. + AuthUrl string `json:"auth_url"` + + // Hostname The hostname the node asks the tailnet for. + Hostname string `json:"hostname"` + + // Origin byre's https origin on the tailnet, empty until the node has a certificate domain. + Origin string `json:"origin"` + + // State The node's backend state: NoState, NeedsLogin, NeedsMachineAuth, Stopped, Starting or Running. + State string `json:"state"` +} + // Task defines model for Task. type Task struct { BaseRef string `json:"base_ref"` @@ -1900,6 +1915,9 @@ type ServerInterface interface { // (POST /api/sessions/{sessionId}/messages) SendMessage(w http.ResponseWriter, r *http.Request, sessionId string) + // (GET /api/tailnet) + GetTailnet(w http.ResponseWriter, r *http.Request) + // (GET /api/tasks/{taskId}) GetTask(w http.ResponseWriter, r *http.Request, taskId string) @@ -2946,6 +2964,20 @@ func (siw *ServerInterfaceWrapper) SendMessage(w http.ResponseWriter, r *http.Re handler.ServeHTTP(w, r) } +// GetTailnet operation middleware +func (siw *ServerInterfaceWrapper) GetTailnet(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetTailnet(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetTask operation middleware func (siw *ServerInterfaceWrapper) GetTask(w http.ResponseWriter, r *http.Request) { @@ -4121,6 +4153,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/inbox/answer-again", wrapper.AnswerAgain) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/events", wrapper.StreamEvents) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/admin/status", wrapper.GetStatus) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tailnet", wrapper.GetTailnet) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/admin/usage-commands", wrapper.GetUsageCommands) m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/api/admin/usage-commands", wrapper.UpdateUsageCommands) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/admin/permissions", wrapper.CheckPermissions) @@ -4137,297 +4170,302 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/server/server.go b/internal/server/server.go index 171b2ed..3c24c6f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "log/slog" - "net" "net/http" "os" "path/filepath" @@ -22,6 +21,8 @@ import ( "g.fc.run/ai/byre/internal/terminal" "g.fc.run/ai/byre/internal/todo" "g.fc.run/ai/byre/internal/workflow" + "tailscale.com/client/local" + "tailscale.com/tsnet" ) type Server struct { @@ -30,6 +31,8 @@ type Server struct { hub *hub clients *clients.Registry terminals *terminal.Terminals + tailnet *local.Client + hostname string startedAt time.Time usersMu sync.Mutex @@ -38,7 +41,7 @@ type Server struct { type ctxKey struct{} -func New(dataDir string, portFirst int) (*Server, error) { +func New(dataDir string, portFirst int, ts *tsnet.Server) (*Server, error) { err := os.MkdirAll(dataDir, 0o755) if err != nil { return nil, err @@ -48,9 +51,15 @@ func New(dataDir string, portFirst int) (*Server, error) { if err != nil { return nil, err } + lc, err := ts.LocalClient() + if err != nil { + return nil, err + } s := &Server{ hub: newHub(), terminals: terminals, + tailnet: lc, + hostname: ts.Hostname, startedAt: time.Now().UTC(), users: map[string]string{}, } @@ -73,7 +82,7 @@ func New(dataDir string, portFirst int) (*Server, error) { } } - s.agents = agents.New(s.store, filepath.Join(dataDir, "worktrees"), filepath.Join(dataDir, "leads"), filepath.Join(dataDir, "hands"), filepath.Join(dataDir, "codex"), filepath.Join(dataDir, "antigravity"), s.status, s.createProject, s.clients, s.terminals) + s.agents = agents.New(s.store, filepath.Join(dataDir, "worktrees"), filepath.Join(dataDir, "leads"), filepath.Join(dataDir, "hands"), filepath.Join(dataDir, "codex"), filepath.Join(dataDir, "antigravity"), s.status, s.createProject, s.clients, s.terminals, ts) err = s.agents.Recover(context.Background()) if err != nil { s.store.Close() @@ -115,26 +124,39 @@ func (s *Server) Handler() http.Handler { return mux } +func (s *Server) LoopbackHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/tailnet", s.GetTailnet) + return mux +} + +func (s *Server) GetTailnet(w http.ResponseWriter, r *http.Request) { + st, err := s.tailnet.StatusWithoutPeers(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + origin := "" + if len(st.CertDomains) > 0 { + origin = "https://" + st.CertDomains[0] + } + writeJSON(w, http.StatusOK, api.Tailnet{State: st.BackendState, AuthUrl: st.AuthURL, Hostname: s.hostname, Origin: origin}) +} + func (s *Server) identify(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - host, _, err := net.SplitHostPort(r.RemoteAddr) + who, err := s.tailnet.WhoIs(r.Context(), r.RemoteAddr) if err != nil { - writeError(w, http.StatusForbidden, err) + writeError(w, http.StatusUnauthorized, fmt.Errorf("identify %s on the tailnet: %w", r.RemoteAddr, err)) return } - ip := net.ParseIP(host) - if ip == nil || !ip.IsLoopback() { - writeError(w, http.StatusForbidden, fmt.Errorf("request from %s did not arrive via tailscale serve", host)) - return - } - - login := r.Header.Get("Tailscale-User-Login") - name := r.Header.Get("Tailscale-User-Name") - if login == "" || name == "" { - writeError(w, http.StatusUnauthorized, errors.New("missing Tailscale-User-Login or Tailscale-User-Name")) + if who.Node.IsTagged() { + writeError(w, http.StatusForbidden, fmt.Errorf("%s is a tagged node, not a user's", who.Node.Name)) return } + login := who.UserProfile.LoginName + name := who.UserProfile.DisplayName err = s.ensureUser(r.Context(), login, name) if err != nil { writeError(w, http.StatusInternalServerError, err) diff --git a/internal/store/preview.go b/internal/store/preview.go index 240cbfb..0ffb265 100644 --- a/internal/store/preview.go +++ b/internal/store/preview.go @@ -57,13 +57,25 @@ func (s *Store) ListPreviews(ctx context.Context, taskID string) ([]api.Preview, if err != nil { return nil, err } + return scanPreviews(rows) +} + +func (s *Store) ActivePreviews(ctx context.Context) ([]api.Preview, error) { + rows, err := s.db.QueryContext(ctx, `SELECT `+previewColumns+` FROM previews WHERE task_id IN (SELECT id FROM tasks WHERE status = ?)`, string(api.TaskStatusActive)) + if err != nil { + return nil, err + } + return scanPreviews(rows) +} + +func scanPreviews(rows *sql.Rows) ([]api.Preview, error) { defer rows.Close() previews := []api.Preview{} for rows.Next() { p := api.Preview{} createdAt := "" - err = rows.Scan(&p.TaskId, &p.Name, &p.Port, &createdAt) + err := rows.Scan(&p.TaskId, &p.Name, &p.Port, &createdAt) if err != nil { return nil, err } diff --git a/internal/tailscale/tailscale.go b/internal/tailscale/tailscale.go deleted file mode 100644 index bb223d2..0000000 --- a/internal/tailscale/tailscale.go +++ /dev/null @@ -1,94 +0,0 @@ -package tailscale - -import ( - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "strconv" - "strings" -) - -const cli = "/Applications/Tailscale.app/Contents/MacOS/Tailscale" - -func Serve(ctx context.Context, port int) error { - _, err := serve(ctx, port, "http://127.0.0.1:"+strconv.Itoa(port)) - return err -} - -func ServeRoot(ctx context.Context, target string) (string, error) { - host, err := serve(ctx, 443, target) - if err != nil { - return "", err - } - return "https://" + strings.TrimSuffix(host, ":443"), nil -} - -func serve(ctx context.Context, port int, target string) (string, error) { - _, err := run(ctx, "serve", "--bg", "--https="+strconv.Itoa(port), target) - if err != nil { - return "", err - } - out, err := run(ctx, "serve", "status", "--json") - if err != nil { - return "", err - } - status := struct { - Web map[string]json.RawMessage - }{} - err = json.Unmarshal(out, &status) - if err != nil { - return "", fmt.Errorf("parse tailscale serve status: %w", err) - } - suffix := ":" + strconv.Itoa(port) - for host := range status.Web { - if strings.HasSuffix(host, suffix) { - return host, nil - } - } - return "", fmt.Errorf("tailscale serve --https=%d exited 0 but port %d is not in serve status", port, port) -} - -func Problem(ctx context.Context) string { - _, err := os.Stat(cli) - if err != nil { - return "Install the Tailscale app from https://tailscale.com/download/mac and sign in." - } - out, err := run(ctx, "status", "--json", "--peers=false") - if err != nil { - return "Open Tailscale and sign in (" + err.Error() + ")." - } - status := struct { - BackendState string - CertDomains []string - }{} - err = json.Unmarshal(out, &status) - if err != nil { - return "Parse tailscale status: " + err.Error() - } - if status.BackendState != "Running" { - return "Open Tailscale and sign in (it is " + status.BackendState + ")." - } - if len(status.CertDomains) == 0 { - return "Turn on HTTPS Certificates on the DNS page of Tailscale's admin console, https://login.tailscale.com/admin/dns." - } - return "" -} - -func Off(ctx context.Context, port int) error { - _, err := run(ctx, "serve", "--https="+strconv.Itoa(port), "off") - return err -} - -func run(ctx context.Context, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, cli, args...) - cmd.Env = append(os.Environ(), "TAILSCALE_BE_CLI=1") - stderr := strings.Builder{} - cmd.Stderr = &stderr - out, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("tailscale %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()+string(out))) - } - return out, nil -} diff --git a/mac/Package.swift b/mac/Package.swift index 5d5f291..73391f8 100644 --- a/mac/Package.swift +++ b/mac/Package.swift @@ -12,6 +12,7 @@ let package = Package( .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.10.0"), ], targets: [ + .binaryTarget(name: "TailscaleKit", path: "Frameworks/TailscaleKit.xcframework"), .target( name: "ByreAPI", dependencies: [ @@ -24,6 +25,7 @@ let package = Package( name: "Byre", dependencies: [ "ByreAPI", + "TailscaleKit", .product(name: "SwiftTerm", package: "SwiftTerm"), .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), diff --git a/mac/Sources/Byre/API.swift b/mac/Sources/Byre/API.swift index bffdcbe..1a1666f 100644 --- a/mac/Sources/Byre/API.swift +++ b/mac/Sources/Byre/API.swift @@ -117,6 +117,7 @@ final class API: Sendable { "Byre-Commit": Bundle.main.object(forInfoDictionaryKey: "ByreCommit") as! String, "Byre-Host": macName, ] + config.proxyConfigurations = [tailnetNode.proxy()] session = URLSession(configuration: config) client = Client( serverURL: origin, diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index 7dd646c..24a76d0 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -1682,8 +1682,11 @@ struct NoteView: View { out += AttributedString(s) case .task(let id): out += taskLink(store, id) - case .url(let port): - let url = previewURL(store.api.origin, Int(port) ?? 0) + case .preview(let taskId, let port): + guard let url = store.forwards.url(taskId, port) else { + out += AttributedString("port \(port)") + continue + } var a = AttributedString(url.absoluteString) a.link = url a.foregroundColor = Palette.accent @@ -1707,15 +1710,6 @@ func taskLink(_ store: Store, _ id: String) -> AttributedString { return a } -func previewURL(_ origin: URL, _ port: Int) -> URL { - var c = URLComponents() - c.scheme = "https" - c.host = origin.host() - c.port = port - c.path = "/" - return c.url! -} - private struct SnapshotRow: View { let snap: Snapshot let review: Review? diff --git a/mac/Sources/Byre/Forwards.swift b/mac/Sources/Byre/Forwards.swift new file mode 100644 index 0000000..3d4861a --- /dev/null +++ b/mac/Sources/Byre/Forwards.swift @@ -0,0 +1,301 @@ +import ByreAPI +import Foundation +import Network +import Observation + +typealias Preview = Components.Schemas.Preview + +@MainActor +@Observable +final class Forwards { + private struct Key: Hashable { + let taskId: String + let name: String + let port: Int + } + + private let host: String + private var localPorts: [Key: Int] = [:] + @ObservationIgnored private var forwards: [Key: Forward] = [:] + + init(host: String) { + self.host = host + } + + func url(_ taskId: String, _ port: Int) -> URL? { + guard let (key, local) = localPorts.first(where: { $0.key.taskId == taskId && $0.key.port == port }) else { + return nil + } + var c = URLComponents() + c.scheme = "http" + c.host = "\(slug(key.name).isEmpty ? "preview" : slug(key.name))-\(taskId.prefix(6)).localhost" + c.port = local + c.path = "/" + return c.url + } + + func load(_ api: API, _ taskId: String) async { + do { + set(taskId, try await api.client.listPreviews(path: .init(taskId: taskId)).ok.body.json) + } catch { + log.error("list previews: \(errorText(error), privacy: .public)") + } + } + + func set(_ taskId: String, _ previews: [Preview]) { + let wanted = Set(previews.map { Key(taskId: taskId, name: $0.name, port: $0.port) }) + for key in forwards.keys where key.taskId == taskId && !wanted.contains(key) { + drop(key) + } + for key in wanted where forwards[key] == nil { + forwards[key] = Forward(remote: "\(host):\(key.port)", port: key.port) { [weak self] local in + Task { @MainActor in + if self?.forwards[key] != nil { + self?.localPorts[key] = local + } + } + } + } + } + + func sync(_ api: API, _ taskIds: Set) { + for key in forwards.keys where !taskIds.contains(key.taskId) { + drop(key) + } + for taskId in taskIds { + Task { + await load(api, taskId) + } + } + } + + func close(_ taskId: String) { + for key in forwards.keys where key.taskId == taskId { + drop(key) + } + } + + func closeAll() { + for key in forwards.keys { + drop(key) + } + } + + private func drop(_ key: Key) { + forwards.removeValue(forKey: key)?.close() + localPorts[key] = nil + } +} + +private final class Forward: @unchecked Sendable { + private let remote: String + private let ready: @Sendable (Int) -> Void + private let queue = DispatchQueue(label: "run.fc.byre.forward") + private var listener: NWListener? + private var splices: [ObjectIdentifier: Splice] = [:] + private var closed = false + + init(remote: String, port: Int, ready: @escaping @Sendable (Int) -> Void) { + self.remote = remote + self.ready = ready + queue.async { + self.listen(NWEndpoint.Port(rawValue: UInt16(port)) ?? .any) + } + } + + func close() { + queue.async { + self.closed = true + self.listener?.newConnectionHandler = nil + self.listener?.cancel() + self.listener = nil + for splice in self.splices.values { + splice.close() + } + self.splices = [:] + } + } + + private func listen(_ port: NWEndpoint.Port) { + guard !closed else { + return + } + let params = NWParameters.tcp + params.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: port) + let listener: NWListener + do { + listener = try NWListener(using: params) + } catch { + log.error("preview listener for \(self.remote, privacy: .public): \(String(describing: error), privacy: .public)") + return + } + self.listener = listener + listener.stateUpdateHandler = { [weak self, weak listener] state in + guard let self, let listener else { + return + } + switch state { + case .ready: + if let port = listener.port { + self.ready(Int(port.rawValue)) + } + case .failed(let error): + listener.cancel() + if port == .any { + log.error("preview listener for \(self.remote, privacy: .public): \(String(describing: error), privacy: .public)") + return + } + self.listen(.any) + default: + break + } + } + listener.newConnectionHandler = { [remote, queue] conn in + conn.start(queue: queue) + DispatchQueue.global().async { + do { + let fd = try tailnetNode.dial(remote) + queue.async { + let splice = Splice(conn, fd, queue) + if self.closed { + splice.close() + return + } + let id = ObjectIdentifier(splice) + self.splices[id] = splice + splice.start { + self.splices[id] = nil + } + } + } catch { + log.error("preview: \(errorText(error), privacy: .public)") + conn.cancel() + } + } + } + listener.start(queue: queue) + } +} + +private final class Splice: @unchecked Sendable { + private let conn: NWConnection + private let fd: Int32 + private let io: DispatchIO + private let queue: DispatchQueue + private static let chunk = 1 << 16 + private var open = 2 + private var ended: (() -> Void)? + private var read = 0 + private var sending = 0 + private var readDone = false + + init(_ conn: NWConnection, _ fd: Int32, _ queue: DispatchQueue) { + self.conn = conn + self.fd = fd + self.queue = queue + io = DispatchIO(type: .stream, fileDescriptor: fd, queue: queue) { _ in + Darwin.close(fd) + } + io.setLimit(lowWater: 1) + } + + func close() { + guard open > 0 else { + return + } + open = 0 + conn.cancel() + io.close(flags: .stop) + ended?() + ended = nil + } + + func start(_ ended: @escaping () -> Void) { + self.ended = ended + readChunk() + receive() + } + + // One chunk at a time: the next read starts only once everything read so far has been sent. + private func readChunk() { + read = 0 + readDone = false + io.read(offset: 0, length: Self.chunk, queue: queue) { done, data, error in + guard self.open > 0 else { + return + } + if let data, !data.isEmpty { + self.read += data.count + self.sending += 1 + self.conn.send(content: Data(data), completion: .contentProcessed { error in + self.sending -= 1 + if error != nil { + self.close() + return + } + self.nextChunk() + }) + } + guard done else { + return + } + if error != 0 || self.read < Self.chunk { + self.conn.send(content: nil, contentContext: .finalMessage, isComplete: true, completion: .idempotent) + self.half() + return + } + self.readDone = true + self.nextChunk() + } + } + + private func nextChunk() { + if open > 0, readDone, sending == 0 { + readChunk() + } + } + + private func receive() { + conn.receive(minimumIncompleteLength: 1, maximumLength: Self.chunk) { data, _, complete, error in + guard self.open > 0 else { + return + } + let finished = complete || error != nil + guard let data, !data.isEmpty else { + finished ? self.endWrites() : self.receive() + return + } + let chunk = unsafe data.withUnsafeBytes { unsafe DispatchData(bytes: $0) } + self.io.write(offset: 0, data: chunk, queue: self.queue) { done, _, error in + if error != 0 { + self.close() + return + } + guard done, self.open > 0 else { + return + } + finished ? self.endWrites() : self.receive() + } + } + } + + private func endWrites() { + io.barrier { + Darwin.shutdown(self.fd, SHUT_WR) + } + half() + } + + private func half() { + guard open > 0 else { + return + } + open -= 1 + if open == 0 { + conn.cancel() + io.close() + ended?() + ended = nil + } + } +} diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index 325ba34..7c70fd9 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -252,7 +252,9 @@ private struct TaskBar: View { HStack(spacing: 12) { Spacer(minLength: 0) ForEach(previews, id: \.port) { p in - PreviewLink(preview: p, url: previewURL(store.api.origin, p.port)) + if let url = store.forwards.url(p.taskId, p.port) { + PreviewLink(preview: p, url: url) + } } } .frame(maxWidth: .infinity) diff --git a/mac/Sources/Byre/Server.swift b/mac/Sources/Byre/Server.swift index aa714ca..2df1e14 100644 --- a/mac/Sources/Byre/Server.swift +++ b/mac/Sources/Byre/Server.swift @@ -4,13 +4,43 @@ import SwiftUI typealias Permission = Components.Schemas.Permission typealias UsageCommands = Components.Schemas.UsageCommands +typealias TailnetInfo = Components.Schemas.Tailnet let serverHelper = Bundle.main.bundleURL.appending(path: "Contents/Helpers/byre").path private let service = "gui/\(getuid())/run.fc.byre" struct ServerState { var on = false - var address: String? + var tailnet: TailnetInfo? + + var address: String? { + guard let tailnet, tailnet.state == "Running", !tailnet.origin.isEmpty else { + return nil + } + return tailnet.origin + } +} + +let noCertificates = "byre's Tailscale node is signed in, but the tailnet has no HTTPS certificates, so byre can't serve its API. Turn on HTTPS Certificates on the DNS page of Tailscale's admin console, https://login.tailscale.com/admin/dns, then turn the server off and on in the Server tab." + +struct ServiceLoginView: View { + let info: TailnetInfo + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("byre's server on this Mac has its own Tailscale node, \(info.hostname). Sign it in to the same tailnet.") + .fixedSize(horizontal: false, vertical: true) + Button("Sign in to Tailscale") { + if let url = URL(string: info.authUrl) { + NSWorkspace.shared.open(url) + } + } + .buttonStyle(WebButtonStyle()) + .disabled(URL(string: info.authUrl) == nil) + Text("Waiting for the sign-in (\(info.state))…") + .foregroundStyle(Palette.muted) + } + } } struct Check: Decodable { @@ -25,6 +55,7 @@ struct Check: Decodable { struct Checks: Decodable { let ready: Bool + let loopback: String let checks: [Check] } @@ -309,22 +340,22 @@ private func launchctl(_ args: String...) async throws { } enum Server { - static func state() async -> ServerState { + static func state(loopback: URL?) async -> ServerState { guard let r = try? await execute("/bin/launchctl", ["print", service]), r.status == 0 else { return ServerState() } - return ServerState(on: true, address: await address()) + guard let loopback else { + return ServerState(on: true) + } + return ServerState(on: true, tailnet: try? await tailnet(loopback)) } - private static func address() async -> String? { - guard let r = try? await execute("/Applications/Tailscale.app/Contents/MacOS/Tailscale", ["serve", "status", "--json"], env: ["TAILSCALE_BE_CLI": "1"]), r.status == 0, - let json = try? JSONSerialization.jsonObject(with: Data(r.output.utf8)) as? [String: Any], - let web = json["Web"] as? [String: Any], - let host = web.keys.first(where: { $0.hasSuffix(":443") }) - else { - return nil + static func tailnet(_ loopback: URL) async throws -> TailnetInfo { + let (data, response) = try await URLSession.shared.data(from: loopback.appending(path: "api/tailnet")) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw ProcessFailure(errorDescription: "\(loopback.absoluteString)/api/tailnet: \(String(decoding: data, as: UTF8.self))") } - return "https://" + host.dropLast(4) + return try JSONDecoder().decode(TailnetInfo.self, from: data) } // byre install copies its PATH into the service, which needs the login shell's to find claude, codex, agy and npx. @@ -341,22 +372,19 @@ enum Server { return checks } - static func install() async throws -> URL { + static func install() async throws { let r = try await helper("install") - let prefix = "byre is at " - guard r.status == 0, let line = r.output.split(separator: "\n").first(where: { $0.hasPrefix(prefix) }), let url = URL(string: String(line.dropFirst(prefix.count))) else { + guard r.status == 0 else { throw ProcessFailure(errorDescription: "byre install failed: \(r.output.trimmingCharacters(in: .whitespacesAndNewlines))") } - return url } - // Reinstalling a running service restarts it, so an address read right after could still be answered by the old one. - static func turnOn() async throws -> URL { - let state = await state() - if state.on, let address = state.address, let url = URL(string: address) { - return url + // Reinstalling a running service restarts it, so a state read right after could still be answered by the old one. + static func turnOn() async throws { + if await state(loopback: nil).on { + return } - return try await install() + try await install() } static func turnOff() async throws { @@ -420,6 +448,12 @@ struct ServerView: View { .background(Palette.bg) .task { await refresh() + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(2)) + if let current = state, current.on, current.address == nil { + state = await Server.state(loopback: loopback) + } + } } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in Task { @@ -428,19 +462,31 @@ struct ServerView: View { } } + private var loopback: URL? { + (try? checks?.get()).flatMap { URL(string: $0.loopback) } + } + @ViewBuilder private func details(_ state: ServerState) -> some View { if state.on { Text("On").foregroundStyle(Palette.ok) if let address = state.address { Text(address).textSelection(.enabled) + } else if let tailnet = state.tailnet, tailnet.state == "Running" { + Text(noCertificates) + .foregroundStyle(Palette.danger) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } else if let tailnet = state.tailnet { + ServiceLoginView(info: tailnet) + } else { + Text("Not answering at \(loopback?.absoluteString ?? "its loopback address") yet.") + .foregroundStyle(Palette.muted) } button("Turn off", Server.turnOff) } else { Text("Off").foregroundStyle(Palette.muted) - button("Turn on") { - _ = try await Server.turnOn() - } + button("Turn on", Server.turnOn) } } @@ -463,9 +509,8 @@ struct ServerView: View { } private func refresh() async { - async let checked = check() - state = await Server.state() - checks = await checked + checks = await check() + state = await Server.state(loopback: loopback) } private func check() async -> Result { diff --git a/mac/Sources/Byre/Startup.swift b/mac/Sources/Byre/Startup.swift index c607e6c..725bde8 100644 --- a/mac/Sources/Byre/Startup.swift +++ b/mac/Sources/Byre/Startup.swift @@ -6,6 +6,9 @@ import Observation import SwiftData import SwiftUI +// Servers saved before byre had its own tailnet node are tailscale serve origins, which no longer answer. +private let tailnetServersSince = Date(timeIntervalSince1970: 1_792_526_916) + @MainActor @Observable final class Servers { @@ -69,6 +72,13 @@ final class Servers { } private func load() { + let legacy = fetchRecords(FetchDescriptor()).filter { $0.saved < tailnetServersSince } + if !legacy.isEmpty { + for record in legacy { + persistence.delete(record) + } + saveRecords() + } var loaded: [(origin: String, record: ServerRecord)] = [] for record in fetchRecords(FetchDescriptor(sortBy: [SortDescriptor(\.saved)])) { if let origin = parseOrigin(record.origin), !loaded.contains(where: { $0.origin == origin.absoluteString }) { @@ -157,12 +167,14 @@ final class Connection { let origin: URL let overlays = Overlays() + let forwards: Forwards var phase: Phase = .starting @ObservationIgnored private var stopped = false @ObservationIgnored private var restarting = false init(origin: URL) { self.origin = origin + forwards = Forwards(host: origin.host()!) Task { await start() } @@ -177,6 +189,7 @@ final class Connection { func stop() async { stopped = true + forwards.closeAll() if let store { await store.stop() } @@ -208,7 +221,7 @@ final class Connection { } private func attempt(_ old: Store?) async -> Bool { - let store = Store(api: API(origin: origin), images: old?.images ?? ImageDrafts()) + let store = Store(api: API(origin: origin), images: old?.images ?? ImageDrafts(), forwards: forwards) do { let status = try await store.api.client.getStatus().ok.body.json async let me: Void = store.loadMe() @@ -231,6 +244,7 @@ final class Connection { self?.reconnected(status.startedAt) } store.remote.start() + forwards.sync(store.api, Set(store.activeTasks().map(\.task.id))) Notifier.shared.deliver() return true } catch { @@ -250,10 +264,11 @@ final class ConnectForm { var problem: String? var missing: [Check] = [] var connecting = false + var serviceLogin: TailnetInfo? func connect() async -> URL? { guard let origin = parseOrigin(serverText) else { - problem = "The server URL must be an https origin with no path, such as https://byre.example.ts.net." + problem = "The server URL must be an https origin with no path, such as https://byre-mac.example.ts.net." return nil } connecting = true @@ -281,17 +296,38 @@ final class ConnectForm { connecting = false return nil } - let origin = try await Server.turnOn() + try await Server.turnOn() + let origin = try await waitForTailnet(URL(string: checks.loopback)!) serverText = origin.absoluteString try await waitForServer(origin) } catch { connecting = false + serviceLogin = nil problem = errorText(error) return nil } return await connect() } + private func waitForTailnet(_ loopback: URL) async throws -> URL { + let started = Date() + while true { + let info = try? await Server.tailnet(loopback) + if let info, info.state == "Running" { + serviceLogin = nil + guard let origin = URL(string: info.origin), !info.origin.isEmpty else { + throw ProcessFailure(errorDescription: noCertificates) + } + return origin + } + serviceLogin = info + if info == nil, Date().timeIntervalSince(started) > 10 { + throw ProcessFailure(errorDescription: "byre was installed but \(loopback.absoluteString) didn't answer within 10 s.") + } + try await Task.sleep(for: .milliseconds(500)) + } + } + private func waitForServer(_ origin: URL) async throws { let api = API(origin: origin) try await withThrowingTaskGroup(of: Void.self) { group in @@ -301,8 +337,8 @@ final class ConnectForm { } } group.addTask { - try await Task.sleep(for: .seconds(10)) - throw ProcessFailure(errorDescription: "byre was installed but \(origin.absoluteString) didn't answer within 10 s.") + try await Task.sleep(for: .seconds(15)) + throw ProcessFailure(errorDescription: "byre's Tailscale node is signed in but \(origin.absoluteString) didn't answer within 15 s.") } defer { group.cancelAll() @@ -327,19 +363,19 @@ func explain(_ error: any Error, _ origin: URL) -> String { if let api = e as? ApiError { switch api.status { case 401: - return "\(host) answered 401, no Tailscale identity: \(api.message). Use byre's ts.net HTTPS address, the one tailscale serve gives it, so the request carries your Tailscale login." + return "\(host) answered 401, it couldn't tell who this Mac's Tailscale node belongs to: \(api.message)" case 403: - return "\(host) answered 403, the request didn't come through tailscale serve: \(api.message). Use the HTTPS address tailscale serve gives byre." + return "\(host) answered 403, it serves only nodes owned by a user, not tagged ones: \(api.message)" default: return api.message } } if let url = e as? URLError { if tlsErrors.contains(url.code) { - return "\(host)'s certificate isn't trusted: \(url.localizedDescription) Use byre's ts.net HTTPS address, whose certificate tailscale serve provides." + return "\(host)'s certificate isn't trusted: \(url.localizedDescription) Use byre's ts.net address, whose certificate its Tailscale node provides." } if unreachableErrors.contains(url.code) { - return "Can't reach \(host): \(url.localizedDescription) Is Tailscale connected?" + return "Can't reach \(host) on the tailnet: \(url.localizedDescription) Is byre running there?" } return url.localizedDescription } @@ -363,7 +399,7 @@ struct ConnectView: View { private var fields: some View { VStack(alignment: .leading, spacing: 10) { Text("Server URL") - TextField("https://byre.example.ts.net", text: $form.serverText) + TextField("https://byre-mac.example.ts.net", text: $form.serverText) .textFieldStyle(.roundedBorder) .onSubmit(connect) HStack(spacing: 10) { @@ -371,6 +407,9 @@ struct ConnectView: View { Button("Run byre on this Mac", action: installHere) } .disabled(form.connecting) + if let login = form.serviceLogin { + ServiceLoginView(info: login) + } if let problem = form.problem { Text(problem) .foregroundStyle(Palette.danger) @@ -444,7 +483,9 @@ struct ServerWindow: View { @ViewBuilder private var content: some View { - if let connection { + if !Tailnet.shared.running { + TailnetLoginView() + } else if let connection { switch connection.phase { case .starting: Color.clear diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index 68d6722..dad3100 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -79,6 +79,7 @@ final class Store { let events = Listeners() let paneFocus: PaneFocus let remote: Remote + let forwards: Forwards private var taskFeeds: [String: Feed] = [:] @ObservationIgnored private var historyLoads: Set = [] @@ -88,9 +89,10 @@ final class Store { @ObservationIgnored private var stopped = false @ObservationIgnored var changesMemory = ChangesMemory() - init(api: API, images: ImageDrafts) { + init(api: API, images: ImageDrafts, forwards: Forwards) { self.api = api self.images = images + self.forwards = forwards leadFeed = Feed(shown: { true }) paneFocus = PaneFocus(api: api) remote = Remote(api: api) @@ -622,6 +624,9 @@ final class Store { if let taskId = ev.taskId, ["task.merged", "task.discarded"].contains(ev.kind) || (ev.kind == "snapshot.created" && !["answer", "fork", "ask_human"].contains(str(ev.payload.value, "trigger"))) { clearTask(self, taskId) } + if let taskId = ev.taskId, ["task.merged", "task.discarded"].contains(ev.kind) { + forwards.close(taskId) + } notifyTask(self, ev) if let projectId = ev.projectId { spawn("load tasks") { @@ -656,6 +661,12 @@ final class Store { spawn("load inbox") { try await self.loadInbox() } + case "preview.exposed": + if let taskId = ev.taskId { + Task { + await forwards.load(api, taskId) + } + } case "session.error", "session.rate_limited": notifyError(self, ev) case "task.merge_stopped", "task.merge_after_review_dropped", "task.workflow_finished": diff --git a/mac/Sources/Byre/Tailnet.swift b/mac/Sources/Byre/Tailnet.swift new file mode 100644 index 0000000..25192f7 --- /dev/null +++ b/mac/Sources/Byre/Tailnet.swift @@ -0,0 +1,149 @@ +import AppKit +import Foundation +import Network +import Observation +import SwiftUI +import TailscaleKit + +func slug(_ text: String) -> String { + var out = "" + var dash = false + for c in text.lowercased() { + if c.isASCII, c.isLetter || c.isNumber { + if dash, !out.isEmpty { + out += "-" + } + out.append(c) + dash = false + continue + } + dash = true + } + return out +} + +final class TailnetNode: Sendable { + let sd: tailscale + let hostname = "byre-app-\(slug(macName))" + private let proxyHost: String + private let proxyPort: UInt16 + private let proxyCredential: String + + init() { + let dir = URL.applicationSupportDirectory.appending(path: "run.fc.byre.mac/tsnet", directoryHint: .isDirectory) + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } catch { + fatalError("create tsnet dir: \(error)") + } + sd = tailscale_new() + Self.check(sd, "set hostname", unsafe tailscale_set_hostname(sd, hostname)) + Self.check(sd, "set dir", unsafe tailscale_set_dir(sd, dir.path)) + Self.check(sd, "set ephemeral", tailscale_set_ephemeral(sd, 0)) + Self.check(sd, "start", tailscale_start(sd)) + var addr = [CChar](repeating: 0, count: 64) + var proxyCred = [CChar](repeating: 0, count: 33) + var apiCred = [CChar](repeating: 0, count: 33) + Self.check(sd, "loopback", unsafe tailscale_loopback(sd, &addr, addr.count, &proxyCred, &apiCred)) + let address = String(decoding: addr.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self) + guard let colon = address.lastIndex(of: ":"), let port = UInt16(address[address.index(after: colon)...]) else { + fatalError("tsnet loopback address \(address)") + } + proxyHost = String(address[.. String { + var buf = [CChar](repeating: 0, count: 1024) + _ = unsafe tailscale_errmsg(sd, &buf, buf.count) + return String(decoding: buf.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self) + } + + func proxy() -> ProxyConfiguration { + let proxy = ProxyConfiguration(socksv5Proxy: .hostPort(host: NWEndpoint.Host(proxyHost), port: NWEndpoint.Port(rawValue: proxyPort)!)) + proxy.applyCredential(username: "tsnet", password: proxyCredential) + return proxy + } + + func status() -> (state: String, authURL: URL?)? { + var out: UnsafeMutablePointer? + guard unsafe tailscale_status_json(sd, &out) == 0, let out = unsafe out else { + return nil + } + defer { + unsafe free(out) + } + struct Status: Decodable { + let BackendState: String + let AuthURL: String + } + guard let status = try? JSONDecoder().decode(Status.self, from: unsafe Data(bytes: out, count: strlen(out))) else { + return nil + } + return (status.BackendState, URL(string: status.AuthURL)) + } + + // Blocks until the connection is made or fails. + func dial(_ address: String) throws -> Int32 { + var conn: tailscale_conn = 0 + guard unsafe tailscale_dial(sd, "tcp", address, &conn) == 0 else { + throw ProcessFailure(errorDescription: "dial \(address) on the tailnet: \(Self.errorMessage(sd))") + } + return conn + } +} + +let tailnetNode = TailnetNode() + +@MainActor +@Observable +final class Tailnet { + static let shared = Tailnet() + + private(set) var state = "Starting" + private(set) var authURL: URL? + + var running: Bool { + state == "Running" + } + + private init() { + Task { + while true { + if let status = tailnetNode.status() { + state = status.state + authURL = status.authURL + } + try? await Task.sleep(for: .seconds(1)) + } + } + } +} + +struct TailnetLoginView: View { + var body: some View { + let tailnet = Tailnet.shared + VStack(alignment: .leading, spacing: 10) { + Text("byre reaches its servers through its own Tailscale node, \(tailnetNode.hostname). Sign it in to the tailnet your byre servers are on.") + .fixedSize(horizontal: false, vertical: true) + Button("Sign in to Tailscale") { + if let url = tailnet.authURL { + NSWorkspace.shared.open(url) + } + } + .disabled(tailnet.authURL == nil) + Text(tailnet.authURL == nil ? "Waiting for Tailscale (\(tailnet.state))…" : "Waiting for the sign-in (\(tailnet.state))…") + .foregroundStyle(Palette.muted) + } + .frame(width: 460) + .padding(.vertical, 24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index e119466..8715de3 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -19,7 +19,7 @@ struct Call { enum NotePart { case text(String) case task(String) - case url(String) + case preview(taskId: String, port: Int) } enum NoteStyle { @@ -833,7 +833,7 @@ final class TranscriptBuilder { return note([.text("didn't merge after the review: \(jsString(p["reason"]))")]) case "preview.exposed": - return note([.text("\(ev.actor) exposed \"\(jsString(p["name"]))\" at "), .url(String(int(p, "port") ?? 0))]) + return note([.text("\(ev.actor) exposed \"\(jsString(p["name"]))\" at "), .preview(taskId: ev.taskId ?? "", port: int(p, "port") ?? 0)]) case "task.merged": let commit = str(p, "commit") ?? "" diff --git a/mac/bundle.sh b/mac/bundle.sh index 9632eaf..3903124 100755 --- a/mac/bundle.sh +++ b/mac/bundle.sh @@ -8,21 +8,34 @@ app=$build/Byre.app svg=$mac/icon.svg icns=$build/byre.icns identity="Developer ID Application" +libtailscale=d1dc45d039edb7759ecbee6ba2aea0c4bca884b8 mkdir -p "$shared" "$build" if [ "$(security cms -D -i "$mac/byre.provisionprofile" | plutil -extract ProvisionsAllDevices raw -)" != true ]; then echo "bundle: mac/byre.provisionprofile is not a Developer ID profile" >&2 exit 1 fi lockf -k "$shared.lock" sh -ec ' -rsync -rlc --delete --exclude /mac/.build --exclude /hands/.build "$1/mac" "$1/api" "$1/hands" "$2/" +kit=$2/libtailscale-$7 +if [ ! -d "$kit/TailscaleKit.xcframework" ]; then + rm -rf "$kit" + git clone --quiet https://github.com/tailscale/libtailscale "$kit" + git -C "$kit" checkout --quiet "$7" + make -C "$kit/swift" macos XCPRETTIFIER=cat + xcodebuild -create-xcframework -framework "$kit/swift/build/Build/Products/Release/TailscaleKit.framework" -output "$kit/TailscaleKit.xcframework" +fi +rsync -rlc --delete --exclude /mac/.build --exclude /mac/Frameworks --exclude /hands/.build "$1/mac" "$1/api" "$1/hands" "$2/" +rm -rf "$2/mac/Frameworks" +mkdir -p "$2/mac/Frameworks" +ditto "$kit/TailscaleKit.xcframework" "$2/mac/Frameworks/TailscaleKit.xcframework" swift build --package-path "$2/mac" --configuration release --no-color-diagnostics swift build --package-path "$2/hands" --configuration release --no-color-diagnostics cp "$2/mac/.build/release/Byre" "$3" cp "$2/hands/.build/release/byre-hands" "$4" cp "$2/mac/.build/release/byre-update" "$6" -rm -rf "$5" +rm -rf "$5" "$8" ditto "$2/mac/.build/release/Sparkle.framework" "$5" -' sh "$root" "$shared" "$build/Byre" "$build/byre-hands" "$build/Sparkle.framework" "$build/byre-update" +ditto "$2/mac/.build/release/TailscaleKit.framework" "$8" +' sh "$root" "$shared" "$build/Byre" "$build/byre-hands" "$build/Sparkle.framework" "$build/byre-update" "$libtailscale" "$build/TailscaleKit.framework" go -C "$root" build -trimpath -o "$build/server/byre" ./cmd/byre if [ ! "$icns" -nt "$svg" ]; then iconset=$build/byre.iconset @@ -43,6 +56,7 @@ cp "$build/server/byre" "$app/Contents/Helpers/byre" cp "$build/byre-hands" "$app/Contents/Helpers/byre-hands" cp "$build/byre-update" "$app/Contents/Helpers/byre-update" ditto "$build/Sparkle.framework" "$app/Contents/Frameworks/Sparkle.framework" +ditto "$build/TailscaleKit.framework" "$app/Contents/Frameworks/TailscaleKit.framework" cp "$mac/Info.plist" "$app/Contents/Info.plist" plutil -insert ByreCommit -string "$(git -C "$root" rev-parse HEAD)" "$app/Contents/Info.plist" plutil -insert CFBundleShortVersionString -string "$version" "$app/Contents/Info.plist" @@ -60,6 +74,7 @@ sign --identifier run.fc.byre --entitlements "$mac/automation.entitlements" "$ap sign "$sparkle/Versions/B/Autoupdate" sign "$sparkle/Versions/B/Updater.app" sign "$sparkle" +sign "$app/Contents/Frameworks/TailscaleKit.framework" sign --identifier run.fc.byre.update "$app/Contents/Helpers/byre-update" sign --entitlements "$mac/byre.entitlements" "$app" echo "$app"