Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
1a625e5
docs: plan headless CLI and persistent serve
guygrigsby Oct 13, 2026
7364cc4
docs: decide headless CLI (0007) and persistent serve (0008)
guygrigsby Oct 13, 2026
d12860f
docs: spec the command-line contract, Name and Port
guygrigsby Oct 13, 2026
5ae5eeb
docs: name resolution is name first, add Endpoint Entry
guygrigsby Oct 13, 2026
f66b544
config: endpoint names, bridge ports and a settings file override
guygrigsby Oct 13, 2026
702631d
docs: help output is part of the command-line surface
guygrigsby Oct 13, 2026
24ead2c
docs: keep single-dash flags, add shorthands, deprecate not remove
guygrigsby Oct 13, 2026
f3accd8
cli: headless command tree with bridge and endpoint groups
guygrigsby Oct 13, 2026
ff723bf
cli: serve runs a persistent bridge on a fixed port
guygrigsby Oct 13, 2026
be13a4a
cli: reset the process-wide settings path between tests
guygrigsby Oct 13, 2026
d8d7171
clients: one headless launch spec per client
guygrigsby Oct 13, 2026
dba1cd9
cli: launch clients directly with argument passthrough
guygrigsby Oct 13, 2026
9dc901b
docs: plan landed
guygrigsby Oct 13, 2026
38c4cb5
cli: close the findings of the security pass
guygrigsby Oct 13, 2026
b312abe
cli: keep the client tail out of aperture's flag rewriting
guygrigsby Oct 13, 2026
fb3844b
cli: warn when a launch reads a non-default settings file
guygrigsby Oct 13, 2026
669e8b1
docs: record why provider and model are not persisted defaults
guygrigsby Oct 13, 2026
484ba89
cli: test the flags that had none
guygrigsby Oct 13, 2026
69cd6dc
e2e: black-box compat and contract suites behind the tailnet tag
guygrigsby Oct 13, 2026
e917299
opencode: remove the temp config file when a write fails
guygrigsby Oct 14, 2026
4f23729
opencode: replay the last headless launch's model
guygrigsby Oct 14, 2026
55bc9c5
config: carry a name across a verified endpoint URL edit
guygrigsby Oct 14, 2026
5f43fe1
bridges: bind the conflict listener to an ephemeral port in the fixed…
guygrigsby Oct 14, 2026
fc39b3e
cli: require a leading flag and honor its value in versionRequested
guygrigsby Oct 14, 2026
0b01e1b
codex: apply the default model when an explicit provider names no model
guygrigsby Oct 14, 2026
4ffe4e1
copilot: apply the default model when an explicit provider names no m…
guygrigsby Oct 14, 2026
e383b65
cli: forward a termination signal to the launched client
guygrigsby Oct 14, 2026
4baaef6
cli: fall back to the environment for an absent tail flag
guygrigsby Oct 14, 2026
83729d8
e2e: let the suite run against an installed binary
guygrigsby Oct 14, 2026
a1e9e32
docs: testing guide with examples for every layer
guygrigsby Oct 14, 2026
711e8b8
ci: run the black-box suites in a Black-box job
guygrigsby Oct 14, 2026
1503066
opencode: run write-failure test in a child process
guygrigsby Oct 15, 2026
a0c3235
runlog: record failed commands, bridge proxy errors and UI failures
guygrigsby Oct 15, 2026
7c23926
cli: skip persistent pre-run config selection for manually parsed com…
guygrigsby Oct 15, 2026
81b70e8
endpoint: print the canonical saved name from settings
guygrigsby Oct 15, 2026
77c0d0e
cli: preserve flag values verbatim during flag rewriting and version …
guygrigsby Oct 15, 2026
ded386c
clients: validate saved model against provider models on headless launch
guygrigsby Oct 15, 2026
8d08922
config: apply replacement name after selecting the candidate entry
guygrigsby Oct 15, 2026
5876034
bridges, cli: add --force flag to bridge delete
guygrigsby Oct 15, 2026
b499193
e2e: clean up tailnet bridge and fix test harness on macOS
guygrigsby Oct 15, 2026
01c76aa
cli: document --force flag in bridge delete help
guygrigsby Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci-linux.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,22 @@ jobs:
# inlined here lives in `make lint`, which check depends on.
- name: Check
run: make check

blackbox:
name: Black-box (tailnet tag)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Set up Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: "1.26.6"

