Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ A bridge is on one tailnet at a time. `Switch tailnet` logs it out, which remove

If verification fails, the endpoint remains configured for retry or editing, and any previous working endpoint remains active.

### Concurrent sessions

Each aperture process on a bridge is its own device on the tailnet. The first process uses the bridge's original identity; each additional one registers a numbered sibling (`aperture-cli-<bridge>-2` and up). Sharing one identity would let the control plane hand the session to whichever process registered last, silently cutting off the others.

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.

### Flags

| Flag | Environment | Description |
Expand All @@ -101,6 +107,20 @@ 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.

### 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. |
| `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. |
| `TMUX`, `TERM` | Detected, not set by you for aperture: picks the escape-sequence wrapping that carries a copied login link through tmux or screen. |
| `LOCALAPPDATA` | Windows only: locates the Claude Desktop configuration. |

## Development

```sh
Expand Down
71 changes: 71 additions & 0 deletions docs/adr/0006-one-machine-slot-per-process.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 0006. One Machine slot per concurrent process

Status: accepted
Date: 2026-09-21

## Why?

APT-330: five to ten agent sessions each running aperture in bridge mode leave
only the last-started one working; the rest hang and their clients get
"api error". Every process used the same state directory for the same Bridge,
so every process registered the same node key under the same hostname. The
control plane treats same-key connections as one node and hands the session to
the newest registrant; the older processes keep reporting themselves Running
locally while their dials silently reach nothing. The state directory was also
written concurrently, which risks corruption on top. The in-process rule "two
Machines for one Bridge would open the same state directory" never covered the
two-process case, and the failure mode it produced was silent.

## Decision

1. A Bridge's Machine identity is numbered by slot. Slot 1 keeps the existing
state directory and hostname (`bridges/<suffix>`,
`aperture-cli-<bridgeID>`); slots 2 and up get sibling directories
`bridges/<suffix>-N` and hostnames `aperture-cli-<bridgeID>-N`. Existing
users keep the device they already authorized.
2. A process claims the lowest free slot when it builds the node, and holds
it until the Machine is closed or destroyed. The claim is an exclusive
non-blocking lock on `bridges/locks/<suffix>-<slot>.lock` (flock where
there is flock, LockFileEx on Windows), held open for the node's life.
Lock files live outside the state directory so removal never deletes an
open lock and a claimant's inode can never be deleted under it.
3. `Machines.Destroy` logs out every slot the bridge has on disk. A slot
locked by a live process makes the whole removal fail before anything is
logged out, naming the conflict, because evicting a running session is
exactly the silent kill this ADR exists to remove.
4. Cap: 100 slots per bridge. Past that the process errors instead of
probing forever.
5. Slot claiming emits no event and writes no settings. It is recorded in
the contracts as deliberately silent.

## Consequences

Each concurrent process is its own device in the admin console: ten parallel
agent sessions are ten devices named `aperture-cli-<bridgeID>` through
`-10`. They accumulate when processes die unclosed (the devices go offline
and stay listed) and re-authorize only when a slot has never been authorized
before — once per slot, not once per launch. The failure mode for a
contended bridge moves from silent eviction to either a fresh slot (normal
case) or a named error (removal).

## Rejected

- One shared bridge daemon per machine, CLI instances dialing it over a local
socket — the correct architecture and roughly what `tailscaled` already is;
it costs a wire protocol, a trust boundary and a daemon lifecycle this CLI
does not otherwise have, for a first iteration that needed to make parallel
sessions work this week.
- A fresh ephemeral node per process — key expiry on ephemeral nodes means a
browser login on every launch, which is worse than the bug for the reported
workflow.
- Failing loudly on the second process with no sharing at all — converts
silent breakage into loud breakage; the report asked for the sessions to
work, not to be refused.

## Revisit when

The admin-console litter or the slot cap becomes the problem someone files,
or a daemon is wanted for its own reasons (credential renewal, one
connection to share). The lock files and slot layout are additive on top of
slot 1's legacy directory, so a daemon migration does not orphan existing
state.
29 changes: 17 additions & 12 deletions docs/specs/bridge-resource-lifecycle.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
# Bridge resource lifecycle

