Skip to content
Merged
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
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ See [docs/tx9-cli-design.md](docs/tx9-cli-design.md) for the full
design and [docs/docker-architecture.md](docs/docker-architecture.md) for
the runtime architecture and its verification history.

For a stable HTTPS Executor origin and OAuth callbacks over a tailnet, see
[Tailscale HTTPS for Executor](docs/tailscale-executor.md).

## Model

```text
Expand All @@ -25,7 +28,7 @@ tx9 create (Go CLI, embedded build assets)
│ │ claude·codex·hermes │ │ Executor daemon only │ │
│ │ gateway, sudo, free │ │ dashboard / + MCP /mcp │ │
│ │ agent volume = the box│ │ scratch volume │ │
│ │ no published ports │ │ one 0.0.0.0 host port │ │
│ │ no published ports │ │ one configurable port │ │
│ └───────────┬───────────┘ └───┬─────────────────────┘ │
│ │ http://executor:4788 │ bearer-token gated │
│ └── private network ───┘ │
Expand Down Expand Up @@ -66,11 +69,21 @@ hb wire-mcp # (re)register Executor's authenticated HTTP MCP for claude+c
hb logs executor
```

The host CLI exposes the safe gateway lifecycle directly:

```bash
tx9 gateway status <box>
tx9 gateway enable <box> --confirm-single-writer
tx9 gateway disable <box>
```

Fresh and restored boxes begin with the Hermes gateway durably disabled so
setup and migration cannot create a second Discord writer; enabling it is
always an explicit, confirmed step. Executor runs remotely from the agent's
perspective (`EXECUTOR_HOST=executor`): `hb` checks reachability and wires
MCP with the injected bearer token; it never spawns or stops the daemon.
always an explicit, confirmed step. Do not use `hermes gateway run` as the
persistent TX9 lifecycle: it is foreground work and exits with its terminal.
Executor runs remotely from the agent's perspective (`EXECUTOR_HOST=executor`):
`hb` checks reachability and wires MCP with the injected bearer token; it never
spawns or stops the daemon.

## Backup guarantees

Expand Down
11 changes: 8 additions & 3 deletions docs/docker-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ table, or user namespace with it.
│ └──── private network ─────┘ │ │
└──────────────────────────── (per-project; no cross-box) ─┼────────┘
host 0.0.0.0:<auto> — LAN + Tailscale
host 0.0.0.0:<auto> by default
or 127.0.0.1:<fixed> behind Tailscale Serve
dashboard at / · MCP at /mcp
bearer token gates every request
Expand Down Expand Up @@ -133,8 +134,12 @@ gVisor (`--runtime=runsc`) remains the one-flag escape hatch if VM-grade
isolation is ever wanted back — the design doesn't change.

Networking stance is unchanged from smolvm (always-on, tools need internet).
The executor's single published port binds 0.0.0.0 (LAN + Tailscale, per
decision); the agent container publishes nothing.
The executor's single published port binds `0.0.0.0` on a Docker-assigned port
by default; the agent container publishes nothing. A box may instead persist a
fixed loopback binding, public `EXECUTOR_WEB_BASE_URL`, and container DNS list
for a reverse proxy such as Tailscale Serve. This preserves the two-container
isolation boundary while giving OAuth redirects a stable trusted HTTPS origin;
see [Tailscale HTTPS for Executor](tailscale-executor.md).

## Lifecycle mapping

Expand Down
240 changes: 240 additions & 0 deletions docs/tailscale-executor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
# Tailscale HTTPS for Executor

Executor OAuth redirects need a stable browser-visible origin. A raw TX9
dashboard URL such as `http://100.x.y.z:32770` is not suitable for providers
that require an HTTPS callback, and Executor otherwise defaults its callback
origin to `http://localhost:4788` inside the container.

The supported TX9 setup is:

```text
browser on the tailnet
|
| https://<node>.<tailnet>.ts.net
v
Tailscale Serve on the TX9 host
|
| http://127.0.0.1:<fixed-port>
v
TX9 Executor container :4788
```

This keeps the dashboard private to the tailnet, gives it a publicly trusted
certificate, and gives Executor an exact origin for OAuth callbacks. Tailscale
Serve is a tailnet-only reverse proxy; do not use Funnel unless public internet
access is intentional.

## Configure an existing box

Run these commands on the TX9 host. Replace `media-bot` and the fixed port if
needed:

```bash
BOX=media-bot
PORT=32770
MAGICDNS=$(tailscale status --json | jq -r '.Self.DNSName' | sed 's/\.$//')
UPSTREAM_DNS=$(
awk '/^nameserver / && $2 ~ /^[0-9]+\./ && $2 !~ /^127\./ { print $2; exit }' \
/run/systemd/resolve/resolv.conf
)

test -n "$MAGICDNS"
test "$MAGICDNS" != null
test -n "$UPSTREAM_DNS"

tx9 upgrade "$BOX" \
--executor-web-base-url "https://$MAGICDNS" \
--executor-publish "127.0.0.1:$PORT" \
--executor-dns "100.100.100.100,$UPSTREAM_DNS"

tailscale serve --bg --yes "http://127.0.0.1:$PORT"
```