# The tagged suites degrade without a tailnet: contract needs nothing
# external, compat's state-read half needs only GitHub, and the
# bridge-login half skips. A TS_AUTHKEY secret enables that half.
- name: Black-box suites
env:
TS_AUTHKEY: ${{ secrets.TS_AUTHKEY }}
run: make test-tailnet
7 changes: 7 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ lint:
check: lint build
go test -race ./...

# The black-box suites: cross-version compatibility against the latest
# release, and golden output for the scripted surface. Tagged out of check
# because they download the release binary and, with TS_AUTHKEY set, log a
# bridge into the local tailnet. Run them on a dev box, not in CI.
test-tailnet:
go test -race -tags tailnet ./e2e/

install:
go install -ldflags "$(LDFLAGS)" ./cmd/aperture

Expand Down
97 changes: 85 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,37 +94,100 @@ Each aperture process on a bridge is its own device on the tailnet. The first pr

A fresh device needs one login. Set `TS_AUTHKEY` to a reusable auth key and new devices authorize without the browser; devices already registered keep their credentials on disk and never consult the key again. The key decides which tailnet a fresh device joins, so use a key from the tailnet your Aperture is on.

### Command line

Everything the TUI configures is also a command, so an aperture setup can
live in a script, a dotfile or a unit file.

```sh
aperture bridge add work --port 8991 # save a bridge with a fixed port
aperture bridge list
aperture bridge describe work
aperture bridge delete work # logs its machines out first
aperture endpoint add e1 --bridge work http://ai # save an endpoint under a name
aperture endpoint list
aperture endpoint delete e1
```

Endpoints and bridges are addressed by name. An endpoint value that is not a
saved name is read as a URL.

Launch an agent directly with its binary name. Aperture consumes its own
flags; the first flag it does not know, or `--`, passes the rest to the
agent verbatim. Provider and model default to your last launch, then the
first compatible pair.

```sh
aperture codex --endpoint e1 # launch codex against e1
aperture codex --model openai/gpt-5-mini # override the model
aperture codex -- --search # pass --search to codex
alias codex='aperture codex' # feels like codex, configures itself
```

`aperture serve` runs one bridge on a fixed port until it is stopped, for
client configurations that need a stable address to point at. The port comes
from the bridge or from `--port`; serve never falls back to a random port.

```sh
aperture serve --bridge work # binds the bridge's stored port
aperture serve --bridge work --port 8992 # one-off override
```

A systemd user unit runs serve under a supervisor:

```ini
# ~/.config/systemd/user/aperture-work.service
[Unit]
Description=aperture bridge work
After=network-online.target

[Service]
ExecStart=/usr/local/bin/aperture serve --bridge work
Restart=on-failure

[Install]
WantedBy=default.target
```

### Flags

| Flag | Environment | Description |
|------|-------------|-------------|
| `-version` | | Print build version and exit |
| `-debug` | | Print environment variables set before launching the agent |
| `-endpoint` | `APERTURE_ENDPOINT` | Aperture URL to open on, instead of the saved one |
| `-bridge` | `APERTURE_BRIDGE` | Connect through the bridge with this name, creating it if there is none |
| Flag | Shorthand | Environment | Description |
|------|-----------|-------------|-------------|
| `--version` | `-v` | | Print build version and exit |
| `--debug` | `-d` | | Print environment variables set before launching the agent |
| `--endpoint` | `-e` | `APERTURE_ENDPOINT` | Aperture URL or saved endpoint name, instead of the active one |
| `--bridge` | `-b` | `APERTURE_BRIDGE` | Connect through the bridge with this name, creating it if there is none |
| `--config` | `-c` | `APERTURE_CONFIG` | Read and write this settings file |
| `--port` | `-p` | | Fixed port for `bridge add` and `serve` |
| `--provider` | `-p` | | Provider for a client launch |
| `--model` | `-m` | | Model for a client launch |

A flag beats its environment variable, so a one-off run can override whatever
the shell was started with. `-bridge` on its own starts at the well-known
the shell was started with. `--bridge` on its own starts at the well-known
location, the same guess the connection picker makes:

```sh
aperture -bridge work # http://ai over the "work" bridge
aperture -bridge work -endpoint aperture.example.com # that URL over the "work" bridge
aperture -endpoint aperture.example.com # direct, no bridge
aperture --bridge work # http://ai over the "work" bridge
aperture --bridge work --endpoint aperture.example.com # that URL over the "work" bridge
aperture --endpoint aperture.example.com # direct, no bridge
```

Neither is made the saved active endpoint until the connection works, so an
unreachable URL passed on the command line does not displace the one that does
work.

The single-dash long forms (`-endpoint`) still parse but are deprecated: they
print a warning and will be removed in a future release.

