Documentation
¶
Overview ¶
Package workbenchdocker is a thin typed wrapper around the Docker Engine SDK (github.com/docker/docker/client), used to create/start/stop/remove the containers and volumes that back a single workbench (a per-user sandbox running two independent ways to drive the `claude` CLI: the in-container chat bridge, deploy/workbench/bridge, which drives it one turn at a time, and a tmux session (see tmux_tabs.go) exposing a real interactive `claude` TUI per tab through ttyd).
This package talks to whichever daemon URL it's constructed with — one client per call, pointed at whichever docker_hosts row a given workbench is assigned to (resolved by internal/service/v1/workbench/workbench.go's resolveClient), not a single startup-time config value: a docker host is expected to be a dedicated second dockerd process, not the daemon Artel's own containers run on. It creates a dedicated, isolated Docker network per container (see container.go's containerNetworkName) rather than attaching containers to one shared network, and does not expose any inbound port on the containers it creates beyond the fixed bridge/ttyd port publications below.
Index ¶
- func WorkbenchImageTag() (string, error)
- type Client
- func (c *Client) CheckClaudeLoggedIn(ctx context.Context, containerID string) (bool, error)
- func (c *Client) ContainerAddress(ctx context.Context, containerID string) (string, error)
- func (c *Client) CreateContainer(ctx context.Context, opts CreateOpts) (string, error)
- func (c *Client) CreateVolume(ctx context.Context, name string) error
- func (c *Client) EnsureImage(ctx context.Context) (string, error)
- func (c *Client) KillTmuxWindow(ctx context.Context, containerID, windowID string) error
- func (c *Client) ListTmuxWindows(ctx context.Context, containerID string) ([]domain.TerminalTab, error)
- func (c *Client) NewTmuxWindow(ctx context.Context, containerID string) (domain.TerminalTab, error)
- func (c *Client) ReadFilesFromVolume(ctx context.Context, containerID string) (map[string][]byte, error)
- func (c *Client) RemoveContainer(ctx context.Context, containerID string) error
- func (c *Client) RemoveVolume(ctx context.Context, name string) error
- func (c *Client) SelectTmuxWindow(ctx context.Context, containerID, windowID string) error
- func (c *Client) StartContainer(ctx context.Context, containerID string, env map[string]string) error
- func (c *Client) StopContainer(ctx context.Context, containerID string) error
- func (c *Client) TtydAddress(ctx context.Context, containerID string) (string, error)
- func (c *Client) WriteFilesToVolume(ctx context.Context, containerID string, files map[string][]byte) error
- type CreateOpts
- type TLSConfig
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func WorkbenchImageTag ¶ added in v1.0.25
WorkbenchImageTag computes a deterministic tag for the workbench image from the content of the embedded deploy/workbench build context: every embedded file's path and content are hashed together (sorted by path, so the result doesn't depend on directory-walk order), and the first workbenchImageTagHashLen hex characters of the resulting sha256 digest become the tag suffix.
This hashes bridge.tar's own bytes as one opaque file rather than unpacking it first — unlike workbenchBuildContext below, which must unpack it (Docker needs the real bridge/... paths, not a literal file called "bridge.tar" in its build context). For tagging purposes the raw bytes are just as good a fingerprint: bridge.tar's content is a pure function of bridge/'s content (see gen_bridge_tar.go), so any change under bridge/ that gets regenerated into bridge.tar still changes this hash.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client wraps the Docker SDK client with the narrow surface a workbench's container/volume lifecycle needs.
func New ¶
New constructs a Client talking to the Docker daemon at host (e.g. "unix:///var/run/docker-workbenches.sock", "tcp://host:2376" for a local/insecure daemon, or "tcp://host:2376" with a populated tlsCfg for a remote mTLS-secured daemon). API version negotiation is enabled so the client stays compatible with the daemon regardless of exactly which API version it speaks.
An empty tlsCfg reproduces exactly the pre-TLS behavior (plain client.WithHost, no custom HTTP client) — this must not regress the local unix-socket/dind path.
func (*Client) CheckClaudeLoggedIn ¶ added in v1.0.25
CheckClaudeLoggedIn reports whether containerID's claude CLI currently has a completed login — ground truth read straight from the container's filesystem (see claudeCredentialsCheckScript), unlike internal/transport/vaults_api/workbench_terminal_shell.go's terminal-output-based sign-in-link detection, which only ever observes what scrolled through a terminal WS relay and has no way to notice a login completing after the user stops watching.
A "command ran, file doesn't exist" outcome (test's own non-zero exit) is reported as false, nil — not an error. Only a failure of the exec machinery itself (Create/Attach/Inspect) is returned as an error. Mirrors execTmuxCommand's Create/Attach/Inspect shape; unlike it, there is nothing caller-controlled in the command, so no positional-arg substitution is needed.
func (*Client) ContainerAddress ¶ added in v1.0.25
ContainerAddress returns containerID's "<daemon-host>:<published-port>" address for the chat bridge running inside it, as reachable from Artel's own process. Used by the workbench reverse-proxy handler in internal/transport/vaults_api.
This deliberately does not resolve the container's IP on its dedicated per-container network (see containerNetworkName): the configured daemon (c.host) is commonly itself a docker:dind *container*, whose inner networks live in a network namespace private to that container, so a workbench container's IP on any of them is never routable from outside the dind container regardless of where Artel's own process runs. Reading back the Docker-assigned host port CreateContainer published bridgePort to and pairing it with c.host's own hostname instead routes through the dind container's (or, for a bare/second-dockerd host, the host's) already-reachable address — the same address Artel used to create/start the container in the first place.
Fails rather than guessing when the daemon hasn't assigned a host port yet (i.e. the container isn't running) — an empty/absent address would otherwise surface much later as an opaque proxy dial failure.
func (*Client) CreateContainer ¶
CreateContainer creates (but does not start) a workbench container: the hardcoded workbench image, attached to a dedicated network created just for it (see containerNetworkName), with opts.VolumeName mounted at homeMountPath, hardcoded CPU/memory/PID limits, capability/privilege hardening (see newWorkbenchHostConfig), and labeled for operational visibility.
The container runs two independently-addressable in-container servers: the chat bridge (bridgePort) and ttyd (ttydPort, the interactive tmux-tab terminal). Each is published to a host port allocated from its own fixed range ([bridgeHostPortRangeStart, bridgeHostPortRangeEnd] and [ttydHostPortRangeStart, ttydHostPortRangeEnd] respectively — see allocateHostPort) rather than left unpublished. Neither is exposed to the wider internet by this alone: the configured daemon is expected to sit on a network only Artel's own process can reach, and both servers are only ever dialed through internal/transport/vaults_api's authenticated reverse proxy, never linked directly to a client.
func (*Client) CreateVolume ¶
CreateVolume creates a named Docker volume, labeled for operational visibility.
func (*Client) EnsureImage ¶ added in v1.0.25
EnsureImage returns the tag of the workbench Docker image on the daemon this Client talks to, building it from the embedded deploy/workbench build context (workbenchimage.Files) if an image with that tag isn't already present. The tag is a deterministic hash of the embedded files' content (see WorkbenchImageTag), so a changed Dockerfile/entrypoint.sh automatically produces a new tag and triggers a rebuild on next use, while an unchanged build context reuses whatever was already built — on this daemon or a previous run against it.
func (*Client) KillTmuxWindow ¶ added in v1.0.25
KillTmuxWindow closes windowID in containerID's workbench session (see SelectTmuxWindow for why windowID alone, with no session qualifier, is a valid target, and why it's passed via Env rather than Cmd). It deliberately does not guard against killing a session's last window — tmux itself would refuse or tear down the whole session depending on version/config, and deciding whether that's allowed needs a window count (via ListTmuxWindows) first, which is a service-layer concern (see workbench.Service.CloseTerminalTab), not this thin client's.
A vanished windowID (e.g. the user closed it manually from inside the terminal, Ctrl-b w) is a real, expected scenario, not a bug — execTmuxCommand surfaces tmux's own "can't find window" stderr in the returned error so a caller can tell the two apart from a generic failure.
func (*Client) ListTmuxWindows ¶ added in v1.0.25
func (c *Client) ListTmuxWindows(ctx context.Context, containerID string) ([]domain.TerminalTab, error)
ListTmuxWindows lists every tmux window (surfaced to the browser as a "terminal tab") in containerID's workbench session, in tmux's own window order.
If the workbench session (or the tmux server itself) no longer exists — expected when the last window's command exited on its own and tore the session down with it, see isSessionMissingErr — this is not treated as an error: it returns an empty, non-nil slice so the caller sees "zero tabs" rather than a failure.
func (*Client) NewTmuxWindow ¶ added in v1.0.25
NewTmuxWindow creates a new tmux window in containerID's workbench session, running `claude` as its command — the same command the entrypoint's initial `tmux new-session` starts (see deploy/workbench/entrypoint.sh) — and returns it as an active domain.TerminalTab.
`-P -F '#{window_id}'` makes `tmux new-window` print just the new window's id to stdout, so the new tab's id is known without a second ListTmuxWindows round trip. Name is deliberately left empty: tmux's automatic-rename (on by default — see deploy/workbench/tmux.conf) only settles the window's name once `claude` starts printing its title escape sequence, which hasn't happened yet by the time this call returns. A caller that needs the settled name should do a fresh ListTmuxWindows shortly after.
If the workbench session (or the tmux server) is gone — expected when the last window's command exited on its own, see isSessionMissingErr — there is nothing left for `new-window` to target, so this falls back once to `tmux new-session`, recreating the workbench session with `claude` running in its (only) window. Any other failure, or a failure of that fallback itself, is returned as a real error.
func (*Client) ReadFilesFromVolume ¶ added in v1.0.25
func (c *Client) ReadFilesFromVolume(ctx context.Context, containerID string) (map[string][]byte, error)
ReadFilesFromVolume reads every regular file under containerID's workspace volume and returns it as a map keyed by path relative to the workspace root — the mirror image of WriteFilesToVolume, used to pull the workbench's edits back out on stop. Like WriteFilesToVolume, this works whether the container is running or stopped.
The Docker Engine API's archive endpoint always wraps the requested path in one leading directory component named after the last path segment of homeMountPath (i.e. "vault/ notes/foo.md" rather than "notes/foo.md") — that leading component is stripped off here so callers see paths relative to the workspace root, matching WriteFilesToVolume's own path shape.
func (*Client) RemoveContainer ¶
RemoveContainer force-removes a workbench container and the dedicated per-container network CreateContainer created for it (see containerNetworkName). It does not remove the container's volume — call RemoveVolume separately.
The network(s) containerID is attached to are read back via ContainerInspect before removal — once the container is gone there is nothing left to inspect them from — and each is deleted via NetworkRemove only after ContainerRemove succeeds, since a network can't be removed while a container endpoint is still attached to it. workbenchNetworkName is deliberately never removed here even if found attached: it's the old shared network every workbench container used to share (see its own doc comment), so removing it out from under a container created before this change would break every other container still using it.
func (*Client) RemoveVolume ¶
RemoveVolume force-removes a named Docker volume.
func (*Client) SelectTmuxWindow ¶ added in v1.0.25
SelectTmuxWindow makes windowID the "current" window of containerID's workbench session — since ttyd just attaches to the session and mirrors whatever window is currently current, this alone is what makes a tab switch reach the browser. tmux window ids ("@N") are unique across the whole tmux server, not just within one session, so no session-qualified target is needed.
windowID is passed via the exec's Env ($WINDOW_ID), never string-concatenated into Cmd or the script text, even though every caller today only ever passes a server-issued "@N" id — same defense-in-depth this package's injectEnv already applies to caller-influenced values, keeping them out of argv/process-list logs.
func (*Client) StartContainer ¶
func (c *Client) StartContainer(ctx context.Context, containerID string, env map[string]string) error
StartContainer starts an already-created workbench container and injects env into it.
The Docker Engine API has no way to attach environment variables to a container at `docker start` time — env is otherwise only settable at `docker create` time, which is deliberately too early here (see CreateOpts). Once started, env is instead written into the container's envDropDir tmpfs via a docker exec (see injectEnv), so the value only ever lives in that tmpfs's pages — never baked into the image, never written to the mounted volume. This is the mechanism the api_key/subscription_login flows (internal/service/v1/workbench/workbench.go) rely on to keep that property intact.
func (*Client) StopContainer ¶
StopContainer gracefully stops a running workbench container, leaving it (and its volume) intact for a later restart.
func (*Client) TtydAddress ¶ added in v1.0.25
TtydAddress returns containerID's "<daemon-host>:<published-port>" address for the ttyd server running inside it (the interactive tmux-tab terminal, restored alongside the chat bridge), as reachable from Artel's own process. Mirrors ContainerAddress exactly, reading back ttydNatPort's binding instead of bridgeNatPort's — a workbench container now runs two independently-addressable in-container servers, so each gets its own address method.
func (*Client) WriteFilesToVolume ¶ added in v1.0.25
func (c *Client) WriteFilesToVolume(ctx context.Context, containerID string, files map[string][]byte) error
WriteFilesToVolume writes files (keys are paths relative to the workspace root, e.g. "notes/foo.md") into containerID's workspace volume by building an in-memory tar archive and uploading it via the Docker Engine API's archive endpoint. Works whether the container is running or stopped — the copy API operates on the container's filesystem directly, not on a live process inside it — so this is used both to materialize a vault into a freshly-created, not-yet-started container (StartWorkbench) and, in principle, against an already-running one.
type CreateOpts ¶
type CreateOpts struct {
// Name is the container name (e.g. "workbench-<vault_id>-<user_id>"). Also the seed for the
// dedicated per-container Docker network CreateContainer creates — see containerNetworkName.
Name string
// VolumeName is the pre-created named volume to mount at homeMountPath.
VolumeName string
}
CreateOpts configures a new workbench container. Deliberately excludes any secret/env value: a workbench container is created with no auth env vars at all; those are only decided and supplied later, at StartContainer time.
type TLSConfig ¶
TLSConfig carries the decrypted, in-memory PEM-encoded TLS client credentials for connecting to a remote Docker daemon over TLS/mTLS — the docker_hosts.ca_cert_enc/client_cert_enc/ client_key_enc columns (migrations/062_docker_hosts_tls.sql), decrypted by dockerhosts.Repo.GetWithCreds. All three fields empty means "no TLS" — see New.