Use `sudo tailscale ...` if the current account is not a Tailscale operator.
The first Serve command may open a Tailscale consent flow to enable HTTPS for
the tailnet.

The TX9 options have separate jobs:

- `--executor-web-base-url` becomes Executor's `EXECUTOR_WEB_BASE_URL`. It must
be the exact origin loaded in the browser: scheme, hostname, and port when
non-default. Executor derives `/api/oauth/callback` from it.
- `--executor-publish` gives Serve a stable loopback target. Loopback prevents
clients from bypassing Serve and reaching the raw HTTP dashboard over LAN.
- `--executor-dns` gives the container both MagicDNS and ordinary DNS. The
Tailscale resolver handles tailnet names; the host's upstream resolver keeps
public OAuth and integration hosts working.

TX9 persists these values in `~/.tx9/boxes/<box>.env`. Future
`tx9 upgrade <box>` calls recreate the container with the same public origin,
port, and DNS settings, so the Serve target remains valid.

## Configure a new or imported box

The same options are accepted by `create` and `import`:

```bash
tx9 create media-bot \
--executor-web-base-url "https://$MAGICDNS" \
--executor-publish "127.0.0.1:$PORT" \
--executor-dns "100.100.100.100,$UPSTREAM_DNS"

tailscale serve --bg --yes "http://127.0.0.1:$PORT"
```

For an import, replace `tx9 create ...` with `tx9 import backup.tx9 ...`.

## Validate the full path

Check the host proxy, HTTPS endpoint, and the actual Executor runtime:

```bash
tailscale serve status
tx9 list
curl -v "https://$MAGICDNS/api/health"

docker exec "tx9-$BOX-executor" \
getent hosts "$MAGICDNS"
docker exec "tx9-$BOX-executor" \
curl -fsS "https://$MAGICDNS/api/health"

tx9 doctor "$BOX"
```

Expected results:

- Serve maps `/` to `http://127.0.0.1:<fixed-port>`.
- `/api/health` returns `ok` over trusted HTTPS.
- `tx9 list` shows the HTTPS MagicDNS URL instead of a raw HTTP IP.
- `tx9 doctor` passes both the local port and configured public URL probes.

Bootstrap the bearer token into the new browser origin once:

```bash
tx9 open "$BOX"
```

Treat the printed `?_token=` URL as a secret. OAuth integrations should use:

```text
https://<node>.<tailnet>.ts.net/api/oauth/callback
```

Do not disable TLS verification; Tailscale Serve supplies a trusted
certificate.

## Run Hermes independently of tmux

`hermes gateway setup` configures Discord, Telegram, or another platform, but
`hermes gateway run` is a foreground process. If it is launched directly in a
tmux pane, closing that pane or its shell takes the gateway offline.

Configure Hermes inside the box, then let TX9 own the long-running process:

```bash
tx9 enter "$BOX"
hermes gateway setup
# Leave any foreground gateway once setup is complete, then detach/exit.
```

From the TX9 host, explicitly pass the single-writer gate:

```bash
tx9 gateway enable "$BOX" --confirm-single-writer
tx9 gateway status "$BOX"
tx9 doctor "$BOX"
```

TX9 first stops any foreground gateway left by setup, then starts it without a
TTY; its reconcile loop restarts it after a crash. Docker's `unless-stopped`
policy restores the container after a host reboot. The confirmation flag means
no other machine is running the same messaging identity; never use it until the
previous gateway is stopped.

To stop the messaging gateway without stopping the whole box:

```bash
tx9 gateway disable "$BOX"
```

## Remove the HTTPS configuration

Return the box to TX9's default Docker-assigned HTTP port and remove its
persisted public URL/DNS overrides:

```bash
tx9 upgrade "$BOX" --clear-executor-config
tailscale serve --https=443 off
```

The Serve command disables the node's default HTTPS listener. If other
services share that listener, edit the Serve configuration instead of clearing
it wholesale. For a non-default HTTPS port, replace `443` with that port.

## More than one Executor on a host

Only one backend can own the root of the node's default HTTPS port. For another
box, use a second fixed host port and a second Serve HTTPS port, then include
that public port in Executor's web base URL:

```bash
BOX=second-bot
PORT=32771
HTTPS_PORT=8443

tx9 upgrade "$BOX" \
--executor-web-base-url "https://$MAGICDNS:$HTTPS_PORT" \
--executor-publish "127.0.0.1:$PORT" \
--executor-dns "100.100.100.100,$UPSTREAM_DNS"

tailscale serve --bg --yes --https="$HTTPS_PORT" \
"http://127.0.0.1:$PORT"
```

## Troubleshooting

### HTTPS works on the host but not in the container

