Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
c6ab062
feat(container): support podman via its Docker-compatible socket
grinnellian Jul 7, 2026
fe0e82b
docs: document podman as a supported container runtime
grinnellian Jul 7, 2026
93430fb
feat(doctor): identify podman behind the docker runtime
grinnellian Jul 7, 2026
a5432f1
docs(changelog): add podman support entry
grinnellian Jul 7, 2026
521b748
fix(container): harden podman detection paths
grinnellian Jul 7, 2026
acbec09
fix(doctor): three-state engine identity; surface idle podman sockets
grinnellian Jul 7, 2026
186a31e
fix(run): persist the Docker endpoint per run and reconnect to it
grinnellian Jul 7, 2026
e6dff55
docs(changelog): fill podman entry PR link (#435)
grinnellian Jul 7, 2026
4834c84
fix(container): adversarial-review hardening of podman paths
grinnellian Jul 7, 2026
274f68e
fix(docs,doctor): truth-audit corrections for podman
grinnellian Jul 7, 2026
480af34
fix(run): record the resolved Docker endpoint, not the raw env var
grinnellian Jul 7, 2026
f316db0
test(container): make podman detection tripwires run in CI
grinnellian Jul 7, 2026
c00d591
feat(cli): show 'docker (podman)' in list and status
grinnellian Jul 7, 2026
e5581b3
fix(run): fail loud when stopping a legacy run's container isn't found
grinnellian Jul 7, 2026
84e2af5
docs(changelog): note engine labeling and wrong-engine stop fix
grinnellian Jul 7, 2026
bf851be
fix(cli): dedupe status runtime header; tighten podman label match
grinnellian Jul 7, 2026
af1c7b8
fix(run): restore run state when Stop cannot resolve the runtime
grinnellian Jul 19, 2026
f1859f5
fix(cli): label custom-named podman machines in list/status
grinnellian Jul 19, 2026
70f515f
fix(container): warn instead of failing when forced docker points at …
grinnellian Jul 19, 2026
6bbe8f8
fix(container): reword double-'but' podman probe error
grinnellian Jul 19, 2026
95bc8cf
docs: soften the libkrun-default claim for podman machine on macOS
grinnellian Jul 19, 2026
0c6614a
fix(container): clear golangci-lint findings in podman detection
grinnellian Jul 24, 2026
d5cdf23
style: bring comment density in line with repo norms
grinnellian Jul 24, 2026
21da839
fix(run): make the pinned-endpoint Stop test hermetic
grinnellian Jul 24, 2026
9f80267
fix(run): limit Stop's loud not-found failure to hosts running podman
grinnellian Jul 24, 2026
12d92af
feat(cli): give destroy's running-run teardown its own flag
grinnellian Jul 24, 2026
475dcdc
fix(container): probe podman's default machine, not the first alphabe…
grinnellian Jul 24, 2026
ec66b1f
test(container): make the podman gVisor warning observable in any tes…
grinnellian Jul 24, 2026
a26de68
docs(readme): drop the unrelated Apple containers version correction
grinnellian Jul 24, 2026
c3cb053
docs: reconcile podman version claims and record the runtime-value de…
grinnellian Jul 24, 2026
8a7ef80
refactor(container): fold the podman detection seams into one struct
grinnellian Jul 25, 2026
0a5f104
feat(container): identify the engine on demand, not at startup
grinnellian Jul 25, 2026
0276cc6
feat(run): record the engine a run was created on instead of guessing it
grinnellian Jul 25, 2026
efca8c3
fix(run): require a live second engine before failing a stop
grinnellian Jul 25, 2026
b28453c
docs: correct the podman version floor and the claims around it
grinnellian Jul 25, 2026
ab4928e
docs(changelog): point the podman entries at their PR
grinnellian Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Adds HTTP request-body inspection to Keep policies. File- and pack-based `networ

- **Copilot CLI settings passthrough** — `moat copilot` now carries over user preferences from the host's Copilot settings file (`$COPILOT_HOME/settings.json` when set, otherwise `~/.copilot/settings.json`; contextTier, effortLevel, footer, includeCoAuthoredBy, model, mouse, subagents, tabs, theme). Legacy `colorMode` values are written as the current `theme` setting. An optional `~/.moat/copilot/settings.json` provides moat-specific overrides that win over host settings. Settings that execute commands (`statusLine`) are only allowed from the moat override file. CLI flags and `moat.yaml` fields take precedence over settings.json values. ([#438](https://github.com/majorcontext/moat/pull/438))
- **GitHub Copilot CLI agent** — run GitHub Copilot CLI with `moat copilot`. Copilot uses the existing `github` grant: Moat injects that GitHub token for GitHub/Copilot API hosts plus HTTPS git, while the container receives only placeholders. `moat copilot` installs `@github/copilot`, stages Copilot config/context, passes `--allow-all` by default, and supports `copilot.model`, `copilot.context`, `copilot.reasoning_effort`, `copilot.experimental`, and `copilot.autopilot` in `moat.yaml`. See [Running GitHub Copilot CLI](https://majorcontext.com/moat/guides/copilot). ([#436](https://github.com/majorcontext/moat/pull/436))
- **Podman support** — moat's Docker runtime now works against Podman's Docker-API-compatible socket. Podman machine sockets (macOS) and rootless/rootful sockets (Linux) are auto-detected like Rancher Desktop's, when the default Docker socket is unreachable and `DOCKER_HOST` is unset; `--runtime podman` / `MOAT_RUNTIME=podman` / `runtime: podman` force it. Each run's actual engine is now recorded (never guessed) and shown in `moat list` and `moat status` as `docker (podman)`; `moat doctor` instead separately reports the engine behind whatever endpoint it can currently reach (a live probe, not a per-run lookup), and no longer trusts Podman's gVisor claim outright there, since its compat API lists configured OCI runtimes even when they aren't installed. A run whose engine can't be reached can be torn down with the new `moat destroy --force-running`. See [Installation](https://majorcontext.com/moat/getting-started/installation) for version and platform requirements. ([#444](https://github.com/majorcontext/moat/pull/444))
- **Pi packages & safe defaults** — declare Pi extensions/skills/themes in `pi.packages` (remote `npm:`/`git:`/`https:`/`ssh:` sources) and Moat installs them into the image at build time via `pi install`, baked into a reproducible cached layer. Every `moat pi` image also bakes a safe `~/.pi/agent/settings.json` — `defaultProjectTrust: never` (a checked-out repo's own `.pi/` extensions, which are arbitrary code, do not auto-load), telemetry off, quiet startup — that a workspace cannot override. Because Pi config can redirect model traffic to any host, `moat pi` now warns under a permissive network policy (only `network.policy: strict` truly constrains egress). See [Running Pi](https://majorcontext.com/moat/guides/pi). ([#434](https://github.com/majorcontext/moat/pull/434))
- **Pi coding agent** — run the [Pi coding agent](https://github.com/earendil-works/pi) with `moat pi`. Pi has no credential of its own; it runs against your existing `anthropic` or `openai` grant. When exactly one is configured it is used automatically; when both are, choose one with `--provider` or `pi.provider` in `moat.yaml`. Only the `anthropic` and `openai` backends are supported today — any other backend, or a missing/ambiguous grant, fails before a container is created. Configure with the `pi:` block (`provider`, `model`). See [Running Pi](https://majorcontext.com/moat/guides/pi) and `examples/agent-pi`. ([#433](https://github.com/majorcontext/moat/pull/433))
- **`opentofu` and `terragrunt` dependencies** — two new managed cloud tools. `opentofu` installs the OpenTofu CLI as the `tofu` command; `terragrunt` installs the Terragrunt orchestration wrapper. Both install as prebuilt release binaries with no image rebuild cost beyond their own layer. Terragrunt delegates to a Terraform or OpenTofu binary on `PATH`, so pair it with an engine — `dependencies: [terraform, terragrunt]`, or `dependencies: [opentofu, terragrunt]` with `env.TERRAGRUNT_TFPATH: tofu`. See [Dependencies](https://majorcontext.com/moat/reference/dependencies). ([#430](https://github.com/majorcontext/moat/pull/430))
Expand All @@ -25,6 +26,7 @@ Adds HTTP request-body inspection to Keep policies. File- and pack-based `networ
### Fixed

- Fix all Claude sessions inside moat freezing at once on macOS during a package install — previously, the shared credential-injecting proxy daemon inherited the host's default `RLIMIT_NOFILE` soft limit (typically 256 on macOS), so a burst of concurrent connections through the single proxy (`bun install` opens up to 64 parallel connections) could exhaust its file descriptors and stall every run's `claude.ai` traffic until the burst cleared, then recover. The daemon now raises its file-descriptor soft limit toward 65536 (capped at the hard limit) at startup, before the proxy accepts connections. ([#439](https://github.com/majorcontext/moat/pull/439))
- Fix `moat stop` silently orphaning a container when the wrong engine is selected — previously, a not-found from the resolved engine only logged a warning and still marked the run stopped, so a run stopped under a different `MOAT_RUNTIME`/`DOCKER_HOST` could be recorded stopped while its container kept running. `moat stop` now fails loudly only when the run has no recorded engine endpoint *and* a different, live Podman endpoint is reachable, where the container could genuinely still be running. A genuinely gone container (pruned, rebooted, `--rm`'d), or a host where Podman is the sole engine, still stops cleanly with no error. The run stays in its prior state; retry with the original runtime, or `moat destroy --force-running`. ([#444](https://github.com/majorcontext/moat/pull/444))
- Fix the injected agent context misrepresenting network access — previously, the "Moat Environment" instructions file (CLAUDE.md/AGENTS.md) listed grant/rule hosts under "Allowed hosts" regardless of policy, so under the default `permissive` policy (where all outbound traffic is allowed) it read as an egress allowlist restricting the agent to those few hosts. The Network Policy section is now policy-aware: `permissive` states that all outbound access is allowed (surfacing only explicit per-path rules, which still apply), and `strict` is described as the allowlist it actually is. The context also now reflects **Docker/DIND availability**, the resolved **workspace mode** (bind vs. ephemeral `volume`), and **installed tool dependencies** — all previously omitted, which could lead an agent to assume capabilities were absent. ([#431](https://github.com/majorcontext/moat/pull/431))
- Fix `moat logs -f` silently doing nothing — previously, follow mode printed a debug-log line ("not yet implemented", only visible with `--verbose`) and exited 0 as if it had streamed, so `-f` looked like it worked. moat now prints a visible notice that follow mode isn't supported yet and shows the current logs. ([#413](https://github.com/majorcontext/moat/pull/413))
- Fix non-deterministic Dockerfile generation causing spurious image rebuilds — previously, dependency `ENV` lines were emitted in random map order, so the generated Dockerfile changed between runs and missed Docker's layer cache. `ENV` keys are now sorted. ([#413](https://github.com/majorcontext/moat/pull/413))
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Or with Go:
go install github.com/majorcontext/moat/cmd/moat@latest
```

**Requirements:** Docker or Apple containers (macOS 15+ with Apple Silicon—auto-detected).
**Requirements:** Docker, Podman, or Apple containers (macOS 15+ with Apple Silicon—auto-detected).

## Quick start

Expand Down Expand Up @@ -174,7 +174,7 @@ See the [CLI reference](docs/content/reference/01-cli.md) for all commands and f

## How it works

**Container runtimes**: Auto-detects Apple containers (macOS 15+, Apple Silicon) or Docker.
**Container runtimes**: Auto-detects Apple containers (macOS 15+, Apple Silicon), Docker, or a Docker-API-compatible engine like Podman.

**Credential injection**: A TLS-intercepting proxy sits between the container and the internet. It inspects requests and injects `Authorization` headers for granted services. The proxy binds to localhost (Docker) or uses per-run token auth (Apple containers).

Expand Down
2 changes: 1 addition & 1 deletion cmd/moat/cli/clean.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ func cleanResources(cmd *cobra.Command, args []string) error {
skippedCount++
continue
}
if err := manager.Destroy(ctx, r.ID); err != nil {
if err := manager.Destroy(ctx, r.ID, false); err != nil {
fmt.Printf("%s\n", ui.Red(fmt.Sprintf("error: %v", err)))
failedCount++
continue
Expand Down
14 changes: 11 additions & 3 deletions cmd/moat/cli/destroy.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import (
"github.com/spf13/cobra"
)

var destroyForce bool
var (
destroyForce bool
destroyForceRunning bool
)

var destroyCmd = &cobra.Command{
Use: "destroy [run]",
Expand All @@ -24,14 +27,19 @@ If a name matches multiple runs, you'll be prompted to confirm.
For volume-mode runs, the workspace lives only in a Docker volume. Destroying
such a run deletes that volume and loses all agent changes unless an extraction
snapshot was captured first. The command refuses to destroy a volume-mode run
with no extraction snapshot; pass --force to override.`,
with no extraction snapshot; pass --force to override.

A run that cannot be stopped cleanly — its container lives on an engine this
process cannot reach — can be torn down with --force-running, which skips the
running-state guard. The two flags are independent.`,
Args: cobra.MaximumNArgs(1),
RunE: destroyRun,
}

func init() {
rootCmd.AddCommand(destroyCmd)
destroyCmd.Flags().BoolVarP(&destroyForce, "force", "f", false, "force destroy even if a volume-mode run has no extraction snapshot")
destroyCmd.Flags().BoolVar(&destroyForceRunning, "force-running", false, "destroy a run that is still running, without stopping it first")
}

// hasExtractionSnapshot reports whether the run has at least one snapshot that
Expand Down Expand Up @@ -113,7 +121,7 @@ func destroyRun(cmd *cobra.Command, args []string) error {
continue
}

if err := manager.Destroy(ctx, runID); err != nil {
if err := manager.Destroy(ctx, runID, destroyForceRunning); err != nil {
return fmt.Errorf("destroying run %s: %w", runID, err)
}

Expand Down
49 changes: 49 additions & 0 deletions cmd/moat/cli/destroy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,52 @@ func writeRunSnapshots(t *testing.T, runID string, metas []snapshot.Metadata) {
t.Fatalf("write snapshots.json: %v", err)
}
}

// TestDestroyForceFlagsAreIndependent pins the split between --force (skip the
// volume-mode extraction-snapshot guard) and --force-running (tear down a run
// that is still running). They were briefly the same flag, which meant anyone
// passing --force for the data-loss guard silently also lost the running-run
// guard.
func TestDestroyForceFlagsAreIndependent(t *testing.T) {
flags := destroyCmd.Flags()

force := flags.Lookup("force")
if force == nil {
t.Fatal("--force should still exist")
}
forceRunning := flags.Lookup("force-running")
if forceRunning == nil {
t.Fatal("--force-running should exist as its own flag")
}
if forceRunning.Shorthand != "" {
t.Errorf("--force-running should not take a shorthand, got -%s", forceRunning.Shorthand)
}

// Each flag must move only its own variable.
t.Cleanup(func() {
destroyForce, destroyForceRunning = false, false
_ = flags.Set("force", "false")
_ = flags.Set("force-running", "false")
})

if err := flags.Set("force", "true"); err != nil {
t.Fatalf("set --force: %v", err)
}
if !destroyForce {
t.Error("--force should set destroyForce")
}
if destroyForceRunning {
t.Error("--force must NOT grant running-run teardown")
}

destroyForce = false
if err := flags.Set("force-running", "true"); err != nil {
t.Fatalf("set --force-running: %v", err)
}
if !destroyForceRunning {
t.Error("--force-running should set destroyForceRunning")
}
if destroyForce {
t.Error("--force-running must NOT skip the extraction-snapshot guard")
}
}
80 changes: 74 additions & 6 deletions cmd/moat/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func (s *containerSection) Print(w io.Writer) error {
// Check which runtimes are available
var runtimes []string
var dockerRT *container.DockerRuntime
identity := engineUnknown

// Check Docker
if rt, err := container.NewDockerRuntime(false); err == nil {
Expand All @@ -112,7 +113,22 @@ func (s *containerSection) Print(w io.Writer) error {
if defaultRT.Type() == container.RuntimeDocker {
marker = " (default)"
}
runtimes = append(runtimes, "docker"+marker)

// Client creation doesn't dial, so ping before trusting IsPodmanEngine.
// On timeout or error identity stays engineUnknown — never fail open.
pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
if rt.Ping(pingCtx) == nil {
if isPodman, err := rt.IsPodmanEngine(pingCtx); err == nil {
if isPodman {
identity = enginePodman
} else {
identity = engineDocker
}
}
}
cancel()

runtimes = append(runtimes, dockerRuntimeEntry(marker, identity))
}

// Check Apple Containers
Expand All @@ -131,14 +147,16 @@ func (s *containerSection) Print(w io.Writer) error {
fmt.Fprintln(tw, "Available:\tnone")
}

// Surface an idle podman socket when the engine isn't already confirmed
// podman — a machine may be running alongside a real Docker daemon.
if line := podmanSocketLine(identity, container.PodmanSocketPaths()); line != "" {
fmt.Fprintf(tw, "Podman:\t%s\n", line)
}

// Check for Docker-specific features
if dockerRT != nil {
// Check gVisor
if hasGVisor() {
fmt.Fprintf(tw, "gVisor:\t%s available\n", ui.OKTag())
} else {
fmt.Fprintf(tw, "gVisor:\t%s not available\n", ui.Dim("—"))
}
fmt.Fprintf(tw, "gVisor:\t%s\n", gvisorLine(identity, hasGVisor()))

// Check BuildKit
buildkit := os.Getenv("DOCKER_BUILDKIT")
Expand Down Expand Up @@ -467,6 +485,56 @@ func (s *storageSection) Print(w io.Writer) error {
return tw.Flush()
}

// engineIdentity is what doctor could confirm about the engine behind the
// Docker-API client. A failed ping or IsPodmanEngine error leaves it
// engineUnknown, which must be treated as untrusted rather than real Docker.
type engineIdentity int

const (
engineUnknown engineIdentity = iota
engineDocker
enginePodman
)

// dockerRuntimeEntry formats the "Available:" entry for the Docker runtime,
// labeling it when the engine is confirmed podman. marker is appended as-is
// (e.g. " (default)"). engineUnknown renders as plain docker — the label must
// not speculate about an identity doctor never confirmed.
func dockerRuntimeEntry(marker string, identity engineIdentity) string {
label := "docker"
if identity == enginePodman {
label = "docker (podman)"
}
return label + marker
}

// gvisorLine formats the doctor "gVisor:" status line. Podman's compat /info
// lists every OCI runtime in containers.conf — runsc included — whether or not
// it's installed, so a reported runsc can't be trusted from podman, nor from
// an unidentified engine that may well be podman. reported is raw hasGVisor().
func gvisorLine(identity engineIdentity, reported bool) string {
switch {
case !reported:
return ui.Dim("—") + " not available"
case identity == enginePodman:
return ui.WarnTag() + " reported by engine — unverified (podman lists configured OCI runtimes even when not installed)"
case identity == engineUnknown:
return ui.WarnTag() + " reported — engine identity unverified (daemon did not respond to ping)"
default:
return ui.OKTag() + " available"
}
}

// podmanSocketLine formats the doctor "Podman:" status line, or "" when it
// should be suppressed — for enginePodman the "Available:" line already says
// so. sockets is the stat-only result of container.PodmanSocketPaths().
func podmanSocketLine(identity engineIdentity, sockets []string) string {
if identity == enginePodman || len(sockets) == 0 {
return ""
}
return fmt.Sprintf("socket found at %s — use --runtime podman", strings.Join(sockets, ", "))
}

// hasBuildx checks if docker buildx is available
func hasBuildx() bool {
cmd := exec.Command("docker", "buildx", "version")
Expand Down
76 changes: 76 additions & 0 deletions cmd/moat/cli/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,82 @@ func TestGetTokenPrefix(t *testing.T) {
}
}

func TestDockerRuntimeEntry(t *testing.T) {
tests := []struct {
name string
marker string
identity engineIdentity
expected string
}{
{"confirmed docker, not default", "", engineDocker, "docker"},
{"confirmed docker, default", " (default)", engineDocker, "docker (default)"},
{"confirmed podman, not default", "", enginePodman, "docker (podman)"},
{"confirmed podman, default", " (default)", enginePodman, "docker (podman) (default)"},
{"unknown identity, not default", "", engineUnknown, "docker"},
{"unknown identity, default", " (default)", engineUnknown, "docker (default)"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := dockerRuntimeEntry(tt.marker, tt.identity)
if result != tt.expected {
t.Errorf("dockerRuntimeEntry(%q, %v) = %q, want %q", tt.marker, tt.identity, result, tt.expected)
}
})
}
}

func TestGvisorLine(t *testing.T) {
tests := []struct {
name string
identity engineIdentity
reported bool
want string
}{
{"confirmed docker, gVisor reported", engineDocker, true, "✓ available"},
{"confirmed docker, gVisor not reported", engineDocker, false, "— not available"},
{"confirmed podman, gVisor reported (untrustworthy)", enginePodman, true, "⚠ reported by engine — unverified (podman lists configured OCI runtimes even when not installed)"},
{"confirmed podman, gVisor not even listed", enginePodman, false, "— not available"},
{"unknown identity, gVisor reported", engineUnknown, true, "⚠ reported — engine identity unverified (daemon did not respond to ping)"},
{"unknown identity, gVisor not reported", engineUnknown, false, "— not available"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := gvisorLine(tt.identity, tt.reported)
if result != tt.want {
t.Errorf("gvisorLine(%v, %v) = %q, want %q", tt.identity, tt.reported, result, tt.want)
}
})
}
}

func TestPodmanSocketLine(t *testing.T) {
tests := []struct {
name string
identity engineIdentity
sockets []string
want string
}{
{"confirmed docker, socket present", engineDocker, []string{"/tmp/podman.sock"}, "socket found at /tmp/podman.sock — use --runtime podman"},
{"confirmed docker, no socket", engineDocker, nil, ""},
{"unknown identity, socket present", engineUnknown, []string{"/tmp/podman.sock"}, "socket found at /tmp/podman.sock — use --runtime podman"},
{"unknown identity, no socket", engineUnknown, nil, ""},
{"confirmed podman, socket present (suppressed — Available already says podman)", enginePodman, []string{"/tmp/podman.sock"}, ""},
{"confirmed podman, no socket", enginePodman, nil, ""},
{"confirmed docker, multiple sockets", engineDocker, []string{"/tmp/a.sock", "/tmp/b.sock"}, "socket found at /tmp/a.sock, /tmp/b.sock — use --runtime podman"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := podmanSocketLine(tt.identity, tt.sockets)
if result != tt.want {
t.Errorf("podmanSocketLine(%v, %v) = %q, want %q", tt.identity, tt.sockets, result, tt.want)
}
})
}
}

func TestPrintClaims(t *testing.T) {
claims := map[string]interface{}{
"exp": float64(1735689600), // Fixed timestamp
Expand Down
Loading