moatinit

package
v0.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: MIT Imports: 23 Imported by: 0

Documentation

Overview

Package moatinit implements the container entrypoint as testable Go.

It is the Go port of internal/deps/scripts/moat-init.sh, with behavioral parity as the contract: same phase ordering, same fail-closed vs best-effort classification per operation, and verbatim user-facing error wording. The catalog of ported requirements lives in docs/plans/2026-07-01-moat-init-go-rewrite-plan.md (Appendix A/B).

The package moves the entrypoint's business logic — env parsing, branch and mode selection, phase ordering, error classification, argument and config assembly, exclude computation, root/moatuser detection — into unit-testable Go. Mechanical, security-sensitive, or well-understood system operations stay delegated to the audited tools already in the base image, invoked as targeted subprocesses:

  • gosu — the privilege drop (Go selects the branch, gosu transitions)
  • socat — the SSH agent TCP↔unix bridge (long-lived child)
  • tar — the workspace volume byte copy (Go owns excludes/args/rc checks)

All identity, filesystem, subprocess, and DNS operations go through the Sys seam so phases can be exercised under `go test` against an injected temp root with no container and no root privileges.

The compiled binary (cmd/moat-init) is embedded into the moat host binary by internal/initbin and shipped into run images as /usr/local/bin/moat-init (the container ENTRYPOINT) by writeEntrypoint.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Plan

func Plan(ctx *Context) []string

Plan returns the ordered actions the entrypoint would take for the current environment and identity, without performing any of them — a side-effect-free dry-run (only read-only stats and lookups). It is the debugging affordance the shell script could never offer, and the release pipeline's positive functional gate: a regenerated-but-defective binary that lost the privilege-drop or scrub phases fails the gate regardless of its checksum.

One line per decision, "<phase>: <action>". Wording here is NOT part of the parity contract (the script has no equivalent); the fatal stderr wordings pinned by the golden tests are.

func ResolveHome added in v0.9.0

func ResolveHome(sys Sys)

ResolveHome repairs HOME for a non-root entrypoint whose UID has no passwd entry in the image. On Linux bind mode the container runs as the workspace owner's UID, and when the image has no account for it Docker sets HOME to "/", where staging fails ("mkdir //.claude: permission denied"). If the image has /home/moatuser — the home the run's mounts and agent staging target — HOME is pointed there and exported, so the user command sees the same value.

A HOME the UID's own passwd entry supplies is left alone: in images built FROM node:*, UID 1000 is the node user with HOME=/home/node, and overriding it would separate agent config from the account's home directory.

func Run

func Run(ctx *Context) int

Run executes all phases in order. On success it never returns: the final phase replaces the process image via Sys.Exec. It returns the process exit code on failure.

Fail-closed: reaching the end of the phase list means the exec dispatch did not run (it can only be skipped in an incomplete build), and starting the user command without the privilege-drop contract would silently run it as root — so refuse instead.

func WatchClipboard added in v0.9.0

func WatchClipboard(ctx context.Context, stdin io.Reader, stdout io.Writer) error

WatchClipboard streams future X11 CLIPBOARD changes to the attached host. It never replays the existing clipboard on attach. Closing stdin ends the watcher, including when a host detaches from an otherwise running container. Stdout is reserved for the wire protocol; diagnostics must not be written there.

Types

type Cmd

type Cmd struct {
	Argv   []string
	Dir    string    // working directory ("" = inherit)
	Env    []string  // nil = inherit the current process environment
	Stdin  io.Reader // nil = /dev/null
	Stdout io.Writer // nil = discard
	Stderr io.Writer // nil = discard

	// LogFile, when set, redirects both stdout and stderr to this file
	// (container-absolute; re-rooted like other fs paths), truncating it —
	// the Go form of `>/var/log/dockerd.log 2>&1`. Overrides Stdout/Stderr.
	LogFile string

	// Timeout, when positive, bounds the run so a hanging probe inside a
	// bounded retry loop cannot consume the loop's whole budget (plan
	// Appendix B: per-attempt timeout below the loop budget). A timed-out
	// command reports a non-zero exit code.
	Timeout time.Duration
}

Cmd describes a subprocess invocation.

type Config