Inspect the configured resolvers and test tailnet plus public names:

```bash
docker inspect "tx9-$BOX-executor" \
--format '{{json .HostConfig.Dns}}'
docker exec "tx9-$BOX-executor" getent hosts "$MAGICDNS"
docker exec "tx9-$BOX-executor" getent hosts github.com
```

Use both `100.100.100.100` and a real upstream resolver. A loopback resolver
such as `127.0.0.53` belongs to the host network namespace and is not a usable
Docker DNS target.

### OAuth still advertises localhost or HTTP

Confirm the environment reached the running Executor process configuration:

```bash
docker inspect "tx9-$BOX-executor" \
--format '{{range .Config.Env}}{{println .}}{{end}}' |
grep '^EXECUTOR_WEB_BASE_URL='
```

If it is missing, rerun `tx9 upgrade` with the three Executor options above.

### The public URL fails after an upgrade

Check that the fixed port is still published and that Serve points to it:

```bash
docker port "tx9-$BOX-executor" 4788/tcp
tailscale serve status
```

Older TX9 versions used an automatically assigned port and did not persist
Executor proxy settings. Apply the configuration again with a TX9 version that
supports the Executor options.

## References

- [Tailscale Serve](https://tailscale.com/docs/features/tailscale-serve)
- [Tailscale Docker parameters](https://tailscale.com/docs/features/containers/docker/docker-params)
- [Executor self-hosting and `EXECUTOR_WEB_BASE_URL`](https://executor.sh/docs/hosted/docker)
16 changes: 14 additions & 2 deletions docs/tx9-cli-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,18 @@ tx9 create
| `tx9 start <box>` / `tx9 stop <box>` | Both containers together. Volumes persist. |
| `tx9 backup <box>` (alias `export`) | Flags: `--path` (default `~/Downloads`), `--password`/env/prompt, `--no-encrypt`. Quiesce → archive agent /data → validate → (encrypt) → verify → `<box>-<timestamp>.tx9`. |
| `tx9 import <file.tx9>` | Flags: `--name`, `--password`/env/prompt. Validate before creating anything; restore staged; arrive quiesced + gateway-disabled + fresh token; fail on name collision. |
| `tx9 gateway <status\|enable\|disable> <box>` | Inspect or control the container-supervised Hermes gateway. Enable requires `--confirm-single-writer`. |
| `tx9 open <box>` | Print (or open) the authenticated dashboard URL (`?_token=`). |
| `tx9 doctor <box>` | In-box `hb doctor` + host-side published-port probe. |
| `tx9 upgrade [box]` | No args: self-update (re-run installer logic). With box: recreate containers on current image + readiness gate. |
| `tx9 delete <box>` | Containers + volumes + token file, typed-name confirmation (`--force` to skip). |
| `tx9 prune` | Remove unused `tx9-box:*` image versions and stale state files. |

`create`, `import`, and box-specific `upgrade` accept the shared
`--executor-web-base-url`, `--executor-publish`, and `--executor-dns` options.
Their resolved values are persisted per box and reused on future upgrades.
`--clear-executor-config` returns a box to the default dynamic HTTP exposure.

## Fixed contracts (carried from the verified bash draft)

These were dry-run- and QA-verified in the boxd prototype and must survive
Expand All @@ -60,7 +66,8 @@ the port:
- **Topology**: agent container (no published ports, volume = the box, sudo,
IPv6 disabled via sysctl) + executor container (daemon only,
`--hostname 0.0.0.0 --port 4788 --auth-token <token>`, one published
0.0.0.0 host port, scratch volume) + per-box private network. Labels
host port (all interfaces/automatic by default, optionally fixed and
loopback-only), scratch volume) + per-box private network. Labels
(`tx9=1`, `tx9.box=<name>`, `tx9.version=<v>`) on every object.
- **Token**: 256-bit random per box, minted at create/import, injected as
`BOXD_EXECUTOR_TOKEN` (outranks any restored on-disk `executor-mcp.env` —
Expand All @@ -72,7 +79,12 @@ the port:
tar on backup. Restores stage-then-promote in a throwaway container and
re-arm quiesce + gateway-disabled markers.
- **Gateway single-writer**: restored boxes never auto-enable the Hermes
gateway. `hb gateway-enable --confirm-single-writer …` stays the only path.
gateway. `tx9 gateway enable <box> --confirm-single-writer` delegates to
`hb gateway-enable` and stays the only host-side path.
- **Executor public origin**: create/import/upgrade optionally persist an exact
`EXECUTOR_WEB_BASE_URL`, fixed host publish address, and container DNS list.
This supports HTTPS reverse proxies such as Tailscale Serve without losing
the callback origin or proxy target on the next container recreation.
- **Entrypoints**: agent PID-1 = `hb init` (fail fast) + hb-workload loop
under docker `--init`; executor PID-1 = foreground daemon. Restart policy
`unless-stopped`.
Expand Down
Loading