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. A harness 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.
Each member's persistent home is mounted only into that member's environment
terminal and runs that use their agent account. Account sharing is the sole
exception: aether account share <member-id> lets that member launch runs with
the owner's home, saved image, configuration, custom harness definitions, and
vendor login. This is equivalent to handing them every credential and file in
that home.
The authenticated launcher remains the run owner, and the run's commits are
authored as that member's git identity, not the account owner's; usage and
cost are attributed to the selected account.
Revoking a grant blocks later launches and relaunches. It does not stop an already-running container or remove the home mounted into it. Stop those runs before revoking access when immediate removal matters.
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 steered 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.
GitHub credentials and signing keys
Connecting GitHub (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 environment terminal 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. Account sharing
hands the recipient's runs the same reach, exactly as it does every other
credential in the home.
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: run gh auth logout --hostname github.com in the environment
terminal, 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.format
aether env reset does none of this - it forgets the saved image and never
touches the home.
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.
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.
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: an agent/run actor cannot approve its own
delivery, and an optional mission identifier is context rather than a
permission grant.
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 selected account's credentials.
The trusted shared-home rule still applies: aether account share gives the
recipient's runs the owner's home, saved login, signing key, and other files.
Do not use account sharing to imply 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
harnesses 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.
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).
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. The only
thing of its own it stores is the server's address, beside 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 a
http://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.
Browser configuration imports
The local onboarding importer reads a directory only after config.roots
returns and a destination is known. Credential names and *.pem files are
filtered before any browser read, while runtime/history paths use the selected
root's runtime_ignores; the server remains authoritative and scans all bytes
that survive those local exclusions. Unknown or ambiguous directory basenames
must be assigned explicitly, and changing the destination re-reads the retained
browser File handles. Generation guards discard stale reads and prevent a
preview prepared for one destination from being submitted to another. The
server-hosted dashboard cannot read a directory on the user's machine, so this
surface exists only in aether gui.
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); 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, and the upload action only inserts the returned shell-quoted path
into the focused terminal - it does not press Enter or run the 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 target account's persistent member 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 terminal. A run target requires Steer and writes into that run's
account member home, including the owner's home when the run uses an explicit
account share.
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. Account sharing therefore has the same implication as for
other home files and credentials: a recipient's run can read images in the
shared account's home.
Browser configuration and Files
The local dashboard's onboarding directory picker is an explicit, one-time
browser import. A server-hosted dashboard has no laptop directory picker; use
aether gui for this step. The browser waits for config.roots and a known
destination before previewing or reading bytes. A known unique basename selects
its destination automatically; an unknown or ambiguous basename requires an
explicit choice. Credential names in any path component and *.pem files are
always skipped before upload. Runtime/history paths come from the selected
root's runtime_ignores metadata and match exact, root-relative paths or
component prefixes case-sensitively after trailing slashes are trimmed.
Changing the destination clears the old preview and re-reads retained browser
File handles; generation guards discard stale reads. It reads remaining
selected regular-file bytes and sends them to the server, where they are
scanned before writing; a secret finding is therefore not proof that the
content stayed local. Empty files and arbitrary binary regular bytes are
preserved under the 1 MiB/file, 20 MiB decoded aggregate, and 2,000-file
limits. The shared HTTP gateway permits a 30 MiB request for config.import,
128 MiB for config.write and files.write, and 1 MiB for ordinary methods.
The 64 MiB editor file limit remains authoritative after JSON decoding. These
are framing limits, not larger decoded import allowances. The SSH control
channel still caps one JSON line at 32 MiB.
Browser metadata is intentionally limited. New imported files are 0644;
existing modes are preserved even when the server uses a restrictive umask.
The browser cannot preserve executable mode or symlinks. 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.
The imported and edited files are in the member's shared read-write home, mounted into that member's environment terminal and runs, including active runs. An account share grants another member's run that same home; it is not a per-run isolated configuration copy. A snapshot pin records launch provenance, not an isolation boundary or a promise that home edits wait for later runs. 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.
SSH port forwarding
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.
Conflict coordination
When two runs edit the same file, each container may receive a run-scoped unix
socket for coordination. 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 terminal or container can use general help or non-run skill guidance, but status, messaging, reporting, and mission 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 base socket exposes six coordination methods and no control verbs.
coord.status,coord.send,coord.inbox,coord.ask,coord.reply, andcoord.reportare the completecoord.*wire set. A mission-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. - A run can widen its own peer set, and the cap is what bounds it. For ordinary runs, the overlap that authorizes a message is computed from the two runs' own diff snapshots, so a run that touches every tracked file is reported as overlapping with every other run in the workspace. Mission assignments instead provide a server-derived peer set that may authorize active integrator and worker runs before file overlap; neither set is caller-selected, and both remain bounded. The server limits each run to 8 distinct correspondents instead of trying to infer intent from a wide refactor. 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.
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
checksums.txt 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 on every
PR in the build-and-test job.
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.


