Dev containers
A dev container has its own home directory. The Watcher client installed on a developer's machine is not present inside it. Without the setup on this page, agent sessions in the container are neither reviewed nor recorded. Nothing warns you.
This page assumes a developer at the keyboard. It assumes a container that is rebuilt often. For unattended containers in CI or an agent fleet, which authenticate with an organization API key instead, see Containers.
The example assumes processes in the workspace are trusted. It does not stop them from reading Watcher credentials. It does not stop them from changing hooks or stopping Watcher. See Hardening dev containers for stronger deployment options and their limits.
Host sign-in
The container reuses the machine's Watcher sign-in when one exists. If the
credentials directory does not exist, the template creates an empty directory
before mounting it. The container still starts. However, its setup script warns
that Watcher's sign-in check did not pass. Sign in before relying on monitoring:
open http://localhost:8228 in a browser on the host. Alternatively, run
watcher login in the container where no browser can reach it (see
Signing in without a browser). The new credentials are
stored in the host directory. They remain available after rebuilding the
container.
The template's mounts and lifecycle commands assume a macOS or Linux host.
The template
Two files, meant to be copied into a repository as .devcontainer/:
devcontainer.jsondeclares the mounts, the forwarded client port that browser sign-in uses, and the lifecycle commands.watcher-devcontainer.shinstalls the client and its agent hooks, starts the client detached, and waits until it answers.
The image in the template is a bare base with no coding agent in it. The normal
path is to merge these entries into the repository's own devcontainer.json.
That file is where the agent comes from. Copying the template wholesale gives
you a monitored container with nothing to monitor.
Merging means adding the containerEnv, mounts, and forwardPorts entries.
Preserve any existing initializeCommand, postCreateCommand, and
postStartCommand values. When the existing commands do not use Watcher's or
the coding agents' mounted directories, use the object form, which runs them in
parallel and keeps both:
"initializeCommand": {
"repo": "<existing initialize command>",
"watcher": "<Watcher initializeCommand from the template>"
},
"postCreateCommand": {
"repo": "<existing post-create command>",
"watcher": "bash .devcontainer/watcher-devcontainer.sh"
},
"postStartCommand": {
"repo": "<existing post-start command>",
"watcher": "bash .devcontainer/watcher-devcontainer.sh"
}
The object form works for every lifecycle command field. If one command must finish before the other starts, run them in order. In particular, if the repository's post-create setup installs or configures Claude Code or Codex, its first access to the agent directories must follow Watcher's ownership repair:
"postCreateCommand": "bash .devcontainer/watcher-devcontainer.sh --prepare-only && <existing post-create command> && bash .devcontainer/watcher-devcontainer.sh"
The prepare-only invocation repairs ownership and seeds local settings without installing or starting Watcher. The full invocation repeats the repair, then installs Watcher and its hooks after the repository has installed its agents. For other ordering dependencies, call the commands from a wrapper script in the required order.
The setup script takes ownership of its own mount points, and that work belongs
there rather than in an onCreateCommand. Two reasons, each of which costs a
container that looks healthy and is not monitored. A lifecycle command that
fails skips every later one, so a single unwritable path stops Watcher from
installing at all. And onCreateCommand does not always run: when the base
image is not yet cached on the machine, the dev container tooling can pull it
and go straight to postCreateCommand, so the first run on a new machine is the
one that breaks.
Three things to set before distributing it:
| What | Where | Notes |
|---|---|---|
WATCHER_API_URL | containerEnv | Your deployment. Its hostname must match the one the machine is signed in to, since credentials are stored per backend hostname. |
WATCHER_CLIENT_VERSION | containerEnv | Pin the client release matching your server, as in an MDM rollout. Empty takes the newest published release, which is not the same as the version an MDM fleet runs if that fleet is on a pre-release. |
| The container user | mounts | The mount targets assume /home/vscode, the default user of the mcr.microsoft.com/devcontainers/* images. |
The Linux client is published for x86_64 only, so on an Apple Silicon Mac the
container runs under emulation: uncomment the runArgs line in the template.
Emulation costs speed; it does not change behavior.
What is shared with the host, and what is not
A developer signs in once, on their machine, and never again per container or per rebuild. That takes one directory shared with the host.
| Path | Where it lives | Why |
|---|---|---|
~/.apollo_monitor/credentials | Bind mount from the host | The sign-in. Shared live so the container starts authenticated. |
The rest of ~/.apollo_monitor | Named volume, private to the container | Settings, session and decision history, and the client's lock and health files. Survives rebuilds. |
The coding agents' configuration (~/.claude, ~/.codex) | Named volumes, private to the container | Their sign-ins and Codex's hook confirmation, kept across rebuilds. |
Why the credentials are shared and not copied
A live share keeps one sign-in true for every client that reads it. Signing in again on the machine reaches the container immediately, with no rebuild, and there is no second copy to go stale or to be left behind when the first one changes.
It also keeps you clear of refresh-token rotation. If your deployment is configured to hand back a new refresh token each time a client uses the old one, and to retire the old one as it does, then independent copies fight: whichever client refreshes first retires the token the other is holding. Clients reading one shared file never meet that, because there is only ever one token to rotate.
Share the directory, not the file. The client writes credentials by renaming a new file into place, which a file-level bind mount does not survive.
To sign in again on the host, use Sign in to Watcher in the macOS menu bar,
open http://localhost:8228, or run watcher auth login (alias:
watcher login). The terminal command uses
device-code sign-in. The container picks up the
credentials on its next read.
Re-seed the file, never the directory. Deleting
~/.apollo_monitor/credentials on the machine detaches every container already
running, permanently: the container's bind mount still points at the directory
that was deleted, so it sees an empty one whatever you put back. Those
containers need rebuilding. Rewriting the file inside the existing directory,
which is what signing in again does, is picked up fine.
Why the rest of the state is not shared
The rest of ~/.apollo_monitor assumes one client per directory, as
Containers describes, so the
template gives the container its own copy. It is a volume rather than nothing,
so a rebuild does not discard the container's session history.
Do not bind-mount the agent's configuration from the host
Do not bind-mount the coding agent's configuration directory from the host on a
machine running Watcher, even though that would carry the agent's sign-in over.
The agent hooks are recorded in that directory as command lines holding the
absolute path of the client binary, and the host's path does not exist inside
the container. Mounting the host's directory gives the container hooks that
cannot run, and the container's own watcher hooks install then rewrites the
file with container paths, which breaks the hooks on the host. The template
keeps that directory on a volume, so you sign the agent in once per container
and that sign-in survives rebuilds.
Starting the client
watcher autostart uses a macOS login agent or a Linux systemd user service. A
dev container has neither, so the template starts the client from the
postCreateCommand and postStartCommand lifecycle commands instead: the first
covers container creation, and the second covers every later start. When the
client is already healthy, the script refreshes the hooks without starting a
second process.
Two details in the script matter, both covered in more depth under Containers:
- The client is started with
setsidand no stdin. A dev container has a terminal, and a background process still attached to it is stopped by the operating system seconds later. - The script exits non-zero if the client does not start. Missing authentication produces a warning instead, so developers without Watcher on the host can still open the container and sign in through its forwarded port.
The example deliberately stops at startup checks. For process supervision, authentication readiness, hook drift, volume loss, and end-to-end upload checks, see Runtime reliability.
Settings follow the organization, not the host
An admin does not have to ship review settings into the container. A client pulls the organization's managed client settings as soon as it starts, and again every few minutes, and replaces its local admin settings file with that document. A container inherits the fleet's policy from the deployment within seconds of coming up, and follows later changes to it without being rebuilt.
That is also why the template does not copy the host's settings in. The host gets its settings from the same place, so a copy would only go stale.
Settings a developer sets for themselves, in settings.local.yaml, are per
machine and are not shared, so the container starts with defaults for those and
keeps its own across rebuilds.
To seed the default profile with the developer's existing overrides, add an environment variable and read-only mount alongside the template's existing entries:
"containerEnv": {
"WATCHER_LOCAL_SETTINGS_SEED": "/tmp/watcher-settings.local.yaml"
},
"mounts": [
"source=${localEnv:HOME}/.apollo_monitor/settings.local.yaml,target=/tmp/watcher-settings.local.yaml,type=bind,readonly"
]
The setup script copies that file into the container's private state after repairing the named volume's ownership. It does not replace a local settings file that already exists, so later edits remain local to the container. Remove the named Watcher state volume before rebuilding if you need to seed the file again.
Distributing it with an MDM rollout
An MDM rollout installs the client on the machine. It does not reach inside the containers that machine runs, and no MDM can: those are built from repositories the fleet's developers control. The rollout puts the sign-in on the machine, which every container on it then reuses.
Publish the template alongside the rollout, with WATCHER_API_URL and the
pinned client version already filled in, so a team adding it to a repository
does not have to know either.
Whether containers are covered depends on each repository. watcher doctor on
the host reports on the host, not on containers started from it, so check that
the template is present in the repository.
Codex
The client decides whether Codex is present by looking for ~/.codex, and it
prints what it found. The template mounts a volume there, so that directory
always exists and Codex is always treated as present: the client writes its
hooks into ~/.codex/hooks.json, and its configuration entries (the approval
policy, plus the sandbox and approval reviewer in enforce mode) into
~/.codex/config.toml, whether or not Codex is installed yet.
That is the useful direction to fail in. A developer who installs Codex later,
inside an already-running container, finds it hooked, instead of unmonitored
until the next container start. A container that never gets Codex carries an
unused configuration, which does nothing. On a machine, where ~/.codex exists
only once Codex does, the client skips Codex instead, without failing.
Codex asks each user to confirm the changed hooks the next time it starts, and
records nothing until they accept. That confirmation, and the Watcher entries
written into Codex's own configuration, live in ~/.codex, which the template
keeps on a volume so they are answered once per container rather than once per
rebuild. See Supported agents for what the hooks do in
each agent.
A container with only one of the two agents can drop the other's volume from
mounts; the setup script skips a mount point that is not there.
Verifying it works
In a terminal inside the container:
watcher doctor
Confirm it reports the deployment you expect, the agent hooks installed, and the
client running and signed in. A prompt to sign in means the container is not
reading the host's credentials: the usual cause is WATCHER_API_URL differing
from the URL the host is signed in to, since credentials are stored per backend
hostname.
Then prove recording end to end, which watcher doctor does not do. Run one
agent turn in the container and check both ends:
watcher logs hooks -n 5
A working container logs a line per tool call, such as
[pre_tool_use] Permission evaluated: Bash -> allow. Then open the
Analyzer and confirm the session is there, attributed
to the developer whose sign-in the container is using.
Re-check with watcher doctor if the sign-in on the machine ever changes,
because a container that loses access to it does not announce that. The client
keeps answering as healthy, and nothing reaches the Analyzer for as long as that
lasts. What happens to tool calls depends on how the access was lost. With no
credentials left for the target, the hooks are disabled and log
watcher signed out ... falling through to native permission flow, so your
coding agent's own permission prompt gates the call. With credentials still
present but rejected by the backend, grading fails instead, and where your
organization settings allow tool calls
through on a grading failure they keep being allowed, logged as
Live grading unavailable - allowed by policy.
On a deployment that authenticates through a reverse proxy there is no sign-in
to lose, so the signal is the backend's own: once it has rejected a run of the
container's requests — a gateway or identity-provider problem — the hooks are
disabled, logging a watcher auth rejected line and falling through to your
coding agent's prompt in the same way. Fix the gateway or the identity headers
reaching the server; the container resumes on its own once a request is
accepted, with nothing to sign in to and no key to replace.
Escalated tool calls are answered in the coding agent's own permission prompt inside the container. The container runs its own client, separate from the machine's. It listens on 8228 inside the container. When the machine's own client already has 8228 on the host, the editor forwards the container's port to a free one. Browser sign-in returns to port 8228, so in that case sign in through the machine's own client; the container reads the same credentials.
Editor port forwarding is not the same as publishing a port.
Containers explains that publishing
8228 with -p reaches nothing, because the client binds loopback and only
answers to localhost. Forwarding works where publishing does not: the editor
connects to loopback from inside the container. Reach for forwardPorts, not
appPort.