Operational guidance for people running an Aether server. This file records the stances that are deliberate, so they are not repeatedly re-raised as findings.
The agent container
The container is the isolation boundary. Aether does not try to build a second sandbox inside it.
- Agents run as root by default. An agent profile may map a run to a non-root UID/GID, but the default image runs as root and nothing in Aether forces otherwise.
- Docker's default capability set is retained deliberately. Agents install
packages, run build tooling, and use
sudoin images that ship a non-root user. Dropping capabilities, settingno-new-privileges, or making the root filesystem read-only each break one of those in practice, including Aether's own setup-script sentinel, which needs a writable/tmp. - The consequence: treat container root as capable of anything the container can reach. Security comes from what the container is given, not from restrictions applied inside it: the mount policy, the network it can see, and the credentials mounted into it.
- Docker inside a run uses Sysbox, not the host daemon. On a host with
the Sysbox runtime (environments.md),
run and Environment containers start with
--runtime sysbox-runcand run their owndockerd. Sysbox gives the container a user namespace: root in the container maps to an unprivileged host uid, so the inner daemon's privileges stop at the container. The container is not--privilegedand never receives the host Docker socket, so an agent cannot start containers on the host daemon or mount host paths through it. The browser companion stays on the daemon default. Without Sysbox nothing changes: a run has no daemon and no socket. - Git trusts the checkout regardless of owner. Every container with a run
checkout gets
safe.directory=/workspacethroughGIT_CONFIG_COUNT,GIT_CONFIG_KEY_nandGIT_CONFIG_VALUE_n, appended after any of the workspace's own entries. An unprivileged server owns the checkout on the host while the agent runs as root; git's ownership check guards against another user's repository on a shared machine, which the container is not. Git readsGIT_CONFIG_COUNTfrom 2.31 on; an image with an older git that carries the ownership check (Debian 11's 2.30.2, Ubuntu 20.04's 2.25.1) still refuses the checkout. The image's ownENV GIT_CONFIG_*entries are replaced, not appended to: set them in the workspace environment instead. - An unprivileged server deletes root-owned run files as root. When it
cannot remove a run checkout or a member home, it starts a container of the
standard image as
0:0with only that directory mounted, runningfind /reclaim -mindepth 1 -deleteand nothing else.find -deletenever follows symlinks, so an agent cannot point it at another host path.
Each member's persistent home is mounted as $HOME only into that member's
own containers: their environment and the runs they launch. An
account share is the one exception, and it reaches only the owner's agent
login, except that an omp share reaches the owner's whole ~/.omp/agent;
see Account sharing.
Real names and email addresses cross into the container with the run. The
git identity of the member who launched it is baked into the container's
GIT_AUTHOR_* and GIT_COMMITTER_* at creation, and
/run/aether/co-authors holds one Co-authored-by: Name <email> line per
member who messaged the run, readable by the agent like any other file under
the coordination mount. A merged branch credits a real upstream account only
if it carries that account's address.
Each member controls their own address, not the operator. aether member git sets your own identity with no admin check - only setting someone
else's needs the admin role (internal/sshd/gitidentity.go) - and the local
dashboard's onboarding wizard asks every new member for one. Setting none
withholds the address alone: the synthetic <member-id>@aether.local
fallback credits nobody upstream, but domain.Member.GitIdentity still
falls back to the display name, or the member id when that cannot be a git
author name, so a name reaches GIT_AUTHOR_NAME and the trailers either
way.
Account sharing
Sharing your agent account with a member (Share account under Agent
account sharing in Profile, or aether account share <member-id>) lets
them launch runs on it from the launch dialog's Account picker (or
aether run --account <your-member-id>), so they use your agent CLI
subscription. The launcher remains the run owner and actor; usage and cost
are attributed to the selected account.
A run always starts from its launcher's saved image, or the standard image,
with the launcher's home as $HOME. Git identity, .gitconfig, the gh login,
the signing key, SSH files, shell history, agent configuration (settings,
hooks, MCP servers, history), installed executables (except a borrowed agent
installation, below), imported configuration, terminal image uploads, and the
profile snapshot pin are all the launcher's.
Candidate verification started by a run's agent runs in the home that run's
container mounts, which Aether records on the run when it is launched; a
handoff (aether handoff) changes the run's owner but not that home, and
verification does not need the run's container to still be alive. On a shared
account, Aether additionally mounts that agent's login path from the
owner's home over the same path in the launcher's home, read-write. The
Login state column of harnesses.md lists
each path; for claude it is ~/.claude/.credentials.json.
When the launcher's home has no ~/.local/bin/<executable> for the agent,
the run borrows the owner's installation. The owner's ~/.local/bin and, when
it exists, ~/.local/lib are mounted read-only at ~/.aether/account/bin and
~/.aether/account/lib, and ~/.aether/account/bin is appended to the end of
PATH. Each install directory the agent declares
(harness.Profile.InstallPaths; ~/.local/share/claude for claude) is
mounted read-only at its own path. A borrowed login also gets the
launcher's own copy of the agent's state file the CLI consults before
using a login (harness.Profile.BorrowedState; for claude,
hasCompletedOnboarding: true in ~/.claude.json, created with mode 0600 or
rewritten in place, never replaced), so the CLI starts signed in rather than
running first-time setup; Claude Code then writes the owner's account email
into that file. Nothing else of the owner's home is
mounted, and ~/.local/share as a whole never is: opencode keeps its login
there. The owner's ~/.local/bin/<executable> counts as an installation only
when every link it follows stays inside those mounted directories; a link
into anything else would dangle in the run, so the agent is reported as not
installed instead. When neither home has the executable, nothing is borrowed
and the run starts whatever the image provides.
Two limits follow from mounting the owner's files unchanged. Claude Code's
installer links by absolute path (/root/... or /home/aether/...), so an
owner who installed as root and a recipient whose image runs as a non-root
user, or the reverse, get a link that does not resolve and the container's
own not found error. A recipient whose image runs as a different non-root
uid can only execute what the owner's file modes allow others to; the
read-only mounts are never re-owned.
A borrowed installation means the owner's executables run in the recipient's
container, with the recipient's home, GitHub login, and signing key. Every
executable in the owner's ~/.local/bin is on PATH, after the recipient's
own and the image's, so a recipient who launches without their own
installation trusts the owner's. The mounts are read-only, so the recipient's
run cannot alter the owner's installation. A recipient who installs the agent
in their own ~/.local/bin runs their own copy and borrows nothing.
omp is the exception. It keeps its login in a SQLite WAL database,
agent.db, beside config.yml, mcp.json, its extensions, usage tables, and
sessions/, and a WAL database cannot be shared file by file. An omp share
therefore mounts the owner's whole ~/.omp/agent directory read-write, and
omp loads extensions, MCP server commands, and settings from it. That cuts
both ways:
- The recipient's run can plant an extension or MCP server command there that
runs in the owner's next omp session, inside the owner's environment with
the owner's whole home, gh token, and signing key. Against a recipient who
does that, an
ompshare is as broad as sharing your home. - The owner's extensions and MCP servers run in the recipient's container, with the recipient's GitHub login. The recipient's run can also read the owner's omp sessions.
Share an omp account only with someone you would give your home to.
Only the shipped agents and server-wide definitions
(harnesses.md) declare a login path to share. A
member's own definition (aether agent add) runs only on that member's own
account; a launch of it on a shared account is refused:
scheduler: harness "<name>" is your own agent definition, which runs only on your own account; on a shared account, only a server-wide definition (aether-server --agent-definitions) can declare the login it sharesA launch is also refused when a definition's login path is the home itself, when the owner has no login at any declared path (a missing or empty file is no login), when any component of that path in the owner's home is a symlink, and when the login file has another hard link.
What a share still hands over: the recipient's run holds the owner's login
itself, including the refresh token. Root in that run can copy it, and
revoking the share does not recall a copied token; the owner rotates it by
logging out and in again. The login is writable because a token refresh
rewrites it, so a recipient's run can also overwrite it, log out (/logout in
Claude Code and codex logout revoke the owner's session at the vendor), or
replace it: /login in Claude Code or codex login in a shared run writes
the recipient's own login into the owner's file. For the file logins
(claude, codex, pi, opencode, copilot, gemini, cursor, antigravity) that is
loss of the login, not access to anything else in the owner's home. For omp, see above.
How the path is mounted. The owner's own containers control every path
inside the owner's home, so a plain bind of a path in it could be swapped for
a symlink between Aether's check and container start, exposing another
member's home or a host path. Aether instead mounts the login path as a
Docker volume subpath of the owner's home, which the engine resolves beneath
the home at every container start. On Docker Engine 28.0.4, a symlink in the
path that leaves the owner's home is refused at container start (path concatenation escapes the base directory or cannot access path), including
one swapped in after the container was created or stopped; a symlink that
stays inside the owner's home is followed. Aether refuses any symlink in the
path at launch, but the owner's own containers can plant one afterwards,
before a start or restart, and so redirect the mount to another file in the
owner's own home, never outside it.
This needs Docker Engine 26.0 or newer (API 1.45) for every container that mounts a path beneath a member home: runs on a shared account, and every container that mounts a sharing owner's home - their runs, their environment terminal, and candidate verification started by them or by their runs. An older engine ignores the subpath and would mount the owner's whole home, so Aether reads the engine's API version first and refuses:
runtime: docker engine API "1.44" cannot mount a path beneath a member home; that needs API 1.45 (Docker Engine 26.0) or newerA DOCKER_API_VERSION below 1.45 in the server's environment is refused the
same way, because requests sent at it ignore the subpath too:
runtime: docker client sends API "1.44" (DOCKER_API_VERSION), which cannot mount a path beneath a member home; that needs API 1.45 or newerAether creates one volume per sharing owner, named
aether-home-<hash> and labelled aether.managed=true, as a bind of that
owner's home; removing the volume does not delete the home.
Claude's login file. Claude Code replaces ~/.claude/.credentials.json by
rename on every token refresh and writes in place only when the rename fails,
so a refresh in the owner's own container would leave recipients' runs holding
the old file. Once a member has shared their account, Aether therefore also
mounts that file in place in the member's own runs and environment,
creating an empty file when none exists, so every writer updates the one file
recipients' runs hold. The empty file is not a login: until the owner logs in,
a recipient's claude launch is refused. A login file with another hard link
is neither mounted in place nor shared. A container the owner started before
sharing, typically the long-lived environment, lacks that mount, so stop it
and open it again. When the environment is running at a first share, or its
state cannot be read, Profile says so and offers Stop environment;
Open on the Environment page starts it again. From the CLI: aether terminal stop, then
aether terminal. In a container with the mount, Claude's /logout revokes
the login at Anthropic and reports success but cannot delete the file; the
dead tokens are cleared on the next refresh. Members who share nothing are
unaffected.
Gemini's login file. Gemini CLI replaces ~/.gemini/oauth_creds.json by
rename too, and rewrites it in place when the rename fails, so Aether mounts
it in place on the same terms as Claude's file above, and the same
stop-and-reopen applies. ~/.gemini/.env is shared but not pinned: Gemini
only reads it. A share of gemini hands over a GEMINI_API_KEY kept there.
Antigravity's login file. ~/.gemini/antigravity-cli/antigravity-oauth-token
is shared like Claude's but not pinned: whether agy replaces it by rename is
unverified, and so is whether agy can refresh it as a shared mount.
Non-root images. With a non-root image, a run hands the login path to its
own uid before it starts. For omp that is the whole ~/.omp/agent, so a
large or deeply nested one slows non-root launches on that account, the
owner's own included, but not the rest of the server. The owner's containers are never refused because of
a recipient's run. A recipient's launch is refused while its uid differs from
that of the owner's live containers, or of another recipient's live run, on
that login; the error names the login <member-id> shares is held by. The
handover is checked again against those containers when it happens, so a
recipient launch that the owner's container overtakes is refused rather than
taking the login back. If the owner starts a container with a different
non-root uid while a recipient's run is live, the owner's container takes the
login back and that run loses access to it. A profile push or rollback by the
owner (aether profile push, aether profile rollback) re-owns every entry
under that agent's profile root in the owner's home, the login included, to
the uid:gid of the owner's home directory unless that is the server's own
uid, and a recipient's live run with another uid loses access the same way.
Revoking a grant blocks later launches and reopens. It does not stop an
already-running container or remove the login mounted into it. Stop those
runs before revoking access when immediate removal matters: Kill run in
the run header's More menu, or aether kill <run-id>.
Usage fallback. A launch whose account is out of usage can start on another account (teams.md), so a run can mount a sharing owner's login without the launcher picking that account for it. The share is the owner's consent; there is no separate grant. Fallback never widens who may launch on an account. Its candidates are only the launcher's own account and the accounts shared with the launcher, each checked again with the share check a hand-picked launch uses, at the moment it is chosen, so a revoked share leaves the pool at once. A fallback account is never one whose agent installation the launcher would borrow, so fallback never runs a teammate's executables for a launcher who did not pick that account, and never one with no login to share, nor one whose login a live container holds under another uid:gid than the launcher's own live containers use. Choosing reads only member homes and the scheduler's state, never the container runtime or a vendor, so nothing slow runs under the launch authorization lock. Without an installation of the launcher's own, an account qualifies only when the requested launch would also have run the image's agent, so a launch that relied on the requested account's installation never lands where the agent is missing. An admin gains nothing, and a member-defined agent still runs only on its member's own account. A swarm worker is checked against the swarm's accountable human, and an integrator launch or replacement against the member who creates or replaces it.
A run whose agent reports its usage limit mid-run moves to another account
the same way: the move is a launch on the new account. Under the launch
authorization lock, with the run read again there so a handoff during the
move's quota read counts, the run's owner and the member whose home its container
mounts (the launcher, who differ after a handoff) must both still be allowed
to launch, as AuthorizeLaunch requires, and each candidate must be shared
with both and pass the same installation and login rules in that home. The
lock is released while the old container is removed, then taken again: the
owner, home member and share are admitted once more and the run records the
new account, as a launch holds the lock until its admission is durable. It
is not held while the replacement starts. A
move never returns to an account the run already used. A swarm worker's
later checks use the account its run is on now, not the one it launched on. The signal comes from inside the run's container (agent
output, the Claude StopFailure hook payload, the ACP error), so the agent
can forge it and force a move, but only to an account the launcher may
already use that has usage left: the same reach as the launcher picking it.
Only the current agent process's output counts: output an earlier process
printed is never read again after a restart or a move.
Revoking a share still does not stop a run that has already moved onto that
account.
Containers created before shares were narrowed to the login path still mount the account owner's whole home until they end. They stay supervised, and reopening one is refused:
scheduler: invalid run state transition: run <run-id> predates the narrowed account share, and its container still mounts the account owner's whole home; it stays closed, so launch a new runStop those runs, before or after upgrading, to end that exposure
immediately: Kill run in the run header's More menu, or
aether kill <run-id>.
This narrows what an account share exposes. It does not change the role model: a collaborator can still message and control another member's live run (teams.md) and reach the home its container mounts, and a handoff transfers a run whose container keeps the home it was created with. The development terminal and run repository gates still also check access to the run's account, as before; that check is not what protects a run owner's home. The Steer capability is. The share boundary is not a per-member sandbox.
GitHub credentials and signing keys
Connecting GitHub (the administrator's browser authorization flow or
aether github connect, see
environment-home.md) puts two secrets,
and the settings that use them, in the member home on the server:
- The gh token, at
homes/<member>/.config/gh/hosts.yml.gh auth loginruns inside the member's environment container, which has no keyring, so gh falls back to writing the token to that file in plain text. - The signing key pair, at
homes/<member>/.ssh/aether_signing(mode0600) and.pub. Aether generates the ed25519 key itself and registers only the public half on GitHub withgh ssh-key add --type signing. - The settings that use them, in
homes/<member>/.gitconfig: gh's credential helper forhttps://github.com, plusgpg.format=ssh,user.signingkey=~/.ssh/aether_signing, andcommit.gpgsign=true.
None of it is in aether.db, and none of it is in a saved environment
image. The member home is a bind mount, and Docker's commit records only
the container's own layer, never a bind mount, so aether env save cannot
capture the token, the key, or the .gitconfig. An integration test starts
a container from a saved image with no home mounted and asserts all three
are absent (TestIntegrationMemberEnvironmentImage).
The token is in the home because the run itself has to hold the credential: the agent pushes its own branch and opens its own pull request. Keeping it out of the container behind a host-side proxy was considered and rejected - the agent asks that proxy for the same pushes and pull requests, so it has the same reach by a longer path.
What that grants an agent is the point of the feature, so it is worth
stating plainly. Container root in any of that member's runs can read and
replace the token, the private key, and .gitconfig. With the token it
can act as the member on GitHub within the token's scopes - gh auth login
as documented in environment-home.md
requests gh's defaults (repo, read:org, gist) plus
admin:ssh_signing_key - so it can push to the workspace's recorded
origin, open pull requests, and register or remove signing keys on the
account. That push is HTTPS: gh's
credential helper authenticates https://github.com and nothing in the home
authenticates SSH, which is why a github.com origin is recorded in its
https form (teams.md).
Replacing reaches further than reading. The home is mounted read-write and
its files are owned by the container user, so an agent can swap the signing
key for one of its own, and Aether then signs its own end-of-run commits
with the replacement; rewrite .gitconfig, which decides the credential
helper and the identity commits are made under; and plant ~/.local/bin/gh,
first on the container's PATH, so the next aether github connect - or
the gh --version the dashboard's GitHub step runs on its own to check
that environment - runs the agent's program instead of gh. An account share
does not extend this reach: a recipient's run has the recipient's own home,
token, and key, never the owner's. An omp share is the exception: code the
recipient's run plants in the owner's ~/.omp/agent runs in the owner's own
omp sessions (Account sharing).
Aether signs its own end-of-run commits with that key while still treating
the home as hostile. It opens the key through a root-confined open on the
home directory, so a symlink planted inside the container cannot lead the
server to a file outside it, and copies the bytes into a private temp file
of the server's own for the length of the commit. gpg.format,
gpg.ssh.program and commit.gpgsign are all passed with git -c on the
command line, because the run checkout's .git/config is agent-writable:
without pinning them, a planted gpg.ssh.program would name a program the
server then runs.
GitHub marks a signature Verified only when the commit's committer
address is a verified address on the account that registered the key. So
the agent's own commits, which the container makes as the member, show as
verified only when that member's git email is on their GitHub account: the
synthetic <member-id>@aether.local fallback, and any address they have
not added there, read Unverified. Aether's end-of-run commits, which are
committed as Aether, never verify on GitHub. Those still verify locally
against the member's public key with git verify-commit, given a
gpg.ssh.allowedSignersFile that lists the address and the key. Signing
does not change who the commit is authored as; see
teams.md.
To revoke the token at GitHub, revoke the GitHub CLI authorization in
GitHub's application settings.
gh auth logout --hostname github.com in the environment terminal only
removes the native local login; it does not revoke a copied token. Remove
the signing key from
github.com/settings/keys, and delete
.config/gh/hosts.yml, .ssh/aether_signing and .ssh/aether_signing.pub
from the member home. Then clear the signing settings in the environment
terminal, or every git commit inside a run starts failing with Load key ...: No such file or directory:
git config --global --unset commit.gpgsign
git config --global --unset user.signingkey
git config --global --unset gpg.formataether env reset does none of this - it forgets the saved image and never
touches the home.
Workspace secrets
A workspace variable marked secret (environments.md) is write-only in the settings and an ordinary environment variable in a run. Hiding it protects the value from someone reading the settings, not from someone who can run code in the workspace.
At rest. The values of a workspace's secrets are one JSON file,
<data-dir>/workspace-secrets/<workspace-id>.json, in plain text with mode
0600 in a mode-0700 directory. They are not in aether.db, the event
log, a member home or a saved image. The server administrator and any
process that can read the data directory can copy the file, and a backup of
workspace-secrets/ is a credential backup. Deleting the workspace deletes
the file. Plain variables and the setup script are in aether.db and are
readable by every member, so neither is a place for a credential. Marking a
plain variable secret hides it from then on: every member could read it
before, and earlier copies of aether.db still hold it, so change the value
when you mark it.
In a run. Each new run container, and each candidate verification container, is created with the workspace's variables and secrets in its environment. From there:
- Every process in the container reads them: the agent, the setup script, and any shell opened on the run. Anyone who can launch a run in the workspace, or message a run there, can make it print a secret, and what it prints lands in the run's transcript like any other output.
- Anyone who can inspect containers on the server host reads them and the
setup script with
docker inspect; Docker access there is root access already. - A container keeps the values it was created with. Removing or replacing a secret does not reach a container that already exists, so rotate a leaked credential at its issuer, not only here.
What never carries a value. workspace.environment.get and the result
of workspace.environment.set name a secret and omit its value, for admins
too. The timeline note for a change lists names. Refusals do not echo a
value. A candidate verification's environment_sha256 covers a secret by
name, not by value.
Setup script output. When the script fails, the launcher gets the end of
what it printed and the server log gets the same text; the run's status
reason, which every member reads, gets only the exit code. A candidate
verification whose setup script fails records the first 8 KiB of that text
as its error, which every member reads. In that text every exact occurrence
of a secret's value is replaced with ***. A value the script transforms
first - encodes or splits - is not recognized and is not masked, and nothing
masks what the agent or a verification command prints later.
The assistant
The assistant is an agent running in a member's environment container, driven from the dashboard. The boundaries:
- Member authority, through the server. Its Aether tools reach the server
over a Unix socket at
<data>/assistant/<member>/sock/assistant.sock. The server binds each socket to one member and runs every call as that member, with the same permission checks as their own CLI. No tool takes a member id, and the socket rejects any parameter containingmember_id, includingaccount_member_id. The one exception isrun.list, which acceptsmember_idas a read filter: every member can see every run anyway. Only a fixed allowlist of methods is served; anything else returnsmethod not found. - Socket placement. The socket directory is mounted read-only at
/run/aether-assistantin that member's environment terminal only. No run container and no other member's terminal receives it. A terminal created before the assistant existed lacks the mount and reportsneeds_restart. - Tiers, enforced by the server. Read methods and
run.pause/run.resumerun at once.run.launch,run.inject,run.killandenv.saveblock inside the server until the member approves a card whose text the server builds from the call and the stored run or workspace. A decline, a 10-minute timeout or a closed session fails the call, and the card is withdrawn if the agent's call goes away. The socket does not serveassistant.confirm; the approval is a control call from the member's own dashboard session. - Auto-approval is the member's, and off by default. With
assistant.settingsauto_approveon, the server passes those four methods without a card, from a fixed list, so a confirmed method added later asks. A message to or stop of a run another member owns always shows a card; the server looks the owner up from the stored run. The server reads the stored setting on each call; nothing the assistant sends is consulted. The dashboard suggests the setting after three cards approved by hand; the tool socket cannot answer that suggestion either. The tool socket serves neitherassistant.settingsnorassistant.confirm, so the assistant cannot turn it on or approve for itself; only a control call authenticated as the member, from their dashboard or CLI, can. Approved and auto-approved calls alike are written to the member's transcript before they run, and a call whose note cannot be written does not run. - Shell work does not ask. The session starts in the mode Enhanced runs
use (Claude Code
bypassPermissions, Codexagent-full-access), so the agent's shell and file tools run without a prompt. The tiers above are the only approvals, and they are enforced by the server, not the harness. - Run text is untrusted. Transcripts and output the assistant reads can contain injected instructions. The assistant's instructions say to treat them as data, but the control is the server: with auto-approval off, a steered assistant still cannot launch, message or stop a run, or save the environment, without a click. With it on, that click is gone, and for the agent the instruction is the only guard: a steered assistant can launch runs, message and stop the member's own runs and save the environment; messaging or stopping another member's run still needs a click. It can run commands in the member's environment, which is the member's own container, either way.
- Notices name the run. Aether follows the runs the assistant launches or
messages and writes a notice to the member's own transcript and to their
devices through Web Push. A notice holds the run's title and a reason,
question or action taken from the run, so it is run text too. The
assistant.noticeevent is a workspace event: any client subscribed to the workspace receives it, the same as the run'srun.statusevents, and the dashboard shows it only to the member named inmember_id. - No account sharing. The assistant session always uses the sign-in in the member's own home and counts against their own subscription. Runs it launches may use quota fallback onto accounts shared with the member, the same as the member's own launches.
Stated plainly:
- Anything that can write the member's home can influence the assistant while
it runs. The member's runs mount that home read-write, so a run can plant
~/.claudesettings or hooks, or.bashrc, that the assistant then loads. - Code running in the member's environment container can reach the tool socket while a session is live. It gets the read tools and pause/resume at once; anything else needs the member's approval on a card, or nothing while auto-approval is on.
- With auto-approval off, every action beyond reading and pause/resume needs that approval, and the card text comes from the server, not the agent. Turning auto-approval on trades that check for speed; the transcript then shows what passed, after the fact.
- With auto-approval on, any code running in the member's environment
container gets the confirmed tools too, not only the agent: a shell script,
a package install hook, or a
~/.claudehook or.bashrcline a run planted in the shared home. Such code can launch runs, message and stop the member's own runs, andenv.savecan persist it into the image new runs and terminals start from. - The assistant cannot change the setting as long as no Aether control
credential is stored in the member's environment home. A member who signs
the
aetherCLI in there gives the assistant, and every run that mounts that home, the means to callassistant.settingsandassistant.confirmas them.
The agent is a process in the member's
environment container, the same as aether terminal. It can read the member's
home, including agent logins and other credentials, and run any command there
without asking. Tiers gate Aether
operations, not what the shell can do to the container. The assistant is told
not to reveal secrets, which is an instruction, not a control.
Remote development and browser isolation
Development terminals, the shared app browser and transient captures require Steer, including reads and captures: an app session can already be authenticated. Ordinary permission to view a run is not permission to inspect that app session. The server also rechecks access to the run's backing account. Local SSH gateways and the server-hosted dashboard use the same checks.
Explicitly retaining selected captures crosses a sharing boundary: retained bytes and verification notes become ordinary workspace evidence, readable with View under the packet's existing scope and expiry, without live Steer or backing-account access. Retain only reviewed content before headless completion or other cleanup. Capture bytes and observation metadata are immutable; the packet's later retained Git revision does not establish capture-time Git state. Notes are at most 4096 UTF-8 bytes; retained plus staged captures are bounded to 64 files and 128 MiB per run, with at most 8 MiB per capture.
Retention re-resolves current authority after taking the per-run evidence lock
and before opening each selected source. Immediately before publishing the
packet it rechecks under the shared authorizationMu admission gate, which
also serializes account, membership, role and workspace-policy changes. A busy
gate refuses publication with a visible retry-retention conflict and rolls
back staged work; it never silently retries. After an uncertain result, inspect
evidence and retry explicitly with the same key and exact request if needed.
A human member and a run agent are distinct principals. The agent's identity comes from its run socket, not a member ID in a request. Each app terminal and browser session has its own writer lease and resource incarnation; commands carry that lease's generation. A human can explicitly take over, which fences stale writes. An agent cannot force a takeover. Taking an app surface does not take the primary agent terminal or release its swarm hold; primary-terminal control and swarm dispatch retain their own rules. Held browser keys, buttons and touches are cleared server-side when the controller releases, loses authority or disconnects, and before replacement control is admitted. A cleanup failure fences new control rather than handing the next controller potentially held input. A disconnected observer is not a controller release.
Inspecting the element under a point (dev.browser.inspect, the Browser's
Annotate) is a read like a frame or a snapshot: it needs Steer, takes no
writer lease and dispatches nothing to the page. What it returns excludes
every form control's value, whether typed or set in markup, and replaces an
attribute value that looks like a credential with [redacted]; visible page
text, URLs and the picture of the element are not redacted. A member chooses
what to send: nothing reaches the agent until Send to agent, and what is
sent is ordinary run history afterwards.
This is shared-input ownership, not a restricted execution sandbox. An app terminal executes in the live run container, under the run owner's ordinary home and credentials, the selected account's agent login, and the container's filesystem and network authority. An agent or human can still execute native tools outside the managed surface. Taking a writer lease does not suspend every process already running there.
The Chromium companion has a different policy from the agent container above:
- It runs as UID/GID
1000:1000, with a read-only root filesystem, all capabilities dropped,no-new-privileges, private IPC and 256 MiB shared memory, bounded memory/CPU, and a private temporary filesystem. - Chromium runs genuinely headless with its namespace and seccomp sandboxes
enabled. A scoped seccomp profile permits the required user-namespace
operations; Docker's stock AppArmor policy remains in place. There is no
privileged, unconfined,
--no-sandbox, Xvfb, or host-display fallback. - Its only bind mount is a private control directory. It cannot mount the checkout, member home, signing keys, agent credentials, or Docker socket. It joins the run's network namespace, never host networking; this lets it reach the app's loopback ports without exposing a browser port on the host.
- Playwright controls Chromium through a debugging pipe, not a CDP TCP listener. The server brokers typed commands and bounded frames over a Unix socket; clients do not receive unrestricted CDP access.
This separation protects member files from browser code; it does not make pages safe to publish. An app test login may contain cookies, tokens, customer data, or other secrets. The browser shares the run's network reach. Use dedicated test accounts, never paste production credentials for recording, and inspect every selected capture before sharing it. Console warnings/errors, failed requests, page URLs, and visible page or terminal content can also contain secrets. There is no automatic public pull-request image upload. See privacy.md for retention and install.md for deployment failures.
Managed selected-path Git commits
These commits execute Git inside the live run environment, using its native
identity and signing configuration and the run's coauthor trailers. Publication
uses one native git update-ref --stdin prepared transaction, with a single
dereferencing update HEAD NEW OLD. Git compares the branch's object ID with
the expected old value as a native compare-and-swap. After Git acknowledges
prepare, Aether verifies symbolic HEAD still names the expected branch
while Git holds both the HEAD and branch locks, then commits or aborts within
that transaction. A changed branch or HEAD fails closed; Aether does not
emulate the guarantee with its own lock or update whichever branch is current
later. Stock Ubuntu 24.04 Git 2.43 supports this primitive; it does not require
a special Git build or new host upgrade prerequisite.
The selected-path index is isolated while constructing the commit: only the
explicitly selected paths enter the commit, and unrelated staging is preserved.
This is not a snapshot lock over files being edited concurrently: selected
content can change while Git reads it. Updating the selected entries in the live
index after publication is a separate step. A result with committed=true
and index_updated=false means the commit exists but index reconciliation
failed; inspect the reported error and repository status rather than blindly
retrying the commit.
The native commit-tree path deliberately does not run commit hooks
(hooks_run=false). Use git commit in the run terminal when commit hooks
are required. Native reference-transaction hooks are not disabled or replaced;
their preparation veto remains effective. Custom environments without the
required native transaction support fail closed, preserving native stderr and
reporting missing protocol acknowledgements rather than falling back to an
unchecked update. Other repository reads remain available. See
install.md for exact diagnostics.
Workspace source mirrors
A workspace without a mirror is local-only. A configured mirror is an
administrator-only, read-only fetch from its source branch into the
workspace's protected base. It is not the checkout Origin: Origin remains
the independent push destination for run branches and pull requests. A mirror
source URL must not contain credentials, query strings, or fragments.
GitHub mode (auth: github) uses the initiating administrator's native
GitHub CLI login on their own Aether server. The server reads
homes/<member>/.config/gh/hosts.yml through a bounded, root-confined read,
verifies the active account with https://api.github.com/user, and uses the
credential only for fixed GitHub API reads and HTTPS fetch of the selected
https://github.com/<owner>/<repo>.git source. API requests and fetches do
not follow redirects. Repository listing includes accessible private
collaborator and organization repositories; repository administration or
push access is not required to import a readable source.
The token remains a native member-home secret. Mirror configuration persists only the authorizing member ID and numeric GitHub account ID as its credential binding, alongside ordinary source metadata, never a token. It is not copied to other members, the browser, the edge, the workspace database, on-disk Git configuration, URLs, or command arguments. The server uses it in memory and passes URL-scoped HTTP authorization to the fetch child through its temporary process environment. Server administrators and processes able to inspect that server can therefore access it.
Fetch-only describes the mirror operation, not the token's permissions.
Native gh authorization retains its scopes, including the broad repo
scope and the signing-key scope described above. The owner's own agents
already can read or replace these native credentials. Importing private
source deliberately shares its code with the workspace's authorized members
and runs; it does not lend them the administrator's GitHub login or grant
them upstream publishing permission. Publishing still requires an explicit,
independent checkout Origin and the publishing member's own credentials.
Every refresh re-reads the native credential and verifies the numeric account binding. Expiry, revocation, logout, removal of the authorizing member, or switching to another account fails refresh without moving the accepted base. Reconnect the same account as the authorizing member, or have an administrator explicitly reconfigure the source to bind their current account. Disabling or deleting a mirror does not log out or revoke native gh authorization. Browser authorization and repository-selection RPCs are admin-only; the server derives the credential owner from the authenticated caller. Existing member-scoped native GitHub setup remains separate, as do edge identity sign-in and agent-provider accounts.
Public mode fetches credential-free HTTPS. Deploy-key mode generates a
dedicated Ed25519 key for each mirror configuration generation. For GitHub,
the operator installs only the printed public half as a repository deploy key
and should leave Allow write access off. Generic SSH sources use the
operator-supplied known_hosts contents; GitHub uses Aether's pinned
github.com host key rather than the server's global known_hosts.
The private key is stored on the server below
<data-dir>/mirrors/<workspace>/private_key.<generation> with mode 0600 and
a mode-0700 parent. It is not in aether.db, a member home, a saved image,
an RPC result, or the dashboard. The server uses it only for the mirror fetch.
The server administrator and any process that can read the server data
directory can nevertheless copy it, and backups of mirrors/ are therefore
credential backups. A compromised server can read the upstream with that key;
for generic SSH, the account's server-side permissions define the scope. Use a
repository-scoped, read-only key wherever the provider supports it.
Reconfiguring rotates to a new generation and removes the old local key files,
but it cannot remove a key already installed at GitHub or another provider.
Revoke old keys there, using the GitHub deploy-key settings URL or the
provider's equivalent. Disabling a mirror removes its local key and restores
client-writable base behavior; it also cannot revoke a remote key. Treat a
deploy-key public key and its fingerprint as operational metadata, but never
publish the private key or the known_hosts file.
Configuration starts pending; it does not fetch until Verify/refresh
(remote import performs that first fetch). The initial observed revision
requires explicit administrator adoption; fetching it is not acceptance.
Adoption requires both the configuration generation and the full reviewed
commit SHA (expected_commit in the API, --commit in the CLI). Generation
pins configuration, not code: a refresh can replace the candidate without
changing generation. Under the Git ref-mutation lock, the server checks the
reviewed SHA against the current candidate on every compare-and-swap attempt,
before moving any base, accepted, or candidate ref. A stale review is rejected
without changing those refs; the new candidate remains inspectable. The
administrator must refresh the review and explicitly accept the new revision,
not retry with an automatically selected latest SHA.
Each launch refreshes exactly the configured branch before a run row is
created. ready accepts an unchanged or forward-only source, while
auth-failed, offline, source-missing, rewritten, diverged, and
error explain a failed or intentionally held observation. Rewrites and
divergence retain an observed candidate without moving the accepted base.
Every failed refresh blocks that launch and never silently starts from stale
data. When an accepted commit is available, an operator may make one explicit
--cached-base <sha> retry; Aether verifies that the protected base is still
exactly that commit and never reuses the cache automatically.
Direct writes to a mirrored base - including git push aether <base>,
dashboard Push now, repo.push, or a daemon base push - are rejected by
the server. Only a forward refresh or an administrator's explicit candidate
adoption can move it. Run branches remain publishable to the workspace, and
aether pull remains the safe review path before a human merges locally and
pushes the reviewed branch to checkout Origin.
Candidate verification and delivery
Candidate operations use the existing workspace capabilities, not a new
integration role. Reading a candidate requires the caller's normal view
authority; preparing, resolving, verifying, requesting delivery, and executing
delivery use the existing Push capability. The service resolves the
current member, workspace, run ownership, and candidate state itself. It
rechecks the caller and the approved human approver at the actual delivery,
so an old page, role change, or stale request cannot turn into authority.
integration.decide is human-only, and an optional swarm identifier is
context rather than a permission grant. The one delivery without a human
decision is a swarm integrator's: its request is recorded as approved by the
swarm's accountable human, who must hold Push when it is requested and again
when it is delivered, and only while the swarm is active. Cancelling the
swarm stops it.
Verification runs against a server-owned isolated candidate revision and a disposable verification tree; candidate inputs and retained evidence are not re-read from a mutable live checkout. The isolation protects the source tree and post-execution integrity check, not the credentials of the environment it runs in. A human's verification runs in their own environment; a run actor's runs in the home that run's container mounts, recorded on the run at launch, which a handoff does not change; it runs whether or not that container is still alive. That home's gh login, signing key, and other files are available to the verification, so do not treat it as a per-candidate credential boundary.
Delivery to a local workspace target is an expected-old atomic ref update.
Delivery to a mirrored target must use the proposal action: it creates a
public refs/heads/aether/proposal-<request-id> ref and a private receipt,
without pushing the upstream or moving its protected mirror base. The proposal
is labelled proposed, not landed; a human fetches and pushes it through the
normal upstream review route. The mirror's read-only deploy key is only for
server fetches and is never reused as a delivery credential.
Nothing here blocks native credential use outside Aether. An agent with a
member home can still run its own git push, gh operation, or pull-request
flow under that member's credentials, subject to the upstream's permissions.
Candidate delivery's Push checks govern only the Aether-managed operation.
See teams.md for the operator flow, integration.md for the exact wire contract, and failure-handling.md for restart and cleanup behavior.
Hostile agents
If you run agents you do not trust, put the --data-dir on a filesystem
mounted nosuid,nodev. Docker exposes no per-bind nosuid/nodev controls, so
without that a root agent can plant a setuid binary through a writable bind
mount and have it survive on the host. See the security note on
ValidateMounts in internal/runtime/mounts.go.
Subscription quota reads
The read-only account.usage control method makes the server, not the
browser, read native Claude Code and Codex OAuth files from the selected
member home and call fixed vendor HTTPS usage endpoints. This is server-side
token use for status reporting, not credential extraction: Aether never copies
the credential bytes, refreshes or rewrites native OAuth files, or sends tokens
or provider response bodies to clients. API-key logins and unsupported
agents do not become quota collectors. Account selection uses the same
explicit directional grant as launches; administrators do not gain implicit
access, and membership/share authorization is checked before and after the
provider read.
Launches read the same cached quota server-side to decide on
usage fallback: for claude and codex runs,
the requested account's, and every candidate's only when that one is
exhausted. A run that hits its usage limit mid-run reads every candidate's. The read happens before the server takes its launch
authorization lock, so a slow vendor endpoint does not hold up other
launches; every account is authorized again under the lock, and a launch
never sends the figures it read to the client.
The dashboard gateways
The dashboard runs over one of two gateways, which share their handlers and
differ only in who they trust (internal/webgate is the shared core;
local-gateway.md). An edge serves no dashboard.
aether gui, on the user's own machine
aether gui serves the dashboard from the user's machine and proxies the API
shape over that machine's SSH connection to the linked server
(internal/localgw).
- It binds 127.0.0.1 and nothing else. There is no exposure flag; the listener is loopback or it does not exist. Nothing about the dashboard widens what the server listens on. A contributor testing on a phone can put the development proxy in front of it on a LAN address, which gives up this boundary for as long as that proxy runs; what that costs is spelled out in dashboard-frontend.md.
- Every request needs a token, loopback included. HTTP cannot identify
a member on its own and any local process can reach a loopback port, so
the gateway mints a bearer token per process (32 random bytes) that every
API and WebSocket request must carry - as
Authorization: Bearer, or?token=on WebSocket handshakes and the initial browser tab. The token dies with the process: there is nothing to revoke, and nothing survives a restart. - The full method map is reachable, not an allowlist. The identity is the member's own SSH key, held by the same process that serves the page, and the bearer token never crosses a network or lands anywhere shareable - it lives in one process and one local browser tab. A method call carries exactly the authority that SSH key already has from a terminal on the same machine, and every call still passes the same capability checks the CLI's calls do. There is no path by which the browser surface can exceed the person sitting at it.
/local/v1executes with the user's own filesystem and git authority - link config,git fetch/pushon the linked clone, systemd user units, scaffold files. That is the point of the surface: it does what the CLI does, for the person already at the keyboard.
The server's own listener, for tailnet devices
With web-port set, aether-server serves the same dashboard itself
(internal/servergw, networking.md). It is the
only HTTP listener the server has, and it exists only where a tailnet can
identify its callers.
- Tailnet addresses only, HTTPS only. It binds the host's tailnet addresses and nothing else, with the certificate tailscaled issues for the node's MagicDNS name. There is no cleartext port and no redirect, and a server that cannot fetch that certificate refuses to start.
- Identity is WhoIs, per request, with no token at all. Every call and
every WebSocket handshake is resolved through the same tailnet WhoIs
lookup and member mapping the SSH
noneauth uses. Nothing is issued to the browser, so there is no credential to leak, copy, or forget to revoke: losing the tailnet loses the dashboard on the next request. A tagged node is refused403, a failed lookup503. - A cross-site page cannot act as the member. With no token, the
browser's tailnet position is the whole credential, so the gateway refuses
any request whose
Originis not its own host and anyPOST /api/v1body not declaredapplication/json; a foreign page can neither send the simple request that skips the CORS preflight nor pass the preflight, which the gateway never answers. WebSocket handshakes apply the same origin rule. Both gateways enforce it. - Who the tailnet address vouches for. WhoIs names the owner of the
node the request came from, so any process on the server host that
connects to the host's own tailnet address is served as the node's owner,
usually the admin, with no credential; a device behind a Tailscale subnet
router arrives as the router node and is served as the router's owner.
SSH on
:2222has had exactly the same boundary since tailnet identity shipped; the dashboard adds no new one. Loopback, LAN and container addresses resolve to nobody and are refused. - The same capability checks, run by the same code. Each identified
member is served in-process through
internal/sshd'sLocalclient, which runs the handlers an SSH channel runs - pending gating, per-method capability checks, the steer check on a shell tab, and the same live revalidation. A request carries what that member's SSH session would carry, no more. - No machine-local verbs.
/local/v1does not exist here: nothing on the server is the caller's own machine, so there is no surface that would act as them on it. - The Android app adds nothing to this boundary. The APK on every release
(install.md) is a WebView on one origin. Of its
own it stores, in app-private preferences, the connection mode, the
server's address, the Edge gateway's loopback port and the update check's
state (when it last ran and last got an answer, that answer's JSON line,
since when the app has been behind, and the dismissed version), plus
files/update-resolv.conf, the DNS servers the update check uses. Beside that are the WebView's ordinary cache of the dashboard's files and the dashboard's local storage of view preferences: no token, no cookie jar it shares with anything, no key, and no JavaScript bridge into the app. What leaves the phone, and where, is privacy.md. HTTPS is pinned in three places, so there is no way to point it at a cleartext listener: the address screen refuses ahttp://URL, the app's network security config forbids cleartext for the whole process, and the WebView refuses mixed content. Certificate errors are never offered to the user to click through. A link or script navigation off the dashboard's origin,target=_blankincluded, is handed to the phone's browser instead of being loaded with the member's tailnet position behind it, and a scheme that is neither - anintent://URL that would start another app with page-chosen extras - is dropped. WebView does not run that check for a POST, so a form on the page could otherwise submit to any origin. Two more gates catch that.shouldInterceptRequestruns before the request is sent and answers an off-origin main-frame request with an empty response, so neither the form's fields nor the member's tailnet position reaches the other origin. The refused navigation still commits, on that empty document, soonPageStartedputs the dashboard back; it hands nothing to the browser, because anything arriving there was not a link the member tapped. Subresources are untouched: those are the dashboard loading its own files. Nothing the app stores leaves it through Google's cloud backup or through device-to-device transfer: the app opts out of both, naming every domain it can store in, because the backup agent walks each one separately. In a release build the page's console output is not written to logcat, where any app holdingREAD_LOGS, or a connectedadb, would read it. WebView Safe Browsing is turned off in the manifest. It matches each URL against a hash-prefix list held on the device and, on a match, asks Google about that 4-byte prefix - never the URL or the host. On a WebView that only ever loads the member's own server it protects nothing, because every other link goes to the phone's browser, which runs its own check; off is the setting under which nothing about a navigation reaches Play services at all.
Both
- The SPA files are served without identity. The bundle is not secret and
has to load before it can present anything; everything behind
/api/,/ws/and/local/is gated. - Live sockets are re-checked by the server, not by the transport. The server re-runs its capability checks on every call and on each subsystem channel, and re-checks live attach, terminal, event and sync channels every few seconds. A write attach that loses the steer capability is dropped exactly as a CLI attach would be - the terminal view falls back to a mirror - and a member removed or set back to pending loses every open channel within that interval. On the local gateway the token cannot be revoked out from under a socket, because it lives and dies with the process serving it. On the server gateway a socket keeps the member it was opened as; the tailnet is asked again on the next request or reconnect, and the server's revalidation is what ends an open socket when the membership itself is removed or set back to pending.
- Every socket is pinged every 30 seconds and closed when the pong does not arrive within 10. A phone that changed networks or went to sleep leaves a half-open connection that reads as live on both ends; without the ping it would keep holding a PTY client whose geometry clamps every other viewer.
Push notifications
A member's push subscription names a URL the server will post to (networking.md), and it arrives from that member's browser, so the server treats it as untrusted:
- Only an
httpsendpoint with a valid P-256 key and a 16-byte secret is stored, and a member holds at most 16. - The server connects only to public addresses. Loopback, private, link-local and carrier-grade NAT addresses, which cover the server's own host, its LAN and the tailnet, are refused when the connection is dialed, after DNS has answered. Proxy settings are ignored so that this check sees the real peer.
- A member can test, replace and remove only their own subscriptions.
push.testreturns the push service's status and the first 512 bytes of its answer, to that member alone.
The payload is encrypted to the browser (RFC 8291) and the request is signed
with the server's VAPID key, <data-dir>/push/vapid_key.pem, mode 0600.
That key identifies the server to the push services and grants nothing on the
server itself. No dependency was added for this: it is the Go standard
library's ECDH, HKDF, AES-GCM and ECDSA, checked against the example message
in RFC 8291. The worker the dashboard registers, /sw.js, has no fetch
handler, so it never sees a request or a response.
Terminal image uploads
terminal.image accepts image bytes, not a client path. The dashboard sends
only the bytes in a user-selected browser File (including an actual image
File from native paste), or the picture of a page element a member
annotated in a run's Browser, cut from the frame on their screen; a text
clipboard value that happens to be a local path remains text. There is no RPC that asks the server to read an arbitrary
client path. In a terminal, the upload action inserts the returned shell-quoted
path without pressing Enter or running a command.
The web gateway permits a 12 MiB request for terminal.image to leave room
for base64 and JSON framing. The server validates the decoded bytes as a
non-empty PNG, JPEG, GIF, or WebP image no larger than 8 MiB, then writes a
generated .aether/terminal-images/image-<random>.<ext> file with mode 0600
in the persistent member home the target container mounts as $HOME, not in a
workspace checkout or source tree. The returned absolute path is the path visible inside the target
container at its $HOME; the client cannot choose the destination or filename.
An upload with no run_id targets the authenticated member's running
environment. A run target requires Steer and writes into the home
that run's container mounts: its launcher's, also on a shared account.
These files follow member-home retention: stopping or resetting an environment
does not remove the home, so images remain until they are removed from that
home or the member is deleted. A member-home bind mount is not part of
env.save's Docker image, so terminal images are not copied into the saved
environment image. Like every file in that home, the images are readable in
each container that mounts it: the member's environment and the runs
they launch.
terminal.file takes any other file a member attaches to a run message on the
same terms: bytes only, the same targets and Steer check, a 96 MiB request
for at most 64 MiB decoded. The content is not inspected. The server writes it
with mode 0600 as .aether/terminal-files/<random>/<name>; <name> is the
original name with /, \ and unprintable characters replaced and leading
dots dropped, so it stays one component inside its generated directory. An
Enhanced agent receives the file's container path as an ACP resource_link,
not its bytes. Retention is the same as for images.
Chat image delivery and previews
Enhanced chat keeps validated attachment references separate from prompt text. The server reads the saved images into ACP image blocks only when delivering the prompt; agents without image support are refused rather than given a filename fallback. The conversation log keeps short room-message references, not image bytes.
Preview requests name a run, persisted message and attachment index. The server checks their association and current View permission, and repeats authorization checks while streaming. It resolves the stored reference only within the run's persisted home member, including after handoff or container shutdown. Reads reject arbitrary names, symlinks, hardlinks, non-regular files, invalid images and files over 8 MiB. MIME types come from validated bytes.
The dashboard fetches previews through the authenticated gateway and displays local object URLs; it never loads an arbitrary URI supplied by the transcript. Deleting the message or run, removing the home file, or losing View access prevents another download. Existing member-home image retention still applies.
Agent configuration and Files
aether config push uploads agent configuration from the computer the CLI
runs on (Upload agent configuration).
The dashboard shows only the prompt that asks a local agent to run it; it
reads no local files. The CLI reads the agent's root from config.roots
and refuses an unknown agent and any path outside the agent's directory.
The root's credential_names combines shared denials and the agent's
DenyNames; names match any path component case-insensitively. Those
names, *.pem, .env, and .env.* files, .aether-profile-ignore, and the
root's runtime_ignores (case-sensitive patterns anchored at the agent's
directory, each covering a path and everything below it, with * matching
within one name) are left out before the CLI reads a file. Symlinks are not followed, special files are skipped, and files over
64 MiB are never read. The CLI then runs the server's secret scanner on each
remaining file and does not send a flagged one; there is no override. None
of these client checks replaces server validation: the server enforces the
same credential, runtime, and .aether-profile-ignore policy and scans every
uploaded file again. A push does not replace a server file with different
content unless it is run with --overwrite, and the server refuses such a
file from any request that does not set overwrite; aether config cat
prints the server's copy first, subject to the same credential and runtime
policy. There is no watcher, automatic configuration synchronization, or
automatic retry.
A config.import request carries at most 2,000 files and 64 MiB decoded,
and each file at most 64 MiB; the CLI sends batches of about 20 MiB. A
request that fails after writing files returns the exact committed paths and
the error, and the server reports them even when a preflight failure
confirms zero writes. A call that returns no result has an unknown outcome;
the CLI reports that batch as unconfirmed and stops.
The shared HTTP gateway permits a 96 MiB request for config.import,
385 MiB for config.write and files.write, and 1 MiB for ordinary methods.
Decoded file and import-request limits remain authoritative. The authenticated
SSH control channel caps a JSON line at 96 MiB; the larger framing budget does
not remove decoded import bounds or filesystem validation.
Before reading an import body, each HTTP gateway admits at most two imports;
admission lasts through backend processing and the response. Excess requests
receive HTTP 503 without their bodies being read. Body reads expire after
30 seconds without progress or 15 minutes total and return HTTP 408.
HTTP admission, body, and JSON-validation refusals carry
error.data.config_import_not_started: true before backend dispatch.
A status code or message alone is not no-write evidence: backend or transport
errors, including HTTP 503 without that marker, retain an unknown outcome.
SSH control frames that grow beyond 64 KiB require admission: at most two
globally and one per member, held through dispatch and the response. Small
control requests do not consume those slots. Partial-frame reads have a
30-second idle timeout and a 15-minute total timeout. Expanded frames retain
the total timeout through dispatch and response; complete small requests do not.
Expiry closes the offending SSH connection, including its other channels, so
a peer cannot retain the large buffer by ignoring channel closure. Idle
channels between frames do not start a frame timer.
New files get the permission bits the request carries, 0644 when it
carries none; existing modes are preserved even when the server uses a
restrictive umask. The server rejects unsafe paths, symlink components,
hardlinks, and nonregular destinations, and retains directory and
staged-file ownership. Every config.* method requires the Launch
capability and targets only the authenticated member's own home; an admin
cannot select another member or account.
Files reaches the whole home, not only agent directories. Its Home
tree lists every file and folder under ~ and creates, renames, moves,
deletes, uploads, and downloads there through the home.* methods
(local-gateway.md). They
require Launch, target only the caller's own home, never follow a symlink,
and keep the hardlink and ownership rules above. The credential, runtime,
and secret-scanner exclusions apply to aether config push only: in Files a
member acts on their own home deliberately, as they can in its terminal.
home.upload has the 96 MiB request limit of config.import and
home.write the 385 MiB of config.write.
Credential files are listed in Files and locked: home.read,
home.download, and home.write refuse a path with a credential name of
any shipped agent, the name .env or .env.*, or a .pem suffix in any
component, anything under ~/.ssh except *.pub, and
~/.config/gh/hosts.yml, ~/.git-credentials, and ~/.netrc. The lock keeps credential bytes out of the browser and out
of downloads. home.rename refuses a locked file, and a folder that holds
one of those fixed paths, because a renamed copy would be served;
home.remove and home.upload work on a locked file, since neither returns
its bytes. It is not a boundary against the member: the Environment terminal
reads the same home, and a credential stored under any other name is not
recognized.
Uploaded and edited files are in the member's shared read-write home,
mounted into that member's environment and the active and future
runs they launch, including runs on a shared account. A member's account
share does not expose them, except the ~/.omp/agent directory an omp
share mounts; otherwise only the agent login path is shared
(Account sharing). The home is not a per-run isolated
configuration copy. A snapshot pin records
optional launch provenance, not an isolation boundary or a promise that home
edits wait for later runs. Uploads and Files edits do not create or
update CLI snapshot history; the HOME persists independently. Manual profile
push and rollback overlay snapshot files into that same HOME and leave paths
absent from the snapshot untouched. Rollback is not an exact-tree restore.
Files edits do not rebuild the installed-agent image.
The Files editor accepts complete UTF-8 text without NUL bytes up to 64 MiB. Binary and oversized files are read-only. Run and configuration saves recheck SHA-256 revisions immediately before atomic rename under Aether's root lock; base-branch commits compare-and-swap the branch head. These locks do not exclude arbitrary live-agent filesystem writers. A stale or failed save leaves the browser draft available. Later uploads do not replace open editor buffers; Reload from server explicitly discards a draft and reads remote content.
SSH into a run
ssh <run-id>.aether (terminal.md) gives a
member a shell, file transfer and port forwarding in a run's container. It
is a second door into the place a run shell already reaches, not a wider
one.
- Admission is the run shell's. The
aether-run-sshsubsystem asks exactly what a writable shell tab asks (developmentAuthorityininternal/sshd, then the scheduler's development authorization): current membership, Steer on the run, access to the run's backing account, and arunningorneeds-attentionrun that is not paused. The check runs again for every session and every forwarded connection, and every three seconds for the connection as a whole, which is closed once the member fails it or the run has finished or is being closed. It fails closed: a check that cannot complete, such as a store error, closes the connection when it happens three times in a row. A pause closes nothing: the connection's processes are frozen with the container, and it starts nothing new until the run resumes. A refusal carries the server's reason. Each connection a member opens to a run they have no other SSH connection to is stamped on the workspace timeline under that member, and refused when the timeline cannot record it. - A session in a swarm worker takes its hold. A person working in a
worker's checkout is the intervention the swarm takeover hold exists for
(coordination.md),
whichever door they came in by. Starting a shell, a command or a file
transfer over SSH commits the hold a writable shell tab commits, after
the admission above and before the process starts: the integrator's input
to that worker is refused until a person releases the hold, the way a
shell tab's is released (terminal.md). A
session whose hold cannot be recorded does not start.
Port forwarding alone takes no hold, and disconnecting releases none. The
server says so when the connection opens, and
aether ssh --stdioprints it on stderr; the Open in editor… dialog says it before that. - The run asks for no second credential. The member is authenticated by
the connection the
aetherCLI makes: tailnet identity, SSH key or edge device (networking.md). The SSH connection the member'ssshclient speaks is nested inside one channel of it and is reachable no other way, so it accepts any user name and no key. It presents the server's own host key, and the server names that key when it admits the connection. The CLI accepts the name only if it is the key it verified on its own connection, then writes it to~/.ssh/aether_known_hosts, which is whysshcan check it strictly without ever prompting. - No sshd, no port and no key in the container. The server speaks the SSH protocol itself and starts each session as an owned execution in the container, the way run shells start. Nothing listens for it in the container or on the host.
- The server picks the container. It resolves the run id to its supervised container after authorization; a client names no container, path, user or host. A session runs as that container's user with its filesystem, network, home and credentials, so what Remote development says of a run shell holds here: it is not a restricted sandbox, and it is not a way out of one. There is no path to a shell on the server host, to another run, or to a container the member could not open a shell in.
- Forwarding stays in the run.
ssh -L,-Dand-Ware accepted forlocalhostand loopback addresses only, and the connection is dialed by a process inside the run's network namespace (aether-internal dev-exec forward), never by the server. It cannot reach the host's loopback or another run. The run's Chromium companion (Remote development) shares that network namespace, so a forward reaches a loopback port the companion listens on exactly as a process in the run does; Chromium itself is driven through a pipe and listens on none. Remote forwarding, agent forwarding and X11 are refused. - A session owns its processes, for as long as the server runs. When a
session ends, its connection drops, or its authorization is withdrawn,
everything it started is stopped. A stop that does not land is tried
again, five times, and only a container Docker reports gone counts as
nothing left to stop; after the last failure the server logs
sshd: ssh execution left runningand the process stays. A frozen container refuses that stop, so for a paused run, and for a closed run whose container is kept, the server holds the stop and delivers it when the run resumes or is relaunched. The pending stop lives only in the server's memory. A restart while it is still owed, whether a shutdown, a self-update or a kill, forgets it: those processes thaw with the container, owned by nobody, and run until they exit or the container stops, even if the member who started them has since lost access. A shutdown waits for the stop of every open session in a running container to finish before it exits, and one that failed then is among the stops the restart forgets; a killed server stops nothing in any container. Only destroying the container, Free container… on a finished run, is certain to end them. - Bounded. A run takes 16 SSH connections, at most 8 of them from one member, so no member can keep the others out; a connection takes 10 sessions and 64 forwarded connections. Every process runs inside the run's container, under its CPU, memory and process limits. Output is never dropped: the server holds at most 256 KiB of each stream a client has not read, and past that the command blocks. An open SSH connection holds a scheduled server update back as an attached terminal does (install.md), one into a paused run included.
- Not recorded. An SSH session has no transcript and no control lease. Teammates see that a member connected, not what they did.
- The member's
sshneeds OpenSSH 9.6 or a build patched for CVE-2023-51385.sshruns theProxyCommandthrough the member's shell with the host name substituted in, and before that fix it substituted any name. On such a client a crafted*.aetherhost name, from a link or a git submodule URL, can run a command on the member's computer, as with anyProxyCommandthat names%h. The block quotes%h, which on those clients leaves a quote character in the name as the only way through.aether ssh --stdioitself refuses a host that is not a run id plus.aetherbefore it reads the link or dials anything.
An editor trusts the machine it attaches to. An editor attached over
SSH runs its own server on the remote and acts on what that server sends
back. Here the remote is a run's container, and the agent in that run
controls everything in it. The Remote - SSH extension's own Security Note
says to connect only to remotes you trust, because a compromised remote
could use the connection to execute code on your local machine; Cursor and
Zed attach the same way. So opening a run in your editor extends your
computer's trust to that run's agent and to whatever it has installed or
fetched. Attach to runs whose task and repository you would run on your own
machine, and use ssh or a run shell for the rest: a plain ssh session
gives the container a terminal and nothing more.
The desktop app lets one kind of link leave its window besides http and
https: the Open in editor… links, matched in full as
vscode://vscode-remote/ssh-remote+<run-id>.aether/workspace, the same
under cursor://, and zed://ssh/<run-id>.aether/workspace. Links in agent
output pass through the same filter, so the worst such a link does is open
an editor on a run the member can already connect to. Every other use of
those schemes is dropped.
SSH port forwarding
aether forward and the dashboard's OAuth forwards open channels on the
CLI's own connection, not through ssh. There, port forwarding is limited
to direct-tcpip channels whose destination is
run:<run-id> or exactly terminal. Run targets require the Steer capability
for the authenticated member; the terminal target resolves that member's own
live environment container and requires current membership. The server
revalidates that authorization while the channel is open and closes the tunnel
when membership or Steer is withdrawn. It resolves addresses itself and dials
only the requested container port. Arbitrary hosts and ports are not targets,
and reverse forwarding is disabled: global forwarding requests are denied.
The local dashboard recognizes OAuth authorization links whose redirect URI is an HTTP loopback address. It binds the matching local callback port before opening the authorization page, then forwards that port only to the terminal where the link appeared. Other links keep the normal browser behavior.
Edge remote access
The edge (edge.md) is an identity broker, not an authority. The server believes it about who signed in; whether that admits a new device is the server's access policy, and membership, role and device status are always the server's own. edge.md tabulates what a taken-over GitHub account and a compromised edge can do under each policy, with the tests that show it; the reasons follow.
- A server uses an edge only when its operator chose one.
edge-urlis empty unless the config file or a flag names an edge.aether-server setupis the only command that sets it without being told: on a host without tailscaled it turns on the project's edge and prints what that edge sees and how to turn it off; with tailscaled it asks, defaulting to no. An upgraded server whose config never named an edge stays off it and discloses nothing (TestIntegrationUpgradeFromMainWithoutTheEdge). - The server trusts an edge by its signing key, not its host name. It
keeps one pinned edge key, whatever
edge-urlnames, and its owner per edge key. A new host name of the same edge keeps both. A different key, at any host name, is refused untilsudo aether-server edge trustpins it after a person compared fingerprints; the server then has no owner for that key and reports itself ownerless to an edge that records one. Changingedge-urlalone never makes the server trust another key. - SSH is end to end. The relay splices bytes between two outbound WebSockets and never holds a key that could decrypt them. Clients check the host key against the server id on every path, so an edge cannot pose as a server.
- Grants are checked by the server. Each relayed connection carries a grant signed by the edge key the server pinned at enrollment. The server checks the signature, its own id, the connection id and a 60-second lifetime, and refuses a connection id it has seen. The key the client offers must be the grant's device key, so a stolen device token alone authenticates nothing.
- Membership stays on the server. The server maps the account to a
member itself and checks the device. A compromised edge can forge a grant
for any account, but that account gets in only as a member or through an
open invitation the server holds. The SSH user name of a relayed
connection names the account the device signed in as, under the device's
own signature, and the server refuses a grant naming another before it
records anything; so an edge cannot file the key of a device it relays
under another account unless it also lied to that device at sign-in. Under
approved-devicesa grant for an invited account changes nothing on the server but a device waiting on the invitation: no member, role or bound account exists, and the invitation stays open, until a person approves that device. Approving it creates the member and uses the invitation in one transaction; the other devices waiting on it are deleted. - The access policy decides what a grant is worth. Under
edge-access account, a grant for a member's account admits a new device of that member: an attacker gets in when either the member's GitHub account or the edge is taken over. Underapproved-devices, every new device key, a member's first included, waits until a person approves it: the member from an approved device, SSH key or tailnet connection, an admin, orsudo aether-server device approve <code>on the server. An attacker then also needs an approver to type the code their device shows. The policy is set only on the server's host, and a missing setting meansapproved-devices. Under either policy a device that only signed in cannot mint an invite code, a bearer credential that would outlive a switch toapproved-devices. Neither policy assumes OAuth is weaker or SSH keys stronger; they differ in how many parties must fail. - An approved device key is a credential on its own on the direct path. The direct SSH port admits approved device keys, under both policies, without asking the edge, as it admits a member SSH key. Through the edge the key also needs the device's token or the edge.
- Approval codes are typed, not listed. The code is derived from the
device key and shown to that device inside SSH;
aether device listnever carries it, so approving proves the approver was handed the code of the device in front of the person. Approve only a code read from a device you are holding. Two keys can derive one code; such a code approves neither, andsudo aether-server device reviewapproves by choosing the device instead. An approval from a connection that signed in with an unapproved device is refused under either policy. - An approver sees what a code admits before approving. The member a
waiting device belongs to follows from the account it signed in as,
which the edge vouches for, so a compromised edge, or someone holding a
member's GitHub account, can make a code admit their own key as that
member.
aether device approve,sudo aether-server device approveand the dashboard first look the code up (member.device.lookup), show the device, the account, and the member and role approving admits it as, and approve only after a yes;member.device.approvetakes the device id the lookup returned and refuses a code that names another. Someone handed a code that admits their own member, or an admin, should refuse it. - Waiting devices are bounded. A relayed connection with a new key
records a device. The server keeps at most 10 pending devices per account
and 10 per invitation, and refuses more with a banner naming
aether device listandsudo aether-server device review. - Enrollment signatures cannot become host signatures. The host key
signs
aether-edge-enroll-v1\x00, the edge origin, the server id and a nonce. That message is longer than any SSH exchange hash, so an edge that collects it cannot use it in a handshake. - Claims travel inside SSH.
aether link --claimopens a claim connection through the edge and offers the code, with the account this device signed in as, in the SSH user name. An SSH client sends the user name only after the key exchange has checked the host key, and the client accepts only a host key that derives the server id in the code, so the code reaches that server encrypted and nothing else. The client's SSH signature covers the user name, and the server refuses a claim whose grant names another account before it tries the code. The edge relays the bytes without reading them; to take a claim it would need the code, which only the server's console printed. An edge that also lied to the client about who signed in could make the claim for its own account: the person then has to notice the account shown byaether login,aether link --claimand the dashboard's claim form, which is the one the edge reported, before the code is sent. - Console recovery restores an existing admin and nothing else.
sudo aether-server edge claim-code --admin <member id>issues a code whose claim binds the claiming account to that admin and approves the claiming device. The code is refused for a member who is not an admin, when issued and again when used; it creates no member, raises no role, and cannot move an account that is another member's. The server logs each use. - One account of a member can be removed. An account linked to a
member while someone else held it is unlinked with
aether member unlinkby the member or an admin, from a credential a person approved: the devices that signed in with it are revoked, not deleted, so their keys stay refused if the account is linked again. - Sign-ins are joined only by proof of both. Nothing joins two
sign-ins because an email, login or name matches.
aether member linkbinds a GitHub account to the caller's own member only once a device signed in with that account opens the join link, is toldThis links your github account <login> to the existing member <name> (<id>), role <role>; you will act as that member., and the member approves that device, under either policy; a member has one open link at a time. A member merge takes both sides: a code minted on one sign-in, shown only there, kept as a SHA-256 hash, 80 bits, used once within 10 minutes; a proposal from the other sign-in; and acceptance back on the first, which recomputes the plan under the lock that member and role changes hold and refuses one that differs from the proposal. No step is taken from a device no person approved, under either policy. The merged member never gains a permission: it takes the lower role, and stays admin only when the admin side asks for it, which an admin can already grant. When it is no longer an admin, the invitations, links and invite codes either member created are revoked (an invite code from an older server, which did not record who minted it, lasts until it expires), and a merge that would leave the edge owner's account on a member who is not an admin is refused. The absorbed member stays as a tombstone that authenticates nothing, so history keeps naming it. A merge keeps an audit record with every setting it discarded and moves the absorbed home, live agent logins included, to<data-dir>/merged/<merge id>/, owner-only and outside every member's home and Files view, for the machine's administrator to remove. A merge code you did not ask for should not be entered: it proposes joining your member with the code's. - The first link decides which server a client pins. Every later
connection must present a host key that derives the linked id. An id
from the server's admin, or from
sudo aether-server edge status, rules out being sent elsewhere. An id fromaether serverscomes from the edge, and an edge that lists a false id can send that first link to another server;aether link --from-edgeand the wizard show the id and host key fingerprint and ask before they pin it. - Device tokens go to two origins only. A token is sent to the relay origin it is stored under and to the sign-in origin that issued it, never to an origin the edge's metadata names later, and the client follows no redirect.
- The edge stores bearer secrets hashed (device tokens, session
cookies, device codes) and rate-limits sign-in, device
codes and claim connections per address; claim connections also per
account and per server. An IPv6 address counts against its /64,
its /56 (4 times the budget) and its /48 (16 times), so one allocation
cannot spend more than its /48's share; a full limiter evicts the block
with the most budget left instead of refusing new addresses. Device-code
entry is also limited per account. The address a relayed connection came from is for
logs and rate limits only, never for authentication. Behind a reverse
proxy the edge reads the address from the right-most
X-Forwarded-Forentry, and only when the connection comes from a loopback address. With--trusted-proxiesit takes the right-most entry outside the named networks, only from a peer inside them, and refuses every other peer, loopback included; a list that trusts every address is refused. A request whose header is missing or not an IP address is refused. The packaged nginx configuration sets the header to the address nginx saw, discarding the client's. - Sign-in and relay are two host names. The sign-in cookies are
__Host-cookies with noDomainattribute, bound to the sign-in host alone, and each host refuses the other's paths. A server's ownership is recorded only from that server's own report on its control connection, for the account whose claim connection presented the code; no client request or operator command records an owner or approves a device. - Deleting an account needs a fresh sign-in in the same browser. The edge deletes an account only on its Account page, from a browser that itself signed in with GitHub in the last 5 minutes, with the login typed back. A device token cannot delete it, and neither can a session cookie from a browser that has not signed in since, even after the person signs in elsewhere. Deleting takes the person's GitHub sign-in, or a cookie stolen within 5 minutes of its sign-in.
- Invitations match only a recently confirmed login or email. The edge
learns an account's GitHub login and verified email only when that person
signs in with GitHub in a browser. Signing in takes the login from
any other account that held it, but someone who renamed on GitHub, or
whose email moved to another account, keeps the old value until they
sign in again. So an invitation matches an account only within 24 hours
of its last sign-in: the edge refuses such an account with
your login and email were last confirmed over 24 hours ago; open this edge in a browser to confirm them, then retry, any edge page opened later asks GitHub again, and the server checks the same age in the grant, which carries when GitHub last confirmed the account. Within those 24 hours an old login or email can still match. Members match by GitHub's immutable user id and are unaffected. - Recovering a GitHub account removes nothing at the edge or on a server. Device tokens do not expire, so devices signed in while the account was taken over keep working until revoked (edge.md).
- Only self-hosted servers exist. The protocol reserves a
hostedkind for a workspace Aether would operate. Such a workspace would not be protected end to end against Aether's operators, who would hold its host key and data (edge.md).
What the edge operator does see: server ids and host names, account identities, device labels, client addresses, timing and byte counts (privacy.md).
Conflict coordination
Each container may receive a run-scoped unix socket that runs in one
workspace use to coordinate, for example when the radar finds two of them
editing the same file. Detail is in docs/coordination.md (host side and
wire) and docs/mcp-bridge.md (the optional in-container bridge); the
operator-facing stances are these.
- Binary availability is not run identity. The canonical
/usr/local/bin/aether-internalCLI is an Aether-provided, version-matched executable available in managed containers, and the staged server binary may also be present for the optional bridge and lifecycle plumbing. Neither path authenticates a caller. The socket at/run/aether/coord3.sockis the run identity: a connection accepted there is treated as that run. An identity-less environment or container can use general help or non-run skill guidance, but status, messaging, reporting, and swarm operations are unavailable. - The mount is the authentication, so no token enters a container. Each run
gets its own socket; there is nothing inside the container to steal, and
nothing to rotate. The host-side modes (
0700on the coordination root,0755on the per-run directory,0666on the socket,0444on the config and the co-author list,0555on the staged binary) are a contract with a semi-trusted container that may not run as root - they are not the access control. Both container paths are reserved:runtime.ValidateMountsrefuses any caller-supplied mount that targets or nests under them, so a credential home cannot shadow either. - Disabling coordination still disables coordination. With
--conflict-coordination=false, the read-only canonical CLI mount remains available, but no usable run socket, borrowed run identity, or MCP bridge is available. Run-bound CLI calls and bridge calls return unavailable; the identity-free CLI can still provide general help and non-run skill guidance. The overlap radar remains active. - The run socket exposes no general control verbs. Its six advertised
coordination methods are
coord.status,coord.send,coord.inbox,coord.ask,coord.reply, andcoord.report. Native hooks also use the internal read-onlycoord.hook.statusendpoint, with the same run identity and status authorization but an independent bounded request budget. Malformed envelopes and unknown methods still consume the ordinary budget; the hook endpoint cannot dispatch mutations. A swarm-assigned run additionally receives only the current assignment'stask.*andworker.*methods over that same run-authenticated socket; those methods are not a general control API. There is norun.kill, no Git access, and no other run's transcript. Messages are capped at 4 KiB, rate-limited per run, bounded at 100 unread per inbox, and every one is recorded on the workspace timeline. The optional bridge is manual and still exposes only its six existing tools; it is not automatic registration or a Release B mission/worker interface. - Three routes authorize a new conversation, none across workspaces.
coord.sendandcoord.askare accepted when the target is a current swarm assignment peer, a radar active/grace overlap peer, or a live run in the sender's workspace that the sender run's owner may message. Every route first requires the sender run's owner to be a current member who is not a viewer. The sender and target must share a workspace on every route, and a finished target is refused.coord.replyis authorized by the question it answers. - Messaging is its own permission. The workspace route checks the
messagecapability for the sender run's owner against the target run. Admins may message any run; a viewer's runs may message no run on any route; collaborators may message any run unless it is protected, and a protected run (aether protect) accepts workspace-route messages only from its owner's runs and admins' runs.steer_others=admins_only(the message-others policy) limits workspace-route messages to another member's run to admins' runs the same way. The swarm and radar routes check neither protection nor that policy. The owner's role and the workspace policy are re-read on every send and status, so a demotion applies to the next message.coord.statusomits runs the sender may not message. - A message grants nothing else. It is an attributed inbox item
(
from_run_id), never an instruction. It confers no steer, kill, input injection, terminal, transcript, Git, secret, or task and worker authority over the recipient, and the socket's method set stays closed. - A run can widen its own peer set, and the cap is what bounds it. A run that touches every tracked file is reported as overlapping with every other run in the workspace, and the workspace route already reaches every live run its owner may message. The server limits each run to 8 distinct correspondents opened over the radar and workspace routes instead of trying to infer intent. Swarm assignment peers are server-derived and do not count, so an integrator reaches every worker it dispatched. A run already sent to stays reachable at the cap. Read this as defence in depth, not a boundary: runs in one workspace already share a repository, so influencing each other through file contents needs no authorization at all. Turn the feature off if that is not acceptable.
- The staged bridge binary is the server's own binary. It is mounted
read-only at
/opt/aether/aether-serverso a container can run the optional MCP bridge without shipping an extra artifact. A container therefore holds a copy of the server's code and can run its available subcommands. This grants nothing new: the binary carries no credentials, reaches no host state that the container was not already given, and the isolation is still the container, exactly as in "The agent container" above.
Sysbox install
When an admin presses Install under Settings > Server
(server.sysbox.install), the server runs its embedded
install-sysbox.sh as root, the user its systemd unit runs as. That is the
same installer sudo aether-server sysbox install runs, and it is part of the
signed release binary: the client sends no script, URL or version. It
downloads the pinned Sysbox package, verifies its SHA-256 and installs it with
apt-get, in a transient systemd unit named aether-sysbox-install when the
host runs systemd; it may also add bip and default-address-pools to
/etc/docker/daemon.json. A server that does not run as root answers off and
names sudo aether-server sysbox install. While it runs, a server update
is refused with a conflict error. Only admins can call either Sysbox
method. An admin can already run arbitrary code on the Docker daemon, so
this adds root only for this one script.
Release signing
GitHub release assets are mutable: anyone who can write to the repository's
releases can replace a binary and the checksums.txt beside it. Updaters
therefore trust a signature, not the release page. Each release carries
aether-release.json (version, commit, store schema, and every asset's
SHA-256 and size) and aether-release.json.sig, an ed25519 signature over
the file's exact bytes. Every updater (aether update, the dashboard's
Update now, aether server update) verifies the signature against the
public keys compiled into internal/selfupdate/keys.go, requires the
manifest's version to equal the tag it installs, so an old signed manifest
cannot be replayed under a new tag, and checks every download's size and
SHA-256 against it. install.md shows the
same check done by hand.
The private key is a 32-byte seed, base64-encoded, held only in the
AETHER_RELEASE_SIGNING_KEY Actions secret. Only the final release job
reads it, after the publisher and exact-main-CI gates. The generator refuses
to write a manifest the tag's own trusted keys would reject, so a wrong
secret fails the release, not the clients.
Key rotation. Clients trust any key in the list, and a client only learns a new key by installing a release that carries it. The generator also refuses a seed whose public key is not in that release's own list. To rotate:
- Generate the next key pair offline and add its public key to
releaseKeysininternal/selfupdate/keys.go, beside the current one. - Publish that release signed with the current key. It is the bridge: every client accepts it, and it trusts both keys.
- Replace the
AETHER_RELEASE_SIGNING_KEYsecret with the new seed and remove the old public key fromreleaseKeys. Every later release is signed with the new key.
A client older than the bridge rejects every later release. Install the
bridge on it first with aether update --version <bridge tag> (or aether server update --version <bridge tag>), then update normally, or reinstall
it with the install script.
If the current key leaks, run the same steps at once: the bridge has to be signed with the leaked key, because no client trusts anything else, and the next release drops it. A key cannot be revoked remotely, so a client that has not installed that next release still accepts a manifest signed with the leaked key from anyone who can also write to the releases.
Server self-update
aether server update (see install.md) lets an admin
replace the running server's own binaries and restart onto them, from their
laptop, with no shell on the server box. That is inside the existing trust
model, not outside it: an admin can already run arbitrary code on the
server's Docker daemon through environment builds, so choosing which release
binary runs grants nothing new.
What bounds it: the version a client supplies is validated as a release tag
vplus semver - and only ever names a release in the pinned3xDevOps/AetherGitHub repository; the client can never supply a URL.
Both binaries are downloaded and verified against that release's signed
manifest (Release signing) before either is replaced, and each is then renamed into
place from a staging file in its own directory. So a bad tag, a network
error, or a checksum mismatch leaves both binaries exactly as they were.
Only the renames at the end could leave aether-server updated and the
aether beside it not, and a rename within one directory fails only when
the filesystem does; the recorded failure then names which binaries were
already replaced. aether server update --status shows it.
Client self-update on macOS
The dashboard's Update now button runs from aether gui, an
unprivileged process. On macOS it can still replace a CLI in a directory
the user cannot write, such as /usr/local/bin
(install.md,
local-gateway.md). No Aether code runs as
root; root is left exactly one thing to do.
When the dialog is offered. Only when the directory is not writable by
this user, only root can write the binary's directory or any directory
above it, this is macOS, and the gateway is in a GUI session. Otherwise the
banner shows sudo aether update. The full rule, and why the privileged
command depends on it, is in
local-gateway.md.
What runs as root. One sh command of system tools, addressed by
absolute path so nothing is looked up on root's PATH, with the staged
file, the destination and the release digest baked into its text
(internal/macinstall). For /usr/local/bin/aether it is, verbatim:
set -e; t=$(/usr/bin/mktemp '/usr/local/bin/.aether.update.XXXXXX'); trap '/bin/rm -f "$t"' EXIT; /usr/bin/install -m 0600 '/Users/you/Library/Caches/aether/update/.aether.update-123456789' "$t"; h=$(/usr/bin/openssl dgst -sha256 "$t"); [ "${h##* }" = '<sha256 of the release asset>' ] || { /bin/echo 'copied binary does not match the release checksum' >&2; exit 65; }; /bin/chmod 0755 "$t"; /bin/mv -f "$t" '/usr/local/bin/aether'Only the staged file's name and the digest vary. /usr/bin/osascript -e 'do shell script "<command>" with prompt "<text>" with administrator privileges' runs it, with the environment
PATH=/usr/bin:/bin:/usr/sbin:/sbin, LANG=C, and HOME, working
directory /, and nothing else from the user's shell - no TMPDIR, no
DYLD_*, no Homebrew PATH. The command is decided before the user is
asked and cannot change after: what the dialog authorizes is that text.
Three checksum checks, in three places. The gateway downloads the
release as the user and compares it with checksums.txt before anything
is staged. Root hashes its own copy and exits 65 on a mismatch, so a
staged file swapped while the dialog is up installs nothing. The gateway
then re-reads the installed file as the user - a regular file, mode
0755, root-owned, the release digest - before it rebuilds the desktop
app or exits; a mismatch there is reported as installed /usr/local/bin/aether does not match the release checksum; do not run it.
0600 until verified. Root copies into a temp file in the destination
directory, which the user cannot write, and keeps it 0600 until the hash
matches. A staged file replaced with a symlink to a root-only file would be
copied, fail the hash, and be removed without ever having been readable.
The final step is mv -f within one directory: atomic, and a running
aether keeps its old inode.
The staging directory is private. The download goes to
<user cache>/aether/update (~/Library/Caches/aether/update), created
0700, and refused when the path is a symlink, not a directory, or owned by
another user, because root reads from it. install copies the staged file;
root never executes or renames it.
No password passes through Aether. The dialog is macOS's own; the
password goes to the system's authorization service and is not read,
stored, piped, or logged by any Aether process. macOS asks on every click:
each click runs a new osascript process with a new command text (the
staged file's name differs), and Apple's TN2065 says the authentication
applies to that specific script text. Nothing is cached across clicks by
Aether or by the system for this right - system.privilege.admin is not
shared across processes.
Why the dialog says osascript. The dialog is titled osascript
because the app is built locally and unsigned. A dialog in Aether's own
name needs a privileged helper installed through SMJobBless or
SMAppService, and both require a Developer ID signed helper. Aether's
prompt text sits beneath the title and says so, naming the file and the
version being installed.
What a same-user process can still do. A process running as the same
user can write the staging directory and can kill or block the gateway.
That lets it make the root step fail - a swapped staged file fails the
hash - or stall it, by putting a FIFO where the staged file was so root's
install blocks on the open. Both are denial of service against this
user's own update, not escalation: nothing that process does can make
root install bytes other than the ones whose digest is in the command text.
That rests on the root-only path rule above: the directory root writes
into, and every directory above it, is root's alone
(local-gateway.md).
Dependency and toolchain vulnerability scanning
make vulncheck runs govulncheck over the whole module. CI runs it in the
lint job. A docs-only pull request does not run it, because it
does not change module dependencies.
The step is advisory (continue-on-error: true), not a gate. Two reachable
Moby CVEs in the Docker SDK (GO-2026-4887, GO-2026-4883) have no fixed
release, and govulncheck has no way to suppress an individual finding, so a hard
gate would leave CI permanently red and train everyone to ignore it. Read the
step output on each PR instead: anything beyond those two Docker findings is new
and should be fixed or explicitly accepted here.
The SSH dependency golang.org/x/crypto must be at least v0.56.0 to fix
GO-2026-6303, GO-2026-6354, and GO-2026-6355. These fixes require
Go 1.26; the module and toolchain requirements in go.mod must not be
downgraded independently of the dependency.
The Go toolchain is part of the attack surface. go.mod carries a toolchain
directive alongside the go directive so that CI, which selects its Go version
from go.mod, builds release binaries with a patched toolchain rather than the
oldest version the module happens to be compatible with. Bump the toolchain
line whenever a Go patch release fixes a standard-library CVE.