Creating a bridge produces three things. Removing one destroys one of them.
The survivors are a device in the user's admin console and a directory on
their disk. Decision: [ADR 0002](../adr/0002-bridge-removal-destroys-the-machine.md).
Creating a bridge produces three things per Machine slot it opens. Removing
one destroys all of them. The survivors are devices in the user's admin
console and directories on their disk. Decisions:
[ADR 0002](../adr/0002-bridge-removal-destroys-the-machine.md),
[ADR 0006](../adr/0006-one-machine-slot-per-process.md).

## What a bridge creates

| Resource | Created by | First exists | Removed by |
|---|---|---|---|
| Machine `aperture-cli-<bridge-id>` | `tsnet.Server` registering | first successful `Activate` | nothing |
| `$UserConfigDir/aperture/bridges/<hex>` | tsnet, from `Server.Dir` | first `Activate`, successful or not | nothing |
| Machine `aperture-cli-<bridge-id>[-N]` | `tsnet.Server` registering | first successful `Activate` of that slot | `Machines.Destroy` |
| `$UserConfigDir/aperture/bridges/<hex>[-N]` | tsnet, from `Server.Dir` | first `Activate` of that slot, successful or not | `Machines.Destroy` |
| `bridges/locks/<hex>-<slot>.lock` | the slot claim, when a node is built | first `Activate` of that slot | nothing; the lock is held open, the file content empty |
| `config.Bridge` | `AddBridge` (`global.go:178`) | the moment a name is typed | `RemoveBridge` (`global.go:219`) |

The device outlives the process because `newTSNetNode` (`node.go`) sets no
Expand All @@ -21,7 +24,9 @@ installer cleanup.

`Machine.LeaveTailnet` and `Machine.Destroy` are the only callers of `Logout`, and its
comment already names the failure mode: a close without a logout leaves the
device orphaned rather than removed.
device orphaned rather than removed. Each concurrent process holds its own
slot (ADR 0006), so parallel sessions register sibling devices rather than
evicting each other on the control plane.

## Where a bridge can be removed

Expand All @@ -45,12 +50,12 @@ login nobody finished, and is the one case with no device to clean up.

## What destroying it needs