type Config struct {
	ExtraHosts string // MOAT_EXTRA_HOSTS: space-separated name:target pairs for /etc/hosts
	SSHTCPAddr string // MOAT_SSH_TCP_ADDR: TCP address of the host-side SSH agent proxy

	ClaudeInit string // MOAT_CLAUDE_INIT: staging dir for ~/.claude
	CodexInit  string // MOAT_CODEX_INIT: staging dir for ~/.codex
	// CodexSubscriptionAuth gates the Codex CLI version check: when "1", the
	// staged auth.json is synthetic and only a verified Codex range can read it.
	CodexSubscriptionAuth string // MOAT_CODEX_SUBSCRIPTION_AUTH
	GeminiInit            string // MOAT_GEMINI_INIT: staging dir for ~/.gemini
	CopilotInit           string // MOAT_COPILOT_INIT: staging dir for ~/.copilot

	InitFiles string // MOAT_INIT_FILES: tab-delimited <path>\t<base64> records

	Clipboard string // MOAT_CLIPBOARD: "1" starts Xvfb :99 and exports DISPLAY

	GitUserName  string // MOAT_GIT_USER_NAME
	GitUserEmail string // MOAT_GIT_USER_EMAIL
	GitSSHGitHub string // MOAT_GIT_SSH_GITHUB: "1" sets the github.com insteadOf rewrite

	DockerDIND string // MOAT_DOCKER_DIND: "1" starts dockerd (dind mode)
	DockerGID  string // MOAT_DOCKER_GID: non-empty enables host-socket group mode

	WorkspaceVolume   string // MOAT_WORKSPACE_VOLUME: "1" populates /workspace from staging
	WorkspaceStaging  string // MOAT_WORKSPACE_STAGING: staging source (default /mnt/host-workspace)
	WorkspaceExcludes string // MOAT_WORKSPACE_EXCLUDES: newline-delimited ./-prefixed patterns

	VolumeChown string // MOAT_VOLUME_CHOWN: space-separated named-volume mount roots

	PreRun string // MOAT_PRE_RUN: hook command run in /workspace before the main command

	Home string // HOME of the entrypoint process (target home on the non-root path)
}

Config is the entrypoint's environment contract, read once at startup and frozen — no MOAT_* control variable is re-read after a user-controlled phase (e.g. the pre_run hook) has executed.