### Environment variables

Everything the launcher reads from the environment:

| Variable | Description |
|----------|-------------|
| `APERTURE_ENDPOINT` | Aperture URL to open on, instead of the saved one. `-endpoint` wins over it. |
| `APERTURE_BRIDGE` | Connect through the bridge with this name, creating it if there is none. `-bridge` wins over it. |
| `APERTURE_ENDPOINT` | Aperture URL to open on, instead of the saved one. `--endpoint` wins over it. |
| `APERTURE_BRIDGE` | Connect through the bridge with this name, creating it if there is none. `--bridge` wins over it. |
| `APERTURE_CONFIG` | Read and write this settings file. `--config` wins over it. |
| `TS_AUTHKEY` | Tailscale auth key that authorizes a fresh bridge device without the browser login. Read only when a new device registers; see [Concurrent sessions](#concurrent-sessions). |
| `CODEX_INSTALL_DIR` | Extra directory searched for a standalone Codex binary, matching the Codex installer's own variable. |
| `CODEX_HOME` | Extra Codex home searched for the standalone install layout, matching Codex's own variable. |
Expand All @@ -136,10 +199,20 @@ Everything the launcher reads from the environment:
```sh
make build # build ./aperture
make test # run tests
make check # lint, build, and the suite under the race detector
make install # install to $GOPATH/bin
make clean # remove built binary
```

### Test suites

`make check` is the gate CI runs: lint, build, then unit and e2e tests under
the race detector. `make test-tailnet` runs the black-box suites (cross-version
compatibility against the latest release, and golden output for the scripted
surface), in CI as the Black-box job and locally on a dev box. [docs/testing.md](docs/testing.md) covers each layer
with examples, including the e2e harness, the `-update` golden regeneration
and testing an installed binary with `APERTURE_E2E_BIN`.

## Releasing

Push a tag. The release workflow runs GoReleaser on a macOS runner: it imports the Developer ID certificate from GitHub secrets into a temporary keychain, and a build hook signs and notarizes each darwin binary before archiving, so nothing unsigned is ever published. A failed signature or a rejected notarization fails the run before upload. Required secrets: `APPLE_CERT_P12` (the base64-encoded .p12), `APPLE_CERT_PASSWORD`, `APPLE_ID` and `APPLE_ID_PASSWORD` (an app-specific password).
Expand Down
124 changes: 46 additions & 78 deletions cmd/aperture/main.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package main

import (
"flag"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
Expand All @@ -14,6 +13,7 @@ import (

tea "github.com/charmbracelet/bubbletea"
"github.com/tailscale/aperture-cli/internal/bridges"
"github.com/tailscale/aperture-cli/internal/cli"
"github.com/tailscale/aperture-cli/internal/config"
"github.com/tailscale/aperture-cli/internal/profiles"
"github.com/tailscale/aperture-cli/internal/tui"
Expand All @@ -30,11 +30,6 @@ import (
)

var (
flagVersion = flag.Bool("version", false, "print version and exit")
flagDebug = flag.Bool("debug", false, "enable bridge diagnostics and print agent launch environment")
flagEndpoint = flag.String("endpoint", "", "Aperture URL to open on, instead of the saved one ($APERTURE_ENDPOINT)")
flagBridge = flag.String("bridge", "", "connect through the bridge with this name, creating it if there is none ($APERTURE_BRIDGE)")

buildVersion = "B0-dev"
buildCommit = "unknown"
buildDate = "unknown"
Expand Down Expand Up @@ -108,116 +103,89 @@ func gitDescribeInDir(dir string) string {
return strings.TrimSpace(string(out))
}

// startRunLog points slog at the run log and returns a function that closes
// it. Records are written straight through, so the os.Exit paths that skip
// the close lose nothing. The close is tidiness, not a flush.
//
// A run that cannot open the file still runs. Diagnostics are not worth
// refusing to start over. Such a run discards them rather than writing to
// stderr, because stderr is the TUI's screen.
//
// verbose only raises the level. The log is on for every run. The run worth
// reading back is the one that went wrong, and nobody knows to pass -debug
// before it does.
func startRunLog(verbose bool) func() {
f, err := config.OpenRunLog()
if err != nil {
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
return func() {}
}
level := slog.LevelInfo
if verbose {
level = slog.LevelDebug
}
slog.SetDefault(slog.New(slog.NewTextHandler(f, &slog.HandlerOptions{Level: level})))
slog.Info("aperture starting", "version", buildVersion, "commit", buildCommit, "pid", os.Getpid())
return func() {
slog.Info("aperture exiting")
f.Close()
}
}

// reportFailure prints err and the run log path to stderr. Every diagnostic
// goes to the run log, which is right for a running TUI and wrong for a run
// that just died. Without this, a failed launch prints nothing and exits 1.
//
// stderr is safe at both call sites. The TUI either never started or has
// already given the terminal back.
func reportFailure(err error) {
slog.Error("command failed", "err", err)
fmt.Fprintln(os.Stderr, "aperture:", err)
if path, pathErr := config.RunLogPath(); pathErr == nil {
fmt.Fprintln(os.Stderr, "details:", path)
}
}

// flagOrEnv returns flag name when it was passed, even empty, and otherwise
// the environment variable key. The variable lets a dotfile, container or
// systemd unit make the same selection a typed invocation can. The flag wins
// so a one-off run can override the shell it started in: -bridge= means no
// bridge.
func flagOrEnv(fs *flag.FlagSet, name, key string) string {
passed := false
fs.Visit(func(f *flag.Flag) { passed = passed || f.Name == name })
if passed {
return fs.Lookup(name).Value.String()
}
return os.Getenv(key)
// versionLine reports the build version with its commit and date when known.
func versionLine() string {
if buildCommit != "unknown" {
return fmt.Sprintf("%s (%s, %s)", buildVersion, buildCommit, buildDate)
}
return buildVersion
}

func main() {
flag.Parse()

if *flagVersion {
if buildCommit != "unknown" {
fmt.Printf("%s (%s, %s)\n", buildVersion, buildCommit, buildDate)
} else {
fmt.Println(buildVersion)
}
os.Exit(0)
// runTUI starts the interactive launcher, the command a bare invocation
// gets. endpoint, bridge and debug come from the command line resolved.
func runTUI(endpoint, bridge string, debug bool) error {
if debug {
config.RaiseRunLog()
}

// Start the log before anything that logs. slog's default handler writes
// to stderr, and on a TUI that owns the terminal that paints a line over
// the screen. Until this runs every diagnostic is either damage or lost.
closeLog := startRunLog(*flagDebug)
defer closeLog()

g, err := config.Load()
if err != nil {
slog.Error("loading launcher config", "err", err)
reportFailure(err)
os.Exit(1)
return err
}
g.Debug = *flagDebug
g.Debug = debug

// Register Claude Desktop on supported platforms (darwin, windows).
profiles.RegisterIfSupported()

// Resolve the flags before the TUI takes the terminal. A URL we cannot use
// then exits non-zero instead of painting an error the script that passed
// it will never see.
start, err := config.EndpointFromFlags(g, flagOrEnv(flag.CommandLine, "endpoint", "APERTURE_ENDPOINT"), flagOrEnv(flag.CommandLine, "bridge", "APERTURE_BRIDGE"))
start, err := config.EndpointFromFlags(g, endpoint, bridge)
if err != nil {
slog.Error("resolving the endpoint to open on", "err", err)
reportFailure(err)
os.Exit(1)
return err
}

machines := bridges.NewMachines(g.Debug)
p := tea.NewProgram(tui.NewModel(g, buildVersion, machines, start))

var exitCode int
var runErr error
if _, err := p.Run(); err != nil {
slog.Error("launcher error", "err", err)
reportFailure(err)
exitCode = 1
runErr = err
}
if err := machines.Close(); err != nil {
slog.Error("shutting down bridges", "err", err)
reportFailure(err)
exitCode = 1
if runErr == nil {
runErr = err
}
}
if exitCode != 0 {
os.Exit(exitCode)
return runErr
}

func main() {
// The log opens before dispatch, so every run logs, headless ones too.
// The TUI still owns the terminal: nothing else writes to stderr here.
closeLog := config.StartRunLog()
defer closeLog()
slog.Info("aperture starting", "version", buildVersion, "commit", buildCommit, "pid", os.Getpid())
if err := cli.Execute(os.Args[1:], versionLine(), runTUI); err != nil {
// A launched client's exit code is aperture's own: an alias for the
// client has to behave like the client. Its stderr already said why.
// A signaled child reports -1, which would wrap to 255.
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
slog.Error("launched client failed", "err", err, "exitCode", exitErr.ExitCode())
if code := exitErr.ExitCode(); code > 0 {
os.Exit(code)
}
os.Exit(1)
}
reportFailure(err)
os.Exit(1)
}
}
34 changes: 0 additions & 34 deletions cmd/aperture/main_test.go

This file was deleted.

Loading
Loading