`Machine.destroy`, on the aggregate that owns the node
([domain model](connection-domain-model.md#machine)): `Logout`, `Close`, then
discard the state directory, which is the Machine's own persistence.
`Machine.Destroy` is the entry point, reached through `Machines.Destroy`, because
destruction has to hold the Machine like every other
operation on that node.
`Machines.Destroy` logs out every slot the bridge has on disk: for each, a
Machine on that slot does `Logout`, `Close`, then discards the slot's state
directory, which is that identity's own persistence. A slot locked by a live
process fails the whole removal before anything is logged out — evicting a
running session is the failure ADR 0006 exists to remove, not a removal
feature.

The state directory goes last and only when the logout succeeded: it holds the
node key, which is what a later attempt would need to deregister the device.
Expand Down
6 changes: 4 additions & 2 deletions docs/specs/connection-context-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ The context boundaries below also describe the proposed broader event refactor.
| Gateway | The address a client is finally told to send requests to. The Endpoint URL when no Bridge is involved, the Route's local end when one is. | The Endpoint. Only equal to it in the direct case. |
| Route | The local door to one Endpoint through one Machine: a `127.0.0.1:0` listener reverse-proxying over the Machine. | A tailnet route or subnet route. |
| Bridge | The thing the user configures and sees in the picker: id, display name, last tailnet joined. Persisted. | The running tsnet node. |
| Machine | What this program runs on the user's tailnet for one Bridge: registers, may need a login, gets an address, carries dials, and shows up under Machines in their admin console. Outlives any one Attempt. | The Bridge record. The proxy. The computer aperture is running on. |
| Machine | What this program runs on the user's tailnet for one Bridge and one Slot: registers, may need a login, gets an address, carries dials, and shows up under Machines in their admin console. Outlives any one Attempt. | The Bridge record. The proxy. The computer aperture is running on. |
| Slot | Which of a Bridge's Machine identities a process holds, numbered from 1. Slot 1 keeps the original unsuffixed state directory and hostname; slot N adds a `-N` suffix to both. Held by an exclusive lock on a lock file, claimed when the node is built, released when the Machine closes or is destroyed. | A tailnet device. The lock file is locked, never the state directory. |
| Machines | The process's Machines, one per Bridge. Where a Machine is created and where they are all closed. | A manager. It does no network work of its own. |
| Login Link | The URL that authorizes a Machine. `https` only, no whitespace, opened in a browser or copied. | Any URL in a log line. |
| Phase | What the Attempt is waiting on right now, named for what the user is waiting for. | `ipn.State`. |
Expand Down Expand Up @@ -128,7 +129,8 @@ two while a login is outstanding, then to one: it exits when the state leaves
|---|---|
| Endpoint list, active endpoint | stored, `settings.json` |
| Bridge id, name, last tailnet | stored, `settings.json` |
| Machine tailnet credentials | stored by tsnet under the bridge state dir, never by us |
| Machine tailnet credentials | stored by tsnet under the slot's bridge state dir, never by us |
| Slot lock | a held-open file lock under `bridges/locks/`, process lifetime, file content empty |
| Machine, Route | transient, process lifetime, keyed by bridge id |
| Connection Attempt, Phase, Progress | transient, attempt lifetime |
| Gateway | transient, overwritten per successful Attempt |
Expand Down
12 changes: 12 additions & 0 deletions docs/specs/connection-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,23 @@ Everything else persisted is unconditional: `Endpoint.URL`, `Endpoint.BridgeID`
(empty means direct, which is a real value and not an absence), `Bridge.ID`,
`Bridge.Name`.

A Bridge's Machine identity lives outside settings, in the config directory:
one state directory per Slot, `bridges/<hex>` for slot 1 and
`bridges/<hex>-N` after, holding the node key tsnet wrote; and one lock file
per slot under `bridges/locks/`, empty, held locked by the process running
that slot. The state directory is the durable evidence a Machine exists
(`HasMachine` reads it); the lock is the durable evidence one is running
(`Machines.Destroy` reads it).

## Cross-check

With no API and no DDL, the cross-check reduces to: every aggregate transition
emits an event, or is recorded here as deliberately silent.

Deliberately silent: claiming and releasing a Slot. It is process-local
fencing between CLI instances, not a fact about the Bridge or the tailnet,
and no surface displays it.

| Transition | Event | Note |
|---|---|---|
| ConnectionAttempt → `StartingMachine` | `PhaseEntered` | |
Expand Down
15 changes: 10 additions & 5 deletions docs/specs/connection-domain-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,8 @@ across Attempts, so it cannot be owned by any one of them.

| Field | Type | Note |
|---|---|---|
| `bridge` | `config.Bridge` | Identity is its ID. At most one Machine per Bridge. |
| `bridge` | `config.Bridge` | Identity is its ID. At most one Machine per Bridge in a process. |
| `slot` | `int` | Which Slot it holds, zero until a node is built. |
| `tailnet` | `string` | The network joined, empty until the netmap lands and after leaving. |
| `routes` | `map[string]*Route` | Keyed by target URL. |

Expand All @@ -242,7 +243,8 @@ Behaviors: `Open(ctx, emit) error`, `RouteTo(ctx, url, emit) (*Route, error)`,

Invariants:
- A Route can only be created through an open Machine. `RouteTo` fails rather than starts a node.
- One operation at a time, cleanup included. Two Machines for one Bridge would open the same state directory, so only `Machines` creates them.
- One operation at a time, cleanup included. Within a process only `Machines` creates Machines, one per Bridge; across processes the Slot lock keeps one state directory to one process ([ADR 0006](../adr/0006-one-machine-slot-per-process.md)).
- The Slot is claimed when the node is built and held until `Close` or `Destroy`: a slot's directory may only be opened by a process holding its lock. `LeaveTailnet` keeps the Slot, so a reopen keeps the identity it had.
- `LeaveTailnet` logs out before closing: credentials live behind the node's own LocalAPI, so a close without a logout silently reuses them next time. `Destroy` also discards the state directory, last and only on success, because it holds the key a later attempt needs to deregister.
- Closing closes every Route first.
- Exactly one IPN bus watch per Machine.
Expand Down Expand Up @@ -282,11 +284,14 @@ tailnet with a same-named node.
## Machines

Collection. The process's Machines, one per Bridge, and the only place a
Machine is created. Getting a member does no network work.
Machine is created. Getting a member does no network work; a member claims
its Slot only when a node is built, so `For` never takes a lock.

Behaviors: `For(Bridge) (*Machine, error)`, which creates an idle member on
first use and refuses after `Close`; `Close() error`, which closes every
member and lets concurrent callers share one result.
member and lets concurrent callers share one result; `Destroy(ctx, bridge,
emit) error`, which logs out every Slot the bridge has on disk and refuses,
before logging anything out, while a live process holds one.

Invariants: at most one Machine per Bridge ID. A Bridge ID that is not the
generated `bridge-<hex>` shape is refused before it can become a hostname.
Expand All @@ -301,7 +306,7 @@ registered for it go together, Machine first (ADR 0002). Four functions in
|---|---|---|
| `CheckRemovable(settings, bridge, endpoint)` | update loop | Returns an error when the endpoint, or the bare bridge, cannot be removed: the active endpoint, or a bridge an endpoint still connects through. |
| `WillDestroyMachine(settings, bridge, endpoint)` | update loop | Reports whether removing the endpoint, or the bare bridge, logs a device out of a tailnet: the bridge started a Machine and no other endpoint connects through it. |
| `Machines.Destroy(ctx, bridge, emit)` | any goroutine | The bounded logout (ADR 0002 decision 6). Returns an error when the tailnet refuses or does not answer in time. Writes nothing. |
| `Machines.Destroy(ctx, bridge, emit)` | any goroutine | The bounded logout (ADR 0002 decision 6), once per Slot the bridge has on disk. Fails before any logout when another process holds a Slot, naming the conflict; otherwise returns an error when a tailnet refuses or does not answer in time. Writes nothing. |
| `RemoveFromSettings(settings, bridge, endpoint)` | update loop | Deletes the endpoint, then the bridge when nothing connects through it. Called only after `Destroy` returned nil, or when nothing needs destroying. |
| `Machines.Tailnet(bridge)` | update loop | The tailnet the running Machine reports, else the one saved on the bridge. |

Expand Down
2 changes: 1 addition & 1 deletion internal/bridges/attempt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func switchingAttempt(t *testing.T, logoutErr error) (*Attempt, *config.Global,
}
m := NewMachines(false)
t.Cleanup(func() { m.Close() })
m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode {
m.newNode = func(config.Bridge, int, string, func(string, ...any), func(string, ...any)) tailnetNode {
return &fakeNode{logoutErr: logoutErr, upErr: errors.New("no login yet")}
}
return a, g, m
Expand Down
8 changes: 3 additions & 5 deletions internal/bridges/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,10 @@ func switchTailnet(ms *Machines, ctx context.Context, bridge config.Bridge, emit
return mc.LeaveTailnet(ctx, emit)
}

// destroyMachine removes the bridge's machines the way the TUI does: through
// Machines.Destroy, which logs out every slot the bridge has on disk.
func destroyMachine(ms *Machines, ctx context.Context, bridge config.Bridge, emit func(connection.Event)) error {
mc, err := ms.For(bridge)
if err != nil {
return err
}
return mc.Destroy(ctx, emit)
return ms.Destroy(ctx, bridge, emit)
}

func tailnetOf(ms *Machines, bridgeID string) string {
Expand Down
Loading
Loading