One deliberate exception mirrors the script: MOAT_INIT_FILES is also removed from the live process environment right after the init-files phase (the script's `unset MOAT_INIT_FILES`), so children spawned by later phases never see it.

func LoadConfig

func LoadConfig(sys Sys) *Config

LoadConfig snapshots the entrypoint environment from sys.

type Context

type Context struct {
	Sys    Sys
	Cfg    *Config
	Argv   []string  // the user command (the entrypoint's "$@")
	Stdin  io.Reader // inherited by interactive children (pre_run hook)
	Stdout io.Writer // inherited by children that surface output (pre_run hook)
	Stderr io.Writer
}

Context carries the frozen configuration, the Sys seam, the user command, and the stderr stream through every phase.

type OSSys

type OSSys struct {
	Root string

	// resolveTimeout bounds each DNS lookup attempt so a retry loop's total
	// budget is honored even when the resolver hangs (plan Appendix B:
	// per-attempt timeout must stay below the loop budget).
	ResolveTimeout time.Duration
}

OSSys is the production Sys backed by the real operating system. Root, when non-empty, re-roots all filesystem paths beneath it (used by integration tests to run phases against a temp directory).

func NewSys

func NewSys() *OSSys

NewSys returns the production Sys.

func (*OSSys) AppendFile

func (s *OSSys) AppendFile(path string, data []byte) error

func (*OSSys) Chmod

func (s *OSSys) Chmod(path string, perm fs.FileMode) error

func (*OSSys) Chown

func (s *OSSys) Chown(path string, uid, gid int) error

func (*OSSys) CopyFilePreserving

func (s *OSSys) CopyFilePreserving(src, dst string) error

CopyFilePreserving mirrors `cp -p src dst`: bytes, mode, and timestamps are preserved (failure is an error); ownership preservation is attempted but, like cp -p without appropriate privileges, its failure is not an error.

func (*OSSys) CopyTreePreserving

func (s *OSSys) CopyTreePreserving(src, dst string) error

CopyTreePreserving mirrors `cp -rp src dst` where dst is the destination path of the copied tree (POSIX -R semantics: symlinks are duplicated as symlinks, never followed).

func (*OSSys) Environ

func (s *OSSys) Environ() []string

func (*OSSys) Exec

func (s *OSSys) Exec(argv []string, env []string) error

func (*OSSys) Getenv

func (s *OSSys) Getenv(key string) string

func (*OSSys) Geteuid

func (s *OSSys) Geteuid() int

func (*OSSys) Getpid

func (s *OSSys) Getpid() int

func (*OSSys) Lchown

func (s *OSSys) Lchown(path string, uid, gid int) error

func (*OSSys) LookPath

func (s *OSSys) LookPath(file string) (string, error)

func (*OSSys) LookupGroupByGID

func (s *OSSys) LookupGroupByGID(gid string) (string, bool)

func (*OSSys) LookupGroupByName

func (s *OSSys) LookupGroupByName(name string) (string, bool)

func (*OSSys) LookupUser

func (s *OSSys) LookupUser(name string) (User, bool)

func (*OSSys) MkdirAll

func (s *OSSys) MkdirAll(path string, perm fs.FileMode) error

func (*OSSys) Pipe

func (s *OSSys) Pipe(src, dst Cmd) (int, int, error)

Pipe runs `src | dst` and returns both exit codes, mirroring the script's capture of the source tar's status alongside the destination's ($? after a POSIX pipeline only reports the rightmost command).

func (*OSSys) ProcessAlive

func (s *OSSys) ProcessAlive(pid int) bool

func (*OSSys) ReadFile

func (s *OSSys) ReadFile(path string) ([]byte, error)

func (*OSSys) RealPath

func (s *OSSys) RealPath(path string) string

func (*OSSys) Remove

func (s *OSSys) Remove(path string) error

func (*OSSys) ResolveAnyFirst

func (s *OSSys) ResolveAnyFirst(host string) string

func (*OSSys) ResolveIPv4First

func (s *OSSys) ResolveIPv4First(host string) string

func (*OSSys) Run

func (s *OSSys) Run(c Cmd) (int, error)

func (*OSSys) Setenv

func (s *OSSys) Setenv(key, value string)

func (*OSSys) Sleep

func (s *OSSys) Sleep(d time.Duration)

func (*OSSys) StartDetached

func (s *OSSys) StartDetached(c Cmd) (int, error)

StartDetached launches a long-lived background child, exactly like the shell's `cmd ... &`: the child shares the entrypoint's process group (job control is off in non-interactive sh, so backgrounded children are NOT re-grouped — parity requires the same signal delivery the shell had), and it survives the entrypoint's exec because exec replaces the process image without touching children. A reaper goroutine collects the child if it dies before the handoff, so ProcessAlive mirrors the shell's `kill -0` on a reaped background job instead of seeing a signalable zombie.

func (*OSSys) Stat

func (s *OSSys) Stat(path string) (fs.FileInfo, error)

func (*OSSys) Unsetenv

func (s *OSSys) Unsetenv(key string)

func (*OSSys) WalkDir

func (s *OSSys) WalkDir(root string, fn fs.WalkDirFunc) error

func (*OSSys) WriteFile

func (s *OSSys) WriteFile(path string, data []byte, perm fs.FileMode) error

type Phase

type Phase struct {
	Name string
	Run  func(*Context) error
}

Phase is one ordered step of the entrypoint. Phases return nil to continue, or an exitError to abort with that code. Any other error is a programming bug and is reported as a generic fatal.

type Sys

type Sys interface {
	// Identity. Lookups use pure-Go os/user (files NSS: /etc/passwd,
	// /etc/group) — parity with the script's `id`/`getent` for the standard
	// moat images; documented divergence for LDAP/SSSD-backed custom images
	// (detection only — the privilege drop itself is delegated to gosu).
	Geteuid() int
	LookupUser(name string) (User, bool)
	LookupGroupByName(name string) (gid string, ok bool)
	LookupGroupByGID(gid string) (name string, ok bool)

	// Process environment. Mirrors the shell's export/unset so that children
	// spawned later (pre_run hook, gosu, the exec'd command) inherit exactly
	// what they would have under the script.
	Getenv(key string) string
	Setenv(key, value string)
	Unsetenv(key string)
	Environ() []string

	// Filesystem.
	Stat(path string) (fs.FileInfo, error)
	MkdirAll(path string, perm fs.FileMode) error
	Chmod(path string, perm fs.FileMode) error
	Chown(path string, uid, gid int) error
	Lchown(path string, uid, gid int) error
	ReadFile(path string) ([]byte, error)
	WriteFile(path string, data []byte, perm fs.FileMode) error
	AppendFile(path string, data []byte) error
	Remove(path string) error
	CopyFilePreserving(src, dst string) error // cp -p
	CopyTreePreserving(src, dst string) error // cp -rp
	WalkDir(root string, fn fs.WalkDirFunc) error
	Getpid() int

	// RealPath maps a container-absolute path to the path a SUBPROCESS must
	// use to reach it. In production this is the identity; under an
	// injected test root it prefixes the root, so phases that hand paths to
	// tar/git/socat (argv or working directory) stay testable against the
	// same tree the filesystem methods manipulate.
	RealPath(path string) string

	// Subprocesses.
	LookPath(file string) (string, error)
	Run(c Cmd) (exitCode int, err error)
	StartDetached(c Cmd) (pid int, err error)
	ProcessAlive(pid int) bool
	Pipe(src, dst Cmd) (srcExit, dstExit int, err error)

	// DNS. ResolveIPv4First is the port of `getent ahostsv4 | awk '{print
	// $1; exit}'`; ResolveAnyFirst of the `getent hosts` fallback. Both
	// consult /etc/hosts then DNS (Go resolver) and return "" on failure.
	ResolveIPv4First(host string) string
	ResolveAnyFirst(host string) string

	Sleep(d time.Duration)

	// Exec replaces the process image (syscall.Exec). It only returns on
	// error. argv[0] is resolved via PATH when not absolute.
	Exec(argv []string, env []string) error
}

Sys abstracts every identity, filesystem, subprocess, and DNS operation the entrypoint performs, so phases can be exercised under `go test` against an injected temp root with no container and no root privileges. OSSys is the production implementation; tests embed *OSSys (pointed at a t.TempDir() root) and shadow the identity/subprocess/DNS methods they need to fake.

Filesystem methods take absolute container paths (/etc/hosts, /home/moatuser/...); OSSys re-roots them under Root when set.

type User

type User struct {
	UID int
	GID int
}

User is a resolved account (the subset of user.User the entrypoint needs).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL