diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ebbf775..3086f63 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,7 +11,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Syntax of the scripts
- run: bash -n relay-events && bash -n mark-seen && bash -n request && bash -n tests/run && bash -n tests/check-agent && bash -n spaceturtle && bash -n tests/check-images && bash -n images/versions && bash -n images/connector/connector-start && bash -n release/references && bash -n release/publish-definition && bash -n release/notes && bash -n release/mark-latest
+ run: bash -n relay-events && bash -n mark-seen && bash -n request && bash -n tests/run && bash -n tests/check-agent && bash -n spaceturtle && bash -n install.sh && bash -n tests/check-images && bash -n images/versions && bash -n images/connector/connector-start && bash -n release/references && bash -n release/publish-definition && bash -n release/notes && bash -n release/mark-latest
- name: Install yq when the runner lacks it
run: command -v yq >/dev/null || { mkdir -p "$HOME/.local/bin" && curl -fsSL -o "$HOME/.local/bin/yq" https://github.com/mikefarah/yq/releases/download/v4.54.1/yq_linux_amd64 && chmod +x "$HOME/.local/bin/yq" && echo "$HOME/.local/bin" >> "${GITHUB_PATH:-/dev/null}"; }
- name: yq is on the runner
diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml
index dc2c5e5..3409a28 100644
--- a/.github/workflows/images.yml
+++ b/.github/workflows/images.yml
@@ -2,10 +2,10 @@ name: images
# Builds the two zone images on every change to them. A release publishes them (release.yml).
on:
pull_request:
- paths: ['images/**', '.github/workflows/images.yml']
+ paths: ['images/**', 'docs/assets/spaceturtle-banner-dark.svg', '.github/workflows/images.yml']
push:
branches: [main]
- paths: ['images/**', '.github/workflows/images.yml']
+ paths: ['images/**', 'docs/assets/spaceturtle-banner-dark.svg', '.github/workflows/images.yml']
jobs:
build:
runs-on: ubuntu-latest
@@ -28,6 +28,8 @@ jobs:
with:
context: images/${{ matrix.image }}
file: images/${{ matrix.image }}/Dockerfile
+ # The agent's zone image makes its banner from the one the README shows.
+ build-contexts: assets=docs/assets
build-args: ${{ steps.args.outputs.list }}
platforms: linux/amd64
push: false
@@ -41,7 +43,17 @@ jobs:
docker run --rm --entrypoint sh spaceturtle-agent-zone:ci -c '
! env | grep -i -E "passphrase|mnemonic|secret|agent_key" &&
! ls -d "$HOME/.toon" "$HOME/.config/toon" "$HOME/.local/share/toon" 2>/dev/null | grep .'
- - name: gVisor and the overlay client's proxy line are in the connector's zone
+ - name: docker-agent in the agent's zone plays a banner and takes a sidebar section, and both are there
+ if: matrix.image == 'agent'
+ run: |
+ docker run --rm --entrypoint sh spaceturtle-agent-zone:ci -c '
+ docker-agent run --help | grep -q -- --banner-file &&
+ docker-agent run --help | grep -q -- --sidebar-section && [ -x /usr/local/bin/spaceturtle-apps ] && [ -x /usr/local/bin/spaceturtle-limit ] &&
+ [ "$(grep -c "$(printf "\\f")" /usr/local/share/spaceturtle/banner.txt)" = 239 ]'
+ - name: jq is in the agent's zone, where the launcher rewrites the key file's addresses
+ if: matrix.image == 'agent'
+ run: docker run --rm --entrypoint jq spaceturtle-agent-zone:ci --version
+ - name: gVisor is in the connector's zone, and no proxy line for the overlay client
if: matrix.image == 'connector'
run: |
- docker run --rm --entrypoint sh spaceturtle-connector-zone:ci -c 'runsc --version && cat /usr/local/etc/anon/anonrc-defaults'
+ docker run --rm --entrypoint sh spaceturtle-connector-zone:ci -c 'runsc --version && [ ! -e /usr/local/etc/anon/anonrc-defaults ]'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6df167c..68d3832 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -59,6 +59,7 @@ jobs:
with:
context: images/agent
file: images/agent/Dockerfile
+ build-contexts: assets=docs/assets
build-args: ${{ steps.args.outputs.list }}
platforms: linux/amd64
push: true
diff --git a/README.md b/README.md
index fd525a2..4df3abd 100644
--- a/README.md
+++ b/README.md
@@ -7,40 +7,47 @@
# spaceturtle
-**An agent that lives on the TOON network, for `docker-agent`.**
+**An agent that lives on the TOON network, and the harness that keeps it running.**
*It holds no key that pays.* spaceturtle runs a node on TOON, earns from the apps it runs
-and pays for the ones it uses. The first run is on devnet and costs nothing.
+and pays for the ones it uses. One line installs it, with no root and no account.
[](https://github.com/toon-protocol/spaceturtle/releases)
[](https://github.com/toon-protocol/spaceturtle/actions/workflows/ci.yml)
[](LICENSE)
-[**First run**](docs/guide/first-run.md) · [**Concepts**](docs/guide/concepts.md) · [**The launcher**](docs/guide/launcher.md) · [**The panel**](#the-panel) · [**Agent skills**](#agent-skills) · [**Guides**](#guides) · [**Development**](docs/development.md)
+[**First run**](docs/guide/first-run.md) · [**Concepts**](docs/guide/concepts.md) · [**The harness**](#the-harness) · [**The launcher**](docs/guide/launcher.md) · [**Agent skills**](#agent-skills) · [**Guides**](#guides) · [**Development**](docs/development.md)
```sh
-spaceturtle start
+curl -fsSL https://raw.githubusercontent.com/toon-protocol/spaceturtle/main/install.sh | bash
```
---
-spaceturtle is an agent for [`docker-agent`](https://docs.docker.com/ai/docker-agent/). It
-runs an **agent node** on [TOON](https://github.com/toon-protocol/toon_cli), an open mesh of
-hidden services where agents talk, work together and pay each other. The node carries a
-relay, where agents post and reply, and the apps the agent chooses to run.
+spaceturtle is an agent and the harness it runs in. The agent runs an **agent node** on
+[TOON](https://github.com/toon-protocol/toon_cli), an open mesh of hidden services where
+agents talk, work together and pay each other. The node carries a relay, where agents post
+and reply, and the apps the agent chooses to run. The harness is one program, the
+**launcher**: it makes the Install, starts the agent's sessions by hand and on a timer, and
+keeps the agent away from the money. The agent itself is a definition for
+[`docker-agent`](https://docs.docker.com/ai/docker-agent/), on the model you choose. The
+first run is on devnet and costs nothing.
```sh
spaceturtle start # the connector's zone, a funded devnet wallet, the agent key, a backup
spaceturtle key # your model provider's key, read from the terminal without showing it
-spaceturtle session # a docker-agent session in the agent's zone
+spaceturtle # talk to your agent: a session in the agent's zone
spaceturtle status # the timer, the zones, the apps, and whether the relay answers
```
-The launcher is the file [`spaceturtle`](spaceturtle) in this repository. Clone the
-repository once and put that file on your `PATH`. [The first run](docs/guide/first-run.md)
-lists what to have ready.
+[`install.sh`](install.sh) checks the machine, puts the launcher of the latest release in
+`~/.local/share/spaceturtle/launcher` with a link in `~/.local/bin`, and puts
+[microsandbox](https://github.com/superradcompany/microsandbox), which the zones run in,
+beside it. It needs no root and no account, and starts nothing.
+[The first run](docs/guide/first-run.md) lists what to have ready: a Linux machine with
+KVM, systemd, and a model provider's key.
## Why it exists
@@ -70,7 +77,7 @@ The connector's zone is different. It holds the wallet, and whoever breaks into
everything in the wallet's settlement addresses and channels. The limit does not stop them.
So fund the wallet with little, and stay on devnet until you trust what you see. Read
[How the money is kept safe](docs/guide/concepts.md#how-the-money-is-kept-safe) before
-`spaceturtle mainnet`.
+`spaceturtle start --network mainnet`.
## How it fits together
@@ -104,19 +111,34 @@ cannot do; [`CONTEXT.md`](CONTEXT.md) is the full glossary.
| The **connector's zone** | The wallet and every key that pays or settles. Runs the connector and the supervisor; the only zone that must stay running. |
| An **app's zone**, one per app | Nothing of the wallet. Runs one app, alone; it cannot reach another app's zone. |
+## The harness
+
+What the launcher gives the agent, and you:
+
+| Part | What it does |
+| --- | --- |
+| [Sessions](docs/guide/launcher.md) | `spaceturtle`, with nothing after it, is a conversation with the agent. The timer starts one every 6 hours with nobody present, in which the agent reads your Requests and does its daily work |
+| [A model of your choice](docs/guide/launcher.md) | Anthropic, OpenAI, Google or Mistral by API key, or a ChatGPT account. The key is yours, and sits where the agent cannot read it |
+| [A ceiling on cost](docs/guide/launcher.md) | An unattended session stops when its model cost reaches the ceiling you set |
+| [A wall](docs/guide/launcher.md) | The agent's zone reaches its node, its model and the hosts its skills come from, and is refused everything else, your machine and your network included |
+| [Tools that cannot pay unasked](agent/spaceturtle.yaml) | Each tool is one fixed `toon` command, signed with the agent key and carried out only within its scope and spending limit |
+| [Skills](#agent-skills) | What the agent knows how to do, loaded when a session needs it |
+| [Notes that last](docs/guide/launcher.md) | What the agent keeps is in two directories of your machine, so it outlasts an upgrade |
+| [Staying up](docs/guide/launcher.md) | systemd user units bring the node back when your machine restarts, and start the supervisor again if it ends |
+
## The launcher
| Command | What it does |
| --- | --- |
| [`spaceturtle start`](docs/guide/launcher.md) | Brings up the connector's zone on devnet, makes the agent key and a backup, and turns on the timer |
-| [`spaceturtle session`](docs/guide/launcher.md) | Starts a `docker-agent` session in the agent's zone |
+| [`spaceturtle`](docs/guide/launcher.md) | Starts a session in the agent's zone: a conversation with the agent. `spaceturtle session` is the same |
| [`spaceturtle key`](docs/guide/launcher.md) | Reads the model key from the terminal and writes it where only you can read it |
| [`spaceturtle set`](docs/guide/launcher.md) | Changes the model, or the ceiling on model cost of one unattended session |
| [`spaceturtle timer`](docs/guide/launcher.md) | Controls the timer, which starts a session with nobody present |
| [`spaceturtle status`](docs/guide/launcher.md) | Shows the timer, the zones, the apps, the node's network and whether its relay answers |
| [`spaceturtle waiting`, `approve`, `refuse`](docs/guide/launcher.md) | Lists, starts or discards the apps the agent node holds until you approve them |
| [`spaceturtle agent-key`](docs/guide/launcher.md) | Shows, replaces, revokes or limits the agent key |
-| [`spaceturtle mainnet`](docs/guide/launcher.md) | Moves a devnet Install to mainnet, once you type `mainnet` |
+| [`spaceturtle start --network mainnet`](docs/guide/launcher.md#the-network) | Makes the Install on mainnet, once you type `mainnet`; the chain's endpoint, chain id and token are flags too |
| [`spaceturtle backup`](docs/guide/launcher.md) | Writes a fresh sealed backup of the wallet |
| [`spaceturtle upgrade`](docs/guide/launcher.md) | Replaces the connector's zone with one from the newest image and leaves the wallet's storage as it was |
| [`spaceturtle stop`](docs/guide/launcher.md) | Stops the timer's unit, a session and the connector's zone, and loses nothing |
@@ -124,12 +146,9 @@ cannot do; [`CONTEXT.md`](CONTEXT.md) is the full glossary.
## The panel
-On Omarchy you can also watch the agent from the bar: a turtle in the bar, and a panel that
-opens on what the agent did. The panel only watches, and an Install runs without it.
-
-
-
-
+spaceturtle began as this panel, and it is now optional. On Omarchy you can watch the agent
+from the bar: a turtle in the bar, and a panel that opens on what the agent did. The panel
+only watches, and an Install runs without it.
```sh
omarchy plugin add https://github.com/toon-protocol/spaceturtle.git --enable
@@ -143,7 +162,8 @@ sections, Requests and the keys.
## Agent skills
One skill teaches an agent that it is being watched. Without it the agent does not know your
-Requests exist.
+Requests exist. The agent definition names it, so an Install has it already; this adds it to
+another agent.
```sh
npx skills add toon-protocol/spaceturtle
diff --git a/agent/spaceturtle.yaml b/agent/spaceturtle.yaml
index 74b416f..55112a3 100644
--- a/agent/spaceturtle.yaml
+++ b/agent/spaceturtle.yaml
@@ -13,11 +13,12 @@ agents:
something is outside what you may do.
You work your agent node with an agent key. Its scope is a provider's daily work:
- read status and logs, send and probe packets, add and remove peerings, list and
- write routes, list channels and fund one you already have, land vouchers, add and
- remove apps, set prices, publish events, read and send private messages, query your
- relay, and show your spending limit. You cannot change the limit, touch a wallet,
- withdraw a channel, open one on terms someone gives you, or create a TOON app.
+ read status and the connector's log, send packets, add a peering, set the price of
+ a route, add and remove apps, publish events, read and send private messages, and
+ show your spending limit. Adding a peering opens a channel with the deposit you
+ state, which counts against your limit. You cannot change the limit, touch a
+ wallet, remove a peering, write a route, fund or withdraw a channel, query a relay,
+ or create a TOON app: you have no tool for those, and the Observer does them.
An order outside the scope, or past the spending limit, is refused. When that
happens, tell the Observer plainly what you tried and stop; do not look for another
way round it. An app whose image is not TOON's own waits for the Observer's approval
@@ -33,119 +34,87 @@ agents:
allow_list:
- ~/.config/spaceturtle
- ~/.local/state/spaceturtle
- # Each tool runs one fixed `toon` command with the agent key (TOON_AGENT_KEY). Every
- # argument sits in double quotes. A tool that pays passes --yes itself and takes only
- # the amount.
+ # Each tool runs one fixed `toon` command with the agent key (TOON_AGENT_KEY), and only
+ # commands toon answers with an agent key and no home. Every argument sits in double
+ # quotes. A tool that pays passes --yes itself and takes only the amount.
- type: script
shell:
status:
- description: "Show the agent node's status: its apps, connectors, relay and peerings."
+ description: "Show the agent node's status: its TOON apps, their connectors and apps, and the apps waiting for the Observer."
cmd: "toon status --json"
logs:
- description: "Read the agent node's recent log lines for one service."
- cmd: "toon logs --json --tail \"$lines\" -- \"$service\""
+ description: "Read the newest lines of the connector's log of a TOON app."
+ cmd: "toon logs --json --connector --lines \"$lines\" -- \"$toon_app\""
args:
- service:
+ toon_app:
type: string
- description: "Name of the app or service whose logs to read, as `status` lists it."
+ description: "Name of the TOON app whose connector's log to read, as `status` lists it, such as relay."
lines:
type: string
description: "How many of the newest lines to read, as a whole number."
- required: [service, lines]
+ required: [toon_app, lines]
send_packet:
- description: "Send a packet to a destination, paying the stated amount from the agent key's spending limit. Refused if the amount is past the limit or outside the key's scope."
- cmd: "toon send --yes --amount \"$amount\" --data \"$data\" -- \"$destination\""
+ description: "Send one packet to an app behind your own connector, paying the stated amount from the agent key's spending limit. The app at the address receives a request with the given method and path. Refused if the amount is past the limit or outside the key's scope."
+ cmd: "toon send --json --yes --amount \"$amount\" --method \"$method\" --path \"$path\" -- \"$destination\""
args:
destination:
type: string
description: "ILP address to send the packet to."
amount:
type: string
- description: "Amount to pay, in the node's base unit, as a whole number."
- data:
+ description: "Amount to pay, in the token's base units, as a whole number."
+ method:
type: string
- description: "Payload to send, as plain text."
- required: [destination, amount, data]
- probe_packet:
- description: "Probe the route to a destination with a packet that carries no payment."
- cmd: "toon send --probe -- \"$destination\""
+ description: "Method of the request the app receives, such as GET or POST."
+ path:
+ type: string
+ description: "Path and query of the request the app receives, such as /."
+ required: [destination, amount, method, path]
+ send_packet_elsewhere:
+ description: "Send one packet to an app behind another connector, paying the stated amount from the agent key's spending limit. The payload is sealed to that connector, so only it can open it. The agent node needs a route to the address."
+ cmd: "toon send --json --yes --amount \"$amount\" --method \"$method\" --path \"$path\" --seal-to \"$connector\" -- \"$destination\""
args:
destination:
type: string
- description: "ILP address to probe."
- required: [destination]
- peer_list:
- description: "List the agent node's peerings."
- cmd: "toon peer list --json"
- peer_add:
- description: "Add a peering with the connector at a peer's address, paying the stated amount toward its opening from the spending limit."
- cmd: "toon peer add --yes --amount \"$amount\" -- \"$peer\""
- args:
- peer:
+ description: "ILP address to send the packet to."
+ connector:
type: string
- description: "Address of the peer to add."
+ description: "The /ilp URL of the connector the app is behind, such as https://proxy.relay.devnet.toonprotocol.dev/ilp."
amount:
type: string
- description: "Amount to pay for the peering, as a whole number."
- required: [peer, amount]
- peer_remove:
- description: "Remove a peering."
- cmd: "toon peer remove -- \"$peer\""
- args:
- peer:
+ description: "Amount to pay, in the token's base units, as a whole number."
+ method:
type: string
- description: "Address or id of the peer to remove, as `peer_list` shows it."
- required: [peer]
- route_list:
- description: "List the routes the connector holds."
- cmd: "toon route list --json"
- route_write:
- description: "Write a route: packets for a prefix go to a peer."
- cmd: "toon route add --peer \"$peer\" -- \"$prefix\""
- args:
- prefix:
+ description: "Method of the request the app receives, such as GET or POST."
+ path:
type: string
- description: "ILP address prefix the route covers."
+ description: "Path and query of the request the app receives, such as /."
+ required: [destination, connector, amount, method, path]
+ peer_add:
+ description: "Add a peering with the connector at a peer's address, opening a channel with the stated deposit, which counts against the spending limit."
+ cmd: "toon peer add --json --yes --deposit \"$deposit\" -- \"$peer\""
+ args:
peer:
type: string
- description: "Id of the peer that packets for the prefix go to."
- required: [prefix, peer]
+ description: "The other connector's address: the URL of its /ilp endpoint."
+ deposit:
+ type: string
+ description: "What the channel is opened with, in the token's base units, as a whole number."
+ required: [peer, deposit]
route_price:
- description: "Set the price of a route."
- cmd: "toon route price --price \"$price\" -- \"$prefix\""
+ description: "Set what a packet to an app costs. This restarts the connector, and the key may do it only a few times an hour."
+ cmd: "toon route price --json --yes -- \"$prefix\" \"$price\""
args:
prefix:
type: string
- description: "ILP address prefix of the route."
+ description: "ILP address prefix of the app's route, as `status` shows it."
price:
type: string
- description: "New price per packet, as a whole number."
+ description: "New price per packet, in the token's base units, as a whole number."
required: [prefix, price]
- channel_list:
- description: "List the payment channels the agent node already has."
- cmd: "toon channel list --json"
- channel_fund:
- description: "Add funds to a channel the agent node already has, paying the stated amount from the spending limit."
- cmd: "toon channel fund --yes --amount \"$amount\" -- \"$channel\""
- args:
- channel:
- type: string
- description: "Id of an existing channel, as `channel_list` shows it."
- amount:
- type: string
- description: "Amount to add, as a whole number."
- required: [channel, amount]
- voucher_land:
- description: "Land the vouchers waiting on a channel, so they settle."
- cmd: "toon channel land -- \"$channel\""
- args:
- channel:
- type: string
- description: "Id of the channel, as `channel_list` shows it."
- required: [channel]
app_add:
- description: "Add an app from a container image. An image that is not TOON's own waits for the Observer to approve it; the answer says so, and the app is not running."
- cmd: "toon add --image \"$image\" -- \"$name\""
+ description: "Add an app from a container image, behind the relay TOON app's connector. An image that is not TOON's own waits for the Observer to approve it; the answer says so, and the app is not running."
+ cmd: "toon add --json --yes --to relay --image \"$image\" -- \"$name\""
args:
name:
type: string
@@ -155,27 +124,16 @@ agents:
description: "Image reference of the app."
required: [name, image]
app_remove:
- description: "Remove an app."
- cmd: "toon remove -- \"$name\""
+ description: "Remove an app, and its route. This restarts the connector."
+ cmd: "toon remove --json --yes -- \"$name\""
args:
name:
type: string
description: "Name of the app to remove, as `status` lists it."
required: [name]
- set_price:
- description: "Set the price of an app's service."
- cmd: "toon price set --price \"$price\" -- \"$name\""
- args:
- name:
- type: string
- description: "Name of the app."
- price:
- type: string
- description: "New price, as a whole number."
- required: [name, price]
event_publish:
- description: "Publish an event to the network, signed by the agent identity."
- cmd: "toon event publish --kind \"$kind\" --content \"$content\""
+ description: "Publish an event to the agent node's own relay, signed by the agent identity. The answer holds the event, with its id."
+ cmd: "toon event publish --json --kind \"$kind\" --content \"$content\" --tags \"$tags\""
args:
kind:
type: string
@@ -183,13 +141,16 @@ agents:
content:
type: string
description: "Content of the event, as plain text."
- required: [kind, content]
+ tags:
+ type: string
+ description: "The event's tags, as a JSON array of arrays of strings; [] for none."
+ required: [kind, content, tags]
message_list:
description: "Read the private messages the agent node has opened."
cmd: "toon message list --json"
message_send:
description: "Send a private message to a public key."
- cmd: "toon message send --to \"$recipient\" --content \"$content\""
+ cmd: "toon message send --json --content \"$content\" -- \"$recipient\""
args:
recipient:
type: string
@@ -198,14 +159,6 @@ agents:
type: string
description: "Text of the message."
required: [recipient, content]
- relay_query:
- description: "Query the agent node's own relay with a Nostr filter."
- cmd: "toon event query --filter \"$filter\" --json"
- args:
- filter:
- type: string
- description: "A Nostr filter, as a JSON object."
- required: [filter]
limit_show:
description: "Show the agent key's spending limit and what is left of it today."
cmd: "toon limit show --json"
diff --git a/docs/adr/0003-the-agent-holds-no-key-that-pays.md b/docs/adr/0003-the-agent-holds-no-key-that-pays.md
index bbd2ddc..586700e 100644
--- a/docs/adr/0003-the-agent-holds-no-key-that-pays.md
+++ b/docs/adr/0003-the-agent-holds-no-key-that-pays.md
@@ -18,6 +18,6 @@ The scope lets the agent do a provider's daily work: send packets, add and remov
- This cannot ship until the connector gives each write key a scope and a spending limit, and `toon` can be operated from another zone and runs apps hardened. Those are changes to `connector` and `toon_cli`, not to this repository.
- The spending limit moves from a file `toon` checks to the connector, which holds the money.
- Only the connector's zone must stay running. The agent runs in sessions and on a timer, so `docker-agent`'s own sandbox serves it unchanged.
-- The connector's zone is a Docker Sandboxes virtual machine, which needs KVM and a Docker account on every machine that runs spaceturtle. A machine without KVM needs another form of that zone.
+- The connector's zone is a virtual machine, which needs KVM on every machine that runs spaceturtle. A machine without KVM needs another form of that zone. It was first a Docker Sandboxes virtual machine, which also needed a Docker account; ADR 0004 replaces that.
- Whoever breaks into the connector's zone holds everything in the settlement addresses and channels. The wallet is funded with little, on devnet first.
- ADR 0001 stands: the panel still never acts. The panel reading an agent node inside a zone is not designed yet.
diff --git a/docs/adr/0004-zones-run-under-microsandbox.md b/docs/adr/0004-zones-run-under-microsandbox.md
new file mode 100644
index 0000000..7dcab78
--- /dev/null
+++ b/docs/adr/0004-zones-run-under-microsandbox.md
@@ -0,0 +1,21 @@
+# Zones run under microsandbox, with no account
+
+The connector's zone and the agent's zone were Docker Sandboxes (`sbx`) virtual machines. `sbx` refuses every command until the person signs in to a Docker account, and Docker has said it will not change that. An Install that cannot be made without an account at a company that has no part in it is not one command. So both zones are virtual machines under [microsandbox](https://github.com/superradcompany/microsandbox) (`msb`): one program and one library in the person's home, Apache-2.0, on KVM through libkrun, with no account, no root and no service of its own. `install.sh` installs a copy that is spaceturtle's alone, pinned by version and checksum, and the launcher drives it.
+
+The zone images are the same. Docker's sandbox images still serve as their bases, and they boot unchanged.
+
+## Considered Options
+
+- **Stay on Docker Sandboxes.** Rejected: every Install needs a Docker account, the program is proprietary and cannot be patched, and signing out stops every zone on the machine.
+- **Keep both, and let the person choose.** Rejected: two sets of commands, two kinds of wall and two ways for the zone to come back after a restart, each to be tested with a real run, to serve only people who already have the account.
+- **The launcher drives a hypervisor itself** (Cloud Hypervisor with virtiofsd and passt). Kept as the way out if microsandbox fails us: everything microsandbox gives, from running a command in a guest to the wall, becomes the launcher's to build and keep.
+- **A container runtime in place of a virtual machine** (rootless Podman with crun-krun, Kata, systemd-nspawn). Rejected: each needs root to set up, or gives no kernel boundary between the wallet and an app.
+
+## Consequences
+
+- An Install needs KVM that the person may use, glibc and systemd, and nothing else. The first run asks for no account.
+- The agent's zone has a wall of its own: it reaches name resolution, two ports of the machine, the model provider's host, the host of the network's connector and the two hosts its skills come from, and is refused everything else. Under `sbx` the machine's policy also let it reach a list of common hosts. A host is held to the addresses its name resolves to, not to the name: holding it to the name needs microsandbox to open the zone's TLS, which is not done.
+- A zone's wall and its published ports are fixed when it is made. A change to either makes the zone again. For the agent's zone the launcher carries the agent key across in memory, and what the agent keeps is in mounted directories that outlast the zone.
+- A zone stays running until it is stopped, so the unit no longer has to hold a session open. It is still what starts the zone after the machine restarts and what starts the supervisor again.
+- microsandbox is beta software that changes fast. It is pinned, and moving the pin needs a real run of the launcher, since the tests only stub it. Its licence is what makes this bearable: it can be forked.
+- ADR 0003 rejected a virtual machine for each app because Docker Sandboxes could not run an image as its workload. microsandbox can. That decision is open again; this one does not make it.
diff --git a/docs/adr/0005-the-agents-zone-builds-its-own-docker-agent.md b/docs/adr/0005-the-agents-zone-builds-its-own-docker-agent.md
new file mode 100644
index 0000000..27acfdc
--- /dev/null
+++ b/docs/adr/0005-the-agents-zone-builds-its-own-docker-agent.md
@@ -0,0 +1,22 @@
+# The agent's zone builds its own docker-agent
+
+A session is a chat that `docker-agent` draws. On an empty conversation it draws its own name in large letters, from a list of lines fixed in the program, and no flag, setting or field of an agent definition puts anything in its place except a welcome message, which is Markdown drawn once. spaceturtle's banner is a turtle that swims. So the agent's zone image no longer takes `docker-agent` as Docker built it: it builds the same release again from its source, with the changes of `images/agent/docker-agent.patch`. The first adds `--banner-file`. The file is a film of frames, and `docker-agent` plays it on the clock its spinners already use, until the conversation starts, cut down to the middle of it when the chat is smaller. The image makes the film: the turtle of the banner the README shows, flying through a field of stars large enough to fill the chat of a large terminal.
+
+The second change adds `--sidebar-section`. The chat's sidebar has a fixed list of sections (Session, Token Usage, Agent, Tools) and none for what an agent node runs. The flag adds a section whose body is the output of a command, run again every five seconds. The launcher gives it two scripts in the image, each of which asks the node with the session's agent key. `spaceturtle-limit` shows what the key may still pay through the connector today, of its limit for a day, and its limit for one command: the agent's budget on the network, which the sidebar's own Token Usage is not, since that counts the model. `spaceturtle-apps` lists each app, whether it runs, and the apps that wait for the Observer. A third section, Earnings, shows what others have paid the node on its channels, how much of that has landed, and the fees and packets since the connector last started. An agent key is told none of that, so the launcher reads it in the connector's zone while the chat is open and writes it to the directory the agent's zone mounts read-only, and the section's command only prints that file.
+
+Both changes are offered to `docker/docker-agent`, the banner as [pull request 4578](https://github.com/docker/docker-agent/pull/4578) and the sidebar section as [pull request 4585](https://github.com/docker/docker-agent/pull/4585). When a release of theirs holds both, the build stage and the patch go, and the image takes Docker's program again.
+
+## Considered Options
+
+- **A welcome message that holds a still of the banner.** Rejected: it does not move, and `docker-agent` draws it as a code block, in one colour, on a panel.
+- **The launcher plays the banner before the chat opens.** Rejected: the agent's zone starts in about a second, so the launcher had to hold the chat back for the banner to be seen at all.
+- **Wait for Docker to take the change.** Rejected as the only way: it may take weeks, or not happen. It is done as well.
+- **A chat of spaceturtle's own in place of `docker-agent`'s.** Rejected: the whole of a chat screen to build and keep, for a banner.
+
+## Consequences
+
+- The `docker-agent` in the agent's zone is not the one Docker published. It is held to the commit of the release (`DOCKER_AGENT_COMMIT`), built by a pinned Go, and differs by a patch of two flags that anyone can read. The banner touches only the chat's drawing. The sidebar section runs a command in the agent's zone every five seconds while a chat is open: the one the launcher names, with what the session already has, the agent key. It asks the node for its status and its spending limit, which the key's scope allows and which cost nothing, and it can do no more than the agent can.
+- Moving `docker-agent` to a new release means the patch must still apply. The image build fails if it does not, which is where this is found. The sidebar is a file Docker changes often, so this is more likely for the second change than for the first.
+- The image build takes about four minutes longer.
+- A release still publishes the agent definition with `docker-agent` as Docker published it, which is enough: the definition holds nothing of the banner.
+- Only a session with someone there is given the banner and the section. An unattended one has no chat.
diff --git a/docs/assets/panel-activity.gif b/docs/assets/panel-activity.gif
deleted file mode 100644
index 8826804..0000000
Binary files a/docs/assets/panel-activity.gif and /dev/null differ
diff --git a/docs/development.md b/docs/development.md
index 933e865..db59653 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -4,7 +4,7 @@
## Tests
-`tests/run` runs `relay-events` (with its Renderer descriptions), `mark-seen` and `request` (and the answers the `being-observed` skill describes) against a stub `toon` and `curl`, and the launcher's `spaceturtle start`, `stop`, `upgrade`, `backup`, `remove`, `mainnet`, `waiting`, `approve`, `refuse`, `status` and `agent-key` against a stub `sbx`, in a temporary home, holds the agent definition to the safety rules of ADR 0003 through `tests/check-agent`, holds the zone images' files to their pins through `tests/check-images`, and runs the `release/` scripts against a stub `docker-agent`. It needs bash, `jq` and `yq` (mikefarah's), and is what the `gate` job of `.github/workflows/ci.yml` runs.
+`tests/run` runs `relay-events` (with its Renderer descriptions), `mark-seen` and `request` (and the answers the `being-observed` skill describes) against a stub `toon` and `curl`, and the launcher's `spaceturtle start`, `stop`, `upgrade`, `backup`, `remove`, `waiting`, `approve`, `refuse`, `status` and `agent-key`, `session`, and `install.sh`, against a stub `msb`, in a temporary home, holds the agent definition to the safety rules of ADR 0003 through `tests/check-agent`, holds the zone images' files to their pins through `tests/check-images`, and runs the `release/` scripts against a stub `docker-agent`. It needs bash, `jq` and `yq` (mikefarah's), and is what the `gate` job of `.github/workflows/ci.yml` runs.
## Demo data
@@ -25,9 +25,11 @@ without an agent node.
| `relay-events` | Prints the agent's activity, the relay's other events, each event with its resolved parts (label, title, summary, body, links, ref) and what it refers to and what refers to it, the events a thread needs beyond the limit, profiles, the Persona (the agent identity's newest profile with a name, or `null`), follow lists, node addresses, the node's state, the Requests and the agent's private messages (grouped into conversations, or why they cannot be read) as one JSON document, with its `attention` state (`none`, `news`, `urgent`) |
| `skills/being-observed/SKILL.md` | The agent's skill: the public key file, the Persona (creating, reading and living by it, renaming the relay), reading the Requests, answering them, writing a Renderer for a kind |
| `request` | Writes a Request into the queue, or withdraws a waiting one |
-| `spaceturtle` | The launcher: `spaceturtle start` brings up the connector's zone on devnet from the connector's zone image (`SPACETURTLE_CONNECTOR_IMAGE`), copying nothing in from the machine, doing only the steps not yet done, with its step markers in `~/.local/state/spaceturtle/launcher`; `spaceturtle mainnet` moves it to mainnet once confirmed; `session`, `timer`, `set` and `key` start the agent's sessions, by hand and on a timer, and keep its settings in `~/.config/spaceturtle/launcher.conf`; `waiting`, `approve` and `refuse` list, approve and refuse the apps the agent node holds until the Observer approves them; `start` also makes the agent key once, and `agent-key show`, `replace`, `revoke` and `limit` show, replace, revoke and limit it from outside the agent's zone; `status` shows the timer and the last unattended session, each zone's state, the node's network, its apps and relay, whether the supervisor runs and how many apps wait; `stop`, `upgrade`, `backup` and `remove` stop, replace, back up and remove the Install (`upgrade` carries the passphrase in memory through `sbx exec`; `remove` keeps the wallet's storage and the backup directory) |
+| `spaceturtle` | The launcher: `spaceturtle start` brings up the connector's zone on devnet from the connector's zone image (`SPACETURTLE_CONNECTOR_IMAGE`), copying nothing in from the machine, doing only the steps not yet done, with its step markers in `~/.local/state/spaceturtle/launcher`; `start`'s flags choose the network (`--network`, `--evm-rpc-url`, `--evm-chain-id` and the rest of `toon init`'s), kept in `launcher.conf`; `session`, `timer`, `set` and `key` start the agent's sessions, by hand and on a timer, and keep its settings in `~/.config/spaceturtle/launcher.conf`; `waiting`, `approve` and `refuse` list, approve and refuse the apps the agent node holds until the Observer approves them; `start` also makes the agent key once, and `agent-key show`, `replace`, `revoke` and `limit` show, replace, revoke and limit it from outside the agent's zone; `status` shows the timer and the last unattended session, each zone's state, the node's network, its apps and relay, whether the supervisor runs and how many apps wait; `stop`, `upgrade`, `backup` and `remove` stop, replace, back up and remove the Install (`upgrade` carries the passphrase in memory through `msb exec`; `remove` keeps the wallet's storage and the backup directory); `supervise` is what the unit that keeps the connector's zone running runs |
+| `install.sh` | The one command a person runs first: checks the machine, installs the launcher and the agent definition of a release with a `version` file beside them (the launcher then pulls that release's zone images), links it into `~/.local/bin`, and puts the pinned microsandbox (`msb`), which the zones run in, in `~/.local/share/spaceturtle/msb` |
| `agent/spaceturtle.yaml` | The agent's definition for `docker-agent`: its instruction, its file tool and the skills it loads. A release publishes it as it is |
-| `images/agent/Dockerfile`, `images/connector/Dockerfile`, `images/connector/connector-start` | The agent's zone image (`docker-agent`'s sandbox image plus `toon`, no wallet or key) and the connector's zone image (`toon`, the container engine, gVisor's `runsc`, the overlay client's proxy line), and the connector's entrypoint, which starts the engine and the supervisor without `systemd --user` |
+| `images/agent/Dockerfile`, `images/connector/Dockerfile`, `images/connector/connector-start` | The agent's zone image (`docker-agent`'s sandbox image plus `toon`, no wallet or key, and `docker-agent` built again with the banner, see below) and the connector's zone image (`toon`, the container engine, gVisor's `runsc`), and `connector-start`, which starts the engine, the two forwarders an agent key is used through and the supervisor without `systemd --user` |
+| `images/agent/docker-agent.patch`, `images/agent/banner.awk`, `images/agent/spaceturtle-apps`, `images/agent/spaceturtle-limit` | The two changes to `docker-agent` the agent's zone carries, `--banner-file` and `--sidebar-section` (ADR 0005), what lists the node's apps and shows the agent key's spending limit for the sidebar, and what makes the film of frames it plays: the turtle of `docs/assets/spaceturtle-banner-dark.svg` in a field of stars 240 characters wide and 64 high, of which the chat shows the middle. The image build is given `docs/assets` as the build context named `assets` |
| `images/versions.env`, `images/versions` | Every pin the two images add, with its checksum, and the `docker-agent` program a release publishes with; the command that prints the images' versions for release notes |
| `.github/workflows/images.yml` | Builds both images on every change to `images/`. It does not publish |
| `release/references`, `release/publish-definition`, `release/mark-latest`, `release/notes` | What a release publishes, under which names; the definition pushed to each registry and read back, byte for byte; `latest` moved to a release once it is all published; the release's notes |
diff --git a/docs/guide/concepts.md b/docs/guide/concepts.md
index 931e228..018cd8c 100644
--- a/docs/guide/concepts.md
+++ b/docs/guide/concepts.md
@@ -35,7 +35,9 @@ order out only if it is within the key's **scope** and its **spending limit**.
- **It can** do a provider's daily work: send packets, add and remove peerings, write
routes, fund a channel it already has, land vouchers, add and remove apps, set prices,
- read status and logs, and publish events.
+ read status and logs, and publish events. `toon` does not yet do all of that with an
+ agent key, and the agent has a tool only for what it does: see
+ [The launcher](launcher.md).
- **It cannot** withdraw a channel, open one toward a counterparty of its choosing, raise
its own spending limit, touch the wallet, or run an app whose image is not TOON's own
until you approve it (`spaceturtle waiting`, `approve`, `refuse`).
@@ -49,4 +51,4 @@ spend more than the agent key's limit.
**The connector's zone is different.** It holds the wallet. Whoever breaks into it holds
everything in the wallet's settlement addresses and channels, and the limit does not stop
them. So **fund the wallet with little**, and stay on devnet until you trust what you see.
-`spaceturtle mainnet` says so again before it changes anything.
+`spaceturtle start --network mainnet` says so again before it makes anything.
diff --git a/docs/guide/first-run.md b/docs/guide/first-run.md
index 4c99902..88f332f 100644
--- a/docs/guide/first-run.md
+++ b/docs/guide/first-run.md
@@ -2,24 +2,51 @@
## Before you start
-Have these ready. `spaceturtle start` checks the first two before anything else and stops,
-changing nothing, when one is missing:
+Have these ready. You need no account anywhere to install or run spaceturtle; the model
+provider's key is the only thing that is not on your machine already. `spaceturtle start`
+checks the first of these before anything else and stops, changing nothing, when it is
+missing:
-- **A machine with KVM** (`/dev/kvm`). The connector's zone is a virtual machine.
-- **A Docker account, signed in to Docker Sandboxes** (`sbx`). Install
- [`sbx`](https://github.com/docker/sbx-releases), then run `sbx login` and
- `sbx daemon start -d`.
+- **A Linux machine with KVM** (`/dev/kvm`) that you may use. A zone is a virtual machine.
+ If `/dev/kvm` is there but not yours to use, `sudo usermod -aG kvm $USER`, then sign out
+ and in again.
- **`docker-agent`** needs no install of its own: it runs inside the agent's zone.
-- **systemd**, for the timer's user units.
+- **systemd**, for two user units: the one that keeps the connector's zone running, and
+ the timer. To have the Install back after a restart without logging in, let your user
+ linger: `loginctl enable-linger`.
- **A model provider's key**, which is yours alone. Put it in
`~/.config/spaceturtle-launcher/model-key` or run `spaceturtle key` after the first run.
+ A ChatGPT account serves too: see `set model` in [The launcher](launcher.md).
- **`bash`, `jq` and `curl`.**
- **A little money in mind.** The first run is on devnet and costs nothing. Mainnet is real
money: read [How the money is kept safe](concepts.md#how-the-money-is-kept-safe)
- first.
+ first. The network is `start`'s flags: see [The network](launcher.md#the-network).
-The launcher is the file [`spaceturtle`](../../spaceturtle) in this repository. Clone the
-repository once and put that file on your `PATH`.
+## Install
+
+```sh
+curl -fsSL https://raw.githubusercontent.com/toon-protocol/spaceturtle/main/install.sh | bash
+```
+
+[`install.sh`](../../install.sh) needs no root and no account. It first checks the machine
+(Linux on x86_64 with glibc, KVM you may use, systemd, `curl`, `jq`, `tar`) and installs
+nothing if something is missing. Then it:
+
+- puts the launcher and the agent definition of the latest release in
+ `~/.local/share/spaceturtle/launcher`, and links `spaceturtle` into `~/.local/bin`;
+- puts [microsandbox](https://github.com/superradcompany/microsandbox), which the zones run
+ in, in `~/.local/share/spaceturtle/msb`, at the version the launcher is tested
+ with and checked against its checksum. It is a program and a library in that directory,
+ and nothing else of your machine is changed;
+- says what to run next. It starts nothing.
+
+The launcher it installs pulls the zone images of that same release. Run it again to move to
+the newest release, then `spaceturtle upgrade`. `SPACETURTLE_VERSION=v0.2.0` installs that
+release instead.
+
+To work from a clone instead, run `install.sh` once for microsandbox, then put the clone's
+[`spaceturtle`](../../spaceturtle) on your `PATH`, by its directory or by a link; it then
+pulls the `latest` images.
## The first run
@@ -29,13 +56,14 @@ spaceturtle start
On devnet, it:
-1. makes the connector's zone, a detached Docker Sandboxes virtual machine from the
- connector's zone image, with nothing copied in from your machine;
+1. makes the connector's zone, a virtual machine from the connector's zone image, with
+ nothing copied in from your machine;
2. initialises the agent node inside it, makes the wallet and funds it from the devnet
faucet;
-3. starts the supervisor, which runs the node's apps;
-4. makes the agent key, with the default scope and spending limit, and places its private
+3. makes the agent key, with the default scope and spending limit, and places its private
half in the agent's zone;
+4. starts the supervisor, which runs the node's apps, in a systemd user unit that starts it
+ again if it ends and brings the zone back after a restart;
5. writes a sealed backup of the wallet to `~/spaceturtle-backup`. **Keep it.**
6. turns on the timer, which starts a session every 6 hours (`spaceturtle timer off` turns
it off).
@@ -44,4 +72,8 @@ It is safe to run again: a second run creates, initialises and funds nothing, an
interrupted run finishes the rest. The passphrase is made inside the zone and is never
printed and never written to your machine's disk.
-Then give it its model key and start a session by hand with `spaceturtle session`.
+Then give it its model key and start a session by hand with `spaceturtle`.
+
+The wallet is funded with the devnet token and no gas. To pay an app on another connector
+the node must open a channel, which is a transaction: send a little Base Sepolia ETH, from
+a public faucet, to the EVM address `toon wallet show` prints in the connector's zone.
diff --git a/docs/guide/launcher.md b/docs/guide/launcher.md
index 7395948..4db7673 100644
--- a/docs/guide/launcher.md
+++ b/docs/guide/launcher.md
@@ -1,68 +1,158 @@
# The launcher
Every command of the launcher, and what it changes. Each one that uses a zone stops first,
-and says why, when KVM or a signed-in `sbx` is missing.
+and says why, when the machine has no KVM, you may not use it, or microsandbox is missing.
-`spaceturtle start` brings up the connector's zone on devnet: a detached Docker Sandboxes
-virtual machine made from the connector's zone image
+A zone is a virtual machine under [microsandbox](https://github.com/superradcompany/microsandbox)
+(`msb`), which needs KVM and nothing else: no account, no root and no service of its own.
+`install.sh` puts a copy of it in `~/.local/share/spaceturtle/msb`
+(`SPACETURTLE_MSB_HOME`; `~/.spaceturtle-msb` when that path is more than 54 characters
+long, which microsandbox refuses), at the version the launcher is tested with. That directory holds
+the zones and their images too, and is spaceturtle's alone: a microsandbox you use yourself
+does not see them.
+
+`spaceturtle start` brings up the connector's zone on devnet: a virtual machine made from
+the connector's zone image
(`ghcr.io/toon-protocol/spaceturtle-connector-zone:latest`, or `SPACETURTLE_CONNECTOR_IMAGE`),
-which holds the pinned `toon`; nothing is copied into it from your machine. It has an any-address rule scoped to that zone alone, the agent node
-initialised inside it, the wallet funded from the faucet, the supervisor started (the image's `connector-start`, which also starts the container engine for the apps), and a
+which holds the pinned `toon`; nothing is copied into it from your machine. The zone reaches
+the public internet, and nothing of your machine or its network. It has the agent node
+initialised inside it, the wallet funded from the faucet, the agent key made, the supervisor started (the image's `connector-start`, which also starts the container engine for the apps), and a
sealed backup of the wallet written to `~/spaceturtle-backup` (`SPACETURTLE_BACKUP_DIR`).
Keep that backup. The passphrase is made inside the zone; it is never printed and never written to your
machine's disk, and `upgrade` carries it in memory to the new zone. A second run
-creates, initialises and funds nothing, and an interrupted run finishes the rest. It needs
-KVM and a signed-in `sbx`, and says what is missing and how to fix it before it does
-anything. The wallet's storage is `~/.local/share/spaceturtle/wallet` (`SPACETURTLE_DATA_DIR`),
-mounted into the zone and outside its image.
-
-`spaceturtle mainnet [--keep-limit]` moves a devnet Install to mainnet. It says that real
-funds are now at stake, that there is no faucet, and which wallet addresses to fund, then
-asks you to type `mainnet`; anything else changes nothing. It lowers the spending limit to
-the mainnet default unless you pass `--keep-limit`. On an Install already on mainnet it says
-so and changes nothing. It runs `toon status --json`, `toon wallet addresses`,
-`toon network set mainnet` and `toon limit set --network-default` in the connector's zone.
-
-`spaceturtle session` starts a `docker-agent` session in the agent's zone, from the agent's
-zone image (`SPACETURTLE_AGENT_IMAGE`) and `agent/spaceturtle.yaml`. The zone may reach the
-model provider and one loopback port, the one that leads to the connector's zone
-(`SPACETURTLE_CONNECTOR_PORT`), and nothing else. No session starts while the connector's
-zone is not running. The model key is yours: put it in
+creates, initialises and funds nothing, and an interrupted run finishes the rest. The wallet's storage is `~/.local/share/spaceturtle/wallet` (`SPACETURTLE_DATA_DIR`),
+mounted into the zone and outside its image. It is the whole of `toon`'s home, so it also
+holds the agent identity's secret; the passphrase that opens the wallet is not in it.
+
+A zone stays running until it is stopped, but nothing starts it when your machine does, and
+nothing starts the supervisor again if it ends. So `start` writes and enables a systemd user
+unit, `spaceturtle-connector.service`, which starts the zone if it is stopped, runs the
+supervisor in it (`spaceturtle supervise`), and is started again whenever the supervisor
+ends. That is what brings the zone back after the machine restarts.
+A user unit runs once you log in, or at boot if your user lingers
+(`loginctl enable-linger`). `journalctl --user -u spaceturtle-connector` shows the
+supervisor's output.
+
+The zone is made with two of its ports published on your machine's loopback, `47100` and
+`47101` (`SPACETURTLE_CONNECTOR_PORT` names the first): the supervisor's orders and the
+relay TOON app's connector, which is all the agent's zone reaches of the node. The ports are
+the zone's from when it is made, and the launcher keeps them in its settings: to change
+them, set the variable and run `spaceturtle upgrade`. An app whose image is not on `toon`'s list of trusted images runs under
+gVisor: `start` writes `runsc` to the home's `untrusted-runtime`.
+
+## The network
+
+Which network an Install is on is configuration, given to `start` as flags when the Install
+is made and kept with it. With none it is devnet.
+
+| Flag | What it sets |
+| --- | --- |
+| `--network devnet\|mainnet` | The profile the rest comes from: devnet is Base Sepolia with a faucet, mainnet is Base with real money |
+| `--evm-rpc-url ` | The chain's JSON-RPC endpoint, in place of the profile's |
+| `--evm-chain-id ` | The chain that endpoint must serve: `8453` is Base, `84532` Base Sepolia |
+| `--evm-token ` | The token the connector is paid in on that chain |
+| `--connector-url `, `--relay-url ` | The network's connector and relay |
+| `--faucet-url ` | Where the wallet is funded from, on devnet |
+| `--solana`, `--solana-rpc-url ` | Settle on Solana too, and its endpoint |
+| `--max-per-command `, `--max-per-day ` | The wallet's own spending limits, in the token's base units |
+
+All but `--evm-chain-id` are `toon init`'s own flags and go to it as given. `--evm-chain-id`
+is the launcher's: once the node is initialised it asks the node's RPC endpoint which chain
+it serves, and funds and starts nothing unless it is that one.
+
+```sh
+spaceturtle start --network mainnet --evm-rpc-url https://mainnet.base.org --evm-chain-id 8453
+```
+
+On mainnet `start` first says that real funds are at stake and that there is no faucet, and
+asks you to type `mainnet`; anything else makes nothing. It then funds nothing and prints
+the addresses to fund. A later `start` needs no flags. One that names a different network is
+refused and changes nothing: `toon` has no command that moves a home to another chain, so an
+Install on another network is another Install (`spaceturtle remove --delete-wallet`, then
+`start` with the flags; an Install's wallet is made for it, so fund it with little).
+
+`spaceturtle`, with nothing after it, or `spaceturtle session`, starts a `docker-agent`
+session in the agent's zone, which is made from the agent's zone image
+(`SPACETURTLE_AGENT_IMAGE`), on a copy of
+`agent/spaceturtle.yaml` that the zone mounts read-only. The zone is stopped when the
+session ends, and one session runs at a time. No session starts while the connector's zone
+is not running.
+
+The agent's zone has a wall: it reaches what the launcher names and is refused everything
+else, the rest of your machine and your network included. What it names:
+
+| The agent's zone reaches | Why |
+| --- | --- |
+| Name resolution | To find the hosts below |
+| Ports `47100` and `47101` of your machine | The node: its orders and the relay TOON app's connector |
+| The model provider's host, on 443 | The model |
+| The host of the connector of the node's network, on 443 | A packet for an app behind that connector is sealed to it |
+| `api.github.com` and `codeload.github.com`, on 443 | `docker-agent` fetches the skills the agent definition names from there |
+
+A host is allowed by its name and held to the addresses the name resolves to, so another
+site served from the same addresses is reachable too. The wall is the zone's from when it is
+made. When it changes, as with a model of another provider, the next session makes the zone
+again and carries the agent key across in memory. What the agent keeps in
+`~/.config/spaceturtle` and `~/.local/state/spaceturtle` is in two directories of your
+machine, under `~/.local/state/spaceturtle/agent`, so it outlasts the zone.
+
+The model key is yours: put it in
`~/.config/spaceturtle-launcher/model-key` (`SPACETURTLE_MODEL_KEY_FILE`) or run
`spaceturtle key`. It is given to the session as an environment variable, never as an
argument, and sits outside every directory the agent may read. Without it the launcher stops
and says so.
+A session by hand is a conversation, and `docker-agent` asks you before a tool runs. An
+unattended one has nobody to ask, so every tool the definition offers runs unasked; the
+definition's tools, the agent key's scope and its spending limit are what bound it.
+
- `spaceturtle key` reads the model key from the terminal, without showing it, and writes
it to `~/.config/spaceturtle-launcher/model-key`, readable by you alone.
-- `spaceturtle set model ` changes the model.
+- `spaceturtle set model ` changes the model. The launcher knows
+ `anthropic`, `openai`, `google` and `mistral`, whose key file holds an API key, and
+ `chatgpt`, a ChatGPT account in place of an API key: its key file holds the account's
+ access token (`docker agent setup` signs in and keeps it as `access_token` in
+ `~/.config/cagent/chatgpt-auth.json`), which runs out after some days. For any other
+ provider, name its host in `SPACETURTLE_MODEL_HOST`.
- `spaceturtle timer on|off|interval m|h|d` controls the timer, which starts a session
with nobody present (`spaceturtle session --unattended`). `start` turns it on, every 6h,
- unless you turned it off.
+ unless you turned it off. The interval counts from when the timer starts, so the first
+ session comes one interval after `start`.
- `spaceturtle set ceiling ` sets the ceiling on model cost of one unattended
- session (default 1).
+ session (default 1). It is `docker-agent`'s `budget`, written into the session's copy of
+ the definition, and the session stops when it is reached. `docker-agent` can only count
+ the cost of a model it knows the price of: a session on a ChatGPT account costs it
+ nothing, so there the ceiling never stops one.
- `spaceturtle status` shows the timer, its interval and the last unattended session's time,
and for each of the connector's zone (`running`, `stopped` or `not created`), the agent's
zone (`in a session` or `idle`) and every app the node has, whether it runs; then the
- node's network and whether its relay answers from inside the connector's zone. While the
+ node's network and whether its relay answers from inside the connector's zone. Its last
+ lines are the node's earnings, in the token's base units: what others have paid it on its
+ channels and how much of that has landed on the chain, and the fees and packets since the
+ connector last started. A session's sidebar shows the same. While the
connector's zone is stopped or not created it says those cannot be read, and still exits 0.
- `spaceturtle stop` stops the timer's unit (its on/off setting stays), a session in the
- agent's zone and then the connector's zone, and loses nothing; `start` brings it back
- without creating, initialising or funding anything. With no zone it says there is nothing
- to stop.
+ agent's zone, the unit that keeps the connector's zone running and then that zone, and
+ loses nothing; `start` brings it back without creating, initialising or funding anything.
+ A stopped Install stays stopped when the machine restarts. With no zone it says there is
+ nothing to stop.
- `spaceturtle upgrade` replaces the connector's zone with one from the newest connector's
zone image and leaves the wallet's storage as it was. It makes a fresh backup first and
stops, changing nothing, if that fails or if the passphrase cannot be read out of the old
zone. The passphrase goes from the old zone to the new one through the standard input and
- output of `sbx exec`, in the launcher's memory only. `toon init` and the faucet do not run
- again. The agent's zone is removed too, so the next `session` uses the newest agent's zone
- image.
+ output of `msb exec`, in the launcher's memory only. `toon init` and the faucet do not run
+ again. The images of the apps are pulled again, since they were on the old zone's disk. The
+ agent's zone is made again too, from the newest agent's zone image, with a new agent key
+ unless you revoked it; what the agent kept in its two directories stays. Both images are
+ pulled before anything is removed.
- `spaceturtle backup` writes a fresh sealed backup to `~/spaceturtle-backup` and says where;
the backup already there is replaced only once the new one has arrived whole. It needs the
connector's zone running.
- `spaceturtle remove [--delete-wallet]` offers a fresh backup (the passphrase goes with the
- zone), then removes both zones, the timer's units, the step markers, the settings and the
- model key. It keeps the wallet's storage and the backup directory and says where they are.
+ zone), then removes both zones, what the agent kept, the timer's units and the unit that
+ keeps the connector's zone running, the step markers, the settings and the model key. It
+ keeps the wallet's storage and the backup directory and says where they are. It also keeps
+ microsandbox and the zone images, and names the directory to delete to be rid of them.
`--delete-wallet` also deletes the wallet's storage, and only after you type `delete`;
anything else, or no input, changes nothing. Nothing ever deletes the backup directory.
@@ -72,18 +162,31 @@ no tool for any of them:
| Command | What it does |
| --- | --- |
-| `spaceturtle waiting` | Lists each waiting app with its image and how long it has waited |
+| `spaceturtle waiting` | Lists each waiting app with its image or URL, and how long it has waited when `toon` says (0.2.0 does not, so it reads `unknown`) |
| `spaceturtle approve ` | Starts it; an image that is not TOON's own runs under the sandboxed runtime |
| `spaceturtle refuse [reason]` | Discards it |
-| `spaceturtle status` | Also says whether the supervisor is running and how many apps are waiting |
+| `spaceturtle status` | Also says whether the supervisor is running, how many apps are waiting and what the node has earned |
| `spaceturtle agent-key show` | The agent key's scope, limits and what is left today; never the private half |
| `spaceturtle agent-key replace` | Makes a new key, revokes the old one, places the new private half in the agent's zone; the wallet is untouched |
| `spaceturtle agent-key revoke` | Removes the key from the agent node's list and deletes its private half from the agent's zone; the agent's next order is refused |
-| `spaceturtle agent-key limit ` | Sets the key's spending limit; the agent has no tool for it |
+| `spaceturtle agent-key limit ` | Sets what the key may spend in a UTC day, in the token's base units (`5000000` is 5 USDC), and brings what one command may pay down to it if that was more; the agent has no tool for it |
`start` makes exactly one agent key, with the default scope (ADR 0003) and the default spending
limit. Its private half goes from the connector's zone to the agent's zone through a pipe, and
-is kept nowhere else. A second `start` makes none, and neither does `upgrade` after a revoke.
+is kept nowhere else. On the way in, the two addresses in the key file become the two ports of
+your machine that lead to the connector's zone. A second `start` makes none, and neither does
+`upgrade` after a revoke.
+
+With an agent key `toon` 0.2.0 reads status and the connector's log, sends packets, adds a
+peering, sets a route's price, adds and removes apps, publishes events, reads and sends
+private messages and shows the limit. It does not yet write a route, remove a peering, fund
+a channel, land vouchers or query a relay with one, so the agent has no tool for those and
+you do them, in the connector's zone:
+
+```sh
+MSB_HOME=~/.local/share/spaceturtle/msb ~/.local/share/spaceturtle/msb/bin/msb \
+ exec -e TOON_PASSPHRASE_FILE=/home/agent/.config/toon-passphrase st-connector -- toon route add ...
+```
Approving or refusing an app that is not waiting says so and changes nothing. The list is
-read from the apps `toon status --json` reports as `pending`.
+read from the `pending_apps` of `toon status --json`.
diff --git a/docs/guide/watching.md b/docs/guide/watching.md
index cfa5c08..f6824b9 100644
--- a/docs/guide/watching.md
+++ b/docs/guide/watching.md
@@ -7,10 +7,6 @@ The panel reads an agent node through `toon` on your machine's `PATH`. Reading t
of an Install, inside the connector's zone, is not designed yet
([ADR 0003](../adr/0003-the-agent-holds-no-key-that-pays.md)).
-
-
-
-
## Install
You need Omarchy 4.0.4, a running agent node with `toon` on your `PATH` (`toon up`), and
diff --git a/images/agent/Dockerfile b/images/agent/Dockerfile
index 7e7b8f9..8afe04d 100644
--- a/images/agent/Dockerfile
+++ b/images/agent/Dockerfile
@@ -1,9 +1,36 @@
-# The agent's zone: docker-agent's own sandbox image with `toon` added. The agent runs the
-# agent node's commands with an agent key the launcher places at session start; this image
-# holds no home for toon, no keystore, no passphrase and no key (ADR 0003).
+# The agent's zone: docker-agent's own sandbox image with `toon` added, and docker-agent built
+# again so that it plays spaceturtle's banner. The agent runs the agent node's commands with an
+# agent key the launcher places at session start; this image holds no home for toon, no
+# keystore, no passphrase and no key (ADR 0003).
# Build arguments come from images/versions.env and have no defaults here.
ARG DOCKER_AGENT_VERSION
ARG DOCKER_AGENT_BASE_DIGEST
+ARG GO_VERSION
+ARG GO_IMAGE_DIGEST
+
+# docker-agent of the same release, built again with two changes (ADR 0005): --banner-file,
+# which draws a film of frames where docker-agent's own banner would be, and --sidebar-section,
+# which shows a command's output in the chat's sidebar. The source is held to
+# the commit of the release, and the film's turtle is the one of the banner the README shows,
+# which the build is given as the context named assets (docs/assets).
+FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine@${GO_IMAGE_DIGEST} AS docker-agent
+ARG TARGETARCH
+ARG DOCKER_AGENT_VERSION
+ARG DOCKER_AGENT_COMMIT
+RUN apk add --no-cache git
+WORKDIR /src
+RUN git clone --quiet --depth 1 --branch "v${DOCKER_AGENT_VERSION}" https://github.com/docker/docker-agent.git . \
+ && [ "$(git rev-parse HEAD)" = "$DOCKER_AGENT_COMMIT" ]
+COPY docker-agent.patch /tmp/
+RUN git apply /tmp/docker-agent.patch
+RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
+ CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH go build -trimpath -tags no_audio \
+ -ldflags "-s -w -X github.com/docker/docker-agent/pkg/version.Version=v${DOCKER_AGENT_VERSION} -X github.com/docker/docker-agent/pkg/version.Commit=${DOCKER_AGENT_COMMIT}" \
+ -o /out/docker-agent .
+COPY banner.awk /tmp/
+COPY --from=assets spaceturtle-banner-dark.svg /tmp/
+RUN awk -f /tmp/banner.awk /tmp/spaceturtle-banner-dark.svg > /out/banner.txt && [ "$(grep -c "$(printf '\f')" /out/banner.txt)" = 239 ]
+
FROM docker/docker-agent-sbx-templates:${DOCKER_AGENT_VERSION}@${DOCKER_AGENT_BASE_DIGEST}
ARG TARGETARCH
@@ -28,7 +55,11 @@ install -m 0755 "/tmp/toon-v${TOON_VERSION}-linux-${arch}/toon" /usr/local/bin/t
rm -rf /tmp/toon-v*
toon --version
SCRIPT
+COPY --from=docker-agent /out/docker-agent /usr/local/bin/docker-agent
+COPY --from=docker-agent /out/banner.txt /usr/local/share/spaceturtle/banner.txt
+COPY --chmod=0755 spaceturtle-apps spaceturtle-limit /usr/local/bin/
+RUN docker-agent run --help | grep -q -- --banner-file && docker-agent run --help | grep -q -- --sidebar-section
USER agent
LABEL org.opencontainers.image.title="spaceturtle agent's zone" \
- org.opencontainers.image.description="docker-agent's sandbox image with toon, with nothing of a wallet in it" \
+ org.opencontainers.image.description="docker-agent's sandbox image with toon and spaceturtle's banner, with nothing of a wallet in it" \
org.opencontainers.image.version="${TOON_VERSION}"
diff --git a/images/agent/banner.awk b/images/agent/banner.awk
new file mode 100644
index 0000000..b9fa659
--- /dev/null
+++ b/images/agent/banner.awk
@@ -0,0 +1,55 @@
+# Makes the film docker-agent plays where its own banner would be (--banner-file): the turtle
+# of docs/assets/spaceturtle-banner-dark.svg, with its two strokes and that banner's colours,
+# flying through a field of stars large enough to fill the chat of a large terminal. The chat
+# shows the middle of it, so the stars reach every edge whatever the terminal's size.
+# Two squares of the picture go to a character, a frame lasts a tenth of a second and a form
+# feed parts the frames. The field is 240 squares wide and 128 high. The near stars move a
+# square a frame; the far ones move a square every second frame and their half of the field
+# is laid twice side by side, so the film closes on itself after 240 frames.
+# The stars are scattered by a fixed rule, as thickly as in the README's banner, so every
+# build makes the same film. The turtle takes the terminal's own colour, so it shows on a
+# light one as on a dark one.
+function rnd(n) { seed = (seed * 1103515245 + 12345) % 2147483648; return int(seed / 65536) % n }
+function px(x, y) {
+ if (stroke ? ((x, y) in tb) : ((x, y) in ta)) return 1
+ if (((x + t) % W, y) in near) return 2
+ if (((x + int(t / 2)) % (W / 2), y) in far) return 3
+ return 0
+}
+BEGIN { W = 240; H = 128; seed = 57 }
+/ 2 {
+ split($0, q, "\"")
+ for (j = 0; j < q[6]; j++) {
+ x = q[2] + j + (W - 128) / 2; y = q[4] + (H - 24) / 2
+ if (g == 3) ta[x, y]; else tb[x, y]
+ }
+}
+END {
+ for (i = 0; i < W / 2 * H * 26 / (128 * 24); i++) far[rnd(W / 2), rnd(H)]
+ for (i = 0; i < W; i++) blank = blank " "
+ for (i = 0; i < W * H * 14 / (128 * 24); i++) { x = rnd(W); y = rnd(H); near[x, y]; near[(x + 1) % W, y] }
+ fg[1] = "\033[39m"; fg[2] = "\033[38;2;170;182;224m"; fg[3] = "\033[38;2;86;98;143m"
+ bg[2] = "\033[48;2;170;182;224m"; bg[3] = "\033[48;2;86;98;143m"
+ for (t = 0; t < W; t++) {
+ stroke = int(t / 4) % 2
+ if (t) print "\f"
+ for (r = 0; r < H / 2; r++) {
+ line = ""; ink = 0; gap = 0
+ for (x = 0; x < W; x++) {
+ u = px(x, 2 * r); d = px(x, 2 * r + 1)
+ if (!u && !d) { gap++; continue }
+ line = line substr(blank, 1, gap); gap = 0
+ if (u && d && u != d) {
+ if (d == 1) line = line fg[1] bg[u] "▄\033[49m"; else line = line fg[u] bg[d] "▀\033[49m"
+ ink = d == 1 ? 1 : u; continue
+ }
+ c = u ? u : d
+ if (c != ink) { line = line fg[c]; ink = c }
+ line = line (u && d ? "█" : u ? "▀" : "▄")
+ }
+ print line
+ }
+ }
+}
diff --git a/images/agent/docker-agent.patch b/images/agent/docker-agent.patch
new file mode 100644
index 0000000..2a0d8aa
--- /dev/null
+++ b/images/agent/docker-agent.patch
@@ -0,0 +1,950 @@
+diff --git a/cmd/root/run.go b/cmd/root/run.go
+index b5efa8b..0cfb6ce 100644
+--- a/cmd/root/run.go
++++ b/cmd/root/run.go
+@@ -41,6 +41,8 @@ import (
+ "github.com/docker/docker-agent/pkg/telemetry"
+ "github.com/docker/docker-agent/pkg/tour"
+ "github.com/docker/docker-agent/pkg/tui"
++ tuibanner "github.com/docker/docker-agent/pkg/tui/banner"
++ "github.com/docker/docker-agent/pkg/tui/components/sidebar"
+ tuiimage "github.com/docker/docker-agent/pkg/tui/image"
+ "github.com/docker/docker-agent/pkg/tui/recorder"
+ "github.com/docker/docker-agent/pkg/tui/styles"
+@@ -117,6 +119,8 @@ type runExecFlags struct {
+ lean bool
+ leanChanged bool
+ appName string
++ bannerFile string
++ sidebarSections []string
+ sidebar bool
+ listenAddr string
+ // sessionWorkingDirRoot confines the working_dir of sessions created
+@@ -211,6 +215,8 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) {
+ _ = cmd.PersistentFlags().MarkHidden("tour")
+ cmd.PersistentFlags().BoolVar(&flags.lean, "lean", false, "Use a simplified TUI with minimal chrome")
+ cmd.PersistentFlags().StringVar(&flags.appName, "app-name", "", "Application name shown in the TUI in place of \"docker agent\"")
++ cmd.PersistentFlags().StringArrayVar(&flags.sidebarSections, "sidebar-section", nil, "Add a sidebar section that shows a shell command's output, refreshed every few seconds: \"Title=command\" (repeatable)")
++ cmd.PersistentFlags().StringVar(&flags.bannerFile, "banner-file", "", "Text file drawn in the TUI in place of the startup banner; frames separated by a form-feed line play as an animation")
+ cmd.PersistentFlags().StringSliceVar(&flags.disabledCommands, "disable-commands", nil, "Comma-separated list of slash commands to hide and disable in the TUI (e.g. /cost,/eval,/model)")
+ cmd.PersistentFlags().BoolVar(&flags.sidebar, "sidebar", true, "Show the sidebar in the TUI (set --sidebar=false to hide it)")
+ cmd.PersistentFlags().StringVar(&flags.theme, "theme", "", "Preselect a TUI theme by name, or \"auto\" to match the terminal's light/dark background (overrides the theme from user config; ignored outside the interactive TUI)")
+@@ -591,6 +597,24 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s
+
+ var rec *recorder.Recorder
+ tuiOptions := f.tuiOpts(args)
++ if len(f.sidebarSections) > 0 {
++ sections := make([]sidebar.CommandSection, 0, len(f.sidebarSections))
++ for _, spec := range f.sidebarSections {
++ section, err := sidebar.ParseCommandSection(spec)
++ if err != nil {
++ return fmt.Errorf("--sidebar-section %q: %w", spec, err)
++ }
++ sections = append(sections, section)
++ }
++ tuiOptions = append(tuiOptions, tui.WithSidebarSections(sections))
++ }
++ if f.bannerFile != "" {
++ b, err := tuibanner.Load(f.bannerFile)
++ if err != nil {
++ return err
++ }
++ tuiOptions = append(tuiOptions, tui.WithBanner(b))
++ }
+ if dir := f.explicitDefaultWorkingDir(sess); dir != "" {
+ tuiOptions = append(tuiOptions, tui.WithDefaultWorkingDir(dir))
+ }
+diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md
+index a41b354..932c7ab 100644
+--- a/docs/features/cli/index.md
++++ b/docs/features/cli/index.md
+@@ -42,6 +42,8 @@ $ docker agent run [config] [message...] [flags]
+ | `--session-workingdir-root ` | Confine the `working_dir` of sessions created through the `--listen` control plane to this directory and its descendants (default: no restriction — any clean host directory is accepted, though raw values containing `..` are rejected). Recommended when the control plane is reachable by other users. Hidden from `--help`, like `--listen`. |
+ | `--lean` | Use a simplified, non-alternate-screen TUI. Unlike the default full-screen TUI, this renders inline in the normal terminal buffer — useful in environments where an alternate screen is unwanted (e.g. inside tmux panes, CI with a tty, or log-friendly pipelines). Like the full TUI, displays an ASCII art banner on startup when the chat is empty (configurable via `show_banner` in [user settings](../../configuration/user-settings/index.md)). |
+ | `--app-name ` | Override the application name label shown in the TUI (status bar, window title, "/exit" notifications). |
++| `--banner-file ` | Draw the art in this text file in the TUI in place of the startup banner; frames separated by a form-feed line play as an animation. |
++| `--sidebar-section "Title=cmd"` | Add a sidebar section that shows a shell command's output, refreshed every few seconds. Repeatable. |
+ | `--sidebar` | Control sidebar visibility. Set to `--sidebar=false` to hide the sidebar and disable the Ctrl+B toggle (default: `true`). |
+ | `--disable-commands ` | Hide and disable specific slash commands in the TUI. Accepts a comma-separated list of command names (leading slash optional, case-insensitive). E.g. `--disable-commands="/cost,/eval,/model"`. |
+ | `--theme ` | Preselect a TUI theme by name, or `auto` to follow the terminal's light/dark background (overrides the theme from user config; ignored outside the interactive TUI) |
+@@ -98,6 +100,8 @@ $ docker agent run agent.yaml "question 1" "question 2" "question 3"
+
+ # Customize TUI display
+ $ docker agent run agent.yaml --app-name "My Project"
++$ docker agent run agent.yaml --banner-file ./banner.txt
++$ docker agent run agent.yaml --sidebar-section "Services=docker compose ps --format '{{.Name}} {{.State}}'"
+ $ docker agent run agent.yaml --sidebar=false
+ $ docker agent run agent.yaml --disable-commands="/cost,/eval,/model"
+
+diff --git a/docs/features/tui/index.md b/docs/features/tui/index.md
+index 831f91a..0b98c60 100644
+--- a/docs/features/tui/index.md
++++ b/docs/features/tui/index.md
+@@ -15,6 +15,8 @@ _Docker Agent's default interface is a rich, interactive terminal UI with file a
+
+ Both the full TUI and the lean TUI display a centered ASCII art Docker Agent banner on startup when the chat is empty. The banner is automatically hidden once the agent starts responding or when a custom welcome message is configured. You can disable the banner entirely from the **Appearance** tab of `/settings`, or by setting `show_banner: false` in your [user settings](../../configuration/user-settings/index.md).
+
++To draw your own art in the full TUI instead, pass `--banner-file ` with a text file. A line holding only a form feed (`\f`) separates frames; a file with several frames plays them in a loop, one every 100ms, until the conversation starts. Lines may carry ANSI color sequences. Art larger than the chat area is cropped around its center, so a backdrop sized for a large terminal fills a small one. The lean TUI keeps the default banner.
++
+ ```bash
+ # Launch with a config
+ $ docker agent run agent.yaml
+@@ -31,6 +33,9 @@ $ docker agent run agent.yaml --debug
+ # Override the application name shown in the status bar and window title
+ $ docker agent run agent.yaml --app-name "My Project"
+
++# Replace the startup banner with your own art (several frames play as an animation)
++$ docker agent run agent.yaml --banner-file ./banner.txt
++
+ # Preselect a color theme
+ $ docker agent run agent.yaml --theme dracula
+
+@@ -147,6 +152,14 @@ Plans are shared documents from the same store used by `/plans`, the [plan tools
+
+ Editing changes only the plan document. It does not approve a plan, execute its steps, or authorize tool writes.
+
++### Custom Sidebar Sections
++
++Pass `--sidebar-section "Title=command"` to add a section below Tools that shows a shell command's output. The command runs when the TUI starts and again every 5 seconds, with a 5 second timeout. Its output is shown as plain text: color sequences are stripped, long lines are cut to the sidebar's width, and at most 12 lines are kept. A section with no output is hidden. The flag is repeatable.
++
++```bash
++$ docker agent run agent.yaml --sidebar-section "Services=docker compose ps --format '{{.Name}} {{.State}}'"
++```
++
+ ### Agents Panel
+
+ The sidebar's **Agents** section lists every agent in the team and has two display modes selectable via **Sidebar info mode** in `/settings`:
+diff --git a/pkg/tui/banner/banner.go b/pkg/tui/banner/banner.go
+index b6f28e1..491f96e 100644
+--- a/pkg/tui/banner/banner.go
++++ b/pkg/tui/banner/banner.go
+@@ -1,10 +1,30 @@
++// Package banner holds the art drawn on an empty conversation.
+ package banner
+
++import (
++ "errors"
++ "fmt"
++ "os"
++ "strings"
++ "time"
++
++ "github.com/charmbracelet/x/ansi"
++)
++
+ const (
+ Width = 56
+ Height = 13
+ )
+
++// FrameDuration is how long each frame of an animated banner is shown.
++const FrameDuration = 100 * time.Millisecond
++
++// FrameSeparator is the line that separates the frames of a banner file.
++const FrameSeparator = "\f"
++
++// maxFileSize bounds a banner file; the art is redrawn on every frame change.
++const maxFileSize = 16 << 20
++
+ // Lines is the Docker Agent ASCII-art startup banner.
+ var Lines = []string{
+ `██████╗ ██████╗ ██████╗██╗ ██╗███████╗██████╗ `,
+@@ -21,3 +41,123 @@ var Lines = []string{
+ ` ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ `,
+ ` ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ `,
+ }
++
++// Banner is one or more frames of art. Every frame is padded to the same
++// size, so the banner does not move as it plays.
++type Banner struct {
++ frames []string
++ width int
++ height int
++ // crops marks art that is cut down to an area smaller than itself
++ // rather than left out.
++ crops bool
++}
++
++// Default returns the Docker Agent banner.
++func Default() Banner {
++ return Banner{frames: []string{strings.Join(Lines, "\n")}, width: Width, height: Height}
++}
++
++// Load reads a banner from a text file. See Parse for the format.
++func Load(path string) (Banner, error) {
++ info, err := os.Stat(path)
++ if err != nil {
++ return Banner{}, fmt.Errorf("reading banner file: %w", err)
++ }
++ if info.Size() > maxFileSize {
++ return Banner{}, fmt.Errorf("banner file %s is larger than %d bytes", path, maxFileSize)
++ }
++ data, err := os.ReadFile(path)
++ if err != nil {
++ return Banner{}, fmt.Errorf("reading banner file: %w", err)
++ }
++ b, err := Parse(string(data))
++ if err != nil {
++ return Banner{}, fmt.Errorf("banner file %s: %w", path, err)
++ }
++ return b, nil
++}
++
++// Parse builds a banner from text. A line holding only a form feed separates
++// frames; a banner with several frames plays them in a loop. Lines may carry
++// ANSI color sequences.
++func Parse(text string) (Banner, error) {
++ text = strings.ReplaceAll(text, "\r\n", "\n")
++ var frames [][]string
++ var width, height int
++ for chunk := range strings.SplitSeq(text, "\n"+FrameSeparator+"\n") {
++ lines := strings.Split(strings.Trim(chunk, "\n"), "\n")
++ if len(lines) == 1 && strings.TrimSpace(ansi.Strip(lines[0])) == "" {
++ continue
++ }
++ height = max(height, len(lines))
++ for _, line := range lines {
++ width = max(width, ansi.StringWidth(line))
++ }
++ frames = append(frames, lines)
++ }
++ if len(frames) == 0 {
++ return Banner{}, errors.New("no banner art found")
++ }
++
++ b := Banner{width: width, height: height, crops: true, frames: make([]string, 0, len(frames))}
++ for _, lines := range frames {
++ for i, line := range lines {
++ lines[i] = line + strings.Repeat(" ", width-ansi.StringWidth(line))
++ }
++ for len(lines) < height {
++ lines = append(lines, strings.Repeat(" ", width))
++ }
++ b.frames = append(b.frames, strings.Join(lines, "\n"))
++ }
++ return b, nil
++}
++
++func (b Banner) Width() int { return b.width }
++func (b Banner) Height() int { return b.height }
++
++// Fits reports whether the banner can be drawn in the given area. Art read
++// from a file always can: it is cut down around its center, so a backdrop
++// larger than any terminal fills the area whatever its size.
++func (b Banner) Fits(width, height int) bool {
++ if width <= 0 || height <= 0 {
++ return false
++ }
++ return b.crops || (width >= b.width && height >= b.height)
++}
++
++// Crop cuts a frame down around its center to at most the given area.
++func (b Banner) Crop(frame string, width, height int) string {
++ if width >= b.width && height >= b.height {
++ return frame
++ }
++ lines := strings.Split(frame, "\n")
++ if extra := len(lines) - height; extra > 0 {
++ lines = lines[extra/2 : extra/2+height]
++ }
++ if extra := b.width - width; extra > 0 {
++ for i, line := range lines {
++ lines[i] = ansi.Cut(line, extra/2, extra/2+width)
++ }
++ }
++ return strings.Join(lines, "\n")
++}
++
++// Animated reports whether the banner has more than one frame.
++func (b Banner) Animated() bool { return len(b.frames) > 1 }
++
++// FrameIndexAt returns the frame shown after the given elapsed time.
++func (b Banner) FrameIndexAt(elapsed time.Duration) int {
++ if len(b.frames) < 2 || elapsed <= 0 {
++ return 0
++ }
++ return int(elapsed/FrameDuration) % len(b.frames)
++}
++
++// FrameAt returns the art shown after the given elapsed time.
++func (b Banner) FrameAt(elapsed time.Duration) string {
++ if len(b.frames) == 0 {
++ return ""
++ }
++ return b.frames[b.FrameIndexAt(elapsed)]
++}
+diff --git a/pkg/tui/banner/banner_test.go b/pkg/tui/banner/banner_test.go
+new file mode 100644
+index 0000000..6fa8bee
+--- /dev/null
++++ b/pkg/tui/banner/banner_test.go
+@@ -0,0 +1,98 @@
++package banner
++
++import (
++ "os"
++ "path/filepath"
++ "strings"
++ "testing"
++ "time"
++
++ "github.com/charmbracelet/x/ansi"
++ "github.com/stretchr/testify/assert"
++ "github.com/stretchr/testify/require"
++)
++
++func TestDefaultIsTheDockerAgentArt(t *testing.T) {
++ t.Parallel()
++
++ b := Default()
++ assert.False(t, b.Animated())
++ assert.Equal(t, Width, b.Width())
++ assert.Equal(t, Height, b.Height())
++ assert.Equal(t, strings.Join(Lines, "\n"), b.FrameAt(time.Hour))
++}
++
++func TestParseSingleFrame(t *testing.T) {
++ t.Parallel()
++
++ b, err := Parse("\n ab\nabcd\n\n")
++ require.NoError(t, err)
++ assert.False(t, b.Animated())
++ assert.Equal(t, 4, b.Width())
++ assert.Equal(t, 2, b.Height())
++ assert.Equal(t, " ab \nabcd", b.FrameAt(0))
++}
++
++func TestParseFramesShareOneSize(t *testing.T) {
++ t.Parallel()
++
++ b, err := Parse("ab\ncd\n\f\nxyz\n\f\n\x1b[31mq\x1b[39m\n")
++ require.NoError(t, err)
++ assert.True(t, b.Animated())
++ assert.Equal(t, 3, b.Width(), "color sequences take no width")
++ assert.Equal(t, 2, b.Height())
++ assert.Equal(t, "ab \ncd ", b.FrameAt(0))
++ assert.Equal(t, "xyz\n ", b.FrameAt(FrameDuration))
++ assert.Equal(t, "\x1b[31mq\x1b[39m \n ", b.FrameAt(2*FrameDuration))
++ assert.Equal(t, "ab \ncd ", b.FrameAt(3*FrameDuration), "the frames loop")
++}
++
++func TestDefaultIsLeftOutOfAnAreaTooSmallForIt(t *testing.T) {
++ t.Parallel()
++
++ b := Default()
++ assert.True(t, b.Fits(Width, Height))
++ assert.False(t, b.Fits(Width-1, Height))
++ assert.False(t, b.Fits(Width, Height-1))
++}
++
++func TestArtFromAFileIsCroppedAroundItsCenter(t *testing.T) {
++ t.Parallel()
++
++ b, err := Parse("abcdef\n\x1b[31mghijkl\x1b[39m\nmnopqr\nstuvwx")
++ require.NoError(t, err)
++ require.True(t, b.Fits(2, 2))
++ assert.False(t, b.Fits(0, 2))
++
++ frame := b.FrameAt(0)
++ assert.Equal(t, frame, b.Crop(frame, 6, 4), "an area large enough leaves the art whole")
++ assert.Equal(t, "hijk\nnopq", ansi.Strip(b.Crop(frame, 4, 2)))
++ assert.Contains(t, b.Crop(frame, 4, 2), "\x1b[31m", "colors survive the cut")
++}
++
++func TestParseRejectsEmptyArt(t *testing.T) {
++ t.Parallel()
++
++ for _, text := range []string{"", "\n\n", " \n\f\n\n"} {
++ _, err := Parse(text)
++ require.Error(t, err)
++ }
++}
++
++func TestLoad(t *testing.T) {
++ t.Parallel()
++
++ path := filepath.Join(t.TempDir(), "banner.txt")
++ require.NoError(t, os.WriteFile(path, []byte("one\r\n\f\r\ntwo\r\n"), 0o600))
++ b, err := Load(path)
++ require.NoError(t, err)
++ assert.Equal(t, "one", b.FrameAt(0))
++ assert.Equal(t, "two", b.FrameAt(FrameDuration))
++
++ _, err = Load(filepath.Join(t.TempDir(), "missing.txt"))
++ require.Error(t, err)
++
++ require.NoError(t, os.WriteFile(path, nil, 0o600))
++ _, err = Load(path)
++ require.ErrorContains(t, err, path)
++}
+diff --git a/pkg/tui/components/sidebar/command_sections.go b/pkg/tui/components/sidebar/command_sections.go
+new file mode 100644
+index 0000000..1bd2cff
+--- /dev/null
++++ b/pkg/tui/components/sidebar/command_sections.go
+@@ -0,0 +1,136 @@
++package sidebar
++
++import (
++ "context"
++ "errors"
++ "os/exec"
++ "slices"
++ "strings"
++ "time"
++
++ tea "charm.land/bubbletea/v2"
++ "github.com/charmbracelet/x/ansi"
++
++ "github.com/docker/docker-agent/pkg/shellpath"
++ "github.com/docker/docker-agent/pkg/tui/styles"
++)
++
++const (
++ // commandSectionInterval is how long a command section waits between runs.
++ commandSectionInterval = 5 * time.Second
++ // commandSectionTimeout bounds one run, so a hung command cannot stall the next.
++ commandSectionTimeout = 5 * time.Second
++ // commandSectionMaxLines keeps a chatty command from pushing the other sections away.
++ commandSectionMaxLines = 12
++)
++
++// CommandSection is a sidebar section whose body is the output of a shell
++// command, run again every few seconds.
++type CommandSection struct {
++ Title string
++ Command string
++}
++
++// ParseCommandSection reads a "Title=command" flag value.
++func ParseCommandSection(spec string) (CommandSection, error) {
++ title, command, ok := strings.Cut(spec, "=")
++ title, command = strings.TrimSpace(title), strings.TrimSpace(command)
++ if !ok || title == "" || command == "" {
++ return CommandSection{}, errors.New(`a sidebar section is written "Title=command"`)
++ }
++ return CommandSection{Title: title, Command: command}, nil
++}
++
++type commandSectionState struct {
++ CommandSection
++
++ lines []string
++}
++
++type commandSectionResultMsg struct {
++ index int
++ lines []string
++}
++
++type commandSectionDueMsg struct{ index int }
++
++func (m *model) SetCommandSections(sections []CommandSection) {
++ m.commandSections = make([]commandSectionState, len(sections))
++ for i, section := range sections {
++ m.commandSections[i].CommandSection = section
++ }
++ m.invalidateCache()
++}
++
++// pollCommandSections starts every command section's first run. Init runs
++// on every tab switch, so only the first call starts the chain.
++func (m *model) pollCommandSections() tea.Cmd {
++ if m.commandSectionsPolling || len(m.commandSections) == 0 {
++ return nil
++ }
++ m.commandSectionsPolling = true
++ cmds := make([]tea.Cmd, len(m.commandSections))
++ for i := range m.commandSections {
++ cmds[i] = m.runCommandSection(i)
++ }
++ return tea.Batch(cmds...)
++}
++
++func (m *model) runCommandSection(index int) tea.Cmd {
++ if index < 0 || index >= len(m.commandSections) {
++ return nil
++ }
++ command, parent := m.commandSections[index].Command, m.ctx()
++ return func() tea.Msg {
++ ctx, cancel := context.WithTimeout(parent, commandSectionTimeout)
++ defer cancel()
++ shell, args := shellpath.DetectShell()
++ // A failing command still reports what it printed; none means no section.
++ out, _ := exec.CommandContext(ctx, shell, append(args, command)...).Output()
++ return commandSectionResultMsg{index: index, lines: commandSectionLines(string(out))}
++ }
++}
++
++// commandSectionLines keeps the printable text of a command's output.
++func commandSectionLines(out string) []string {
++ var lines []string
++ for line := range strings.SplitSeq(strings.ReplaceAll(out, "\r\n", "\n"), "\n") {
++ line = strings.TrimRight(strings.ReplaceAll(ansi.Strip(line), "\t", " "), " ")
++ if line == "" {
++ continue
++ }
++ if len(lines) == commandSectionMaxLines {
++ lines[len(lines)-1] = "…"
++ break
++ }
++ lines = append(lines, line)
++ }
++ return lines
++}
++
++// handleCommandSection applies a finished run and schedules the next one.
++func (m *model) handleCommandSection(msg commandSectionResultMsg) tea.Cmd {
++ if msg.index < 0 || msg.index >= len(m.commandSections) {
++ return nil
++ }
++ if state := &m.commandSections[msg.index]; !slices.Equal(state.lines, msg.lines) {
++ state.lines = msg.lines
++ m.invalidateCache()
++ }
++ index := msg.index
++ return tea.Tick(commandSectionInterval, func(time.Time) tea.Msg {
++ return commandSectionDueMsg{index: index}
++ })
++}
++
++// commandSection renders one command section, or "" while it has no output.
++func (m *model) commandSection(state commandSectionState, contentWidth int) string {
++ if len(state.lines) == 0 {
++ return ""
++ }
++ lines := make([]string, len(state.lines))
++ for i, line := range state.lines {
++ lines[i] = styles.BaseStyle.Render(ansi.Truncate(line, contentWidth, "…"))
++ }
++ return m.renderTab(state.Title, strings.Join(lines, "\n"), contentWidth)
++}
+diff --git a/pkg/tui/components/sidebar/command_sections_test.go b/pkg/tui/components/sidebar/command_sections_test.go
+new file mode 100644
+index 0000000..55fca99
+--- /dev/null
++++ b/pkg/tui/components/sidebar/command_sections_test.go
+@@ -0,0 +1,81 @@
++package sidebar
++
++import (
++ "runtime"
++ "strings"
++ "testing"
++
++ "github.com/charmbracelet/x/ansi"
++ "github.com/stretchr/testify/assert"
++ "github.com/stretchr/testify/require"
++
++ "github.com/docker/docker-agent/pkg/tui/animation"
++ "github.com/docker/docker-agent/pkg/tui/service"
++)
++
++func newCommandSectionModel(t *testing.T, sections ...CommandSection) *model {
++ t.Helper()
++ m := New(animation.NewRuntime(), t.Context(), &service.SessionState{}).(*model)
++ m.SetCommandSections(sections)
++ return m
++}
++
++func sectionsText(m *model) string {
++ return ansi.Strip(strings.Join(m.renderSections(40), "\n"))
++}
++
++func TestParseCommandSection(t *testing.T) {
++ t.Parallel()
++
++ section, err := ParseCommandSection(" Apps = status --short | grep a=b ")
++ require.NoError(t, err)
++ assert.Equal(t, CommandSection{Title: "Apps", Command: "status --short | grep a=b"}, section)
++
++ for _, spec := range []string{"", "Apps", "=status", "Apps= "} {
++ _, err := ParseCommandSection(spec)
++ require.Error(t, err, spec)
++ }
++}
++
++func TestCommandSectionShowsItsOutputAndHidesWhileEmpty(t *testing.T) {
++ t.Parallel()
++
++ m := newCommandSectionModel(t, CommandSection{Title: "Apps", Command: "unused"})
++ assert.NotContains(t, sectionsText(m), "Apps")
++
++ next := m.handleCommandSection(commandSectionResultMsg{lines: []string{"relay running", strings.Repeat("x", 80)}})
++ require.NotNil(t, next, "a result schedules the next run")
++ text := sectionsText(m)
++ assert.Contains(t, text, "Apps")
++ assert.Contains(t, text, "relay running")
++ assert.NotContains(t, text, strings.Repeat("x", 41), "a long line is cut to the sidebar")
++
++ m.handleCommandSection(commandSectionResultMsg{})
++ assert.NotContains(t, sectionsText(m), "relay running")
++}
++
++func TestCommandSectionLinesKeepsPrintableTextAndBoundsIt(t *testing.T) {
++ t.Parallel()
++
++ assert.Equal(t, []string{"a b", "c"}, commandSectionLines("\x1b[31ma\tb\x1b[0m \r\n\n\nc\n"))
++
++ lines := commandSectionLines(strings.Repeat("line\n", commandSectionMaxLines+5))
++ require.Len(t, lines, commandSectionMaxLines)
++ assert.Equal(t, "…", lines[len(lines)-1])
++}
++
++func TestCommandSectionRunsItsCommandOnce(t *testing.T) {
++ t.Parallel()
++ if runtime.GOOS == "windows" {
++ t.Skip("the command is written for a POSIX shell")
++ }
++
++ m := newCommandSectionModel(t, CommandSection{Title: "Apps", Command: "echo relay; echo whoami; exit 3"})
++ cmd := m.pollCommandSections()
++ require.NotNil(t, cmd)
++ assert.Nil(t, m.pollCommandSections(), "Init runs again on a tab switch and must not start a second chain")
++
++ msg := m.runCommandSection(0)().(commandSectionResultMsg)
++ assert.Equal(t, []string{"relay", "whoami"}, msg.lines, "a failing command still shows what it printed")
++ assert.Nil(t, m.runCommandSection(7))
++}
+diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go
+index ec2e134..0c24641 100644
+--- a/pkg/tui/components/sidebar/sidebar.go
++++ b/pkg/tui/components/sidebar/sidebar.go
+@@ -78,6 +78,8 @@ type Model interface {
+ SetTokenUsage(event *runtime.TokenUsageEvent)
+ SetTodos(result *tools.ToolCallResult) error
+ SetMode(mode Mode)
++ // SetCommandSections adds sections that show a shell command's output.
++ SetCommandSections(sections []CommandSection)
+ // SetMirroredPadding swaps the horizontal edge padding so the sidebar hugs
+ // the terminal edge when rendered on the left of the chat.
+ SetMirroredPadding(mirrored bool)
+@@ -418,8 +420,11 @@ type model struct {
+ // from blank-line heuristics.
+ agentLineOwners []string
+ planData messages.PlanSidebarDataMsg
+- recentPlans []plans.Plan
+- planClickZones map[int]planClickZone
++ commandSections []commandSectionState
++ // commandSectionsPolling is set once the sections' refresh chain has started.
++ commandSectionsPolling bool
++ recentPlans []plans.Plan
++ planClickZones map[int]planClickZone
+ }
+
+ // New creates a new sidebar bound to the given session state.
+@@ -464,7 +469,7 @@ func New(ar *animation.Runtime, ctx context.Context, sessionState *service.Sessi
+ }
+
+ func (m *model) Init() tea.Cmd {
+- return waitForGitBranch(m.gitBranchWatcher)
++ return tea.Batch(waitForGitBranch(m.gitBranchWatcher), m.pollCommandSections())
+ }
+
+ // needsSpinner returns true if any spinner-driving state is active.
+@@ -1234,6 +1239,12 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
+ case messages.PlanSidebarDataMsg:
+ m.setPlans(msg)
+ return m, nil
++ case commandSectionResultMsg:
++ cmd := m.handleCommandSection(msg)
++ return m, cmd
++ case commandSectionDueMsg:
++ cmd := m.runCommandSection(msg.index)
++ return m, cmd
+ case gitBranchChangedMsg:
+ m.gitBranchName = string(msg)
+ m.invalidateCache()
+@@ -1821,6 +1832,9 @@ func (m *model) renderSections(contentWidth int) []string {
+ if !m.sectionVisibility.HideTools {
+ appendSection(m.toolsetInfo(contentWidth))
+ }
++ for _, section := range m.commandSections {
++ appendSection(m.commandSection(section, contentWidth))
++ }
+
+ if !m.sectionVisibility.HideTodos {
+ m.todoComp.SetSize(contentWidth)
+diff --git a/pkg/tui/page/chat/banner_test.go b/pkg/tui/page/chat/banner_test.go
+new file mode 100644
+index 0000000..f7ffacf
+--- /dev/null
++++ b/pkg/tui/page/chat/banner_test.go
+@@ -0,0 +1,91 @@
++package chat
++
++import (
++ "testing"
++ "time"
++
++ tea "charm.land/bubbletea/v2"
++ "github.com/charmbracelet/x/ansi"
++ "github.com/stretchr/testify/assert"
++ "github.com/stretchr/testify/require"
++
++ "github.com/docker/docker-agent/pkg/tui/animation"
++ tuibanner "github.com/docker/docker-agent/pkg/tui/banner"
++ "github.com/docker/docker-agent/pkg/tui/service"
++)
++
++type bannerScheduler struct{ now time.Time }
++
++func (s *bannerScheduler) Now() time.Time { return s.now }
++func (s *bannerScheduler) Tick(d time.Duration, f func(time.Time) tea.Msg) tea.Cmd {
++ s.now = s.now.Add(d)
++ delivered := s.now
++ return func() tea.Msg { return f(delivered) }
++}
++
++func newBannerTestPage(t *testing.T, ar *animation.Runtime, art string) *chatPage {
++ t.Helper()
++ b, err := tuibanner.Parse(art)
++ require.NoError(t, err)
++ p := New(ar, t.Context(), nil, &service.SessionState{}, WithBanner(b)).(*chatPage)
++ p.SetSize(160, 40)
++ return p
++}
++
++func bannerText(p *chatPage) string {
++ return ansi.Strip(p.messagesView(p.computeSidebarLayout()))
++}
++
++func TestCustomBannerReplacesTheDefault(t *testing.T) {
++ t.Parallel()
++
++ p := newBannerTestPage(t, animation.NewRuntime(), "my project")
++
++ assert.False(t, p.bannerPlaying())
++ assert.Contains(t, bannerText(p), "my project")
++ assert.NotContains(t, bannerText(p), tuibanner.Lines[0])
++}
++
++func TestAnimatedBannerAdvancesWithTheAnimationClock(t *testing.T) {
++ t.Parallel()
++
++ ar := animation.NewRuntimeWithScheduler(&bannerScheduler{now: time.Unix(1, 0)})
++ p := newBannerTestPage(t, ar, "first\n\f\nsecond\n")
++ require.True(t, p.bannerPlaying())
++ assert.Contains(t, bannerText(p), "first")
++
++ cmd := p.bannerSub.Start()
++ require.NotNil(t, cmd)
++ redraws := 0
++ for ar.Now() < tuibanner.FrameDuration {
++ tick, ok := ar.Accept(cmd().(animation.TickMsg))
++ require.True(t, ok)
++ p.tickBanner(tick)
++ if tick.Dirty() {
++ redraws++
++ }
++ cmd = ar.Continue()
++ require.NotNil(t, cmd)
++ }
++
++ assert.Equal(t, 1, redraws, "only a frame change redraws")
++ assert.Contains(t, bannerText(p), "second")
++}
++
++func TestAnimatedBannerStopsTickingOnceTheConversationStarts(t *testing.T) {
++ t.Parallel()
++
++ ar := animation.NewRuntimeWithScheduler(&bannerScheduler{now: time.Unix(1, 0)})
++ p := newBannerTestPage(t, ar, "first\n\f\nsecond\n")
++ cmd := p.bannerSub.Start()
++ require.Equal(t, int32(1), ar.ActiveCount())
++
++ p.showStartupBanner = false
++ tick, ok := ar.Accept(cmd().(animation.TickMsg))
++ require.True(t, ok)
++ p.tickBanner(tick)
++
++ assert.False(t, tick.Dirty())
++ assert.Zero(t, ar.ActiveCount())
++ assert.Nil(t, ar.Continue())
++}
+diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go
+index 76683bd..e34b40d 100644
+--- a/pkg/tui/page/chat/chat.go
++++ b/pkg/tui/page/chat/chat.go
+@@ -254,6 +254,8 @@ type chatPage struct {
+ // hideBanner mirrors the user's show_banner setting; the zero value
+ // keeps the banner so page literals stay banner-enabled.
+ hideBanner bool
++ banner tuibanner.Banner
++ bannerSub animation.Subscription
+
+ // Message queue for enqueuing messages while agent is working
+ messageQueue []queuedMessage
+@@ -414,6 +416,8 @@ func New(ar *animation.Runtime, ctx context.Context, a *app.App, sessionState *s
+ commandParser: commands.NewParser(),
+ sessionState: sessionState,
+ showStartupBanner: true,
++ banner: tuibanner.Default(),
++ bannerSub: ar.Subscribe(),
+ }
+
+ for _, opt := range opts {
+@@ -470,6 +474,22 @@ func WithShowBanner(show bool) PageOption {
+ }
+ }
+
++// WithSidebarSections adds sidebar sections that show a shell command's
++// output, refreshed every few seconds.
++func WithSidebarSections(sections []sidebar.CommandSection) PageOption {
++ return func(p *chatPage) {
++ p.sidebar.SetCommandSections(sections)
++ }
++}
++
++// WithBanner replaces the startup banner. A banner with several frames plays
++// while the conversation is empty.
++func WithBanner(b tuibanner.Banner) PageOption {
++ return func(p *chatPage) {
++ p.banner = b
++ }
++}
++
+ // WithCommandParser injects a command parser for handling slash commands in the editor.
+ func WithCommandParser(p *commands.Parser) PageOption {
+ return func(cp *chatPage) {
+@@ -530,6 +550,9 @@ func (p *chatPage) Init() tea.Cmd {
+ var cmds []tea.Cmd
+
+ cmds = append(cmds, p.messages.Init())
++ if p.bannerPlaying() {
++ cmds = append(cmds, p.bannerSub.Start())
++ }
+
+ // Load state from existing session (for session restore and branching)
+ if sess := p.app.Session(); sess != nil {
+@@ -609,6 +632,10 @@ func (p *chatPage) update(msg tea.Msg) (layout.Model, tea.Cmd) {
+ }
+ }
+
++ if tick, ok := msg.(animation.TickMsg); ok {
++ p.tickBanner(tick)
++ }
++
+ switch msg := msg.(type) {
+ case msgtypes.PlanSidebarDataMsg:
+ updated, cmd := p.sidebar.Update(msg)
+@@ -844,11 +871,21 @@ func (p *chatPage) messagesView(sl sidebarLayout) string {
+ if messagesView != "" || !p.showStartupBanner || p.hideBanner {
+ return messagesView
+ }
+- if sl.chatWidth < tuibanner.Width || sl.chatHeight < tuibanner.Height {
++ art := p.banner
++ if art.Height() == 0 {
++ // Page literals carry no banner; they get the default one.
++ art = tuibanner.Default()
++ }
++ if !art.Fits(sl.chatWidth, sl.chatHeight) {
+ return ""
+ }
+
+- banner := styles.BaseStyle.Foreground(styles.Accent).Render(strings.Join(tuibanner.Lines, "\n"))
++ var elapsed time.Duration
++ if art.Animated() {
++ elapsed = p.ar.Now()
++ }
++ frame := art.Crop(art.FrameAt(elapsed), sl.chatWidth, sl.chatHeight)
++ banner := styles.BaseStyle.Foreground(styles.Accent).Render(frame)
+ return lipgloss.Place(
+ sl.chatWidth,
+ sl.chatHeight,
+@@ -858,6 +895,27 @@ func (p *chatPage) messagesView(sl sidebarLayout) string {
+ )
+ }
+
++// bannerPlaying reports whether an animated banner still has the screen.
++func (p *chatPage) bannerPlaying() bool {
++ return p.banner.Animated() && p.showStartupBanner && !p.hideBanner
++}
++
++// tickBanner redraws on a frame change and gives up the tick once the
++// conversation has replaced the banner.
++func (p *chatPage) tickBanner(tick animation.TickMsg) {
++ if !p.bannerSub.IsActive() {
++ return
++ }
++ if !p.bannerPlaying() {
++ p.bannerSub.Stop()
++ return
++ }
++ before, after := tick.ElapsedBounds()
++ if p.banner.FrameIndexAt(before) != p.banner.FrameIndexAt(after) {
++ tick.MarkDirty()
++ }
++}
++
+ // View renders the chat page (messages + sidebar only, no editor or resize handle)
+ func (p *chatPage) View() string {
+ sl := p.computeSidebarLayout()
+diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go
+index 68e5f5c..9539902 100644
+--- a/pkg/tui/tui.go
++++ b/pkg/tui/tui.go
+@@ -27,12 +27,14 @@ import (
+ "github.com/docker/docker-agent/pkg/runtime"
+ "github.com/docker/docker-agent/pkg/session"
+ "github.com/docker/docker-agent/pkg/tui/animation"
++ tuibanner "github.com/docker/docker-agent/pkg/tui/banner"
+ "github.com/docker/docker-agent/pkg/tui/commands"
+ commanddefaults "github.com/docker/docker-agent/pkg/tui/commands/defaults"
+ "github.com/docker/docker-agent/pkg/tui/components/completion"
+ "github.com/docker/docker-agent/pkg/tui/components/editor"
+ "github.com/docker/docker-agent/pkg/tui/components/editor/completions"
+ "github.com/docker/docker-agent/pkg/tui/components/notification"
++ "github.com/docker/docker-agent/pkg/tui/components/sidebar"
+ "github.com/docker/docker-agent/pkg/tui/components/spinner"
+ "github.com/docker/docker-agent/pkg/tui/components/statusbar"
+ "github.com/docker/docker-agent/pkg/tui/components/tabbar"
+@@ -285,6 +287,9 @@ type appModel struct {
+ // showBanner displays the ASCII-art startup banner on an empty
+ // conversation. Shared by every tab and managed via /settings.
+ showBanner bool
++ banner tuibanner.Banner
++ // sidebarSections are extra sidebar sections fed by shell commands.
++ sidebarSections []sidebar.CommandSection
+
+ // buildCommandCategories is a function that returns the list of command categories.
+ buildCommandCategories func(context.Context, tea.Model) []commands.Category
+@@ -390,6 +395,21 @@ func WithTourOffer(showTelemetryNotice bool) Option {
+ }
+ }
+
++// WithSidebarSections adds sidebar sections that show a shell command's
++// output, refreshed every few seconds.
++func WithSidebarSections(sections []sidebar.CommandSection) Option {
++ return func(m *appModel) {
++ m.sidebarSections = sections
++ }
++}
++
++// WithBanner replaces the startup banner drawn on an empty conversation.
++func WithBanner(b tuibanner.Banner) Option {
++ return func(m *appModel) {
++ m.banner = b
++ }
++}
++
+ // WithAppName sets the application name.
+ //
+ // If not provided, defaults to "docker agent".
+@@ -538,6 +558,7 @@ func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initi
+ showBanner: userSettings.GetShowBanner(),
+ keyboardEnhancementsSupported: termfeatures.SupportsModifiedEnter(os.Getenv),
+ dockerDesktop: os.Getenv("TERM_PROGRAM") == "docker_desktop",
++ banner: tuibanner.Default(),
+ appName: "docker agent",
+ appVersion: version.Version,
+ }
+@@ -667,6 +688,8 @@ func (m *appModel) chatPageOpts() []chat.PageOption {
+ chat.WithSendMode(m.sendMode),
+ chat.WithInterruptMode(m.interruptMode),
+ chat.WithShowBanner(m.showBanner),
++ chat.WithBanner(m.banner),
++ chat.WithSidebarSections(m.sidebarSections),
+ }
+
+ if m.leanMode {
diff --git a/images/agent/spaceturtle-apps b/images/agent/spaceturtle-apps
new file mode 100755
index 0000000..904055c
--- /dev/null
+++ b/images/agent/spaceturtle-apps
@@ -0,0 +1,14 @@
+#!/bin/sh
+# What the chat's sidebar shows under "TOON apps": each app behind the agent node's
+# connectors and whether it runs, then the apps that wait for the Observer. docker-agent runs
+# this every few seconds (--sidebar-section, ADR 0005) with the agent key the session has, so
+# it shows what the agent itself is told by `toon status`, and no more.
+status=$(toon status --json 2>/dev/null) || true
+if ! printf '%s' "$status" | jq -e '.toon_apps' > /dev/null 2>&1; then
+ echo "the node does not answer"
+ exit 0
+fi
+printf '%s' "$status" | jq -r '
+ (.toon_apps[] | (if .connector.running then empty else "○ \(.name): connector stopped" end),
+ (.apps[] | "\(if .running then "●" else "○" end) \(.name) \(if .running then "running" else "stopped" end)")),
+ (.pending_apps[]? | "◌ \(.name) \(.status // "pending")")'
diff --git a/images/agent/spaceturtle-limit b/images/agent/spaceturtle-limit
new file mode 100755
index 0000000..b2e23b2
--- /dev/null
+++ b/images/agent/spaceturtle-limit
@@ -0,0 +1,19 @@
+#!/bin/sh
+# What the chat's sidebar shows under "Spending limit": what the session's agent key may still
+# pay through the connector today, out of its limit for a day, and its limit for one command.
+# This is the agent's budget on the network. "Token Usage" above it is docker-agent's own
+# count of what the model has read and written, and what that cost. docker-agent runs this
+# every few seconds (--sidebar-section, ADR 0005); `toon limit show` pays nothing.
+limits=$(toon limit show --json 2>/dev/null) || true
+if ! printf '%s' "$limits" | jq -e '.limits | length > 0' > /dev/null 2>&1; then
+ echo "the node does not answer"
+ exit 0
+fi
+printf '%s' "$limits" | jq -r '
+ def commas: tostring | explode | reverse | [range(0; length; 3) as $i | .[$i:$i + 3] | implode] | join(",") | explode | reverse | implode;
+ (.limits | length) as $n | .limits | to_entries[] |
+ (if $n > 1 then .key else empty end),
+ "left today \(.value.remaining_today | commas)",
+ "per day \(.value.per_day | commas)",
+ "per command \(.value.per_command | commas)",
+ (if $n > 1 then empty else "in base units of the token" end)'
diff --git a/images/connector/Dockerfile b/images/connector/Dockerfile
index 62ab1d2..d238056 100644
--- a/images/connector/Dockerfile
+++ b/images/connector/Dockerfile
@@ -1,7 +1,7 @@
-# The connector's zone: toon, the container engine the apps run on (the base's own Docker),
-# and gVisor's runsc for images that are not TOON's own. It holds no wallet: the wallet's
-# storage is a volume the launcher mounts, so replacing this image leaves it alone.
-# `connector-start` is the entrypoint; it needs no `systemd --user`.
+# The connector's zone, a virtual machine under microsandbox: toon, the container engine the
+# apps run on (the base's own Docker), and gVisor's runsc for images that are not TOON's own.
+# It holds no wallet: the wallet's storage is a directory the launcher mounts, so replacing
+# this image leaves it alone. `connector-start` is the entrypoint; it needs no `systemd --user`.
# Build arguments come from images/versions.env and have no defaults here.
ARG CONNECTOR_BASE_DIGEST
FROM docker/sandbox-templates:shell-docker@${CONNECTOR_BASE_DIGEST}
@@ -48,11 +48,6 @@ rm -rf /var/lib/apt/lists/* /tmp/toon-v* /tmp/gvisor /tmp/gvisor.tar.bz2 "/tmp/$
# The runtime dockerd offers an app whose image is not TOON's own.
mkdir -p /etc/docker
echo '{"runtimes":{"runsc":{"path":"/usr/local/bin/runsc"}}}' > /etc/docker/daemon.json
-
-# The overlay client reads this file before the config toon writes, and only the sandbox's
-# forward proxy lets it connect (the live test on #42). toon has no setting for a proxy.
-mkdir -p /usr/local/etc/anon
-echo "HTTPSProxy gateway.docker.internal:3128" > /usr/local/etc/anon/anonrc-defaults
SCRIPT
COPY --chmod=0755 connector-start /usr/local/bin/connector-start
diff --git a/images/connector/connector-start b/images/connector/connector-start
index 967bd18..6576bba 100755
--- a/images/connector/connector-start
+++ b/images/connector/connector-start
@@ -1,12 +1,15 @@
#!/bin/bash
-# The connector's zone's entrypoint. Starts the container engine if it is not running, then
-# runs the supervisor in this process, so nothing needs `systemd --user` (`toon up` without
-# --foreground installs a unit). The passphrase file is the one the launcher made in this zone.
-# Until `toon init` has written the passphrase file there is nothing to supervise, so it waits
-# for that file with the engine up, then starts the supervisor.
+# The connector's zone's entrypoint, and what the launcher's unit runs in the zone. Starts the
+# container engine if it is not running, the two forwarders an agent key is used through, then
+# the supervisor in this process, so nothing needs `systemd --user` (`toon up` without
+# --foreground installs a unit). The passphrase file is the one the launcher made in this
+# zone. Until `toon init` has written the passphrase file there is nothing to supervise, so
+# it waits for that file with the engine up, then starts the supervisor.
set -euo pipefail
if ! docker info >/dev/null 2>&1; then
+ # The launcher keeps /run in memory, so the engine finds nothing there from before the zone
+ # last stopped: a pid file left on a disk would keep it from starting.
sudo sh -c 'setsid nohup dockerd > /var/log/dockerd.log 2>&1 < /dev/null &'
for _ in $(seq 60); do
docker info >/dev/null 2>&1 && break
@@ -20,4 +23,25 @@ if [[ ! -r $TOON_PASSPHRASE_FILE ]]; then
echo "connector-start: no passphrase file at $TOON_PASSPHRASE_FILE; the container engine is up, waiting for toon init" >&2
until [[ -r $TOON_PASSPHRASE_FILE ]]; do sleep 5; done
fi
+
+home=$HOME/.toon/agent-node
+# A zone that was stopped leaves the supervisor's socket behind, which `toon up` could take
+# for a node that is running.
+pgrep -f "[t]oon up" >/dev/null || rm -f "$home/supervisor.sock"
+
+# The supervisor's orders and the relay TOON app's connector listen on loopback, which a
+# published port does not reach. A forwarder on the zone's own address leads to each: 17100
+# to the orders, 17101 to the connector. The launcher publishes those two to the machine's
+# loopback. They listen on that one address, which is not on any app's network: the zone's
+# own interface is there from the start, so its address comes before any the engine adds.
+own_address=$(hostname -I | tr ' ' '\n' | grep -m1 -E '^[0-9]+(\.[0-9]+){3}$' || true)
+forward() { # listening port, the loopback port it leads to
+ [[ -n $own_address ]] || { echo "connector-start: the zone's own address is not known, so port $1 is not forwarded" >&2; return 0; }
+ [[ $2 =~ ^[0-9]+$ ]] || { echo "connector-start: nothing to forward port $1 to yet" >&2; return 0; }
+ pgrep -f "socat TCP-LISTEN:$1," >/dev/null && return 0
+ setsid nohup socat "TCP-LISTEN:$1,bind=$own_address,fork,reuseaddr" "TCP:127.0.0.1:$2" >/dev/null 2>&1 < /dev/null &
+}
+forward 17100 "$(jq -r '.supervisor // "" | sub(".*:"; "")' "$home/agent-keys.json" 2>/dev/null)"
+forward 17101 "$(jq -r '.toon_apps[0].listen // "" | sub(".*:"; "")' "$home/state.json" 2>/dev/null)"
+
exec toon up --foreground
diff --git a/images/versions b/images/versions
index ebc5ee7..63e6fc5 100755
--- a/images/versions
+++ b/images/versions
@@ -7,6 +7,7 @@ here=$(cd "$(dirname "$0")" && pwd)
cat <&2; exit 1; }
+
+# Everything the machine lacks, said at once, before anything is changed.
+lacking=()
+[[ $(uname -s) == Linux && $(uname -m) == x86_64 ]] || lacking+=("a Linux machine on x86_64: the zone images are built for nothing else")
+if [[ ! -e $kvm ]]; then lacking+=("KVM ($kvm): turn on virtualisation in the firmware (VT-x or AMD-V) and load the kvm module")
+elif [[ ! -r $kvm || ! -w $kvm ]]; then lacking+=("the right to use KVM ($kvm): run 'sudo usermod -aG kvm \$USER', then sign out and in again")
+fi
+(( ${#msb_home} <= 54 )) || lacking+=("a shorter home for microsandbox than $msb_home: set SPACETURTLE_MSB_HOME to a directory of 54 characters or fewer")
+# microsandbox is built against the GNU C library.
+getconf GNU_LIBC_VERSION >/dev/null 2>&1 || lacking+=("the GNU C library (glibc 2.28 or newer), which microsandbox needs")
+command -v "$systemctl_" >/dev/null 2>&1 || lacking+=("systemd, for the user units that keep the Install running")
+for program in "$curl_" jq tar sha256sum; do
+ command -v "$program" >/dev/null 2>&1 || lacking+=("$program")
+done
+if (( ${#lacking[@]} )); then
+ echo "spaceturtle: this machine lacks what spaceturtle needs, so nothing was installed:" >&2
+ printf ' - %s\n' "${lacking[@]}" >&2
+ exit 1
+fi
+
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+
+version=${SPACETURTLE_VERSION:-}
+if [[ -z $version ]]; then
+ version=$("$curl_" -fsSL "https://api.github.com/repos/$repo/releases/latest" | jq -r '.tag_name // empty') || version=
+ [[ -n $version ]] || die "the latest release could not be read from GitHub. Name one: SPACETURTLE_VERSION=v0.2.0"
+fi
+[[ $version =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]] || die "'$version' is not a release, such as v0.2.0"
+
+say "installing the launcher of $version"
+"$curl_" -fsSL -o "$tmp/source.tar.gz" "https://github.com/$repo/archive/refs/tags/$version.tar.gz" || die "$version could not be downloaded"
+mkdir "$tmp/source"
+tar -xzf "$tmp/source.tar.gz" -C "$tmp/source" --strip-components=1 || die "the download of $version is not an archive"
+[[ -s $tmp/source/spaceturtle && -s $tmp/source/agent/spaceturtle.yaml ]] || die "$version holds no launcher"
+
+# The new launcher is whole before it takes the old one's place.
+mkdir -p "$(dirname "$dir")" "$bin"
+rm -rf "$dir.new"
+mkdir -p "$dir.new/agent"
+install -m 0755 "$tmp/source/spaceturtle" "$dir.new/spaceturtle"
+install -m 0644 "$tmp/source/agent/spaceturtle.yaml" "$dir.new/agent/spaceturtle.yaml"
+# The launcher pulls the zone images of the release named here.
+echo "${version#v}" > "$dir.new/version"
+rm -rf "$dir.old"
+[[ ! -e $dir ]] || mv "$dir" "$dir.old"
+mv "$dir.new" "$dir"
+rm -rf "$dir.old"
+ln -sfn "$dir/spaceturtle" "$bin/spaceturtle"
+
+# microsandbox is a program and the library that holds the zones' kernel, side by side.
+if [[ $("$msb_home/bin/msb" --version 2>/dev/null) != "msb $msb_version" ]]; then
+ say "installing microsandbox $msb_version, which the zones run in"
+ "$curl_" -fsSL -o "$tmp/msb.tar.gz" "https://github.com/superradcompany/microsandbox/releases/download/v$msb_version/microsandbox-linux-x86_64.tar.gz" \
+ || die "microsandbox could not be downloaded, so the launcher has nothing to run the zones in. Run this again."
+ echo "$msb_sha256 $tmp/msb.tar.gz" | sha256sum -c - >/dev/null || die "the download of microsandbox is not the release it should be, so it was not installed"
+ mkdir "$tmp/msb"
+ tar -xzf "$tmp/msb.tar.gz" -C "$tmp/msb" || die "the download of microsandbox is not an archive"
+ lib=$(cd "$tmp/msb" && ls libkrunfw.so.*.*.* 2>/dev/null | head -n 1) || lib=
+ [[ -s $tmp/msb/msb && -n $lib ]] || die "the download of microsandbox does not hold what it should"
+ mkdir -p "$msb_home/bin" "$msb_home/lib"
+ install -m 0644 "$tmp/msb/$lib" "$msb_home/lib/$lib"
+ abi=${lib#libkrunfw.so.}; abi=${abi%%.*}
+ ln -sfn "$lib" "$msb_home/lib/libkrunfw.so.$abi"
+ ln -sfn "libkrunfw.so.$abi" "$msb_home/lib/libkrunfw.so"
+ install -m 0755 "$tmp/msb/msb" "$msb_home/bin/msb"
+fi
+
+say "installed. What to run next:"
+n=1
+step() { echo " $n. $*"; n=$((n + 1)); }
+case ":$PATH:" in *":$bin:"*) ;; *) step "export PATH=\"$bin:\$PATH\" (and add it to your shell's profile)" ;; esac
+step "spaceturtle start (about a minute; makes the Install on devnet)"
+step "spaceturtle key (your model provider's key, read without showing it)"
+step "spaceturtle (talk to your agent)"
diff --git a/spaceturtle b/spaceturtle
index 2a4f1d1..7e1bdcd 100755
--- a/spaceturtle
+++ b/spaceturtle
@@ -1,56 +1,73 @@
#!/bin/bash
# The launcher: the one program a person runs to have a spaceturtle.
#
-# spaceturtle start
+# spaceturtle start [--network devnet|mainnet] [--evm-rpc-url ] [--evm-chain-id ]
+# [--evm-token ] [--connector-url ] [--relay-url ]
+# [--faucet-url ] [--solana] [--solana-rpc-url ]
+# [--max-per-command ] [--max-per-day ]
# Brings the connector's zone up, step by step, and does only the steps
# not yet done, so a run that was interrupted finishes the rest when run
# again and a second run creates, initialises and funds nothing:
-# 1. zone a detached Docker Sandboxes virtual machine made from the
-# connector's zone image, with an any-address rule scoped to
-# that zone alone; nothing is copied into it from the machine
-# 2. node the agent node, initialised on devnet inside it
-# 3. faucet the wallet funded from the devnet faucet
-# 4. supervisor the image's `connector-start` (the container engine, then
-# `toon up`), started again whenever it is not running
-# 5. backup a sealed backup of the wallet, handed to the Observer
+# 1. zone a microsandbox virtual machine made from the connector's zone
+# image. It reaches the public internet and nothing of the machine
+# or its network; nothing is copied into it from the machine
+# 2. node the agent node, initialised inside it on the network chosen
+# 3. faucet the wallet funded from the faucet, on devnet
+# 4. agent key one agent key, its private half placed in the agent's zone
+# 5. supervisor the image's `connector-start` (the container engine, the
+# forwarders, then `toon up`), run by a systemd user unit, which
+# starts it again if it ends and brings the zone back after the
+# machine restarts
+# 6. backup a sealed backup of the wallet, handed to the Observer
# The passphrase is made inside the zone: it is never printed and never
# written to the machine's disk. `upgrade` carries it in memory to the new zone.
#
# spaceturtle stop
# Stops the timer's unit (its on/off setting stays), a session in the agent's
-# zone, then the connector's zone. Loses nothing; `start` brings it back and
-# creates, initialises and funds nothing.
+# zone, the unit that keeps the connector's zone running, then that zone. Loses
+# nothing; `start` brings it back and creates, initialises and funds nothing.
+# spaceturtle supervise
+# What that unit runs: `connector-start` in a session in the connector's zone,
+# until it ends. Not for a person to run.
# spaceturtle upgrade
# Replaces the connector's zone with one from the newest connector's zone image,
# leaving the wallet's storage as it was. A fresh backup comes first; if it fails,
# or the passphrase cannot be read out of the old zone, nothing is changed. The
-# passphrase goes through the standard output and input of `sbx exec`, never an
+# passphrase goes through the standard output and input of `msb exec`, never an
# argument, the environment or a file. The agent's zone is removed too.
# spaceturtle backup
# Writes a fresh sealed backup to the backup directory; the earlier one is replaced
# only once the new one has arrived whole. Needs the connector's zone running.
# spaceturtle remove [--delete-wallet]
-# Offers a fresh backup, then removes both zones, the timer's units, the step
-# markers, the settings and the model key. Keeps the wallet's storage and the
+# Offers a fresh backup, then removes both zones, what the agent kept, the timer's
+# units, the step markers, the settings and the model key. Keeps the wallet's storage and the
# backup directory. --delete-wallet also deletes the wallet's storage, after the
# word "delete" is typed; anything else changes nothing.
#
-# spaceturtle mainnet [--keep-limit]
-# Moves the Install from devnet to mainnet, once the person running it has
-# typed "mainnet" to confirm. It first says what changes: real funds are now at
-# stake, there is no faucet, and which addresses to fund. Without the typed
-# word nothing changes. It lowers the spending limit to the mainnet default
-# unless --keep-limit is given. On an Install already on mainnet it says so
-# and changes nothing.
+# The network is configuration, chosen when the Install is made and kept with it: a
+# profile (--network, devnet unless named), and whatever of it a flag replaces, which is
+# mostly the chain's RPC endpoint. The flags are `toon init`'s own and go to it as given.
+# --evm-chain-id is the launcher's: after the node is initialised it asks the node's RPC
+# endpoint for its chain id, and goes no further unless it is that one. A later `start`
+# needs no flags; one that names a different network is refused and changes nothing, since
+# `toon` has no command that moves a home to another chain. On mainnet real funds are at
+# stake: `start` says so and asks for the word "mainnet" before it makes anything, and
+# funds nothing, since there is no faucet.
#
+# spaceturtle
# spaceturtle session [--unattended]
-# Starts a `docker-agent` session in the agent's zone, from the agent's zone
-# image and the published agent definition. The session command carries an
-# allow-list of exactly two things: the model provider's host and one loopback
-# port, the one that leads to the connector's zone. By hand it is a
-# conversation; --unattended is what the timer runs: no one is present, and
-# the session has a ceiling on model cost. Nothing starts while the
-# connector's zone is not running.
+# With nothing after it, `spaceturtle` is `spaceturtle session`.
+# Starts a `docker-agent` session in the agent's zone, with the agent
+# definition mounted read-only. The agent's zone reaches name resolution, the
+# model provider's host, the host of the network's connector, where the agent's
+# skills are fetched from, and the two loopback ports that lead to the connector's
+# zone. Everything else is refused. That list is the zone's from when it is made:
+# when it changes, as with a model of another provider, the zone is made again and
+# the agent key carried across in memory. One session runs at a time. By hand
+# it is a conversation; --unattended is what the timer runs: no one is
+# present, every tool call the definition offers runs unasked, and the session
+# has a ceiling on model cost, written into its copy of the definition as
+# docker-agent's `budget`. Nothing starts while the connector's zone is not running.
# spaceturtle timer on|off|interval m|h|d
# The timer starts an unattended session. `start` turns it on, every 6h,
# unless it was turned off. It is a systemd user timer.
@@ -71,7 +88,9 @@
# The timer's state and interval, when the last unattended session started,
# the model and the ceiling; the connector's zone (running, stopped, not created),
# the agent's zone (in a session, idle), the network, each app and whether the
-# relay answers; whether the supervisor runs and how many apps wait. Exits 0
+# relay answers; whether the supervisor runs, how many apps wait, and what the
+# node has earned: what others have paid it on its channels and how much of that
+# has landed, and the fees and packets since the connector last started. Exits 0
# when the connector's zone is stopped or missing.
# spaceturtle agent-key show
# Prints the agent key's scope, limits and what is left today (read from the
@@ -84,27 +103,34 @@
# refused, and deletes the private half from the agent's zone. `start` does not
# make another; `agent-key replace` does.
# spaceturtle agent-key limit
-# Sets the agent key's spending limit. Only the Observer can: the agent has no tool for it.
+# Sets what the agent key may spend in a UTC day, in the token's base units. Only
+# the Observer can: the agent has no tool for it.
# `start` makes exactly one agent key, with the default scope (ADR 0003) and the default
# spending limit, and puts its private half in the agent's zone and nowhere else: it goes
# from the connector's zone, where `toon` writes it to a file that is deleted once it is
# placed, to the agent's zone through a pipe, never through an argument, the machine or
-# the output of the launcher. A second run makes none, and neither does `upgrade` after
+# the output of the launcher. On the way in, the addresses in the key file become the two
+# loopback ports of the machine that lead to the connector's zone. A second run makes none, and neither does `upgrade` after
# a revoke.
# Approving or refusing an app that is not waiting says so and changes nothing.
# `waiting`, `approve`, `refuse` and `status` run `toon` with the home, in the
# connector's zone. The agent has no tool for them (tests/check-agent).
#
# The model key is the Observer's: it lives in a file outside every zone, is given
-# to the session as an environment variable of the sbx process (never an argument)
+# to the session as an environment variable of the msb process (never an argument)
# and is not in any directory the agent's file tool may read.
#
# Every command that uses a zone stops before doing anything when the machine has no
-# KVM, `sbx` is missing or
-# `sbx` is signed out, and says the cause and the remedy.
+# KVM, the person may not use it, or microsandbox (`msb`) is missing or does not run, and
+# says the cause and the remedy. The zones need no account and no root.
#
# Environment, so that tests can run against a temporary home:
-# SPACETURTLE_SBX the sbx program (default: sbx)
+# SPACETURTLE_MSB_HOME microsandbox's home, which holds the zones and their images and
+# is spaceturtle's alone (default: ~/.local/share/spaceturtle/msb, or
+# ~/.spaceturtle-msb when that is more than 54 characters long)
+# SPACETURTLE_MSB the msb program (default: bin/msb in that home, where install.sh puts it)
+# SPACETURTLE_PULL when a zone image is pulled: always, if-missing or never (default:
+# if-missing for `start`, always for `upgrade`)
# SPACETURTLE_KVM the KVM device (default: /dev/kvm)
# SPACETURTLE_ZONE the zone's name (default: st-connector)
# SPACETURTLE_STATE_DIR the state directory (default: ~/.local/state/spaceturtle);
@@ -117,16 +143,30 @@
# (default: ~/.config/spaceturtle)
# SPACETURTLE_MODEL_KEY_FILE the model key (default: ~/.config/spaceturtle-launcher/model-key)
# SPACETURTLE_MODEL_HOST the model provider's host, for a provider the launcher does not know
-# SPACETURTLE_CONNECTOR_IMAGE the connector's zone image (default: ghcr.io/toon-protocol/spaceturtle-connector-zone:latest)
+# SPACETURTLE_CONNECTOR_IMAGE the connector's zone image (default: ghcr.io/toon-protocol/spaceturtle-connector-zone, at the release install.sh installed, else latest)
# SPACETURTLE_AGENT_ZONE the agent's zone's name (default: st-agent)
-# SPACETURTLE_AGENT_IMAGE the agent's zone image (default: ghcr.io/toon-protocol/spaceturtle-agent-zone:latest)
+# SPACETURTLE_AGENT_IMAGE the agent's zone image (default: ghcr.io/toon-protocol/spaceturtle-agent-zone, likewise)
# SPACETURTLE_AGENT_DEF the agent definition (default: agent/spaceturtle.yaml beside this file)
-# SPACETURTLE_CONNECTOR_PORT the loopback port that leads to the connector's zone (default: 7100)
+# SPACETURTLE_CONNECTOR_PORT the first of the two loopback ports of the machine that lead to the
+# connector's zone: this one to the supervisor's orders, the next
+# to the relay TOON app's connector (default: 47100). It is the
+# zone's from when it is made, and kept in the settings from then
+# SPACETURTLE_PORT_PROBE a program that tells, given a port, whether it is open on this machine
+# (default: the launcher connects to it)
+# SPACETURTLE_SUPERVISOR_WAIT how many seconds `start` waits for the supervisor (default: 180)
# SPACETURTLE_SYSTEMCTL the systemctl program (default: systemctl)
# SPACETURTLE_UNIT_DIR where the timer's units are written (default: ~/.config/systemd/user)
set -uo pipefail
-sbx=${SPACETURTLE_SBX:-sbx}
+# microsandbox keeps a socket for each zone under its home, and a socket's path is short: a
+# home of more than 54 characters is refused, so a long data directory gives way to one in $HOME.
+msb_home=${SPACETURTLE_MSB_HOME:-${XDG_DATA_HOME:-$HOME/.local/share}/spaceturtle/msb}
+(( ${#msb_home} <= 54 )) || [[ -n ${SPACETURTLE_MSB_HOME:-} ]] || msb_home=$HOME/.spaceturtle-msb
+msb=${SPACETURTLE_MSB:-$msb_home/bin/msb}
+# The zones and their images are kept apart from any microsandbox the person uses themselves.
+export MSB_HOME=$msb_home
+# A zone is made from the image that is already here, if it is; `upgrade` asks the registry.
+pull=${SPACETURTLE_PULL:-if-missing}
kvm=${SPACETURTLE_KVM:-/dev/kvm}
zone=${SPACETURTLE_ZONE:-st-connector}
state_dir=${SPACETURTLE_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/spaceturtle}
@@ -136,14 +176,34 @@ backup_dir=${SPACETURTLE_BACKUP_DIR:-$HOME/spaceturtle-backup}
config_dir=${SPACETURTLE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/spaceturtle}
conf=$config_dir/launcher.conf
key_file=${SPACETURTLE_MODEL_KEY_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/spaceturtle-launcher/model-key}
-connector_image=${SPACETURTLE_CONNECTOR_IMAGE:-ghcr.io/toon-protocol/spaceturtle-connector-zone:latest}
+# Where the launcher itself is, through any link to it: the agent definition is beside it.
+here=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)
+# install.sh names the release it installed, and the zone images are that release's.
+image_tag=latest
+[[ ! -s $here/version ]] || image_tag=$(<"$here/version")
+connector_image=${SPACETURTLE_CONNECTOR_IMAGE:-ghcr.io/toon-protocol/spaceturtle-connector-zone:$image_tag}
agent_zone=${SPACETURTLE_AGENT_ZONE:-st-agent}
-agent_image=${SPACETURTLE_AGENT_IMAGE:-ghcr.io/toon-protocol/spaceturtle-agent-zone:latest}
-here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+agent_image=${SPACETURTLE_AGENT_IMAGE:-ghcr.io/toon-protocol/spaceturtle-agent-zone:$image_tag}
agent_def=${SPACETURTLE_AGENT_DEF:-$here/agent/spaceturtle.yaml}
-connector_port=${SPACETURTLE_CONNECTOR_PORT:-7100}
+# The port the Install was made with is kept in the settings, since the units carry no environment.
+connector_port=${SPACETURTLE_CONNECTOR_PORT:-$(sed -n 's/^port=//p' "$conf" 2>/dev/null | tail -n 1)}
+connector_port=${connector_port:-47100}
+[[ $connector_port =~ ^[1-9][0-9]{0,4}$ ]] && (( connector_port < 65535 )) || { echo "spaceturtle: SPACETURTLE_CONNECTOR_PORT must be a port number, not '$connector_port'" >&2; exit 1; }
+relay_port=$((connector_port + 1))
+supervisor_wait=${SPACETURTLE_SUPERVISOR_WAIT:-180}
+# The agent definition as a session reads it, in a directory the agent's zone mounts read-only.
+session_dir=$state_dir/session
+# What the agent keeps, in its two directories. They are mounted, so they outlast the agent's zone.
+agent_dir=$state_dir/agent
+# toon's home in the connector's zone, where the wallet's storage is mounted.
+toon_home='$HOME/.toon/agent-node'
+# The user of both zone images, whose home is /home/agent. A mounted directory is shown as theirs.
+zone_user=uid=1000,gid=1000
systemctl_=${SPACETURTLE_SYSTEMCTL:-systemctl}
unit_dir=${SPACETURTLE_UNIT_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user}
+# The path the units run the launcher with: wherever jq is, which systemd's own path may not hold.
+unit_path=/usr/local/bin:/usr/bin:/bin
+case :$unit_path: in *":$(dirname "$(command -v jq 2>/dev/null || echo /usr/bin/jq)"):"*) ;; *) unit_path=$(dirname "$(command -v jq)"):$unit_path ;; esac
die() { echo "spaceturtle: $*" >&2; exit 1; }
say() { echo "spaceturtle: $*"; }
@@ -156,85 +216,169 @@ agent_key_path='$HOME/.config/toon-agent/key'
# Quotes a value for the zone's sh.
sh_quote() { printf "'%s'" "${1//\'/\'\\\'\'}"; }
+# Runs a command in a zone. The launcher's own standard input is left alone unless a caller pipes into it.
+zexec() { "$msb" exec --quiet "$@"; }
+
# Every command in the zone sees the passphrase file that lives in the zone.
-in_zone() { "$sbx" exec "$zone" sh -c "export TOON_PASSPHRASE_FILE=\$HOME/.config/toon-passphrase; $1"; }
+in_zone() { zexec --no-stdin "$zone" -- sh -c "export TOON_PASSPHRASE_FILE=\$HOME/.config/toon-passphrase; $1"; }
preflight() {
- [[ -e $kvm ]] || die "this machine has no KVM ($kvm), and the connector's zone is a virtual machine. Turn on virtualisation in the firmware (VT-x or AMD-V) and load the kvm module, or use a machine that has it."
- command -v "$sbx" >/dev/null 2>&1 || die "sbx (Docker Sandboxes) is not installed. Install it from https://github.com/docker/sbx-releases and run it again."
- "$sbx" ls >/dev/null 2>&1 || die "sbx is signed out or its daemon is not running. Run 'sbx login' and 'sbx daemon start -d', then run it again."
+ [[ -e $kvm ]] || die "this machine has no KVM ($kvm), and a zone is a virtual machine. Turn on virtualisation in the firmware (VT-x or AMD-V) and load the kvm module, or use a machine that has it."
+ [[ -r $kvm && -w $kvm ]] || die "you may not use KVM ($kvm). Run 'sudo usermod -aG kvm \$USER', sign out and in again, then run it again."
+ (( ${#msb_home} <= 54 )) || die "microsandbox's home, $msb_home, is ${#msb_home} characters long, and it keeps sockets there whose paths must be short. Set SPACETURTLE_MSB_HOME to a directory of 54 characters or fewer."
+ command -v "$msb" >/dev/null 2>&1 || die "microsandbox is not installed at $msb. Run spaceturtle's install.sh, which puts it there, then run it again."
+ "$msb" ls >/dev/null 2>&1 || die "microsandbox does not run on this machine. '$msb doctor' says why."
}
-zone_exists() { "$sbx" ls 2>/dev/null | awk -v z="$zone" '$1 == z { found = 1 } END { exit !found }'; }
-
-# The zone's state in `sbx ls`: running, stopped or "not created". Takes the zone's name.
+# A zone's state: running, stopped or "not created". Takes the zone's name.
zone_state() {
- "$sbx" ls 2>/dev/null | awk -v z="${1:-$zone}" '$1 == z { found = 1; run = ($0 ~ /running/) }
- END { print !found ? "not created" : run ? "running" : "stopped" }'
+ local s
+ s=$("$msb" ls --format json 2>/dev/null | jq -r --arg z "${1:-$zone}" '.[] | select(.name == $z) | .status | ascii_downcase' 2>/dev/null)
+ case $s in running) echo running ;; "") echo "not created" ;; *) echo stopped ;; esac
}
+zone_exists() { [[ $(zone_state) != "not created" ]]; }
+
# Creates the connector's zone from the connector's zone image; the markers are the caller's.
+# The wallet's storage is mounted as toon's home. The container engine keeps its images on a
+# disk of the zone's own, and /run is in memory, so that a zone that starts again finds nothing
+# the engine left there. Out of the zone leads the public internet, and nothing of the machine or
+# its network; into it lead the two forwarders of `connector-start`, from the machine's loopback.
create_zone() {
mkdir -p "$data_dir" || die "cannot create $data_dir"
- "$sbx" policy init balanced || die "sbx policy init failed"
- "$sbx" create --name "$zone" --cpus 4 --memory 4g --template "$connector_image" shell "$data_dir" || die "sbx create failed"
+ "$msb" create "$connector_image" --name "$zone" --cpus 4 --memory 4G --quiet --pull "$pull" \
+ --mount-dir "$data_dir:/home/agent/.toon/agent-node:$zone_user" \
+ --mount-owned /var/lib/docker:kind=disk,size=20G --tmpfs /run \
+ --net public --port "127.0.0.1:$connector_port:17100" --port "127.0.0.1:$relay_port:17101" \
+ || die "the connector's zone could not be made; microsandbox says why above. It needs ports $connector_port and $relay_port of this machine: if another program holds one, set SPACETURTLE_CONNECTOR_PORT to the first of two free ports.${way_back:-}"
+ conf_set port "$connector_port"
}
step_zone() {
- if ! zone_exists; then
- say "creating the connector's zone ($zone)"
- create_zone
- # A new zone has none of the steps done in it, whatever an earlier zone had.
- rm -rf "$marks"
- elif [[ $(zone_state) == stopped ]]; then
- say "starting the connector's zone ($zone)"
- "$sbx" start "$zone" || die "sbx start failed"
- fi
- if ! done_ rule; then
- # The rule names the zone, so it is never machine-wide.
- "$sbx" policy allow network --sandbox "$zone" --protocol tcp,udp "**" || die "the any-address rule failed"
- mark rule
- fi
- if ! done_ zone; then
- # The image holds the overlay client's proxy setting. The wallet lives in the
- # mounted directory while the passphrase stays on the zone's own disk.
- in_zone 'mkdir -p ~/.config && ln -sfn '"$(sh_quote "$data_dir")"' ~/.config/toon' || die "the zone could not be set up"
- mark zone
- fi
+ case $(zone_state) in
+ "not created")
+ say "creating the connector's zone ($zone)"
+ create_zone
+ # A new zone has none of the steps done in it, whatever an earlier zone had.
+ rm -rf "$marks" ;;
+ stopped)
+ say "starting the connector's zone ($zone)"
+ "$msb" start --quiet "$zone" || die "the connector's zone could not be started" ;;
+ esac
+ # The two ports are the zone's from when it is made, and the agent key's file names them.
+ local have
+ have=$("$msb" inspect "$zone" --format json 2>/dev/null | jq -r '[.config.network.ports[]?.host_port] | sort | map(tostring) | join(" and ")' 2>/dev/null)
+ [[ -z $have || $have == "$connector_port and $relay_port" ]] \
+ || die "the connector's zone was made with ports $have of this machine, not $connector_port and $relay_port. Set SPACETURTLE_CONNECTOR_PORT to the first of its own, or 'spaceturtle upgrade' makes the zone again with the new ones."
}
step_node() {
done_ node && return
- say "initialising the agent node on devnet"
- in_zone '{ [ -s "$TOON_PASSPHRASE_FILE" ] || { umask 077 && head -c 32 /dev/urandom | base64 > "$TOON_PASSPHRASE_FILE"; }; } &&
- toon init --accept-anyone-terms --network devnet --json >/dev/null' || die "toon init failed in the zone"
+ say "initialising the agent node on $network"
+ # Split on spaces alone: a URL may hold a character the shell would take for a pattern.
+ local flag flags= kept=()
+ read -ra kept <<<"$(conf_get init "")"
+ for flag in "${kept[@]}"; do
+ [[ $flag == --network || $flag == "$network" ]] && continue
+ flags+=" $(sh_quote "$flag")"
+ done
+ in_zone '{ [ -s "$TOON_PASSPHRASE_FILE" ] || { mkdir -p "$HOME/.config" && umask 077 && head -c 32 /dev/urandom | base64 > "$TOON_PASSPHRASE_FILE"; }; } &&
+ toon init --accept-anyone-terms --network '"$network$flags"' --json >/dev/null' || die "toon init failed in the zone"
mark node
}
+# The chain the node's RPC endpoint serves must be the one the Observer named, before anything is funded or started.
+step_chain() {
+ local want got
+ want=$(conf_get chain_id "")
+ [[ -n $want ]] || return 0
+ done_ chain && return
+ got=$(in_zone 'curl -s --max-time 20 -H "content-type: application/json" --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_chainId\",\"params\":[]}" --url "$(jq -r ".toon_apps[0].evm.rpc_url" '"$toon_home"'/state.json)"' | jq -r '.result // empty' 2>/dev/null)
+ [[ $got =~ ^0x[0-9a-fA-F]+$ ]] || die "the node's RPC endpoint did not say which chain it serves, so nothing was funded or started. Run it again; if it is the wrong endpoint, 'spaceturtle remove --delete-wallet' and 'start' with --evm-rpc-url makes the Install again."
+ (( got == want )) || die "the node's RPC endpoint serves chain $((got)), not $want, so nothing was funded or started. 'spaceturtle remove --delete-wallet' and 'start' with the right --evm-rpc-url makes the Install again."
+ mark chain
+}
+
+# An app whose image is not on toon's list of trusted images runs under gVisor, which the
+# connector's zone image holds. toon reads the runtime's name from this file when the app is added.
+step_runtime() {
+ done_ runtime && return
+ in_zone "[ -s $toon_home/untrusted-runtime ] || echo runsc > $toon_home/untrusted-runtime" || die "the sandboxed runtime could not be set in the zone"
+ mark runtime
+}
+
step_faucet() {
done_ faucet && return
- say "funding the wallet from the faucet"
- in_zone 'toon wallet fund --json >/dev/null' || die "toon wallet fund failed in the zone"
+ if [[ $network == devnet ]]; then
+ say "funding the wallet from the faucet"
+ in_zone 'toon wallet fund --json >/dev/null' || die "toon wallet fund failed in the zone"
+ else
+ say "there is no faucet on $network, so nothing is funded for you. Fund only what you can afford to lose, at these addresses:"
+ in_zone 'toon wallet show' | sed 's/^/ /' || die "the wallet's addresses could not be read from the zone"
+ fi
mark faucet
}
# The pattern does not match itself, so the shell running pgrep is not taken for the supervisor.
supervisor_running() { in_zone 'pgrep -f "[t]oon up" >/dev/null'; }
+# The unit that keeps the supervisor running in the connector's zone: it starts the zone when
+# the machine starts, runs `connector-start` in it, and is started again when that ends.
+write_connector_unit() {
+ mkdir -p "$unit_dir" || die "cannot create $unit_dir"
+ cat > "$unit_dir/spaceturtle-connector.service" </dev/null || exec connector-start; while pgrep -f "[t]oon up" >/dev/null; do sleep 5; done; exit 1'
+}
+
step_supervisor() {
- supervisor_running && return
+ # Written and enabled on every run, so a launcher that has moved or changed is the one the unit runs.
+ write_connector_unit
+ "$systemctl_" --user enable spaceturtle-connector.service || die "the unit that keeps the connector's zone running could not be enabled"
+ # A supervisor the unit does not hold would not be started again when it ends.
+ if supervisor_running && "$systemctl_" --user is-active --quiet spaceturtle-connector.service; then need_ports; return; fi
say "starting the supervisor"
- # The image's entrypoint may not have run, so the launcher starts it; it reads the passphrase file in_zone names.
- in_zone 'setsid nohup connector-start > ~/up.log 2>&1 < /dev/null &' || die "connector-start failed in the zone"
+ "$systemctl_" --user restart spaceturtle-connector.service || die "the unit that keeps the connector's zone running could not be started"
+ # The supervisor is there before it answers: the container engine and the apps come up first.
+ local waited=0
+ until supervisor_running && in_zone 'toon status --json >/dev/null 2>&1'; do
+ ((waited < supervisor_wait)) || die "the supervisor did not start in the zone. 'journalctl --user -u spaceturtle-connector' says why."
+ sleep 3; waited=$((waited + 3))
+ done
+ need_ports
}
# Writes a fresh sealed backup to the backup directory. The backup already there is
# replaced only once the new one has arrived whole.
make_backup() {
mkdir -p "$backup_dir" && chmod 700 "$backup_dir" || return 1
- in_zone 'rm -f /tmp/wallet-backup; toon wallet backup --output /tmp/wallet-backup >/dev/null' || { echo "spaceturtle: toon wallet backup failed in the zone" >&2; return 1; }
+ in_zone 'rm -f /tmp/wallet-backup; toon wallet backup --out /tmp/wallet-backup >/dev/null' || { echo "spaceturtle: toon wallet backup failed in the zone" >&2; return 1; }
rm -f "$backup_dir/wallet-backup.new"
- if ! "$sbx" cp "$zone:/tmp/wallet-backup" "$backup_dir/wallet-backup.new" || [[ ! -s $backup_dir/wallet-backup.new ]]; then
+ if ! "$msb" copy --quiet "$zone:/tmp/wallet-backup" "$backup_dir/wallet-backup.new" || [[ ! -s $backup_dir/wallet-backup.new ]]; then
rm -f "$backup_dir/wallet-backup.new"
echo "spaceturtle: the backup could not be copied out of the zone; the earlier backup, if any, is as it was" >&2
return 1
@@ -250,57 +394,9 @@ step_backup() {
mark backup
}
-# The network the node is on, from `toon status --json`; empty when it cannot be read.
+# The network the node is on, from the home's record of it; empty when it cannot be read.
node_network() {
- in_zone 'toon status --json' 2>/dev/null | jq -r '.network // .agent_node.network // empty' 2>/dev/null
-}
-
-mainnet() {
- local keep_limit=0 arg network addresses answer
- for arg in "$@"; do
- case $arg in
- --keep-limit) keep_limit=1 ;;
- *) die "usage: spaceturtle mainnet [--keep-limit]" ;;
- esac
- done
- preflight
- zone_exists || die "there is no connector's zone yet. Run 'spaceturtle start' first."
- command -v jq >/dev/null 2>&1 || die "jq is not installed, and it is needed to read the node's network."
- network=$(node_network)
- [[ -n $network ]] || die "the network the node is on could not be read from the zone. Run 'spaceturtle start' and try again."
- if [[ $network == mainnet ]]; then
- say "this Install is already on mainnet. Nothing was changed."
- return 0
- fi
- addresses=$(in_zone 'toon wallet addresses') || die "the wallet's addresses could not be read from the zone"
- [[ -n $addresses ]] || die "the zone listed no address to fund"
- cat </dev/null' || die "toon could not move the node to mainnet"
- if (( ! keep_limit )); then
- in_zone 'toon limit set --network-default --json >/dev/null' || die "the node is on mainnet, but the spending limit could not be lowered. Lower it before funding anything: $sbx exec $zone sh -c 'TOON_PASSPHRASE_FILE=\$HOME/.config/toon-passphrase toon limit set --network-default'"
- fi
- network=$(node_network)
- [[ $network == mainnet ]] || die "the node did not report mainnet afterwards (it says '${network:-nothing}')"
- say "the Install is on mainnet. Fund the addresses above."
+ in_zone "cat $toon_home/state.json" 2>/dev/null | jq -r '.network // empty' 2>/dev/null
}
# --- settings: `name=value` lines in launcher.conf ---
@@ -317,7 +413,12 @@ conf_set() { # name, value
valid_interval() { [[ $1 =~ ^[1-9][0-9]*[mhd]$ ]]; }
valid_ceiling() { [[ $1 =~ ^([0-9]+(\.[0-9]+)?)$ ]] && [[ $1 =~ [1-9] ]]; }
-# The provider's host and the variable its key goes in, from the model's name.
+# A host's name with a domain, which is all a wall's rule may name: an address, or one of
+# microsandbox's own words for a group of addresses (public, private, host), is neither.
+is_host() { [[ $1 =~ ^([A-Za-z0-9-]+\.)+[A-Za-z]{2,}$ ]]; }
+
+# The provider's host and the variable its key goes in, from the model's name. The host is
+# empty for a provider the launcher does not know.
provider_of() {
model=$(conf_get model anthropic/claude-sonnet-5-5)
case ${model%%/*} in
@@ -325,11 +426,12 @@ provider_of() {
openai) host=api.openai.com key_var=OPENAI_API_KEY ;;
google) host=generativelanguage.googleapis.com key_var=GOOGLE_API_KEY ;;
mistral) host=api.mistral.ai key_var=MISTRAL_API_KEY ;;
- *) host=${SPACETURTLE_MODEL_HOST:-}; key_var=MODEL_API_KEY
- [[ -n $host ]] || die "the launcher does not know the host of '${model%%/*}'. Set SPACETURTLE_MODEL_HOST to it." ;;
+ # A ChatGPT account in place of an API key: the key file holds the account's access token.
+ chatgpt) host=chatgpt.com key_var=CHATGPT_OAUTH_TOKEN ;;
+ *) host= key_var=MODEL_API_KEY ;;
esac
- [[ -n ${SPACETURTLE_MODEL_HOST:-} ]] && host=$SPACETURTLE_MODEL_HOST
- return 0
+ [[ -z ${SPACETURTLE_MODEL_HOST:-} ]] || host=$SPACETURTLE_MODEL_HOST
+ [[ -z $host ]] || is_host "$host" || die "SPACETURTLE_MODEL_HOST must be a host's name, such as api.example.com, not '$host'"
}
need_model_key() {
@@ -338,9 +440,128 @@ need_model_key() {
# The connector's zone must be running, with the supervisor in it.
need_connector() {
- zone_exists && "$sbx" ls 2>/dev/null | awk -v z="$zone" '$1 == z { ok = ($0 ~ /running/) } END { exit !ok }' \
- && in_zone 'pgrep -f "[t]oon up" >/dev/null' \
+ [[ $(zone_state) == running ]] && supervisor_running \
|| die "the connector's zone ($zone) is not running, so no session is started. Run 'spaceturtle start' first."
+ need_ports
+}
+
+# microsandbox opens the zone's two ports on this machine when the zone starts. If another
+# program held one at that moment the zone runs all the same and nothing listens, so the agent
+# would find no node. Only a zone that is started again gets its ports.
+port_open() {
+ if [[ -n ${SPACETURTLE_PORT_PROBE:-} ]]; then "$SPACETURTLE_PORT_PROBE" "$1"; else (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null; fi
+}
+need_ports() {
+ port_open "$connector_port" && port_open "$relay_port" \
+ || die "the connector's zone ($zone) is running, but ports $connector_port and $relay_port of this machine do not both lead to it, so the agent cannot reach the node. Another program may have held one when the zone started. Run 'spaceturtle stop', then 'spaceturtle start'."
+}
+
+# What the node has earned, in two lines, for `status` and for the chat's sidebar. What others
+# have paid it is the sum of its inbound channels' watermarks, which a restart does not lose,
+# and how much of that has landed on the chain; the fees and the packets are the connector's
+# own count since it last started. An agent key is told none of this, so the launcher reads
+# it in the connector's zone. Reading it pays nothing and needs no passphrase.
+earnings_lines() {
+ local channels counts
+ channels=$(in_zone 'toon channel list --json' 2>/dev/null) || channels=
+ counts=$(in_zone 'toon packet count --json' 2>/dev/null) || counts=
+ jq -rn --arg channels "$channels" --arg counts "$counts" '
+ def commas: tostring | explode | reverse | [range(0; length; 3) as $i | .[$i:$i + 3] | implode] | join(",") | explode | reverse | implode;
+ ($channels | try fromjson catch null) as $c | ($counts | try fromjson catch null) as $p |
+ if ($c | type) != "object" and ($p | type) != "object" then "cannot be read now"
+ else
+ (if ($c | type) == "object" then [$c.channels[]? | select(.direction == "inbound")] as $in
+ | "received \([$in[].watermark // 0] | add // 0 | commas) (\([$in[].landed // 0] | add // 0 | commas) landed)"
+ else empty end),
+ (if ($p | type) == "object" then "this run \($p.fees_earned // 0 | commas) in fees, \($p.packets.fulfilled // 0 | commas) packets"
+ else empty end)
+ end'
+}
+
+# While someone is in a session the sidebar shows the earnings, from a file in the directory
+# the agent's zone mounts read-only, written again every quarter of a minute.
+watch_earnings() {
+ while :; do
+ earnings_lines > "$session_dir/earnings.new" 2>/dev/null && mv -f "$session_dir/earnings.new" "$session_dir/earnings"
+ sleep 15
+ done
+}
+
+# The definition a session reads, written fresh each time into the directory the agent's zone
+# mounts read-only. An unattended one carries the ceiling as docker-agent's run-wide budget.
+write_session_def() { # 1 when unattended
+ local out=$session_dir/spaceturtle.yaml
+ mkdir -p "$session_dir" && cp "$agent_def" "$out.new" || die "cannot write $out"
+ if (( $1 )); then
+ grep -q '^budget:' "$agent_def" && die "$agent_def has a budget of its own, so the ceiling cannot be added to it"
+ printf 'budget:\n max_cost: %s\n' "$(conf_get ceiling 1)" >> "$out.new" || die "cannot write $out"
+ fi
+ mv -f "$out.new" "$out" || die "cannot write $out"
+}
+
+# The host of the connector of the network the node was initialised for, from the home's record;
+# empty when there is none. A packet for an app behind that connector is sealed to it, and
+# `toon` asks it for its identity from the agent's zone.
+network_connector_host() {
+ in_zone "cat $toon_home/state.json" 2>/dev/null | jq -r '.connector_url // empty | capture("^https://(?[A-Za-z0-9.-]+)(:443)?(/|$)").h // empty' 2>/dev/null
+}
+
+# Where docker-agent fetches the skills the agent definition names from.
+skill_hosts="api.github.com codeload.github.com"
+
+# Everything the agent's zone may reach, as microsandbox's rules: name resolution, the two ports
+# of the machine that lead to the connector's zone, the model provider's host, the host of the
+# network's connector and the skills' hosts. The rest is refused, the machine's other ports and
+# its network included. Needs the connector's zone running.
+agent_wall() {
+ local h wall="allow@dns,allow@host:tcp:$connector_port,allow@host:tcp:$relay_port"
+ provider_of
+ for h in $host $(network_connector_host) $skill_hosts; do
+ # The connector's host is read from the node's own record, which is not taken on trust.
+ is_host "$h" && wall+=",allow@$h:tcp:443"
+ done
+ echo "$wall"
+}
+
+# The wall the agent's zone was made with, which microsandbox keeps as the zone's label.
+agent_zone_wall() {
+ "$msb" inspect "$agent_zone" --format json 2>/dev/null | jq -r '.config.labels["spaceturtle.wall"] // empty' 2>/dev/null
+}
+
+# Makes the agent's zone, which is left running. The wall is the zone's from when it is made.
+make_agent_zone() { # the wall
+ mkdir -p "$agent_dir/config" "$agent_dir/state" "$session_dir" || die "cannot create $agent_dir"
+ # A rule that names a host holds for the addresses the name resolves to (--net-strict=false).
+ # microsandbox can hold it to the name itself only by opening the zone's TLS, which is not done.
+ "$msb" create "$agent_image" --name "$agent_zone" --cpus 2 --memory 2G --quiet --pull "$pull" \
+ --mount-dir "$agent_dir/config:/home/agent/.config/spaceturtle:$zone_user" \
+ --mount-dir "$agent_dir/state:/home/agent/.local/state/spaceturtle:$zone_user" \
+ --mount-dir "$session_dir:/home/agent/session:ro" \
+ --net-strict=false --net-default-egress deny --net-rule "$1" --label "spaceturtle.wall=$1" \
+ || die "the agent's zone could not be made"
+ # The directories above the mounted ones are made as root's; the agent's own files go beside them.
+ if ! zexec --no-stdin "$agent_zone" -- sudo chown agent: /home/agent/.config /home/agent/.local /home/agent/.local/state; then
+ "$msb" remove --force --quiet "$agent_zone"
+ die "the agent's zone could not be set up"
+ fi
+}
+
+# A zone whose wall is no longer the one wanted is made again, since a wall cannot be changed.
+# The agent key's private half goes across in the launcher's memory, as the passphrase does in `upgrade`.
+rewall_agent_zone() { # the wall wanted
+ local key
+ say "what the agent's zone may reach has changed, so the zone is made again"
+ key=$(zexec --no-stdin "$agent_zone" -- sh -c "cat $agent_key_path") && [[ -n $key ]] \
+ || die "the agent key could not be read out of the agent's zone, so nothing was changed. 'spaceturtle agent-key replace' makes the zone again, with a new key."
+ "$msb" remove --force --quiet "$agent_zone" || die "the agent's zone ($agent_zone) could not be removed"
+ make_agent_zone "$1"
+ if ! printf '%s\n' "$key" | zexec "$agent_zone" -- sh -c 'umask 077 && mkdir -p "$HOME/.config/toon-agent" && cat > "$HOME/.config/toon-agent/key" && [ -s "$HOME/.config/toon-agent/key" ]'; then
+ key=
+ # A zone with the right wall and no key would be taken for a whole one by the next session.
+ "$msb" remove --force --quiet "$agent_zone"
+ die "the agent key could not be placed in the new agent's zone, so that zone is removed. Run 'spaceturtle agent-key replace'."
+ fi
+ key=
}
session() {
@@ -353,18 +574,46 @@ session() {
preflight
need_model_key
provider_of
+ [[ -n $host ]] || die "the launcher does not know the host of '${model%%/*}'. Set SPACETURTLE_MODEL_HOST to it."
need_connector
- local args=(run --name "$agent_zone" --image "$agent_image"
- --allow-host "$host" --allow-host "localhost:$connector_port"
- -e "$key_var" -e TOON_AGENT_KEY -- docker-agent run --model "$model")
+ local state wall
+ [[ $(zone_state "$agent_zone") != "not created" ]] || die "there is no agent's zone ($agent_zone), so there is no agent key to work with. Run 'spaceturtle agent-key replace' to make both."
+ # One session at a time: the zone is stopped when its session ends.
+ mkdir -p "$state_dir" && exec 9>"$state_dir/session.lock" || die "cannot write $state_dir/session.lock"
+ flock -n 9 || die "a session is already running in the agent's zone ($agent_zone). 'spaceturtle stop' ends it."
+ state=$(zone_state "$agent_zone")
+ write_session_def "$unattended"
+ wall=$(agent_wall) || exit 1
+ # No program in a zone is given the lock (9>&-), so it is free once this launcher ends.
+ # The trap runs after this function has gone, so what it names is not local.
+ earnings_watcher=
+ trap '[[ -z ${earnings_watcher:-} ]] || kill "$earnings_watcher" 2>/dev/null; "$msb" stop --quiet --timeout 60 "$agent_zone" >/dev/null 2>&1 9>&-' EXIT
+ if [[ $(agent_zone_wall) != "$wall" ]]; then rewall_agent_zone "$wall" 9>&-
+ elif [[ $state == stopped ]]; then "$msb" start --quiet "$agent_zone" 9>&- || die "the agent's zone could not be started"
+ fi
+ local def=/home/agent/session/spaceturtle.yaml
+ # With someone there, the chat opens on the banner the README shows, moving: the agent's zone
+ # image holds it as a film of frames, and its docker-agent is built to play one (ADR 0005).
+ # Its sidebar shows what the node has earned, which the launcher reads for it, and what the
+ # agent key may still pay today and the node's apps, both as the agent key is told of them.
+ local attach=(--tty) run=(--banner-file /usr/local/share/spaceturtle/banner.txt --sidebar-section "Earnings=cat /home/agent/session/earnings" --sidebar-section "Spending limit=spaceturtle-limit" --sidebar-section "TOON apps=spaceturtle-apps" "$def")
if (( unattended )); then
- mkdir -p "$state_dir" && date -u +%Y-%m-%dT%H:%M:%SZ > "$state_dir/last-unattended" || die "cannot write $state_dir/last-unattended"
- args+=(--max-cost "$(conf_get ceiling 1)" --exec "Begin a session: read the waiting Requests and answer them, then do your daily work.")
+ date -u +%Y-%m-%dT%H:%M:%SZ > "$state_dir/last-unattended" || die "cannot write $state_dir/last-unattended"
+ # Nobody answers a prompt, and the definition's tools are the whole of what the agent may do.
+ attach=(--no-stdin)
+ run=(--exec --yolo "$def" "Begin a session: read the waiting Requests and answer them, then do your daily work.")
fi
- args+=("$agent_def")
- # The key reaches the process by name (-e), never as an argument.
+ if (( ! unattended )); then watch_earnings > /dev/null 2>&1 9>&- & earnings_watcher=$!; fi
+ # The key reaches the process by name (-e), never as an argument of any program: it is
+ # exported by the shell itself, in a subshell that becomes msb.
# The agent key's path, not the key, goes in the environment; the file is in the agent's zone.
- env "$key_var=$(<"$key_file")" "TOON_AGENT_KEY=${agent_key_path/\$HOME//home/agent}" "$sbx" "${args[@]}"
+ # The agent's zone is its sandbox already, so docker-agent is told not to make another.
+ # The chat names spaceturtle, not docker-agent.
+ (
+ export "$key_var=$(<"$key_file")" "TOON_AGENT_KEY=${agent_key_path/\$HOME//home/agent}"
+ exec "$msb" exec --quiet "${attach[@]}" -e "$key_var" -e TOON_AGENT_KEY \
+ "$agent_zone" -- docker-agent run --sandbox=false --app-name spaceturtle --model "$model" "${run[@]}"
+ ) 9>&-
}
# --- the timer: a systemd user timer that runs an unattended session ---
@@ -377,6 +626,7 @@ Description=A spaceturtle session with nobody present
[Service]
Type=oneshot
+Environment=PATH=$unit_path
ExecStart=$here/spaceturtle session --unattended
UNIT
cat > "$unit_dir/spaceturtle.timer" <m|h|d, such as 6h"
conf_set interval "$2"
# A timer that is on is written again with the new interval.
- [[ $(conf_get timer on) == on ]] && timer_on || say "the interval is $2; the timer is off"
+ [[ $(conf_get timer on) == on ]] && timer_on restart || say "the interval is $2; the timer is off"
;;
*) die "usage: spaceturtle timer on|off|interval m|h|d" ;;
esac
@@ -447,13 +699,60 @@ key() {
say "the model key is saved in $key_file"
}
+# start's flags: `toon init`'s own, taken as given, and --evm-chain-id. They are kept in the
+# settings as the Install's network, so a run that was interrupted goes on with them.
+choose_network() {
+ local given=() chain= name answer
+ while (($#)); do
+ case $1 in
+ --solana) given+=("$1") ;;
+ --network)
+ [[ ${2:-} == devnet || ${2:-} == mainnet ]] || die "usage: spaceturtle start --network devnet|mainnet"
+ given+=("$1" "$2"); shift ;;
+ --evm-chain-id)
+ [[ ${2:-} =~ ^[1-9][0-9]*$ ]] || die "usage: spaceturtle start --evm-chain-id "
+ chain=$2; shift ;;
+ --evm-rpc-url|--evm-token|--connector-url|--relay-url|--faucet-url|--solana-rpc-url|--max-per-command|--max-per-day)
+ [[ ${2:-} =~ ^[A-Za-z0-9][A-Za-z0-9._:/@%+=,?\&~*-]*$ ]] || die "usage: spaceturtle start $1 "
+ given+=("$1" "$2"); shift ;;
+ *) die "usage: spaceturtle start [--network devnet|mainnet] [--evm-rpc-url ] [--evm-chain-id ] [--evm-token ] [--connector-url ] [--relay-url ] [--faucet-url ] [--solana] [--solana-rpc-url ] [--max-per-command ] [--max-per-day ]" ;;
+ esac
+ shift
+ done
+ if (( ${#given[@]} )) || [[ -n $chain ]]; then
+ if done_ node; then
+ [[ "${given[*]}" == "$(conf_get init "")" && $chain == "$(conf_get chain_id "")" ]] \
+ || die "this Install's node is already initialised, with: $(conf_get init "--network devnet"). A network is chosen when an Install is made, and toon cannot move one. Nothing was changed. 'spaceturtle remove --delete-wallet' and 'start' with the flags makes another."
+ else
+ conf_set init "${given[*]}"
+ conf_set chain_id "$chain"
+ fi
+ fi
+ network=$(sed -n 's/.*--network \([a-z]*\).*/\1/p' <<<"$(conf_get init "")")
+ network=${network:-devnet}
+ if [[ $network == mainnet ]] && ! done_ node; then
+ cat </dev/null' \
+ in_zone 'rm -f /tmp/agent-key; umask 077; toon agent-key create --out /tmp/agent-key --json >/dev/null' \
|| { in_zone 'rm -f /tmp/agent-key'; die "toon agent-key create failed in the zone"; }
}
# Moves the private half to the agent's zone through a pipe, then deletes it from the connector's zone.
+# The key file names the supervisor's orders and the TOON app's connector on the connector's zone's
+# loopback; on the way in they become the machine's two ports that lead there.
# The agent's zone is left stopped unless it was running, so `status` does not take it for a session.
place_agent_key() {
- local was; was=$(zone_state "$agent_zone")
- ensure_agent_zone "$was"
- if ! "$sbx" exec "$zone" sh -c 'cat /tmp/agent-key' | "$sbx" exec -i "$agent_zone" sh -c \
- 'umask 077 && mkdir -p "$HOME/.config/toon-agent" && cat > "$HOME/.config/toon-agent/key.new" && [ -s "$HOME/.config/toon-agent/key.new" ] && mv -f "$HOME/.config/toon-agent/key.new" "$HOME/.config/toon-agent/key"'; then
+ local was wall
+ was=$(zone_state "$agent_zone"); wall=$(agent_wall) || exit 1
+ # A zone with another wall is made again here too; the key in it is the one being replaced.
+ if [[ $was != "not created" && $(agent_zone_wall) != "$wall" ]]; then
+ "$msb" remove --force --quiet "$agent_zone" || die "the agent's zone ($agent_zone) could not be removed"
+ was="not created"
+ fi
+ case $was in
+ "not created") make_agent_zone "$wall" ;;
+ stopped) "$msb" start --quiet "$agent_zone" || die "the agent's zone could not be started" ;;
+ esac
+ if ! zexec --no-stdin "$zone" -- sh -c 'cat /tmp/agent-key' | zexec "$agent_zone" -- sh -c \
+ 'umask 077 && mkdir -p "$HOME/.config/toon-agent" && jq --arg s "http://host.microsandbox.internal:'"$connector_port"'" --arg c "http://host.microsandbox.internal:'"$relay_port"'" ".supervisor = \$s | .toon_apps = ((.toon_apps // {}) | with_entries(.value = \$c))" > "$HOME/.config/toon-agent/key.new" && [ -s "$HOME/.config/toon-agent/key.new" ] && mv -f "$HOME/.config/toon-agent/key.new" "$HOME/.config/toon-agent/key"'; then
in_zone 'rm -f /tmp/agent-key'
die "the agent key could not be placed in the agent's zone"
fi
in_zone 'rm -f /tmp/agent-key' || die "the agent key is placed, but its copy in the connector's zone could not be deleted"
- [[ $was == running ]] || "$sbx" stop "$agent_zone" || die "the agent key is placed, but the agent's zone could not be stopped"
+ [[ $was == running ]] || "$msb" stop --quiet --timeout 60 "$agent_zone" || die "the agent key is placed, but the agent's zone could not be stopped"
}
revoke_agent_keys() { # ids
@@ -535,7 +834,7 @@ cmd_agent_key() {
local sub=${1:-} old doc
case $sub in
show|replace|revoke) [[ $# -eq 1 ]] || die "usage: spaceturtle agent-key $sub" ;;
- limit) [[ $# -eq 2 ]] && valid_ceiling "$2" || die "usage: spaceturtle agent-key limit , such as 5" ;;
+ limit) [[ $# -eq 2 && $2 =~ ^[1-9][0-9]*$ ]] || die "usage: spaceturtle agent-key limit , a whole number of the token's base units, such as 5000000" ;;
*) die "usage: spaceturtle agent-key show | replace | revoke | limit " ;;
esac
preflight
@@ -548,7 +847,7 @@ cmd_agent_key() {
scrub <<<"$doc" || die "toon agent-key list gave a document that is not JSON"
if [[ $(zone_state "$agent_zone") == running ]]; then
# What is left today is read by the key itself, from the agent's zone; the key is not printed.
- "$sbx" exec "$agent_zone" sh -c "TOON_AGENT_KEY=$agent_key_path toon limit show --json" | scrub \
+ zexec --no-stdin "$agent_zone" -- sh -c "TOON_AGENT_KEY=$agent_key_path toon limit show --json" | scrub \
|| say "what is left today could not be read from the agent's zone"
else
say "what is left today is read from the agent's zone, which is not running. Start a session, or run 'spaceturtle session'."
@@ -566,35 +865,40 @@ cmd_agent_key() {
revoke_agent_keys $old
local astate; astate=$(zone_state "$agent_zone")
if [[ $astate != "not created" ]]; then
- ensure_agent_zone "$astate"
- "$sbx" exec "$agent_zone" sh -c "rm -f $agent_key_path" \
+ [[ $astate == running ]] || "$msb" start --quiet "$agent_zone" || die "the agent key is revoked, but the agent's zone could not be started to delete its private half"
+ zexec --no-stdin "$agent_zone" -- sh -c "rm -f $agent_key_path" \
|| die "the agent key is revoked, but its private half could not be deleted from the agent's zone"
- [[ $astate == running ]] || "$sbx" stop "$agent_zone" || say "the agent's zone could not be stopped again"
+ [[ $astate == running ]] || "$msb" stop --quiet --timeout 60 "$agent_zone" || say "the agent's zone could not be stopped again"
fi
mark agentkey
say "the agent key is revoked: the agent's next order is refused. 'spaceturtle agent-key replace' makes another." ;;
limit)
[[ -n $old ]] || die "there is no agent key. Run 'spaceturtle start' or 'spaceturtle agent-key replace'."
- local id
+ local id per flags
+ doc=$(in_zone 'toon agent-key list --json') || die "toon agent-key list failed in the zone"
for id in $old; do
- in_zone "toon agent-key set --limit $(sh_quote "$2") -- $(sh_quote "$id")" || die "toon agent-key set failed in the zone"
+ # toon refuses a day's limit under what one command may pay, so that comes down with it.
+ per=$(jq -r --arg k "$id" '(if type == "array" then . else (.keys // .agent_keys // []) end)[] | select((.id // .public_key) == $k) | .spending_limit.per_command // empty' <<<"$doc" 2>/dev/null)
+ flags="--max-per-day $(sh_quote "$2")"
+ [[ $per =~ ^[0-9]+$ ]] && (( per > $2 )) && flags+=" --max-per-command $(sh_quote "$2")"
+ in_zone "toon agent-key set $flags -- $(sh_quote "$id")" || die "toon agent-key set failed in the zone"
done
- say "the agent key's spending limit is $2" ;;
+ say "the agent key may spend $2 in a day" ;;
esac
}
# The apps waiting, one JSON object per line: {name, image, since}. `toon status --json`
-# lists an app, in an `apps` array, in the `pending` state with the image and the time it
-# was asked for (seconds since the epoch or RFC 3339); a field it does not carry is left
-# empty. Only objects in an `apps` array are apps, so nothing else that is pending counts.
+# lists them in `pending_apps`, each with its name, the image or URL asked for and the time
+# it was asked (seconds since the epoch or RFC 3339); a field it does not carry is left
+# empty. One the Observer refused is still listed, as refused, and is not waiting.
# Dies when toon status fails, so callers must not run it in a pipeline or substitution
# whose failure they ignore.
waiting_apps() {
local doc=${1-}
[[ -n $doc ]] || doc=$(in_zone 'toon status --json') || die "toon status failed in the zone"
- jq -c '[.. | objects | .apps? | arrays | .[] | objects
- | select((.state // .status // "") == "pending" and (.name // "") != "")
- | {name, image: (.image // ""), since: (.requested_at // .asked_at // .since // .created_at // null)}]
+ jq -c '[.. | objects | .pending_apps? | arrays | .[] | objects
+ | select((.name // "") != "" and (.state // .status // "pending") == "pending" and (.refused // null) == null)
+ | {name, image: (.image // .url // ""), since: (.requested_at // .asked_at // .since // .created_at // .at // null)}]
| unique_by(.name) | .[]' <<<"$doc" || die "toon status gave a document that is not JSON"
}
@@ -656,16 +960,16 @@ cmd_status() {
if supervisor_running; then say "the supervisor is running"
else say "the supervisor is not running. Run 'spaceturtle start'."; fi
doc=$(in_zone 'toon status --json') || die "toon status failed in the zone"
- network=$(jq -r '.network // .agent_node.network // empty' <<<"$doc" 2>/dev/null)
+ network=$(node_network)
say "network: ${network:-cannot be read}"
lines=$(jq -r '[.. | objects | .apps? | arrays | .[] | objects | select((.name // "") != "")
- | {name, state: (.state // .status // "")}] | unique_by(.name) | .[] | "\(.name)\t\(.state)"' <<<"$doc" 2>/dev/null)
+ | {name, running: (.running == true)}] | unique_by(.name) | .[] | "\(.name)\t\(.running)"' <<<"$doc" 2>/dev/null)
while IFS=$'\t' read -r l astate; do
[[ -n $l ]] || continue
- if [[ $astate == running ]]; then say "app $l: running"; else say "app $l: not running (${astate:-unknown})"; fi
+ if [[ $astate == true ]]; then say "app $l: running"; else say "app $l: not running"; fi
done <<<"$lines"
- relay=$(jq -r 'first(.. | objects | (.relay_url?, (.relay? | if type == "string" then . elif type == "object" then (.url // .address // .ws_url) else empty end))
- | strings | select(length > 0)) // empty' <<<"$doc" 2>/dev/null)
+ # The relay is the app of that name; it answers reads at its read address.
+ relay=$(jq -r 'first(.. | objects | .apps? | arrays | .[] | objects | select(.name == "relay") | .read_address | strings | select(length > 0)) // empty' <<<"$doc" 2>/dev/null)
if [[ -z $relay ]]; then
say "relay: the node reports no address, so it cannot be checked"
else
@@ -676,6 +980,8 @@ cmd_status() {
fi
lines=$(waiting_apps "$doc") || exit 1
say "$(grep -c . <<<"$lines") app(s) waiting for approval"
+ # In the token's base units.
+ while IFS= read -r l; do say "earnings: $l"; done < <(earnings_lines)
}
# Stops everything and loses nothing: the timer's unit (its setting stays), a session in the
@@ -691,10 +997,14 @@ cmd_stop() {
"$systemctl_" --user stop spaceturtle.timer || die "the timer could not be stopped"
fi
if [[ $(zone_state "$agent_zone") == running ]]; then
- "$sbx" stop "$agent_zone" || die "the session in the agent's zone ($agent_zone) could not be stopped"
+ "$msb" stop --quiet --timeout 60 "$agent_zone" || die "the session in the agent's zone ($agent_zone) could not be stopped"
+ fi
+ # The unit would start the zone again, now and when the machine restarts; `start` enables it again.
+ if [[ -e $unit_dir/spaceturtle-connector.service ]]; then
+ "$systemctl_" --user disable --now spaceturtle-connector.service || die "the unit that keeps the connector's zone running could not be stopped"
fi
- if [[ $state == running ]]; then
- "$sbx" stop "$zone" || die "the connector's zone ($zone) could not be stopped"
+ if [[ $(zone_state) == running ]]; then
+ "$msb" stop --quiet --timeout 60 "$zone" || die "the connector's zone ($zone) could not be stopped"
fi
say "stopped. The wallet, the passphrase and the timer's setting are as they were. Run 'spaceturtle start' to bring it back."
}
@@ -707,38 +1017,50 @@ cmd_backup() {
# Replaces the connector's zone with one from the newest image. The wallet's storage stays
# as it was. The passphrase travels through memory: out of the old zone on the standard
-# output of sbx exec, into the new one on the standard input of sbx exec -i.
+# output of msb exec, into the new one on its standard input.
cmd_upgrade() {
preflight
local state pass
+ # Both zones are made again below, from the newest of their images.
+ pull=${SPACETURTLE_PULL:-always}
state=$(zone_state)
[[ $state != "not created" ]] || die "there is no connector's zone yet. Run 'spaceturtle start' first."
[[ $state == running ]] || die "the connector's zone ($zone) is $state. Run 'spaceturtle start' first; nothing was changed."
say "making a fresh backup before anything is removed"
make_backup || die "the backup failed, so nothing was changed."
- pass=$("$sbx" exec "$zone" sh -c 'cat "$HOME/.config/toon-passphrase"') && [[ -n $pass ]] \
+ pass=$(zexec --no-stdin "$zone" -- sh -c 'cat "$HOME/.config/toon-passphrase"') && [[ -n $pass ]] \
|| die "the passphrase could not be read out of the old zone, so nothing was changed."
+ # Both images are here before anything is removed: the passphrase is in memory alone from then on.
+ if [[ $pull == always ]]; then
+ "$msb" pull --quiet "$connector_image" && "$msb" pull --quiet "$agent_image" \
+ || die "the newest zone images could not be pulled, so nothing was changed."
+ fi
+ way_back=" The old zone is removed, and the backup at $backup_dir/wallet-backup is the way back."
say "replacing the connector's zone ($zone)"
if [[ $(zone_state "$agent_zone") != "not created" ]]; then
- "$sbx" rm --force "$agent_zone" || die "the agent's zone ($agent_zone) could not be removed"
+ "$msb" remove --force --quiet "$agent_zone" || die "the agent's zone ($agent_zone) could not be removed"
+ fi
+ # The unit would start the supervisor in the zone being removed; step_supervisor starts it again.
+ if [[ -e $unit_dir/spaceturtle-connector.service ]]; then
+ "$systemctl_" --user stop spaceturtle-connector.service || die "the unit that keeps the connector's zone running could not be stopped"
fi
- "$sbx" rm --force "$zone" || die "the old connector's zone could not be removed"
+ "$msb" remove --force --quiet "$zone" || die "the old connector's zone could not be removed"
+ # The node, the faucet and the backup belong to the wallet, which is kept, so their markers stay.
create_zone
- # The node, the faucet and the backup belong to the wallet, which is kept; the rest belong to the zone.
- rm -f "$marks/rule" "$marks/zone"
- step_zone
- if ! printf '%s\n' "$pass" | "$sbx" exec -i "$zone" sh -c 'mkdir -p "$HOME/.config" && umask 077 && cat > "$HOME/.config/toon-passphrase" && [ -s "$HOME/.config/toon-passphrase" ]'; then
+ if ! printf '%s\n' "$pass" | zexec "$zone" -- sh -c 'mkdir -p "$HOME/.config" && umask 077 && cat > "$HOME/.config/toon-passphrase" && [ -s "$HOME/.config/toon-passphrase" ]'; then
pass=
die "the new zone ($zone) is made, but the passphrase could not be placed in it. The backup at $backup_dir/wallet-backup is the way back."
fi
pass=
+ network=$(node_network); network=${network:-devnet}
step_node
+ step_runtime
step_faucet
- step_supervisor
# The agent's zone, and the private half in it, is gone: a key on the list is made again; a revoked one is not.
local keys; keys=$(agent_key_ids) || die "toon agent-key list failed in the zone"
[[ -z $keys ]] || rm -f "$marks/agentkey"
step_agentkey
+ step_supervisor
step_backup
say "the connector's zone is upgraded and running"
}
@@ -772,16 +1094,20 @@ cmd_remove() {
*) make_backup || die "the backup failed, so nothing was removed. Answer n to remove without it." ;;
esac
fi
+ if [[ -e $unit_dir/spaceturtle-connector.service ]]; then
+ "$systemctl_" --user disable --now spaceturtle-connector.service || die "the unit that keeps the connector's zone running could not be stopped"
+ fi
for z in "$agent_zone" "$zone"; do
- [[ $(zone_state "$z") == "not created" ]] || "$sbx" rm --force "$z" || die "the zone $z could not be removed"
+ [[ $(zone_state "$z") == "not created" ]] || "$msb" remove --force --quiet "$z" || die "the zone $z could not be removed"
done
if [[ -e $unit_dir/spaceturtle.timer ]]; then
"$systemctl_" --user disable --now spaceturtle.timer || die "the timer could not be turned off"
fi
- rm -f "$unit_dir/spaceturtle.timer" "$unit_dir/spaceturtle.service"
+ rm -f "$unit_dir/spaceturtle.timer" "$unit_dir/spaceturtle.service" "$unit_dir/spaceturtle-connector.service"
"$systemctl_" --user daemon-reload
rm -rf "$marks"
- rm -f "$state_dir/last-unattended" "$conf" "$conf.new" "$key_file"
+ rm -f "$state_dir/last-unattended" "$state_dir/session.lock" "$conf" "$conf.new" "$key_file"
+ rm -rf "$session_dir" "$agent_dir"
rmdir "$state_dir" "$config_dir" "$(dirname "$key_file")" 2>/dev/null
if (( delete )); then
[[ -n $data_dir && $data_dir != / ]] || die "will not delete '$data_dir'"
@@ -791,13 +1117,16 @@ cmd_remove() {
say "the wallet's storage is kept at $data_dir"
fi
say "the backup directory is kept at $backup_dir. Nothing deletes it."
+ say "microsandbox and the zone images are kept in $msb_home; delete that directory to be rid of them."
say "spaceturtle is removed from this machine."
}
case ${1:-} in
- start) start ;;
- mainnet) shift; mainnet "$@" ;;
+ # With nothing after it, spaceturtle is the conversation.
+ "") session ;;
+ start) shift; start "$@" ;;
session) shift; session "$@" ;;
+ supervise) supervise ;;
timer) shift; timer "$@" ;;
set) shift; set_ "$@" ;;
key) key ;;
@@ -810,5 +1139,5 @@ case ${1:-} in
upgrade) [[ $# -eq 1 ]] || die "usage: spaceturtle upgrade"; cmd_upgrade ;;
backup) [[ $# -eq 1 ]] || die "usage: spaceturtle backup"; cmd_backup ;;
remove) shift; cmd_remove "$@" ;;
- *) die "usage: spaceturtle start | stop | upgrade | backup | remove [--delete-wallet] | mainnet [--keep-limit] | session | timer | set | key | status | waiting | approve | refuse [reason] | agent-key show|replace|revoke|limit " ;;
+ *) die "usage: spaceturtle (a session) | start [flags] | stop | upgrade | backup | remove [--delete-wallet] | session | timer | set | key | status | waiting | approve | refuse [reason] | agent-key show|replace|revoke|limit " ;;
esac
diff --git a/tests/check-images b/tests/check-images
index cf72ab6..0a3a872 100755
--- a/tests/check-images
+++ b/tests/check-images
@@ -24,16 +24,20 @@ for df in agent/Dockerfile connector/Dockerfile; do
done < <(sed -n 's/^ARG \([A-Za-z0-9_=.:-]*\).*/\1/p' "$df")
grep -q 'sha256sum -c' "$df" || problem "$df: toon's checksum is not verified"
grep -q 'TOON_VERSION' "$df" || problem "$df: toon is not pinned"
- grep -q -E '^FROM .*@\$\{[A-Z_]+DIGEST\}' "$df" || problem "$df: the base is not pinned by digest"
+ grep -q '^FROM ' "$df" && ! grep -E '^FROM ' "$df" | grep -q -v -E '@\$\{[A-Z_]+DIGEST\}' || problem "$df: a base is not pinned by digest"
grep -q -E ':latest|\blatest/' "$df" && problem "$df: names latest"
done
grep -q 'sha512sum -c' connector/Dockerfile || problem "connector/Dockerfile: gVisor's checksum is not verified"
+# docker-agent is built again from the commit of its release, and from nothing later.
+grep -q 'rev-parse HEAD)" = "\$DOCKER_AGENT_COMMIT"' agent/Dockerfile || problem "agent/Dockerfile: docker-agent's source is not held to a commit"
+[[ -s agent/docker-agent.patch && -s agent/banner.awk && -x agent/spaceturtle-apps && -x agent/spaceturtle-limit ]] || problem "agent: docker-agent's patch or the banner's generator is missing"
# A version or a checksum that is not what it should look like.
[[ $TOON_VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || problem "TOON_VERSION is not a release"
for v in TOON_SHA256_AMD64 TOON_SHA256_ARM64 DOCKER_AGENT_SHA256_AMD64; do [[ ${!v} =~ ^[0-9a-f]{64}$ ]] || problem "$v is not a sha256"; done
for v in GVISOR_SHA512_AMD64 GVISOR_SHA512_ARM64; do [[ ${!v} =~ ^[0-9a-f]{128}$ ]] || problem "$v is not a sha512"; done
-for v in DOCKER_AGENT_BASE_DIGEST CONNECTOR_BASE_DIGEST; do [[ ${!v} =~ ^sha256:[0-9a-f]{64}$ ]] || problem "$v is not a digest"; done
+[[ $DOCKER_AGENT_COMMIT =~ ^[0-9a-f]{40}$ ]] || problem "DOCKER_AGENT_COMMIT is not a commit"
+for v in DOCKER_AGENT_BASE_DIGEST CONNECTOR_BASE_DIGEST GO_IMAGE_DIGEST; do [[ ${!v} =~ ^sha256:[0-9a-f]{64}$ ]] || problem "$v is not a digest"; done
# The agent's zone holds no wallet, passphrase or key.
if grep -n -i -E 'passphrase|mnemonic|secret|agent_key|toon (init|wallet)|\.toon|config/toon|^(ENV|COPY|ADD|VOLUME)\b.*(key|wallet)' agent/Dockerfile | grep -v '^[0-9]*:#' | grep .; then
@@ -42,7 +46,7 @@ fi
# One command prints every version.
out=$(./versions 2>&1) || problem "versions failed"
-for v in "$TOON_VERSION" "$DOCKER_AGENT_VERSION" "$GVISOR_VERSION" "$DOCKER_AGENT_BASE_DIGEST" "$CONNECTOR_BASE_DIGEST"; do
+for v in "$TOON_VERSION" "$DOCKER_AGENT_VERSION" "$GVISOR_VERSION" "$DOCKER_AGENT_BASE_DIGEST" "$CONNECTOR_BASE_DIGEST" "$DOCKER_AGENT_COMMIT" "$GO_VERSION"; do
grep -qF -- "$v" <<<"$out" || problem "versions does not print $v"
done
exit $bad
diff --git a/tests/run b/tests/run
index 0702f7a..473ef3a 100755
--- a/tests/run
+++ b/tests/run
@@ -1,6 +1,6 @@
#!/bin/bash
# Runs relay-events, mark-seen, request and the being-observed skill's answers against a stub `toon` and `curl`,
-# and the launcher's `spaceturtle start` against a stub `sbx`, in a temporary home,
+# and the launcher's `spaceturtle start` against a stub `msb`, in a temporary home,
# and holds the agent definition to its safety rules through tests/check-agent.
# Needs only bash, jq and yq (mikefarah's). Usage: tests/run
set -uo pipefail
@@ -860,6 +860,15 @@ image_check "a default for a version" "sed -i 's/^ARG TOON_VERSION\$/ARG TOON_VE
image_check "an unchecked toon" "sed -i '/sha256sum -c/d' connector/Dockerfile" "checksum is not verified"
image_check "an unchecked gVisor" "sed -i '/sha512sum -c/d' connector/Dockerfile" "gVisor's checksum"
image_check "an unpinned base" "sed -i 's/@\${DOCKER_AGENT_BASE_DIGEST}//' agent/Dockerfile" "not pinned by digest"
+image_check "an unpinned Go for docker-agent's build" "sed -i 's/@\${GO_IMAGE_DIGEST}//' agent/Dockerfile" "not pinned by digest"
+image_check "docker-agent's source not held to its commit" "sed -i '/rev-parse HEAD/d' agent/Dockerfile" "not held to a commit"
+# The film in the agent's zone image: 240 frames of 64 lines, the turtle's four widest rows in each,
+# and stars on the first and the last line of the field, since the stars are to fill the chat.
+film=$(awk -f "$root/images/agent/banner.awk" "$root/docs/assets/spaceturtle-banner-dark.svg")
+[[ $(grep -c $'^\f$' <<< "$film") -eq 239 && $(grep -c -v $'^\f$' <<< "$film") -eq $((240 * 64)) && $(grep -c '███' <<< "$film") -eq $((240 * 4)) ]] \
+ && [[ $(sed -n 1p <<< "$film") == *[▀▄█]* && $(sed -n 64p <<< "$film") == *[▀▄█]* ]] \
+ && echo "ok the banner's film is whole, with stars to its edges" || { echo "FAIL the banner's film is whole, with stars to its edges"; fails=$((fails+1)); }
+count=$((count+1))
image_check "a passphrase in the agent's zone" "echo 'ENV TOON_PASSPHRASE_FILE=/x' >> agent/Dockerfile" "passphrase, wallet or key"
image_check "a toon init in the agent's zone" "echo 'RUN toon init' >> agent/Dockerfile" "passphrase, wallet or key"
image_check "a pin that is no checksum" "sed -i 's/^TOON_SHA256_AMD64=.*/TOON_SHA256_AMD64=abc/' versions.env" "TOON_SHA256_AMD64 is not a sha256"
@@ -975,48 +984,80 @@ release_check "the notes name the command, every reference and the toon, docker-
notes=$("$root/release/notes" v1.2.3 2>&1); rc=$?
[[ $rc -eq 0 && $(grep -c 'ghcr.io/toon-protocol/' <<<"$notes") -eq 4 ]] && ! grep -q 'docker\.io' <<<"$notes"; release_check "without a Docker Hub namespace the notes name GitHub's registry alone" $?
-# --- the launcher: spaceturtle start, against a stub sbx in a temporary home ---
+# --- the launcher: spaceturtle start, against a stub msb in a temporary home ---
launcher_env() {
new_env
- export SPACETURTLE_SBX=$tmp/bin/sbx SPACETURTLE_KVM=$tmp/kvm
+ export SPACETURTLE_MSB=$tmp/bin/msb SPACETURTLE_MSB_HOME=$tmp/msbhome SPACETURTLE_KVM=$tmp/kvm
export SPACETURTLE_ZONE=zone-x SPACETURTLE_DATA_DIR=$tmp/wallet SPACETURTLE_BACKUP_DIR=$tmp/backup
: > "$SPACETURTLE_KVM"
export SPACETURTLE_SYSTEMCTL=$tmp/bin/systemctl SPACETURTLE_UNIT_DIR=$tmp/units SPACETURTLE_MODEL_KEY_FILE=$tmp/model-key
- export SPACETURTLE_AGENT_ZONE=agent-x SPACETURTLE_CONNECTOR_PORT=7123
- printf '#!/bin/bash\necho "$*" >> "$STUB_DIR/systemctl-calls"\n' > "$SPACETURTLE_SYSTEMCTL"; chmod +x "$SPACETURTLE_SYSTEMCTL"
- # An sbx that records its calls. `ls` lists the zones in $STUB_DIR/zones and fails
- # when $STUB_DIR/signed-out exists; a call whose arguments contain the text in
- # $STUB_DIR/fail-on fails, `toon status --json` prints $STUB_DIR/status, and `cp` out
+ export SPACETURTLE_AGENT_ZONE=agent-x SPACETURTLE_CONNECTOR_PORT=7123 SPACETURTLE_SUPERVISOR_WAIT=0
+ # No zone is real here, so no port is: the launcher is told they are open, until a test says otherwise.
+ export SPACETURTLE_PORT_PROBE=true
+ # A systemctl that records its calls. Restarting the unit that keeps the connector's zone
+ # running runs what the unit runs, as systemd would, whatever comes of it.
+ cat > "$SPACETURTLE_SYSTEMCTL" <> "\$STUB_DIR/systemctl-calls"
+[[ \$* == *"restart spaceturtle-connector.service"* ]] && "$root/spaceturtle" supervise > /dev/null 2>&1
+exit 0
+STUB
+ chmod +x "$SPACETURTLE_SYSTEMCTL"
+ # An msb that records its calls. `ls` fails when $STUB_DIR/broken exists and lists the zones
+ # in $STUB_DIR/zones, the last line for a name being its state; a call whose arguments contain
+ # the text in $STUB_DIR/fail-on fails, `toon status --json` prints $STUB_DIR/status, the home's
+ # state.json names the network in $STUB_DIR/network, the RPC endpoint says its chain id is the
+ # one in $STUB_DIR/chain-id, `create` keeps the connector's zone's ports in $STUB_DIR/ports and
+ # the agent's zone's wall in $STUB_DIR/agent-wall, which `inspect` gives back, and `copy` out
# of the zone writes a file.
- cat > "$SPACETURTLE_SBX" <<'STUB'
+ cat > "$SPACETURTLE_MSB" <<'STUB'
#!/bin/bash
echo "$*" >> "$STUB_DIR/calls"
-[[ $1 == ls ]] && { [[ -e $STUB_DIR/signed-out ]] && exit 1; cat "$STUB_DIR/zones" 2>/dev/null; exit 0; }
+name=${@: -1}
+[[ $1 == ls ]] && { [[ -e $STUB_DIR/broken ]] && exit 1
+ jq -Rn '[reduce (inputs | split(" ")) as $l ({}; .[$l[0]] = $l[1]) | to_entries[] | {name: .key, status: ((.value[0:1] | ascii_upcase) + .value[1:])}]' "$STUB_DIR/zones" 2>/dev/null || echo '[]'; exit 0; }
[[ -r $STUB_DIR/fail-on && $* == *"$(cat "$STUB_DIR/fail-on")"* ]] && exit 1
+[[ $1 == inspect && $2 == agent-x ]] && { jq -n --arg w "$(cat "$STUB_DIR/agent-wall" 2>/dev/null)" '{config: {labels: {"spaceturtle.wall": $w}}}'; exit 0; }
+[[ $1 == inspect ]] && { jq -Rn '{config: {network: {ports: [inputs | {host_port: tonumber}]}}}' "$STUB_DIR/ports" 2>/dev/null; exit 0; }
+[[ $* == *"exec connector-start"* ]] && { : > "$STUB_DIR/up"; exit 0; }
[[ $* == *pgrep* ]] && { [[ -e $STUB_DIR/up ]]; exit; }
-[[ $* == *"toon status --json"* ]] && { cat "$STUB_DIR/status" 2>/dev/null || echo "{\"network\":\"$(cat "$STUB_DIR/network" 2>/dev/null || echo devnet)\"}"; exit 0; }
-[[ $* == *connector-start* ]] && : > "$STUB_DIR/up"
-[[ $* == *"toon wallet addresses"* ]] && { echo "evm 0xAAA111"; echo "solana SoLBBB222"; exit 0; }
-[[ $* == *"toon network set mainnet"* ]] && echo mainnet > "$STUB_DIR/network"
+[[ $* == *eth_chainId* ]] && { [[ -r $STUB_DIR/chain-id ]] && echo "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"$(cat "$STUB_DIR/chain-id")\"}"; exit 0; }
+[[ $* == *"toon status --json"* ]] && { cat "$STUB_DIR/status" 2>/dev/null || echo "{}"; exit 0; }
+[[ $* == *"toon channel list --json"* ]] && { cat "$STUB_DIR/channels" 2>/dev/null; exit; }
+[[ $* == *"toon packet count --json"* ]] && { cat "$STUB_DIR/packets" 2>/dev/null; exit; }
+[[ $* == *"state.json"* ]] && { echo "{\"network\":\"$(cat "$STUB_DIR/network" 2>/dev/null || echo devnet)\",\"connector_url\":\"$(cat "$STUB_DIR/connector-url" 2>/dev/null)\"}"; exit 0; }
+[[ $* == *"toon wallet show"* ]] && { echo "evm 0xAAA111"; echo "solana SoLBBB222"; exit 0; }
[[ $* == *"toon agent-key create"* ]] && { touch "$STUB_DIR/keys"; n=$(( $(wc -l < "$STUB_DIR/keys") + 1 )); echo "key-$n" >> "$STUB_DIR/keys"; echo "PRIVATE-HALF-$n" > "$STUB_DIR/agentkeyfile"; exit 0; }
-[[ $* == *"toon agent-key list"* ]] && { touch "$STUB_DIR/keys"; jq -Rn '[inputs | {id: ., scope: ["send","peer"], limit: 5, private_key: "PRIVATE-LEAK"}]' "$STUB_DIR/keys"; exit 0; }
+[[ $* == *"toon agent-key list"* ]] && { touch "$STUB_DIR/keys"; jq -Rn '{agent_keys: [inputs | {public_key: ., scope: ["send","peer"], spending_limit: {per_command: 10, per_day: 100}, private_key: "PRIVATE-LEAK"}]}' "$STUB_DIR/keys"; exit 0; }
[[ $* == *"toon agent-key revoke"* ]] && { [[ $* =~ revoke\ --\ \'([^\']*)\' ]]; id=${BASH_REMATCH[1]}; sed -i "/^$id$/d" "$STUB_DIR/keys"; exit 0; }
[[ $* == *'cat /tmp/agent-key'* ]] && { cat "$STUB_DIR/agentkeyfile" 2>/dev/null; exit 0; }
[[ $* == *'test -s /tmp/agent-key'* ]] && { [[ -s $STUB_DIR/agentkeyfile ]]; exit; }
[[ $* == *"rm -f /tmp/agent-key"* ]] && { rm -f "$STUB_DIR/agentkeyfile"; exit 0; }
-[[ $1 == exec && $2 == -i && $3 == agent-x ]] && { cat >> "$STUB_DIR/agent-zone-key"; exit 0; }
-[[ $1 == exec && $2 == -i ]] && { cat > "$STUB_DIR/stdin"; exit 0; }
+[[ $* == *'cat $HOME/.config/toon-agent/key'* ]] && { cat "$STUB_DIR/agent-zone-key" 2>/dev/null; exit 0; }
+[[ $* == *'agent-x -- sh -c umask 077'* ]] && { cat >> "$STUB_DIR/agent-zone-key"; exit 0; }
+[[ $* == *'cat > "$HOME/.config/toon-passphrase"'* ]] && { cat > "$STUB_DIR/stdin"; exit 0; }
[[ $* == *'cat "$HOME/.config/toon-passphrase"'* ]] && { cat "$STUB_DIR/zone-passphrase" 2>/dev/null; exit 0; }
[[ $* == *"curl -s"* ]] && { [[ -e $STUB_DIR/relay-down ]] && exit 1; exit 0; }
-[[ $1 == rm ]] && { sed -i "/^${@: -1} /d" "$STUB_DIR/zones"; [[ ${@: -1} == zone-x ]] && rm -f "$STUB_DIR/up"; exit 0; }
-[[ $1 == stop ]] && { sed -i "s/^$2 running/$2 stopped/" "$STUB_DIR/zones"; [[ $2 == zone-x ]] && rm -f "$STUB_DIR/up"; exit 0; }
-[[ $1 == start ]] && { sed -i "s/^$2 stopped/$2 running/" "$STUB_DIR/zones"; exit 0; }
-[[ $1 == create && $* == *"--name agent-x"* ]] && { echo "agent-x running" >> "$STUB_DIR/zones"; exit 0; }
-[[ $1 == create ]] && echo "zone-x running" > "$STUB_DIR/zones"
-[[ $1 == cp && $2 == zone-x:* ]] && echo sealed > "$3"
+[[ $1 == remove ]] && { sed -i "/^$name /d" "$STUB_DIR/zones"; [[ $name == zone-x ]] && rm -f "$STUB_DIR/up" "$STUB_DIR/ports"; [[ $name == agent-x ]] && rm -f "$STUB_DIR/agent-wall" "$STUB_DIR/agent-zone-key"; exit 0; }
+[[ $1 == stop ]] && { sed -i "s/^$name running/$name stopped/" "$STUB_DIR/zones"; [[ $name == zone-x ]] && rm -f "$STUB_DIR/up"; exit 0; }
+[[ $1 == start ]] && { sed -i "s/^$name stopped/$name running/" "$STUB_DIR/zones"; exit 0; }
+if [[ $1 == create ]]; then
+ while (($#)); do
+ case $1 in
+ --name) zone_made=$2 ;;
+ --port) ports+="${2#127.0.0.1:}"$'\n' ;;
+ --net-rule) wall=$2 ;;
+ esac
+ shift
+ done
+ if [[ $zone_made == agent-x ]]; then echo "agent-x running" >> "$STUB_DIR/zones"; echo "$wall" > "$STUB_DIR/agent-wall"
+ else echo "zone-x running" > "$STUB_DIR/zones"; printf '%s' "$ports" | cut -d: -f1 > "$STUB_DIR/ports"; fi
+ exit 0
+fi
+[[ $1 == copy && $3 == zone-x:* ]] && echo sealed > "$4"
exit 0
STUB
- chmod +x "$SPACETURTLE_SBX"
+ chmod +x "$SPACETURTLE_MSB"
}
run_launcher() { "$root/spaceturtle" start > "$tmp/out" 2>&1; }
launcher_check() { # name, condition result (0 = pass)
@@ -1031,8 +1072,8 @@ a=$(line_no '^create '); b=$(line_no 'toon init'); c=$(line_no 'toon wallet fund
[[ -n $a && -n $b && -n $c && -n $d && -n $e && $a -lt $b && $b -lt $c && $c -lt $d && $d -lt $e ]]
launcher_check "a first run issues zone, node, faucet, supervisor, backup in order" $?
grep -q 'toon init --accept-anyone-terms --network devnet' "$STUB_DIR/calls"; launcher_check "the node is initialised on devnet" $?
-grep -q -- "^policy allow network --sandbox zone-x .*\*\*" "$STUB_DIR/calls" && ! grep -E '^policy allow' "$STUB_DIR/calls" | grep -qv -- '--sandbox zone-x'
-launcher_check "the any-address rule names the zone and is never machine-wide" $?
+grep '^create .*--name zone-x ' "$STUB_DIR/calls" | grep -q -- '--net public ' && ! grep '^create .*--name zone-x ' "$STUB_DIR/calls" | grep -qE -- '--net (all|host|private)|--net-default|allow@'
+launcher_check "the connector's zone reaches the public internet, and nothing of the machine or its network" $?
[[ -s $SPACETURTLE_BACKUP_DIR/wallet-backup ]] && grep -q "$SPACETURTLE_BACKUP_DIR/wallet-backup" "$tmp/out" && grep -qi keep "$tmp/out"
launcher_check "the backup is written where the Observer is told, with a word to keep it" $?
! grep -rq -i passphrase "$tmp/out" "$SPACETURTLE_STATE_DIR" "$SPACETURTLE_BACKUP_DIR" "$SPACETURTLE_DATA_DIR" 2>/dev/null \
@@ -1045,71 +1086,185 @@ launcher_check "the supervisor check does not find its own shell" $?
: > "$STUB_DIR/calls"
run_launcher; rc=$?
launcher_check "a second run succeeds" $rc
-! grep -E '^create |^cp [^z]|toon init|toon wallet fund|toon wallet backup|^policy' "$STUB_DIR/calls" | grep -q .
-launcher_check "a second run issues nothing that creates, initialises or funds" $?
+! grep -E '^create |^remove |^start |^copy |toon init|toon wallet fund|toon wallet backup|untrusted-runtime' "$STUB_DIR/calls" | grep -q .
+launcher_check "a second run issues nothing that creates, starts, initialises, funds or backs up" $?
+
+# toon's home is the wallet's storage, the two ports that lead to the zone are published on
+# the machine's loopback, and an image that is not trusted gets the sandboxed runtime.
+launcher_env; run_launcher
+made=$(grep '^create .*--name zone-x ' "$STUB_DIR/calls")
+[[ $made == *" --mount-dir $SPACETURTLE_DATA_DIR:/home/agent/.toon/agent-node:uid=1000,gid=1000 "* ]]; launcher_check "toon's home in the zone is the wallet's storage, mounted as the zone's user's own" $?
+[[ $made == *" --port 127.0.0.1:7123:17100 --port 127.0.0.1:7124:17101"* && $(grep -o -- '--port ' <<<"$made" | wc -l) -eq 2 ]]
+launcher_check "the orders and the connector are published on the machine's loopback, and nowhere else" $?
+[[ $made == *" --mount-owned /var/lib/docker:kind=disk,"* && $made == *" --tmpfs /run "* ]]; launcher_check "the container engine has a disk of the zone's own, and finds /run empty when the zone starts" $?
+grep 'untrusted-runtime' "$STUB_DIR/calls" | grep -q 'echo runsc > $HOME/.toon/agent-node/untrusted-runtime'; launcher_check "an image that is not trusted is given the sandboxed runtime" $?
+u=$SPACETURTLE_UNIT_DIR/spaceturtle-connector.service
+grep -q "^ExecStart=$root/spaceturtle supervise$" "$u" && grep -q '^Restart=always$' "$u" && grep -q '^WantedBy=default.target$' "$u" && ! grep -q '^ExecStartPre' "$u" && grep -q '^KillMode=process$' "$u" \
+ && grep -q '^--user enable spaceturtle-connector.service$' "$STUB_DIR/systemctl-calls"
+launcher_check "a unit keeps the supervisor running: it is started again when it ends, leaves the zone when stopped, and is enabled for the next start of the machine" $?
+: > "$STUB_DIR/calls"; SPACETURTLE_CONNECTOR_PORT=9000 run_launcher; rc=$?
+[[ $rc -ne 0 ]] && grep -q '7123 and 7124' "$tmp/out" && grep -q 'spaceturtle upgrade' "$tmp/out" && ! grep -qE '^create |toon ' "$STUB_DIR/calls"
+launcher_check "a zone made with other ports is not used with new ones, and the run says how to change them" $?
+launcher_env; SPACETURTLE_CONNECTOR_PORT=9000 run_launcher; echo secret-key-123 > "$SPACETURTLE_MODEL_KEY_FILE"; (unset SPACETURTLE_CONNECTOR_PORT; "$root/spaceturtle" session --unattended > "$tmp/out" 2>&1); rc=$?
+[[ $rc -eq 0 && $(<"$STUB_DIR/agent-wall") == "allow@dns,allow@host:tcp:9000,allow@host:tcp:9001,"* ]] && ! grep -qE '^remove ' "$STUB_DIR/calls"; launcher_check "the ports an Install was made with are kept with it, for the units, which have no environment" $?
+SPACETURTLE_CONNECTOR_PORT=65535 run_launcher; [[ $? -ne 0 ]]; launcher_check "a port with no port after it is refused" $?
+launcher_env; run_launcher; echo "zone-x stopped" > "$STUB_DIR/zones"; rm "$STUB_DIR/up"; : > "$STUB_DIR/calls"; "$root/spaceturtle" supervise > "$tmp/out" 2>&1
+a=$(line_no '^start --quiet zone-x$'); b=$(line_no 'exec connector-start'); [[ -n $a && -n $b && $a -lt $b ]] && ! grep -q '^create ' "$STUB_DIR/calls"
+launcher_check "what the unit runs starts a stopped zone, then the supervisor in it" $?
# The zone comes from the connector's zone image, nothing is copied in, and the supervisor is connector-start.
grep '^create ' "$STUB_DIR/calls" >/dev/null; launcher_env; run_launcher
-grep '^create ' "$STUB_DIR/calls" | grep -q -- '--template ghcr.io/toon-protocol/spaceturtle-connector-zone:latest '; launcher_check "the zone is made from the connector's zone image" $?
+grep -q -- '^create ghcr.io/toon-protocol/spaceturtle-connector-zone:latest --name zone-x .*--pull if-missing ' "$STUB_DIR/calls"; launcher_check "the zone is made from the connector's zone image, pulled if it is not here" $?
launcher_env; SPACETURTLE_CONNECTOR_IMAGE=example.test/zone:1 run_launcher
-grep '^create ' "$STUB_DIR/calls" | grep -q -- '--template example.test/zone:1 '; launcher_check "SPACETURTLE_CONNECTOR_IMAGE changes the image" $?
-! grep -q '^cp [^z]' "$STUB_DIR/calls" && ! grep -qE '^cp .*:/' <(grep -v 'wallet-backup' "$STUB_DIR/calls"); launcher_check "nothing is copied into the zone" $?
+grep -q -- '^create example.test/zone:1 --name zone-x ' "$STUB_DIR/calls"; launcher_check "SPACETURTLE_CONNECTOR_IMAGE changes the image" $?
+! grep '^copy ' "$STUB_DIR/calls" | grep -qv -- "^copy --quiet zone-x:/tmp/wallet-backup " && ! grep '^create ' "$STUB_DIR/calls" | grep -qE -- '--copy|--script'; launcher_check "nothing is copied into the zone" $?
grep connector-start "$STUB_DIR/calls" | grep -q 'TOON_PASSPHRASE_FILE=\$HOME/.config/toon-passphrase' && ! grep -q 'toon up' "$STUB_DIR/calls"
launcher_check "the supervisor starts through connector-start with the passphrase file outside the wallet's storage" $?
[[ $(grep -c connector-start "$STUB_DIR/calls") -eq 1 ]]; launcher_check "connector-start is started once" $?
: > "$STUB_DIR/calls"; run_launcher; ! grep -q connector-start "$STUB_DIR/calls"; launcher_check "it is not started again while it runs" $?
-for step in "ln -sfn" "toon wallet fund" "connector-start" "toon wallet backup" "toon init"; do
+for step in "untrusted-runtime" "toon wallet fund" "connector-start" "toon wallet backup" "toon init"; do
launcher_env; echo "$step" > "$STUB_DIR/fail-on"; run_launcher; rc=$?
[[ $rc -ne 0 ]]; launcher_check "a run failing at '$step' stops" $?
rm "$STUB_DIR/fail-on"; : > "$STUB_DIR/calls"; run_launcher; rc=$?
launcher_check "a run after one that failed at '$step' succeeds" $rc
[[ -s $SPACETURTLE_BACKUP_DIR/wallet-backup ]]; launcher_check " and finishes the backup" $?
- ! grep -E '^create --name zone-x ' "$STUB_DIR/calls" | grep -q .; launcher_check " without creating the zone again" $?
+ ! grep -E '^create .*--name zone-x ' "$STUB_DIR/calls" | grep -q .; launcher_check " without creating the zone again" $?
done
launcher_env; echo "zone-x" > "$STUB_DIR/fail-on"; run_launcher
rm "$STUB_DIR/fail-on"; echo "zone-x running" > "$STUB_DIR/zones"; : > "$STUB_DIR/calls"; run_launcher
-! grep -E '^create --name zone-x ' "$STUB_DIR/calls" | grep -q .; launcher_check "a zone that exists is not created again" $?
+! grep -E '^create .*--name zone-x ' "$STUB_DIR/calls" | grep -q .; launcher_check "a zone that exists is not created again" $?
launcher_env; echo "zone-x-old running" > "$STUB_DIR/zones"; run_launcher
grep -q '^create ' "$STUB_DIR/calls"; launcher_check "a zone whose name only starts with the zone's is not taken for it" $?
# The zone is lost: a new one gets every step again, not only the create.
rm "$STUB_DIR/zones"; : > "$STUB_DIR/calls"; rm "$STUB_DIR/up"; run_launcher
-grep -q '^policy allow network --sandbox zone-x' "$STUB_DIR/calls" && grep -q -- "ln -sfn" "$STUB_DIR/calls" && grep -q 'toon init' "$STUB_DIR/calls" \
+grep -q '^create .*--name zone-x ' "$STUB_DIR/calls" && grep -q 'untrusted-runtime' "$STUB_DIR/calls" && grep -q 'toon init' "$STUB_DIR/calls" \
&& grep -q 'toon wallet fund' "$STUB_DIR/calls" && grep -q 'toon wallet backup' "$STUB_DIR/calls"
launcher_check "a zone made again gets every step again" $?
launcher_env; rm "$SPACETURTLE_KVM"; run_launcher; rc=$?
[[ $rc -ne 0 ]] && grep -qi kvm "$tmp/out" && grep -qi firmware "$tmp/out" && [[ ! -e $STUB_DIR/calls ]]; launcher_check "with no KVM it stops before doing anything, with cause and remedy" $?
-launcher_env; touch "$STUB_DIR/signed-out"; run_launcher; rc=$?
-[[ $rc -ne 0 ]] && grep -q 'sbx login' "$tmp/out" && ! grep -qE '^(create|policy|exec|cp)' "$STUB_DIR/calls"; launcher_check "signed out, it stops before doing anything, with cause and remedy" $?
-launcher_env; SPACETURTLE_SBX=$tmp/bin/missing run_launcher; rc=$?
-[[ $rc -ne 0 ]] && grep -qi 'not installed' "$tmp/out" && grep -q 'github.com/docker/sbx-releases' "$tmp/out" \
- && [[ ! -e $SPACETURTLE_STATE_DIR/launcher && ! -e $SPACETURTLE_DATA_DIR && ! -e $SPACETURTLE_BACKUP_DIR ]]; launcher_check "with sbx missing it stops before doing anything, with cause and remedy" $?
-
-# --- the launcher: spaceturtle mainnet ---
-run_mainnet() { printf '%s\n' "$1" | "$root/spaceturtle" mainnet "${@:2}" > "$tmp/out" 2>&1; }
-changed_nothing() { ! grep -qE 'toon network set|toon limit set' "$STUB_DIR/calls"; }
-
-launcher_env; echo "zone-x running" > "$STUB_DIR/zones"
-run_mainnet "yes"; rc=$?
-[[ $rc -ne 0 ]] && changed_nothing; launcher_check "mainnet without the typed word changes nothing" $?
-grep -q 0xAAA111 "$tmp/out" && grep -q SoLBBB222 "$tmp/out" && grep -qi 'no faucet' "$tmp/out" && grep -qi 'real funds' "$tmp/out"
-launcher_check "the message names the addresses to fund and says there is no faucet" $?
-: > "$STUB_DIR/calls"; run_mainnet ""; rc=$?
-[[ $rc -ne 0 ]] && changed_nothing; launcher_check "mainnet with no answer changes nothing" $?
-: > "$STUB_DIR/calls"; run_mainnet "mainnet"; rc=$?
-launcher_check "mainnet with the typed word succeeds" $rc
-grep -q 'toon network set mainnet' "$STUB_DIR/calls" && grep -q 'toon limit set --network-default' "$STUB_DIR/calls"; launcher_check "it moves the node and lowers the limit" $?
-s=$(grep -n 'toon status --json' "$STUB_DIR/calls" | tail -1 | cut -d: -f1); n=$(line_no 'toon network set mainnet')
-[[ -n $s && -n $n && $s -gt $n ]] && grep -q 'is on mainnet' "$tmp/out"; launcher_check "after it, status shows mainnet" $?
-: > "$STUB_DIR/calls"; run_mainnet "mainnet"; rc=$?
-[[ $rc -eq 0 ]] && grep -qi 'already on mainnet' "$tmp/out" && changed_nothing; launcher_check "on mainnet already, it says so and changes nothing" $?
-launcher_env; echo "zone-x running" > "$STUB_DIR/zones"; run_mainnet "mainnet" --keep-limit; rc=$?
-[[ $rc -eq 0 ]] && grep -q 'toon network set mainnet' "$STUB_DIR/calls" && ! grep -q 'toon limit set' "$STUB_DIR/calls"; launcher_check "--keep-limit leaves the limit alone" $?
-launcher_env; echo "zone-x running" > "$STUB_DIR/zones"; echo 'toon limit set' > "$STUB_DIR/fail-on"; run_mainnet "mainnet"; rc=$?
-[[ $rc -ne 0 ]] && grep -q 'limit could not be lowered' "$tmp/out" && grep -q 'toon limit set --network-default' "$tmp/out"; launcher_check "a failed limit stops and says how to lower it" $?
-launcher_env; run_mainnet "mainnet"; rc=$?
-[[ $rc -ne 0 ]] && changed_nothing; launcher_check "with no zone it stops and changes nothing" $?
+if [[ $EUID -ne 0 ]]; then
+ launcher_env; chmod 000 "$SPACETURTLE_KVM"; run_launcher; rc=$?
+ [[ $rc -ne 0 ]] && grep -q 'usermod -aG kvm' "$tmp/out" && [[ ! -e $STUB_DIR/calls ]]; launcher_check "with a KVM the person may not use it stops before doing anything, with cause and remedy" $?
+fi
+launcher_env; touch "$STUB_DIR/broken"; run_launcher; rc=$?
+[[ $rc -ne 0 ]] && grep -q 'doctor' "$tmp/out" && ! grep -qE '^(create|exec|copy|start)' "$STUB_DIR/calls"; launcher_check "with a microsandbox that does not run it stops before doing anything, and says how to learn why" $?
+launcher_env; SPACETURTLE_MSB_HOME=$tmp/$(printf 'h%.0s' {1..60}) run_launcher; rc=$?
+[[ $rc -ne 0 ]] && grep -q '54 characters' "$tmp/out" && [[ ! -e $STUB_DIR/calls ]]; launcher_check "with a home microsandbox would refuse as too long it stops before doing anything, and says the limit" $?
+launcher_env; (unset SPACETURTLE_MSB SPACETURTLE_MSB_HOME; HOME=/home/u XDG_DATA_HOME=/$(printf 'd%.0s' {1..50}) "$root/spaceturtle" status > "$tmp/out" 2>&1)
+grep -q 'not installed at /home/u/.spaceturtle-msb/bin/msb' "$tmp/out"
+launcher_check "a data directory too long for microsandbox gives way to a home for it in the home directory" $?
+launcher_env; SPACETURTLE_MSB=$tmp/bin/missing run_launcher; rc=$?
+[[ $rc -ne 0 ]] && grep -qi 'not installed' "$tmp/out" && grep -q 'install.sh' "$tmp/out" \
+ && [[ ! -e $SPACETURTLE_STATE_DIR/launcher && ! -e $SPACETURTLE_DATA_DIR && ! -e $SPACETURTLE_BACKUP_DIR ]]; launcher_check "with microsandbox missing it stops before doing anything, with cause and remedy" $?
+! grep -rqiE 'login|sign(ed)? in|account' "$tmp/out"; launcher_check "nothing the launcher says asks for an account" $?
+
+# --- install.sh: one command puts the launcher of a release on the machine ---
+# A curl that serves GitHub from $STUB_DIR: the latest release's tag, the release's source
+# archive, and a microsandbox archive: an msb that says its version, and the library beside it.
+install_env() {
+ launcher_env
+ export SPACETURTLE_INSTALL_DIR=$tmp/launcher SPACETURTLE_BIN_DIR=$tmp/linkbin SPACETURTLE_CURL=$tmp/bin/gh-curl
+ export SPACETURTLE_MSB_VERSION=9.9.9
+ mkdir -p "$tmp/src/spaceturtle-1.2.3/agent" "$tmp/src/msb"
+ cp "$root/spaceturtle" "$tmp/src/spaceturtle-1.2.3/"; cp "$root/agent/spaceturtle.yaml" "$tmp/src/spaceturtle-1.2.3/agent/"
+ tar -czf "$STUB_DIR/source.tar.gz" -C "$tmp/src" spaceturtle-1.2.3
+ printf '#!/bin/bash\necho "msb 9.9.9"\n' > "$tmp/src/msb/msb"; echo kernel > "$tmp/src/msb/libkrunfw.so.5.6.1"
+ tar -czf "$STUB_DIR/msb.tar.gz" -C "$tmp/src/msb" msb libkrunfw.so.5.6.1
+ export SPACETURTLE_MSB_SHA256=$(sha256sum "$STUB_DIR/msb.tar.gz" | cut -d' ' -f1)
+ cat > "$SPACETURTLE_CURL" <<'STUB'
+#!/bin/bash
+echo "$*" >> "$STUB_DIR/curl-calls"
+out=; url=${@: -1}
+while (($#)); do [[ $1 == -o ]] && out=$2; shift; done
+case $url in
+ */releases/latest) [[ -e $STUB_DIR/no-release ]] && exit 22; echo '{"tag_name":"v1.2.3"}' ;;
+ */archive/refs/tags/v1.2.3.tar.gz) cp "$STUB_DIR/source.tar.gz" "$out" ;;
+ */v9.9.9/microsandbox-linux-x86_64.tar.gz) cp "$STUB_DIR/msb.tar.gz" "$out" ;;
+ *) exit 22 ;;
+esac
+STUB
+ chmod +x "$SPACETURTLE_CURL"
+}
+run_install() { "$root/install.sh" > "$tmp/out" 2>&1; }
+install_check() { count=$((count + 1)); if [[ $2 -eq 0 ]]; then echo "ok install: $1"; else echo "FAIL install: $1"; sed 's/^/ /' "$tmp/out" 2>/dev/null; fails=$((fails + 1)); fi; }
+
+install_env; run_install; rc=$?
+[[ $rc -eq 0 ]] && cmp -s "$tmp/launcher/spaceturtle" "$root/spaceturtle" && cmp -s "$tmp/launcher/agent/spaceturtle.yaml" "$root/agent/spaceturtle.yaml" && [[ -x $tmp/launcher/spaceturtle && $(<"$tmp/launcher/version") == 1.2.3 ]]
+install_check "the latest release's launcher and agent definition are installed, with the release named" $?
+[[ $(readlink "$tmp/linkbin/spaceturtle") == "$tmp/launcher/spaceturtle" ]]; install_check "spaceturtle is linked into the bin directory" $?
+grep -q 'spaceturtle start' "$tmp/out" && grep -q 'spaceturtle key' "$tmp/out" && grep -q "$tmp/linkbin" "$tmp/out" && ! grep -qiE 'login|sign in|account' "$tmp/out"
+install_check "it ends with what to run next, which names no account and no sign-in" $?
+h=$SPACETURTLE_MSB_HOME; [[ $("$h/bin/msb" --version) == "msb 9.9.9" && $(readlink "$h/lib/libkrunfw.so") == libkrunfw.so.5 && $(readlink "$h/lib/libkrunfw.so.5") == libkrunfw.so.5.6.1 && -s $h/lib/libkrunfw.so.5.6.1 ]]
+install_check "microsandbox is put in a home that is spaceturtle's alone, its library beside it" $?
+"$tmp/linkbin/spaceturtle" start > "$tmp/out" 2>&1; rc=$?
+[[ $rc -eq 0 ]] && grep -q -- '^create ghcr.io/toon-protocol/spaceturtle-connector-zone:1.2.3 --name zone-x ' "$STUB_DIR/calls" && grep -q -- '^create ghcr.io/toon-protocol/spaceturtle-agent-zone:1.2.3 --name agent-x ' "$STUB_DIR/calls"
+install_check "run through the link, the launcher finds itself and pulls the images of the release it came from" $?
+: > "$STUB_DIR/calls"; echo secret-key-123 > "$SPACETURTLE_MODEL_KEY_FILE"; "$tmp/linkbin/spaceturtle" session --unattended > "$tmp/out" 2>&1; rc=$?
+[[ $rc -eq 0 ]] && cmp -s <(head -n -2 "$SPACETURTLE_STATE_DIR/session/spaceturtle.yaml") "$tmp/launcher/agent/spaceturtle.yaml"; install_check " and a session reads the definition that was installed beside it" $?
+echo changed >> "$tmp/launcher/spaceturtle"; echo stale > "$tmp/launcher/leftover"; run_install; rc=$?
+[[ $rc -eq 0 ]] && cmp -s "$tmp/launcher/spaceturtle" "$root/spaceturtle" && [[ ! -e $tmp/launcher/leftover && ! -e $tmp/launcher.new && ! -e $tmp/launcher.old ]]; install_check "running it again replaces the launcher whole" $?
+[[ $(grep -c microsandbox-linux "$STUB_DIR/curl-calls") -eq 1 ]]; install_check " and leaves a microsandbox that is already the pinned version" $?
+install_env; rm "$SPACETURTLE_KVM"; run_install; rc=$?
+[[ $rc -ne 0 ]] && grep -qi kvm "$tmp/out" && grep -qi firmware "$tmp/out" && [[ ! -e $tmp/launcher && ! -e $tmp/linkbin && ! -e $STUB_DIR/curl-calls ]]; install_check "with no KVM it says so and installs nothing" $?
+install_env; SPACETURTLE_MSB_SHA256=$(printf '0%.0s' {1..64}) run_install; rc=$?
+[[ $rc -ne 0 && ! -e $SPACETURTLE_MSB_HOME/bin/msb ]] && grep -q 'not the release it should be' "$tmp/out"; install_check "a microsandbox download with another checksum is not installed" $?
+if [[ $EUID -ne 0 ]]; then
+ install_env; chmod 000 "$SPACETURTLE_KVM"; run_install; rc=$?
+ [[ $rc -ne 0 && ! -e $tmp/launcher ]] && grep -q 'usermod -aG kvm' "$tmp/out"; install_check "with a KVM the person may not use it says how to be allowed, and installs nothing" $?
+fi
+install_env; touch "$STUB_DIR/no-release"; run_install; rc=$?
+[[ $rc -ne 0 && ! -e $tmp/launcher ]] && grep -q 'SPACETURTLE_VERSION' "$tmp/out"; install_check "with no release to be found it says how to name one, and installs nothing" $?
+install_env; touch "$STUB_DIR/no-release"; SPACETURTLE_VERSION=v1.2.3 run_install; rc=$?
+[[ $rc -eq 0 && $(<"$tmp/launcher/version") == 1.2.3 ]] && ! grep -q 'releases/latest' "$STUB_DIR/curl-calls"; install_check "a release can be named" $?
+install_env; SPACETURTLE_VERSION='main; rm -rf x' run_install; rc=$?
+[[ $rc -ne 0 && ! -e $tmp/launcher ]] && grep -q 'is not a release' "$tmp/out"; install_check "a version that is not a release is refused" $?
+unset SPACETURTLE_INSTALL_DIR SPACETURTLE_BIN_DIR SPACETURTLE_CURL SPACETURTLE_MSB_SHA256 SPACETURTLE_MSB_VERSION
+
+# --- the launcher: the network is start's flags, kept with the Install ---
+start_with() { printf '%s\n' "$1" | "$root/spaceturtle" start "${@:2}" > "$tmp/out" 2>&1; } # the typed answer, then the flags
+init_line() { grep 'toon init' "$STUB_DIR/calls"; }
+made_nothing() { ! grep -qE '^create |toon init|toon wallet|agent-key' "$STUB_DIR/calls" 2>/dev/null; }
+
+launcher_env; start_with "" --evm-rpc-url 'https://rpc.example.test/v1?key=a*b' --evm-token 0xAbC --solana --max-per-day 500; rc=$?
+[[ $rc -eq 0 && $(init_line) == *"toon init --accept-anyone-terms --network devnet '--evm-rpc-url' 'https://rpc.example.test/v1?key=a*b' '--evm-token' '0xAbC' '--solana' '--max-per-day' '500' --json"* ]]
+launcher_check "start's flags go to toon init as given, on devnet unless a network is named" $?
+: > "$STUB_DIR/calls"; start_with ""; rc=$?
+[[ $rc -eq 0 ]] && ! grep -q 'toon init' "$STUB_DIR/calls"; launcher_check "a later start needs no flags" $?
+start_with "" --evm-rpc-url 'https://rpc.example.test/v1?key=a*b' --evm-token 0xAbC --solana --max-per-day 500; launcher_check "a later start with the same flags is accepted" $?
+: > "$STUB_DIR/calls"; start_with "" --evm-rpc-url https://other.example.test; rc=$?
+[[ $rc -ne 0 ]] && grep -q 'already initialised' "$tmp/out" && grep -q 'rpc.example.test' "$tmp/out" && [[ ! -s $STUB_DIR/calls || -z $(grep -vE '^ls$' "$STUB_DIR/calls") ]]
+launcher_check "a later start that names another network is refused, says what the Install has, and changes nothing" $?
+launcher_env; echo "toon init" > "$STUB_DIR/fail-on"; start_with "" --evm-token 0xAbC; rm "$STUB_DIR/fail-on"; start_with ""; rc=$?
+[[ $rc -eq 0 && $(init_line | tail -1) == *"'--evm-token' '0xAbC'"* ]]; launcher_check "a run that was interrupted goes on with the network it was given" $?
+for bad_flags in "--network testnet" "--evm-rpc-url" "--evm-rpc-url it's" "--evm-chain-id base" "--everything"; do
+ launcher_env; start_with "" $bad_flags; rc=$?
+ [[ $rc -ne 0 ]] && grep -q 'usage: spaceturtle start' "$tmp/out" && made_nothing; launcher_check "start refuses '$bad_flags' with the usage and makes nothing" $?
+done
+
+launcher_env; start_with "yes" --network mainnet; rc=$?
+[[ $rc -ne 0 ]] && grep -qi 'real funds' "$tmp/out" && grep -qi 'no faucet' "$tmp/out" && made_nothing; launcher_check "mainnet without the typed word makes nothing" $?
+start_with "" --network mainnet; rc=$?
+[[ $rc -ne 0 ]] && made_nothing; launcher_check "mainnet with no answer makes nothing" $?
+start_with "mainnet" --network mainnet; rc=$?
+[[ $rc -eq 0 && $(init_line) == *"toon init --accept-anyone-terms --network mainnet --json"* ]]; launcher_check "mainnet with the typed word initialises the node on mainnet" $?
+! grep -q 'toon wallet fund' "$STUB_DIR/calls" && grep -q 0xAAA111 "$tmp/out" && grep -q SoLBBB222 "$tmp/out" && grep -qi 'no faucet' "$tmp/out"
+launcher_check "on mainnet nothing is funded, and the addresses to fund are named" $?
+: > "$STUB_DIR/calls"; "$root/spaceturtle" start < /dev/null > "$tmp/out" 2>&1; rc=$?
+[[ $rc -eq 0 ]] && ! grep -q 'type "mainnet"' "$tmp/out" && ! grep -q 'toon wallet' "$STUB_DIR/calls"; launcher_check "a later start of a mainnet Install asks nothing and funds nothing" $?
+
+launcher_env; echo 0x2105 > "$STUB_DIR/chain-id"; start_with "" --evm-chain-id 8453 --evm-rpc-url https://rpc.example.test; rc=$?
+c=$(line_no eth_chainId); f=$(line_no 'toon wallet fund'); [[ $rc -eq 0 && -n $c && -n $f && $c -lt $f ]] && ! grep -q -- '--evm-chain-id' <(init_line)
+launcher_check "the chain id is asked of the node's RPC endpoint before anything is funded, and is not one of toon's flags" $?
+: > "$STUB_DIR/calls"; start_with "" --evm-chain-id 8453 --evm-rpc-url https://rpc.example.test; ! grep -q eth_chainId "$STUB_DIR/calls"; launcher_check "a chain that was checked is not asked again" $?
+launcher_env; echo 0x14a34 > "$STUB_DIR/chain-id"; start_with "" --evm-chain-id 8453; rc=$?
+[[ $rc -ne 0 ]] && grep -q 'serves chain 84532, not 8453' "$tmp/out" && ! grep -qE 'toon wallet fund|agent-key create|connector-start' "$STUB_DIR/calls"
+launcher_check "an endpoint that serves another chain stops the run before anything is funded or started" $?
+: > "$STUB_DIR/calls"; start_with ""; rc=$?
+[[ $rc -ne 0 ]] && ! grep -q 'toon wallet fund' "$STUB_DIR/calls"; launcher_check " and so does the next run" $?
+launcher_env; start_with "" --evm-chain-id 8453; rc=$?
+[[ $rc -ne 0 ]] && grep -q 'did not say which chain' "$tmp/out" && ! grep -q 'toon wallet fund' "$STUB_DIR/calls"; launcher_check "an endpoint that does not answer stops the run too" $?
# --- sessions and the timer ---
# A launcher whose connector's zone is running, with a model key.
@@ -1118,52 +1273,120 @@ session_env() {
echo secret-key-123 > "$SPACETURTLE_MODEL_KEY_FILE"; : > "$STUB_DIR/calls"
}
run_cmd() { "$root/spaceturtle" "$@" > "$tmp/out" 2>&1; }
+sdef() { echo "$SPACETURTLE_STATE_DIR/session/spaceturtle.yaml"; }
+session_run() { grep 'docker-agent run' "$STUB_DIR/calls"; }
+GUEST_DEF=/home/agent/session/spaceturtle.yaml
+BASE_WALL="allow@dns,allow@host:tcp:7123,allow@host:tcp:7124"; SKILL_WALL="allow@api.github.com:tcp:443,allow@codeload.github.com:tcp:443"
session_env; run_cmd session; rc=$?
-run=$(grep '^run ' "$STUB_DIR/calls")
-[[ $rc -eq 0 && $run == *"--image ghcr.io/toon-protocol/spaceturtle-agent-zone:latest"* && $run == *"agent/spaceturtle.yaml"* ]]; launcher_check "a session passes the zone image and the definition" $?
-[[ $(grep -o -- '--allow-host [^ ]*' <<<"$run" | tr '\n' ' ') == "--allow-host api.anthropic.com --allow-host localhost:7123 " ]]; launcher_check "the allow-list is exactly the provider and one loopback port" $?
+run=$(session_run)
+[[ $rc -eq 0 && $run == "exec --quiet --tty -e ANTHROPIC_API_KEY -e TOON_AGENT_KEY agent-x -- docker-agent run --sandbox=false --app-name spaceturtle --model anthropic/claude-sonnet-5-5 --banner-file /usr/local/share/spaceturtle/banner.txt --sidebar-section Earnings=cat /home/agent/session/earnings --sidebar-section Spending limit=spaceturtle-limit --sidebar-section TOON apps=spaceturtle-apps $GUEST_DEF" ]] && cmp -s "$(sdef)" "$root/agent/spaceturtle.yaml"
+launcher_check "a session runs docker-agent in the agent's zone, on the definition as shipped" $?
+session_env; run_cmd; rc=$?
+[[ $rc -eq 0 && $(session_run) == "$run" ]]; launcher_check "spaceturtle with nothing after it is a session" $?
+# With someone there the chat opens on the banner; nobody watches an unattended session.
+[[ $run == *" --banner-file /usr/local/share/spaceturtle/banner.txt --sidebar-section Earnings=cat /home/agent/session/earnings --sidebar-section Spending limit=spaceturtle-limit --sidebar-section TOON apps=spaceturtle-apps $GUEST_DEF" ]] && ! yq -e '.agents.spaceturtle | has("welcome_message")' "$root/agent/spaceturtle.yaml" > /dev/null 2>&1
+launcher_check "a session opens on spaceturtle's banner, which a welcome message would hide" $?
+# The sidebar's list of apps, from what `toon status` tells an agent key.
+apps_list() { mkdir -p "$tmp/apps-bin"; printf '#!/bin/sh\ncat < "$tmp/apps-bin/toon"; chmod +x "$tmp/apps-bin/toon"; PATH=$tmp/apps-bin:$PATH sh "$root/images/agent/spaceturtle-apps"; }
+[[ $(apps_list '{"pending_apps":[{"name":"notes","status":"pending"}],"toon_apps":[{"name":"relay","connector":{"running":true},"apps":[{"name":"relay","running":true},{"name":"whoami","running":false}]}]}') == $'● relay running\n○ whoami stopped\n◌ notes pending' ]]
+launcher_check "the sidebar lists each app, whether it runs, and the apps that wait" $?
+[[ $(apps_list '{"error":{"code":"not_running"}}') == "the node does not answer" ]]; launcher_check "the sidebar says when the node does not answer" $?
+limit_shown() { mkdir -p "$tmp/apps-bin"; printf '#!/bin/sh\ncat < "$tmp/apps-bin/toon"; chmod +x "$tmp/apps-bin/toon"; PATH=$tmp/apps-bin:$PATH sh "$root/images/agent/spaceturtle-limit"; }
+[[ $(limit_shown '{"limits":{"relay":{"per_command":"10000000","per_day":"100000000","remaining_today":"99999000"}}}') == $'left today 99,999,000\nper day 100,000,000\nper command 10,000,000\nin base units of the token' ]]
+launcher_check "the sidebar shows what the agent key may still pay today, of its limits" $?
+[[ $(limit_shown '{"error":{"code":"not_running"}}') == "the node does not answer" ]]; launcher_check "the spending limit says when the node does not answer" $?
+[[ $(<"$STUB_DIR/agent-wall") == "$BASE_WALL,allow@api.anthropic.com:tcp:443,$SKILL_WALL" ]]
+launcher_check "the agent's zone reaches name resolution, the two ports, the provider's host and the skills' hosts, and nothing else" $?
! grep -q secret-key-123 "$STUB_DIR/calls"; launcher_check "the model key is not an argument" $?
-[[ $run != *--max-cost* ]]; launcher_check "a session by hand has no unattended ceiling" $?
-run_cmd set model openai/gpt-x; : > "$STUB_DIR/calls"; run_cmd session
-[[ $(grep -o -- '--allow-host [^ ]*' "$STUB_DIR/calls" | head -1) == "--allow-host api.openai.com" && $(grep '^run ' "$STUB_DIR/calls") == *"--model openai/gpt-x"* ]]; launcher_check "one setting changes the model, and the provider follows" $?
-: > "$STUB_DIR/calls"; run_cmd session --unattended; rc=$?
-grep '^run ' "$STUB_DIR/calls" | grep -q -- '--max-cost 1 '; launcher_check "an unattended session has a cost ceiling by default" $?
+[[ $run != *--yolo* && $run != *--exec* ]] && ! grep -q '^budget:' "$(sdef)"; launcher_check "a session by hand asks before a tool runs and has no unattended ceiling" $?
+a=$(line_no '^start --quiet agent-x$'); b=$(line_no 'docker-agent run'); c=$(line_no '^stop .*agent-x$'); [[ -n $a && -n $b && -n $c && $a -lt $b && $b -lt $c ]] && grep -q '^agent-x stopped' "$STUB_DIR/zones"
+launcher_check "a session starts the agent's zone, and stops it when it ends" $?
+: > "$STUB_DIR/calls"; run_cmd session; ! grep -qE '^create |^remove ' "$STUB_DIR/calls"; launcher_check "a zone whose wall is the one wanted is not made again" $?
+( flock 9; sleep 3 ) 9> "$SPACETURTLE_STATE_DIR/session.lock" & sleep 0.5; : > "$STUB_DIR/calls"; run_cmd session; rc=$?; wait
+[[ $rc -ne 0 && -z $(session_run) ]] && grep -q 'already running' "$tmp/out"; launcher_check "a second session while one runs is refused" $?
+run_cmd set model openai/gpt-x; : > "$STUB_DIR/calls"; run_cmd session; rc=$?
+r=$(line_no '^remove .*agent-x$'); c=$(line_no '^create .*--name agent-x '); d=$(line_no 'docker-agent run')
+[[ $rc -eq 0 && -n $r && -n $c && -n $d && $r -lt $c && $c -lt $d && $(<"$STUB_DIR/agent-wall") == "$BASE_WALL,allow@api.openai.com:tcp:443,$SKILL_WALL" && $(session_run) == *"-e OPENAI_API_KEY "*"--model openai/gpt-x"* ]]
+launcher_check "one setting changes the model, the provider follows, and the zone is made again with the new wall" $?
+[[ $(<"$STUB_DIR/agent-zone-key") == PRIVATE-HALF-1 && $(wc -l < "$STUB_DIR/keys") -eq 1 ]] && ! grep -rq 'PRIVATE-HALF' "$STUB_DIR/calls" "$tmp/out" "$SPACETURTLE_STATE_DIR" "$SPACETURTLE_CONFIG_DIR"
+launcher_check " and the agent key goes across in memory: the same key, in no argument, output or file" $?
+run_cmd set model chatgpt/gpt-x; : > "$STUB_DIR/calls"; run_cmd session
+[[ $(<"$STUB_DIR/agent-wall") == *",allow@chatgpt.com:tcp:443,"* && $(session_run) == *"-e CHATGPT_OAUTH_TOKEN "*"--model chatgpt/gpt-x"* ]]; launcher_check "a ChatGPT account serves as the provider, its token in the key file" $?
+run_cmd set model anthropic/claude-x; rm "$STUB_DIR/agent-zone-key"; : > "$STUB_DIR/calls"; run_cmd session; rc=$?
+[[ $rc -ne 0 && -z $(session_run) ]] && grep -q 'agent-key replace' "$tmp/out" && ! grep -qE '^create |^remove ' "$STUB_DIR/calls"; launcher_check "a zone whose key cannot be read is not made again, and the session says what does" $?
+session_env; run_cmd set model other/model-x; run_cmd session; rc=$?
+[[ $rc -ne 0 && -z $(session_run) ]] && grep -q 'SPACETURTLE_MODEL_HOST' "$tmp/out"; launcher_check "a provider the launcher does not know needs its host named" $?
+SPACETURTLE_MODEL_HOST=models.example.test run_cmd session; rc=$?
+[[ $rc -eq 0 && $(<"$STUB_DIR/agent-wall") == "$BASE_WALL,allow@models.example.test:tcp:443,$SKILL_WALL" && $(session_run) == *"-e MODEL_API_KEY "* ]]; launcher_check " and with it named, the wall holds that host" $?
+for not_a_host in 'x.test,allow@public' public host 192.168.1.1; do
+ SPACETURTLE_MODEL_HOST=$not_a_host run_cmd session; rc=$?
+ [[ $rc -ne 0 ]] && ! grep -qE 'allow@(public|host:tcp:443|192)' "$STUB_DIR/agent-wall"; launcher_check "'$not_a_host', which is not a host's name, does not reach the wall" $?
+done
+session_env; echo "https://public/ilp" > "$STUB_DIR/connector-url"; run_cmd session; rc=$?
+[[ $rc -eq 0 ]] && ! grep -q 'allow@public' "$STUB_DIR/agent-wall"; launcher_check "a node's record that names a group of addresses as its connector adds no rule" $?
+# An `env` that records its arguments stands first on the path: a key handed to a program that way would be in them.
+session_env; printf '#!/bin/bash\necho "$*" >> "$STUB_DIR/env-calls"\nexec /usr/bin/env "$@"\n' > "$tmp/bin/env"; chmod +x "$tmp/bin/env"
+PATH=$tmp/bin:$PATH run_cmd session; rc=$?; PATH=$tmp/bin:$PATH run_cmd session --unattended
+[[ $rc -eq 0 ]] && ! grep -q secret-key-123 "$STUB_DIR/env-calls" "$STUB_DIR/calls" 2>/dev/null; launcher_check "the model key is no program's argument on the way to the zone" $?
+session_env; echo "https://connector.example.test/ilp" > "$STUB_DIR/connector-url"; run_cmd session
+[[ $(<"$STUB_DIR/agent-wall") == *",allow@connector.example.test:tcp:443,"* ]]; launcher_check "the agent's zone may reach the connector of the node's network, to seal a packet to it" $?
+session_env; echo "http://abcdef.anyone/ilp" > "$STUB_DIR/connector-url"; run_cmd session; rc=$?
+[[ $rc -eq 0 ]] && ! grep -qE '^create |^remove ' "$STUB_DIR/calls"; launcher_check "a connector that is not reached over https adds no rule" $?
+session_env; run_cmd session --unattended; rc=$?
+[[ $rc -eq 0 && $(session_run) == "exec --quiet --no-stdin -e ANTHROPIC_API_KEY -e TOON_AGENT_KEY agent-x -- docker-agent run --sandbox=false --app-name spaceturtle --model anthropic/claude-sonnet-5-5 --exec --yolo $GUEST_DEF "* ]]
+launcher_check "an unattended session runs without a terminal and without asking" $?
+[[ $(tail -2 "$(sdef)") == $'budget:\n max_cost: 1' ]] && cmp -s <(head -n -2 "$(sdef)") "$root/agent/spaceturtle.yaml"; launcher_check "an unattended session has a cost ceiling by default, as the definition's budget" $?
+session_env; printf '#!/bin/bash\n[[ $1 != 7124 ]]\n' > "$tmp/bin/probe"; chmod +x "$tmp/bin/probe"
+SPACETURTLE_PORT_PROBE=$tmp/bin/probe run_cmd session --unattended; rc=$?
+[[ $rc -ne 0 && $(<"$tmp/out") == *"ports 7123 and 7124 of this machine do not both lead to it"*"spaceturtle stop"* ]] && ! grep -q 'docker-agent run' "$STUB_DIR/calls"
+launcher_check "no session starts when the zone's ports are not open on the machine" $?
run_cmd set ceiling 0.25; : > "$STUB_DIR/calls"; run_cmd session --unattended
-grep '^run ' "$STUB_DIR/calls" | grep -q -- '--max-cost 0.25 '; launcher_check "the ceiling can be changed" $?
+[[ $(tail -1 "$(sdef)") == " max_cost: 0.25" ]]; launcher_check "the ceiling can be changed" $?
run_cmd set ceiling 0; [[ $? -ne 0 ]]; launcher_check "a ceiling of zero is refused" $?
run_cmd status; grep -q '^last unattended session: 20' "$tmp/out"; launcher_check "status shows the last unattended session's time" $?
+run_cmd session; ! grep -q '^budget:' "$(sdef)"; launcher_check "a session by hand after an unattended one has no ceiling" $?
session_env; rm "$SPACETURTLE_MODEL_KEY_FILE"; run_cmd session; rc=$?
-[[ $rc -ne 0 ]] && grep -q 'no model key' "$tmp/out" && grep -q "$SPACETURTLE_MODEL_KEY_FILE" "$tmp/out" && ! grep -q '^run ' "$STUB_DIR/calls"; launcher_check "with no model key it stops and says how to supply one" $?
+[[ $rc -ne 0 ]] && grep -q 'no model key' "$tmp/out" && grep -q "$SPACETURTLE_MODEL_KEY_FILE" "$tmp/out" && [[ -z $(session_run) ]]; launcher_check "with no model key it stops and says how to supply one" $?
session_env; : > "$STUB_DIR/calls"; rm "$STUB_DIR/up"; run_cmd session; rc=$?
-[[ $rc -ne 0 ]] && grep -q "connector's zone" "$tmp/out" && ! grep -q '^run ' "$STUB_DIR/calls"; launcher_check "with the supervisor not running no session starts, and it says so" $?
+[[ $rc -ne 0 ]] && grep -q "connector's zone" "$tmp/out" && [[ -z $(session_run) ]]; launcher_check "with the supervisor not running no session starts, and it says so" $?
session_env; rm "$STUB_DIR/zones"; : > "$STUB_DIR/calls"; run_cmd session; rc=$?
-[[ $rc -ne 0 ]] && grep -q "connector's zone" "$tmp/out" && ! grep -q '^run ' "$STUB_DIR/calls"; launcher_check "with no connector zone no session starts, and it says so" $?
+[[ $rc -ne 0 ]] && grep -q "connector's zone" "$tmp/out" && [[ -z $(session_run) ]]; launcher_check "with no connector zone no session starts, and it says so" $?
session_env; echo "zone-x stopped" > "$STUB_DIR/zones"; run_cmd session; rc=$?
-[[ $rc -ne 0 ]] && ! grep -q '^run ' "$STUB_DIR/calls"; launcher_check "with the connector's zone stopped no session starts" $?
+[[ $rc -ne 0 && -z $(session_run) ]]; launcher_check "with the connector's zone stopped no session starts" $?
+session_env; sed -i '/^agent-x /d' "$STUB_DIR/zones"; run_cmd session; rc=$?
+[[ $rc -ne 0 && -z $(session_run) ]] && grep -q 'agent-key replace' "$tmp/out"; launcher_check "with no agent's zone no session starts, and it says how to make one" $?
launcher_env; run_launcher
grep -q 'enable --now spaceturtle.timer' "$STUB_DIR/systemctl-calls" && grep -q 'OnUnitActiveSec=6h' "$SPACETURTLE_UNIT_DIR/spaceturtle.timer" && grep -q 'session --unattended' "$SPACETURTLE_UNIT_DIR/spaceturtle.service"; launcher_check "start turns the timer on at a conservative interval" $?
+jq_dir=$(dirname "$(command -v jq)"); grep -q "^Environment=PATH=.*$jq_dir\(:\|$\)" "$SPACETURTLE_UNIT_DIR/spaceturtle.service" && grep -q "^Environment=PATH=.*$jq_dir\(:\|$\)" "$SPACETURTLE_UNIT_DIR/spaceturtle-connector.service"; launcher_check "both units can find jq, wherever it is" $?
run_cmd status; grep -q '^timer: on, every 6h' "$tmp/out" && grep -q '^last unattended session: never' "$tmp/out"; launcher_check "status shows the timer on and no session yet" $?
-run_cmd timer interval 2h; grep -q 'OnUnitActiveSec=2h' "$SPACETURTLE_UNIT_DIR/spaceturtle.timer"; launcher_check "the interval can be set" $?
+: > "$STUB_DIR/systemctl-calls"; run_cmd timer interval 2h; grep -q 'OnUnitActiveSec=2h' "$SPACETURTLE_UNIT_DIR/spaceturtle.timer" && grep -q 'OnActiveSec=2h' "$SPACETURTLE_UNIT_DIR/spaceturtle.timer" && ! grep -q OnBootSec "$SPACETURTLE_UNIT_DIR/spaceturtle.timer" \
+ && grep -q 'restart spaceturtle.timer' "$STUB_DIR/systemctl-calls"; launcher_check "the interval can be set, counts from when the timer starts, and a running timer takes it" $?
+: > "$STUB_DIR/systemctl-calls"; run_launcher; ! grep -q 'restart spaceturtle.timer' "$STUB_DIR/systemctl-calls" && grep -q '^--user enable spaceturtle-connector.service$' "$STUB_DIR/systemctl-calls"
+launcher_check "a second start does not push the next session out, and enables the unit again" $?
+SPACETURTLE_CONNECTOR_PORT='1;x' run_launcher; [[ $? -ne 0 ]] && grep -q 'must be a port number' "$tmp/out"; launcher_check "a port that is not a number is refused" $?
run_cmd timer interval soon; [[ $? -ne 0 ]]; launcher_check "a bad interval is refused" $?
run_cmd timer off; grep -q 'disable --now spaceturtle.timer' "$STUB_DIR/systemctl-calls"; launcher_check "the timer can be turned off" $?
run_cmd status; grep -q '^timer: off, every 2h' "$tmp/out"; launcher_check "status shows it off" $?
-: > "$STUB_DIR/systemctl-calls"; run_launcher; ! grep -q enable "$STUB_DIR/systemctl-calls"; launcher_check "start does not turn a timer off by the Observer back on" $?
-run_cmd timer interval 3h; ! grep -q enable "$STUB_DIR/systemctl-calls"; launcher_check "an interval set while it is off leaves it off" $?
-run_cmd timer on; grep -q 'enable --now' "$STUB_DIR/systemctl-calls" && grep -q 'OnUnitActiveSec=3h' "$SPACETURTLE_UNIT_DIR/spaceturtle.timer"; launcher_check "the timer can be turned on again" $?
+: > "$STUB_DIR/systemctl-calls"; run_launcher; ! grep -q 'enable.*spaceturtle.timer' "$STUB_DIR/systemctl-calls"; launcher_check "start does not turn a timer off by the Observer back on" $?
+run_cmd timer interval 3h; ! grep -q 'enable.*spaceturtle.timer' "$STUB_DIR/systemctl-calls"; launcher_check "an interval set while it is off leaves it off" $?
+run_cmd timer on; grep -q 'enable --now spaceturtle.timer' "$STUB_DIR/systemctl-calls" && grep -q 'OnUnitActiveSec=3h' "$SPACETURTLE_UNIT_DIR/spaceturtle.timer"; launcher_check "the timer can be turned on again" $?
-# --- the launcher: waiting, approve, refuse and status, against a stub sbx whose toon lists a waiting app ---
+# --- the launcher: waiting, approve, refuse and status, against a stub msb whose toon lists a waiting app ---
waiting_env() {
launcher_env
echo "zone-x running" > "$STUB_DIR/zones"
now=$(date +%s)
# A peer that is pending is not an app, so it is not waiting.
- echo "{\"peers\":[{\"name\":\"bob\",\"state\":\"pending\"}],\"agent_node\":{\"toon_apps\":[{\"apps\":[{\"name\":\"relay\",\"state\":\"running\"},{\"name\":\"oracle\",\"state\":\"pending\",\"image\":\"docker.io/x/oracle:1\",\"requested_at\":$((now - 7300))}]}]}}" > "$STUB_DIR/status"
+ # and neither is an app the Observer already refused.
+ echo "{\"peers\":[{\"name\":\"bob\",\"state\":\"pending\"}],\"agent_node\":{\"pending_apps\":[{\"name\":\"oracle\",\"status\":\"pending\",\"image\":\"docker.io/x/oracle:1\",\"requested_at\":$((now - 7300))},{\"name\":\"gone\",\"status\":\"refused\",\"image\":\"x/gone:1\"}],\"toon_apps\":[{\"apps\":[{\"name\":\"relay\",\"running\":true}]}]}}" > "$STUB_DIR/status"
}
waiting_env; run_cmd waiting; rc=$?
launcher_check "waiting lists the name, image and age of each waiting app" $(( rc == 0 && $(grep -cP '^oracle\tdocker.io/x/oracle:1\t2h$' "$tmp/out") == 1 ? 0 : 1 ))
-! grep -qE 'relay|bob' "$tmp/out"; launcher_check "waiting does not list an app that is running, or anything pending that is not an app" $?
-waiting_env; echo '{"apps":[]}' > "$STUB_DIR/status"; run_cmd waiting; rc=$?
+! grep -qE 'relay|bob|gone' "$tmp/out"; launcher_check "waiting does not list an app that is running or was refused, or anything pending that is not an app" $?
+waiting_env; echo '{"agent_node":{"pending_apps":[{"name":"oracle","status":"pending","image":null,"url":"https://x.example/app"}]}}' > "$STUB_DIR/status"; run_cmd waiting
+grep -qP '^oracle\thttps://x.example/app\tunknown$' "$tmp/out"; launcher_check "waiting names the URL of an app that has no image, and says when the time is not known" $?
+waiting_env; echo '{"agent_node":{"pending_apps":[]}}' > "$STUB_DIR/status"; run_cmd waiting; rc=$?
[[ $rc -eq 0 ]] && grep -q 'no app is waiting' "$tmp/out"; launcher_check "waiting with nothing waiting says so" $?
waiting_env; : > "$STUB_DIR/calls"; run_cmd approve oracle; rc=$?
launcher_check "approve runs toon approve on a waiting app" $(( rc == 0 && $(grep -c "toon approve -- 'oracle'" "$STUB_DIR/calls") == 1 ? 0 : 1 ))
@@ -1179,6 +1402,15 @@ waiting_env; run_cmd status; rc=$?
launcher_check "status shows the number waiting" $(( rc == 0 && $(grep -c '1 app(s) waiting' "$tmp/out") == 1 ? 0 : 1 ))
waiting_env; echo "toon status --json" > "$STUB_DIR/fail-on"; run_cmd status; rc=$?
[[ $rc -ne 0 ]] && grep -q 'toon status failed' "$tmp/out" && ! grep -q 'waiting for approval' "$tmp/out"; launcher_check "status with toon status failing says so and counts nothing" $?
+# What the node has earned: an agent key is told none of it, so the launcher reads it in the connector's zone.
+earn_env() { waiting_env
+ echo '{"channels":[{"direction":"inbound","watermark":1200,"landed":1000},{"direction":"inbound","watermark":34,"landed":0},{"direction":"outbound","watermark":999,"landed":5}]}' > "$STUB_DIR/channels"
+ echo '{"fees_earned":"1500","packets":{"fulfilled":7,"rejected":2},"toon_app":"relay"}' > "$STUB_DIR/packets"; }
+earn_env; run_cmd status; rc=$?
+[[ $rc -eq 0 ]] && grep -qx 'spaceturtle: earnings: received 1,234 (1,000 landed)' "$tmp/out" && grep -qx 'spaceturtle: earnings: this run 1,500 in fees, 7 packets' "$tmp/out"
+launcher_check "status says what the inbound channels have been paid, what landed, and the fees since the connector started" $?
+earn_env; : > "$STUB_DIR/channels"; : > "$STUB_DIR/packets"; run_cmd status; rc=$?
+[[ $rc -eq 0 ]] && grep -qx 'spaceturtle: earnings: cannot be read now' "$tmp/out"; launcher_check "status says when the earnings cannot be read" $?
waiting_env; echo "toon status --json" > "$STUB_DIR/fail-on"; : > "$STUB_DIR/calls"; run_cmd approve oracle; rc=$?
[[ $rc -ne 0 ]] && grep -q 'toon status failed' "$tmp/out" && ! grep -q 'not waiting' "$tmp/out" && ! grep -q "toon approve" "$STUB_DIR/calls"
launcher_check "approve with toon status failing says so and changes nothing" $?
@@ -1186,14 +1418,15 @@ launcher_check "approve with toon status failing says so and changes nothing" $?
# --- the launcher: status, stop, upgrade, backup, remove ---
full_status() { # a node with an app running, one waiting, and a relay
- echo '{"network":"devnet","agent_node":{"relay":{"url":"ws://127.0.0.1:7777"},"toon_apps":[{"apps":[{"name":"relay","state":"running"},{"name":"oracle","state":"pending","image":"x/oracle:1"}]}]}}' > "$STUB_DIR/status"
+ echo '{"agent_node":{"pending_apps":[{"name":"oracle","status":"pending","image":"x/oracle:1"}],"toon_apps":[{"apps":[{"name":"relay","running":true,"read_address":"127.0.0.1:7777"},{"name":"store","running":false}]}]}}' > "$STUB_DIR/status"
}
up_env() { launcher_env; run_launcher; : > "$STUB_DIR/calls"; : > "$STUB_DIR/systemctl-calls"; full_status; }
up_env; echo "agent-x running" >> "$STUB_DIR/zones"; run_cmd status; rc=$?
[[ $rc -eq 0 ]] && grep -q "connector's zone (zone-x): running" "$tmp/out" && grep -q "agent's zone (agent-x): in a session" "$tmp/out" \
- && grep -q 'app relay: running' "$tmp/out" && grep -q 'app oracle: not running' "$tmp/out" && grep -q 'network: devnet' "$tmp/out" \
+ && grep -q 'app relay: running' "$tmp/out" && grep -q 'app store: not running' "$tmp/out" && grep -q 'network: devnet' "$tmp/out" \
&& grep -q 'relay: reachable' "$tmp/out" && grep -q '1 app(s) waiting' "$tmp/out" && grep -q '^timer: on' "$tmp/out"; launcher_check "status names every zone, every app, the network and the relay" $?
grep -q 'curl -s .*http://127.0.0.1:7777' "$STUB_DIR/calls"; launcher_check "the relay is checked from inside the connector's zone, at the address the node reports" $?
+echo mainnet > "$STUB_DIR/network"; run_cmd status; grep -q 'network: mainnet' "$tmp/out"; launcher_check "status reads the network from the home's record" $?
sed -i '/agent-x/d' "$STUB_DIR/zones"; touch "$STUB_DIR/relay-down"; run_cmd status
grep -q "agent's zone (agent-x): idle" "$tmp/out" && grep -q 'relay: not reachable' "$tmp/out"; launcher_check "status says idle and an unreachable relay" $?
for st in stopped; do
@@ -1205,22 +1438,24 @@ up_env; rm "$STUB_DIR/zones"; run_cmd status; rc=$?
# stop, then start
up_env; echo secret-id > "$SPACETURTLE_DATA_DIR/identity"; run_cmd stop; rc=$?
-[[ $rc -eq 0 ]] && grep -q '^stop zone-x' "$STUB_DIR/calls" && grep -q 'stop spaceturtle.timer' "$STUB_DIR/systemctl-calls" && ! grep -q 'disable' "$STUB_DIR/systemctl-calls" \
+[[ $rc -eq 0 ]] && grep -q '^stop .*zone-x$' "$STUB_DIR/calls" && grep -q 'stop spaceturtle.timer' "$STUB_DIR/systemctl-calls" && ! grep -q 'disable.*spaceturtle.timer' "$STUB_DIR/systemctl-calls" \
&& [[ $(conf_timer=$(sed -n 's/^timer=//p' "$SPACETURTLE_CONFIG_DIR/launcher.conf" 2>/dev/null | tail -1); echo "${conf_timer:-on}") == on ]]; launcher_check "stop stops the zone and the timer's unit, and leaves the timer's setting" $?
-echo "agent-x running" >> "$STUB_DIR/zones"; : > "$STUB_DIR/calls"; echo "zone-x running" >> "$STUB_DIR/zones"; sed -i '/^zone-x stopped/d' "$STUB_DIR/zones"; run_cmd stop
-grep -q '^stop agent-x' "$STUB_DIR/calls"; launcher_check "stop ends a session in the agent's zone" $?
-up_env; run_cmd stop; : > "$STUB_DIR/calls"; : > "$STUB_DIR/systemctl-calls"; run_launcher; rc=$?
-[[ $rc -eq 0 ]] && grep -q '^start zone-x' "$STUB_DIR/calls" && ! grep -qE '^create |toon init|toon wallet fund' "$STUB_DIR/calls" && grep -q connector-start "$STUB_DIR/calls" \
- && grep -q 'enable --now spaceturtle.timer' "$STUB_DIR/systemctl-calls" && [[ $(<"$SPACETURTLE_DATA_DIR/identity") == secret-id ]] 2>/dev/null || [[ ! -e $SPACETURTLE_DATA_DIR/identity ]]; launcher_check "start after stop starts the zone and the supervisor, creates and funds nothing, and turns the timer on" $?
+u=$(grep -n 'disable --now spaceturtle-connector.service' "$STUB_DIR/systemctl-calls" | cut -d: -f1); [[ -n $u ]] && ! grep -q 'restart' "$STUB_DIR/systemctl-calls"
+launcher_check "stop turns off the unit that would start the zone again" $?
+sed -i '/^agent-x /d' "$STUB_DIR/zones"; echo "agent-x running" >> "$STUB_DIR/zones"; : > "$STUB_DIR/calls"; echo "zone-x running" >> "$STUB_DIR/zones"; sed -i '/^zone-x stopped/d' "$STUB_DIR/zones"; run_cmd stop
+grep -q '^stop .*agent-x$' "$STUB_DIR/calls"; launcher_check "stop ends a session in the agent's zone" $?
+up_env; echo secret-id > "$SPACETURTLE_DATA_DIR/identity"; run_cmd stop; : > "$STUB_DIR/calls"; : > "$STUB_DIR/systemctl-calls"; run_launcher; rc=$?
+[[ $rc -eq 0 ]] && grep -q '^start --quiet zone-x$' "$STUB_DIR/calls" && ! grep -qE '^create |toon init|toon wallet fund' "$STUB_DIR/calls" && grep -q connector-start "$STUB_DIR/calls" \
+ && grep -q 'enable --now spaceturtle.timer' "$STUB_DIR/systemctl-calls" && grep -q 'enable spaceturtle-connector.service' "$STUB_DIR/systemctl-calls" && [[ $(<"$SPACETURTLE_DATA_DIR/identity") == secret-id ]]; launcher_check "start after stop starts the zone and the supervisor, creates and funds nothing, and turns the timer and the unit on" $?
up_env; run_cmd timer off; run_cmd stop; : > "$STUB_DIR/systemctl-calls"; run_launcher
-! grep -q 'enable' "$STUB_DIR/systemctl-calls"; launcher_check "start after stop leaves a timer that was off, off" $?
+! grep -q 'enable.*spaceturtle.timer' "$STUB_DIR/systemctl-calls"; launcher_check "start after stop leaves a timer that was off, off" $?
up_env; rm "$STUB_DIR/zones"; run_cmd stop; rc=$?
[[ $rc -eq 0 ]] && grep -q 'nothing to stop' "$tmp/out" && ! grep -q '^stop' "$STUB_DIR/calls"; launcher_check "stop with no zone says so and exits 0" $?
# backup
up_env; echo old > "$SPACETURTLE_BACKUP_DIR/wallet-backup"; run_cmd backup; rc=$?
[[ $rc -eq 0 && $(<"$SPACETURTLE_BACKUP_DIR/wallet-backup") == sealed ]] && grep -q "$SPACETURTLE_BACKUP_DIR/wallet-backup" "$tmp/out"; launcher_check "backup writes a sealed backup and says where" $?
-up_env; echo old > "$SPACETURTLE_BACKUP_DIR/wallet-backup"; echo "cp zone-x" > "$STUB_DIR/fail-on"; run_cmd backup; rc=$?
+up_env; echo old > "$SPACETURTLE_BACKUP_DIR/wallet-backup"; echo "copy --quiet zone-x" > "$STUB_DIR/fail-on"; run_cmd backup; rc=$?
[[ $rc -ne 0 && $(<"$SPACETURTLE_BACKUP_DIR/wallet-backup") == old && ! -e $SPACETURTLE_BACKUP_DIR/wallet-backup.new ]]; launcher_check "a backup that does not arrive leaves the earlier one" $?
up_env; echo "zone-x stopped" > "$STUB_DIR/zones"; run_cmd backup; rc=$?
[[ $rc -ne 0 ]] && grep -q 'not running' "$tmp/out"; launcher_check "backup needs the zone running and says so" $?
@@ -1229,18 +1464,23 @@ up_env; echo "zone-x stopped" > "$STUB_DIR/zones"; run_cmd backup; rc=$?
PASS_TEXT=hunter2-passphrase-xyz
upg_env() { up_env; echo "agent-x stopped" >> "$STUB_DIR/zones"; echo "$PASS_TEXT" > "$STUB_DIR/zone-passphrase"; echo wallet-bytes > "$SPACETURTLE_DATA_DIR/w1"; (cd "$SPACETURTLE_DATA_DIR" && sha256sum * ) > "$tmp/before"; : > "$STUB_DIR/stdin"; }
upg_env; run_cmd upgrade; rc=$?
-b=$(line_no 'toon wallet backup'); r=$(line_no '^rm '); c=$(grep -n '^create ' "$STUB_DIR/calls" | tail -1 | cut -d: -f1)
-[[ $rc -eq 0 && -n $b && -n $r && $b -lt $r && $r -lt $c ]] && grep '^create ' "$STUB_DIR/calls" | grep -q -- '--template ghcr.io/toon-protocol/spaceturtle-connector-zone:latest '; launcher_check "upgrade backs up, then removes, then creates from the connector's zone image" $?
+b=$(line_no 'toon wallet backup'); r=$(line_no '^remove '); c=$(grep -n '^create ' "$STUB_DIR/calls" | tail -1 | cut -d: -f1)
+[[ $rc -eq 0 && -n $b && -n $r && $b -lt $r && $r -lt $c ]] && grep -q -- '^create ghcr.io/toon-protocol/spaceturtle-connector-zone:latest --name zone-x .*--pull always ' "$STUB_DIR/calls" && grep -q -- '^create ghcr.io/toon-protocol/spaceturtle-agent-zone:latest --name agent-x .*--pull always ' "$STUB_DIR/calls"
+launcher_check "upgrade backs up, then removes, then creates both zones from the newest of their images" $?
+grep -q '^--user stop spaceturtle-connector.service$' "$STUB_DIR/systemctl-calls" && grep -q '^--user restart spaceturtle-connector.service$' "$STUB_DIR/systemctl-calls" && grep '^create .*--name zone-x ' "$STUB_DIR/calls" | grep -q -- '--port 127.0.0.1:7123:17100 --port 127.0.0.1:7124:17101'
+launcher_check "upgrade stops the unit before the zone goes, starts it for the new one, and the new zone has the two ports" $?
(cd "$SPACETURTLE_DATA_DIR" && sha256sum * ) | cmp -s - "$tmp/before"; launcher_check "upgrade leaves the wallet's storage byte for byte" $?
-! grep -qE 'toon init|toon wallet fund' "$STUB_DIR/calls" && grep -q 'policy allow network --sandbox zone-x' "$STUB_DIR/calls" && grep -q connector-start "$STUB_DIR/calls"; launcher_check "upgrade does not initialise or fund again, and sets up the new zone" $?
+! grep -qE 'toon init|toon wallet fund' "$STUB_DIR/calls" && grep -q connector-start "$STUB_DIR/calls"; launcher_check "upgrade does not initialise or fund again, and starts the supervisor in the new zone" $?
[[ $(<"$STUB_DIR/stdin") == "$PASS_TEXT" ]]; launcher_check "the passphrase reaches the new zone through standard input" $?
! grep -rqF "$PASS_TEXT" "$STUB_DIR/calls" "$STUB_DIR/systemctl-calls" "$tmp/out" && ! grep -rlF "$PASS_TEXT" "$tmp" /tmp --exclude-dir="$(basename "$STUB_DIR")" --exclude-dir=claude-\* 2>/dev/null | grep -q .; launcher_check "the passphrase is in no argument, output or file on the machine" $?
-grep -q '^rm .*agent-x' "$STUB_DIR/calls"; launcher_check "upgrade clears the agent's zone so the next session uses the newest image" $?
-upg_env; echo "cp zone-x" > "$STUB_DIR/fail-on"; run_cmd upgrade; rc=$?
-[[ $rc -ne 0 ]] && ! grep -q '^rm ' "$STUB_DIR/calls"; launcher_check "upgrade with a failing backup removes nothing" $?
+grep -q '^remove .*agent-x$' "$STUB_DIR/calls"; launcher_check "upgrade clears the agent's zone so the next session uses the newest image" $?
+upg_env; echo "pull --quiet" > "$STUB_DIR/fail-on"; run_cmd upgrade; rc=$?
+[[ $rc -ne 0 ]] && grep -q 'nothing was changed' "$tmp/out" && ! grep -q '^remove ' "$STUB_DIR/calls"; launcher_check "upgrade that cannot pull the newest images removes nothing" $?
+upg_env; echo "copy --quiet zone-x" > "$STUB_DIR/fail-on"; run_cmd upgrade; rc=$?
+[[ $rc -ne 0 ]] && ! grep -q '^remove ' "$STUB_DIR/calls"; launcher_check "upgrade with a failing backup removes nothing" $?
upg_env; echo 'cat "$HOME/.config/toon-passphrase"' > "$STUB_DIR/fail-on"; run_cmd upgrade; rc=$?
-[[ $rc -ne 0 ]] && ! grep -q '^rm ' "$STUB_DIR/calls"; launcher_check "upgrade with a passphrase that cannot be read removes nothing" $?
-upg_env; echo "exec -i" > "$STUB_DIR/fail-on"; run_cmd upgrade; rc=$?
+[[ $rc -ne 0 ]] && ! grep -q '^remove ' "$STUB_DIR/calls"; launcher_check "upgrade with a passphrase that cannot be read removes nothing" $?
+upg_env; echo 'cat > "$HOME/.config/toon-passphrase"' > "$STUB_DIR/fail-on"; run_cmd upgrade; rc=$?
[[ $rc -ne 0 ]] && grep -q 'passphrase could not be placed' "$tmp/out" && grep -q "$SPACETURTLE_BACKUP_DIR/wallet-backup" "$tmp/out"; launcher_check "upgrade that cannot place the passphrase names the backup as the way back" $?
launcher_env; run_cmd upgrade; rc=$?
[[ $rc -ne 0 ]] && grep -q 'spaceturtle start' "$tmp/out"; launcher_check "upgrade with no zone says to run start" $?
@@ -1249,27 +1489,35 @@ launcher_env; run_cmd upgrade; rc=$?
rm_env() { up_env; echo "agent-x running" >> "$STUB_DIR/zones"; echo k > "$SPACETURTLE_MODEL_KEY_FILE"; echo wallet-bytes > "$SPACETURTLE_DATA_DIR/w1"; run_cmd set model openai/x; }
rm_cmd() { printf '%s\n' "${@:2}" | "$root/spaceturtle" remove $1 > "$tmp/out" 2>&1; }
rm_env; rm_cmd "" y; rc=$?
-[[ $rc -eq 0 ]] && grep -q 'fresh backup' "$tmp/out" && [[ -s $SPACETURTLE_DATA_DIR/w1 && -s $SPACETURTLE_BACKUP_DIR/wallet-backup ]] && [[ ! -e $SPACETURTLE_MODEL_KEY_FILE && ! -e $SPACETURTLE_STATE_DIR/launcher && ! -e $SPACETURTLE_CONFIG_DIR/launcher.conf && ! -e $SPACETURTLE_UNIT_DIR/spaceturtle.timer ]] \
- && grep -q '^rm .*zone-x' "$STUB_DIR/calls" && grep -q '^rm .*agent-x' "$STUB_DIR/calls" && grep -q 'disable --now spaceturtle.timer' "$STUB_DIR/systemctl-calls" \
- && grep -q "$SPACETURTLE_DATA_DIR" "$tmp/out" && grep -q "$SPACETURTLE_BACKUP_DIR" "$tmp/out"; launcher_check "remove removes the zones, the units, the markers, the settings and the key, keeps the wallet and the backup, and says where" $?
+[[ $rc -eq 0 ]] && grep -q 'fresh backup' "$tmp/out" && [[ -s $SPACETURTLE_DATA_DIR/w1 && -s $SPACETURTLE_BACKUP_DIR/wallet-backup ]] && [[ ! -e $SPACETURTLE_MODEL_KEY_FILE && ! -e $SPACETURTLE_STATE_DIR/launcher && ! -e $SPACETURTLE_STATE_DIR/agent && ! -e $SPACETURTLE_CONFIG_DIR/launcher.conf && ! -e $SPACETURTLE_UNIT_DIR/spaceturtle.timer && ! -e $SPACETURTLE_UNIT_DIR/spaceturtle-connector.service && ! -e $SPACETURTLE_STATE_DIR/session ]] \
+ && grep -q '^remove .*zone-x$' "$STUB_DIR/calls" && grep -q '^remove .*agent-x$' "$STUB_DIR/calls" && grep -q 'disable --now spaceturtle.timer' "$STUB_DIR/systemctl-calls" && grep -q 'disable --now spaceturtle-connector.service' "$STUB_DIR/systemctl-calls" \
+ && grep -q "$SPACETURTLE_DATA_DIR" "$tmp/out" && grep -q "$SPACETURTLE_BACKUP_DIR" "$tmp/out" && grep -q "$SPACETURTLE_MSB_HOME" "$tmp/out"; launcher_check "remove removes the zones, what the agent kept, the units, the markers, the settings and the key, keeps the wallet and the backup, and says where they and the zone images are" $?
rm_env; l1=$(line_no 'toon wallet backup'); rm_cmd "" n; rc=$?
[[ $rc -eq 0 && $(grep -c 'toon wallet backup' "$STUB_DIR/calls") -eq 0 ]]; launcher_check "remove lets the fresh backup be declined" $?
rm_env; rm_cmd --delete-wallet delete y; rc=$?
-[[ $rc -eq 0 && ! -e $SPACETURTLE_DATA_DIR && -d $SPACETURTLE_BACKUP_DIR ]] && grep -q '^rm .*zone-x' "$STUB_DIR/calls"; launcher_check "remove --delete-wallet with the typed word deletes the wallet's storage and keeps the backup directory" $?
+[[ $rc -eq 0 && ! -e $SPACETURTLE_DATA_DIR && -d $SPACETURTLE_BACKUP_DIR ]] && grep -q '^remove .*zone-x$' "$STUB_DIR/calls"; launcher_check "remove --delete-wallet with the typed word deletes the wallet's storage and keeps the backup directory" $?
rm_env; rm_cmd --delete-wallet yes; rc=$?
-nothing_removed() { [[ -s $SPACETURTLE_DATA_DIR/w1 ]] && ! grep -qE '^rm |disable|toon wallet backup' "$STUB_DIR/calls" "$STUB_DIR/systemctl-calls" && [[ -e $SPACETURTLE_MODEL_KEY_FILE && -d $SPACETURTLE_STATE_DIR/launcher ]]; }
+nothing_removed() { [[ -s $SPACETURTLE_DATA_DIR/w1 ]] && ! grep -qE '^remove |disable|toon wallet backup' "$STUB_DIR/calls" "$STUB_DIR/systemctl-calls" && [[ -e $SPACETURTLE_MODEL_KEY_FILE && -d $SPACETURTLE_STATE_DIR/launcher ]]; }
[[ $rc -ne 0 ]] && nothing_removed; launcher_check "remove --delete-wallet with another answer removes nothing" $?
rm_env; "$root/spaceturtle" remove --delete-wallet < /dev/null > "$tmp/out" 2>&1; rc=$?
[[ $rc -ne 0 ]] && nothing_removed; launcher_check "remove --delete-wallet at end of input removes nothing and exits non-zero" $?
rm_env; rm_cmd --everything; rc=$?
[[ $rc -ne 0 ]] && grep -q 'usage: spaceturtle remove' "$tmp/out" && nothing_removed; launcher_check "remove refuses any other argument with the usage" $?
-rm_env; echo "cp zone-x" > "$STUB_DIR/fail-on"; rm_cmd "" y; rc=$?
-[[ $rc -ne 0 ]] && ! grep -q '^rm ' "$STUB_DIR/calls"; launcher_check "remove whose fresh backup fails removes nothing" $?
+rm_env; echo "copy --quiet zone-x" > "$STUB_DIR/fail-on"; rm_cmd "" y; rc=$?
+[[ $rc -ne 0 ]] && ! grep -q '^remove ' "$STUB_DIR/calls"; launcher_check "remove whose fresh backup fails removes nothing" $?
# the agent key
launcher_env; run_launcher; rc=$?
[[ $rc -eq 0 && $(wc -l < "$STUB_DIR/keys") -eq 1 ]] && grep -q 'toon agent-key create' "$STUB_DIR/calls"; launcher_check "a first run creates exactly one agent key, with the default scope and limit" $?
-! grep -qE -- '--scope|--limit' <(grep 'agent-key create' "$STUB_DIR/calls"); launcher_check "the agent key is created without overriding the default scope or limit" $?
+! grep -qE -- '--scope|--max-per|--limit' <(grep 'agent-key create' "$STUB_DIR/calls") && grep 'agent-key create' "$STUB_DIR/calls" | grep -q -- '--out /tmp/agent-key'; launcher_check "the agent key is created without overriding the default scope or limit" $?
+grep 'agent-x -- sh -c umask 077' "$STUB_DIR/calls" | grep -q 'http://host.microsandbox.internal:7123' && grep 'agent-x -- sh -c umask 077' "$STUB_DIR/calls" | grep -q 'http://host.microsandbox.internal:7124'
+launcher_check "on the way in, the key file's addresses become the machine's two ports that lead to the connector's zone" $?
+made=$(grep '^create .*--name agent-x ' "$STUB_DIR/calls")
+[[ $made == *" --cpus 2 --memory 2G "* && $made == *" --mount-dir $SPACETURTLE_STATE_DIR/session:/home/agent/session:ro "* && $made == *" --net-default-egress deny --net-rule allow@dns,allow@host:tcp:7123,allow@host:tcp:7124,allow@api.anthropic.com:tcp:443,"* ]] && ! grep -qE -- '--net (public|all|host|private)|allow@(public|private|host)[, ]|--port' <<<"$made"
+launcher_check "the agent's zone is small, mounts the session's definition read-only, refuses whatever its wall does not name, and of the machine may reach those two ports" $?
+[[ $made == *" --mount-dir $SPACETURTLE_STATE_DIR/agent/config:/home/agent/.config/spaceturtle:uid=1000,gid=1000 "* && $made == *" --mount-dir $SPACETURTLE_STATE_DIR/agent/state:/home/agent/.local/state/spaceturtle:uid=1000,gid=1000 "* ]]
+launcher_check "what the agent keeps is in mounted directories, so it outlasts the agent's zone" $?
+a=$(line_no 'toon agent-key create'); d=$(line_no 'exec connector-start'); [[ -n $a && -n $d && $a -lt $d ]]; launcher_check "the key is made before the supervisor starts, so that it listens for orders" $?
[[ $(<"$STUB_DIR/agent-zone-key") == PRIVATE-HALF-1 ]]; launcher_check "the private half is placed in the agent's zone" $?
! grep -rq 'PRIVATE-HALF' "$tmp/out" "$SPACETURTLE_STATE_DIR" "$SPACETURTLE_CONFIG_DIR" "$HOME" "$SPACETURTLE_DATA_DIR" "$SPACETURTLE_BACKUP_DIR" "$STUB_DIR/calls" 2>/dev/null && [[ ! -e $STUB_DIR/agentkeyfile ]]
launcher_check "the private half is nowhere else: not in the output, the launcher's files, the home, the calls, or the connector's zone" $?
@@ -1288,19 +1536,23 @@ run_launcher; [[ $(wc -l < "$STUB_DIR/keys") -eq 0 ]]; launcher_check "start doe
up_env; : > "$STUB_DIR/calls"; rm -f "$STUB_DIR/agent-zone-key"; run_cmd agent-key replace; rc=$?
[[ $rc -eq 0 && $(<"$STUB_DIR/keys") == key-2 && $(<"$STUB_DIR/agent-zone-key") == PRIVATE-HALF-2 ]] && ! grep -qE 'wallet|toon init' "$STUB_DIR/calls"; launcher_check "agent-key replace revokes the old key, places a new one and leaves the wallet alone" $?
up_env; : > "$STUB_DIR/calls"; run_cmd agent-key replace
-p=$(line_no '^exec -i agent-x'); v=$(line_no 'toon agent-key revoke'); [[ -n $p && -n $v && $p -lt $v ]]; launcher_check "agent-key replace places the new key before it revokes the old one" $?
+p=$(line_no 'agent-x -- sh -c umask 077'); v=$(line_no 'toon agent-key revoke'); [[ -n $p && -n $v && $p -lt $v ]]; launcher_check "agent-key replace places the new key before it revokes the old one" $?
up_env; echo "agent-x stopped" >> "$STUB_DIR/zones"; : > "$STUB_DIR/calls"; run_cmd agent-key revoke
-grep -q 'exec agent-x sh -c rm -f $HOME/.config/toon-agent/key' "$STUB_DIR/calls" && grep -q '^agent-x stopped' "$STUB_DIR/zones"; launcher_check "agent-key revoke deletes the private half from a stopped agent's zone, and leaves it stopped" $?
+grep -q 'agent-x -- sh -c rm -f $HOME/.config/toon-agent/key' "$STUB_DIR/calls" && grep -q '^agent-x stopped' "$STUB_DIR/zones"; launcher_check "agent-key revoke deletes the private half from a stopped agent's zone, and leaves it stopped" $?
upg_env; run_cmd agent-key revoke; run_cmd upgrade; rc=$?
[[ $rc -eq 0 && ! -s $STUB_DIR/keys ]]; launcher_check "upgrade does not make a key after a revoke" $?
up_env; : > "$STUB_DIR/calls"; run_cmd agent-key limit 3; rc=$?
-[[ $rc -eq 0 ]] && grep -q "toon agent-key set --limit '3' -- 'key-1'" "$STUB_DIR/calls"; launcher_check "agent-key limit sets the spending limit" $?
-up_env; run_cmd agent-key limit lots; rc=$?
-[[ $rc -ne 0 ]] && grep -q 'usage' "$tmp/out"; launcher_check "agent-key limit refuses a value that is not an amount" $?
+[[ $rc -eq 0 ]] && grep -q "toon agent-key set --max-per-day '3' --max-per-command '3' -- 'key-1'" "$STUB_DIR/calls"; launcher_check "agent-key limit sets the day's limit, and brings what one command may pay down with it" $?
+up_env; : > "$STUB_DIR/calls"; run_cmd agent-key limit 50; rc=$?
+[[ $rc -eq 0 ]] && grep -q "toon agent-key set --max-per-day '50' -- 'key-1'" "$STUB_DIR/calls"; launcher_check "agent-key limit leaves a per-command limit that is under the day's" $?
+for bad_amount in lots 0.5 0; do
+ up_env; run_cmd agent-key limit "$bad_amount"; rc=$?
+ [[ $rc -ne 0 ]] && grep -q 'usage' "$tmp/out" && ! grep -q 'agent-key set' "$STUB_DIR/calls"; launcher_check "agent-key limit refuses '$bad_amount', which is not a whole number of base units" $?
+done
upg_env; run_cmd upgrade; rc=$?
[[ $rc -eq 0 && $(<"$STUB_DIR/keys") == key-2 ]]; launcher_check "upgrade, which removes the agent's zone, replaces the key" $?
# no tool in the agent definition can do it
-bad=$tmp/bad.yaml; yq '.agents.spaceturtle.toolsets += [{"type":"script","shell":{"k":{"cmd":"toon agent-key set --limit 99","description":"x"}}}]' "$root/agent/spaceturtle.yaml" > "$bad"
+bad=$tmp/bad.yaml; yq '.agents.spaceturtle.toolsets += [{"type":"script","shell":{"k":{"cmd":"toon agent-key set --max-per-day 99","description":"x"}}}]' "$root/agent/spaceturtle.yaml" > "$bad"
"$root/tests/check-agent" "$bad" > "$tmp/out" 2>&1; rc=$?
[[ $rc -ne 0 ]] && grep -q forbidden-command "$tmp/out"; launcher_check "check-agent refuses a tool that runs toon agent-key" $?