feat(container): support podman via its Docker-compatible socket - #444
Open
grinnellian wants to merge 36 commits into
Open
feat(container): support podman via its Docker-compatible socket#444grinnellian wants to merge 36 commits into
grinnellian wants to merge 36 commits into
Conversation
Podman's compat API (v1.44 verified) works with moat's Docker runtime unmodified, so podman is served by the existing DockerRuntime pointed at a podman socket rather than a new Runtime implementation: - Auto-detect podman sockets alongside Rancher Desktop's in alternativeDockerSockets(): podman machine API sockets on macOS ($TMPDIR/podman/*-api.sock), rootless ($XDG_RUNTIME_DIR) and rootful (/run/podman) sockets on Linux. - Accept --runtime podman / MOAT_RUNTIME=podman / runtime: podman, which probes podman sockets (or verifies an explicit DOCKER_HOST actually answers as podman) and errors with start hints otherwise. - Add DockerRuntime.IsPodmanEngine, identifying podman by the 'Podman Engine' entry in /version Components.
Covers install and machine setup (including the podman 6 libkrun/krunkit provider pitfall on macOS), Linux socket activation, DOCKER_HOST usage, auto-detection, --runtime podman, and verified caveats: podman's compat API reports containers.conf OCI runtimes (runsc) as available even when not installed, root-user base image requirement, and podman >= 4.1 for the host-gateway sentinel.
Label the Available entry 'docker (podman)' when the connected engine is podman's compat API (confirmed via ping + IsPodmanEngine), and stop printing an unconditional 'gVisor: available' there — podman's /info lists every containers.conf OCI runtime (including runsc) whether or not it is installed, so the doctor line now says the report is unverified.
Review findings on the podman branch: - Forced docker (--runtime docker / MOAT_RUNTIME=docker / runtime: docker) no longer falls back to podman sockets; its probe covers genuine Docker engines only (Rancher Desktop). Auto-detect and existing-run reconnection still probe all candidates. - Socket probing reports why a found socket was unusable (e.g. gVisor required but unavailable) instead of claiming no socket was found. - IsPodmanEngine returns (bool, error) and no longer caches transient /version failures as 'not podman'. - Under podman with sandbox enabled, warn once that the engine-reported runsc listing is unverified (podman lists configured OCI runtimes even when not installed). - Test hermeticity: rootful podman socket path is now a test seam; added fake-engine tests for both DOCKER_HOST verification directions.
Review findings on the podman branch: doctor no longer fails open when
the docker ping times out (unknown engine identity gets an honest
'unverified' gVisor annotation instead of resurfacing the trusting
report), and when docker is unreachable but a podman socket exists on
disk, doctor points at it ('use --runtime podman') without dialing it
or mutating DOCKER_HOST.
Runs record only Runtime="docker" in metadata, so lifecycle commands in a fresh process re-probed the default Docker socket; a run created on a podman (or Rancher Desktop) socket while a real Docker daemon was also reachable would reconnect to the wrong engine — container-not-found on stop/logs and a leaked container on the actual engine. Run metadata now records the DOCKER_HOST endpoint the run was created against (docker_host, additive field), and reconnection routes such runs through a host-pinned Docker runtime (RuntimePool.GetDockerAt) without mutating the process-wide DOCKER_HOST.
- GetDockerAt: take a context, construct and ping outside the pool mutex (a wedged endpoint no longer serializes startup N x 5s or starves Default/Get/Close), negatively cache failed hosts, and append a podman-aware recovery hint (machine start command + metadata.json breadcrumb) when a recorded podman endpoint is dead. - NewDockerRuntimeWithHost: layer WithHost over FromEnv so TLS and API-version env config survive reconnects to tcp:// endpoints. - ForEachAvailable: visit host-pinned runtimes so clean/status see images, containers, and networks on a podman engine (skipping same-endpoint duplicates via new DaemonHost accessor). - MOAT_RUNTIME=podman auto-probe verifies candidates answer as podman (IsPodmanEngine) instead of trusting the candidate list. - Linux rootless detection falls back to /run/user/<uid> when XDG_RUNTIME_DIR is unset (sudo/cron/CI). - 'no container runtime available' error now suggests --runtime podman. - Pin previously-untested behavior: forced docker never falls back to a podman socket (mutation-verified gap), concurrent GetDockerAt does not block the pool, negative-cache and ctx-cancellation paths.
- doctor: list a found podman socket whenever the connected engine is not itself podman (previously only when engine identity was unknown). - installation: state real auto-detection precedence (Apple containers win on macOS 26+ Apple Silicon; podman is reached explicitly or when Docker's default socket is dead), scope libkrun-default to podman 6.x and the machine-socket glob to podman 5.x+, and replace the verify block with live-verified output. - README: correct Apple containers requirement to macOS 26+ on the enumeration lines this branch touches.
Endpoint pinning was one-directional: a run created on the default Docker socket recorded docker_host="" (raw DOCKER_HOST env), and on reconnect "" meant 'the current process's default docker-type engine'. With MOAT_RUNTIME=podman exported, 'moat stop' on such a run asked podman, got not-found, warned, marked the run stopped, and tore down its proxy registration while the container kept running on Docker. Run creation now records the runtime's resolved DaemonHost() (never empty for docker-type runtimes), so reconnection is pinned in both directions; empty now only ever means a legacy run, which keeps the old pool-default routing. The reconnect routing decision is collapsed into one shared helper (runtimeForEndpoint) used by both runtimeForRun and run reconciliation, so the two paths cannot drift.
The forced-docker-excludes-podman test and its auto-detect companion skipped whenever a live default dockerd was reachable — which includes GitHub's ubuntu-latest runners, so the regression guard for the forced-docker hardening ran on effectively no machine. Add a newDefaultDockerRuntime seam (mirroring podmanRootfulSocket) so the tests deterministically force the default-socket-unreachable precondition and execute everywhere; production behavior unchanged. Add the missing companion that pins the podman auto-probe rejecting a non-podman engine found on a candidate socket. Both mutation-verified. shortTempDir works around macOS's unix-socket path-length limit the skip had been masking.
A run started with --runtime podman uses the Docker runtime over a podman socket, so its recorded runtime type is 'docker' — 'moat list' and 'moat status' showed 'docker' for a run the user explicitly asked to run on podman. Label it 'docker (podman)' when the recorded DOCKER_HOST endpoint is a podman socket, matching 'moat doctor'. Derived from the recorded endpoint string with no live engine call, so listing stays cheap and side-effect-free.
For a run with no recorded engine endpoint (created before per-run docker_host tracking), 'moat stop' resolving to a docker-type engine that reports the container as not-found is ambiguous: the container may be genuinely gone, or still running on a different engine than the one resolved (e.g. started on Docker, stopped under MOAT_RUNTIME=podman). Previously moat only warned and recorded the run stopped, silently orphaning a live container and tearing down its proxy registration. Stop now fails loudly in that case, reverts the run to its prior state, and points at recovery: retry with the engine the run was created on, or 'moat destroy --force' if the container is genuinely gone. When the endpoint IS recorded we are pinned to the right engine, so not-found means genuinely gone and cleanup proceeds unchanged; Apple's StopContainer swallows not-found, so this only affects docker. 'moat destroy --force' now also tears down a still-running run (skipping the stop-it-first guard) so a run that can't be stopped cleanly is never wedged. Verified live: wrong-engine stop leaves the real container up and the run running; correct-engine retry and --force both recover.
Two follow-ups from adversarial verification of the podman labeling: - 'moat status' listed 'docker' twice in the Runtimes header when a podman run was active, because ForEachAvailable now also visits the host-pinned podman runtime (Type() 'docker'). Dedupe the header by runtime type; the per-run RUNTIME column still distinguishes the engine. Image enumeration is unchanged (still visits every engine). - The 'docker (podman)' label matched a bare 'podman' substring in the endpoint, so a docker socket under a path merely containing 'podman' (e.g. a user home) could be mislabeled. Match the socket basename instead — podman sockets are always named podman.sock or podman-machine-<name>-api.sock.
Stop set StateStopping before resolving the run's runtime, but the resolution-error path returned without restoring it, so the run was stuck in Stopping and every subsequent Stop hit the 'already stopped' early return and silently no-opped while the container may still exist. Pre-existing flaw, but endpoint pinning promotes runtime-resolution failure to a routine event (a pinned podman endpoint whose machine is stopped fails GetDockerAt and is negative-cached), so restore the prior state before returning, mirroring the ambiguous-not-found branch below.
The 'docker (podman)' label matched 'podman' in the socket basename only, but a custom-named podman machine's socket on macOS is $TMPDIR/podman/<name>-api.sock — e.g. dev-api.sock — which loses the 'podman' filename prefix while keeping the podman parent directory. Such runs silently displayed plain 'docker'. Match when the basename contains 'podman' OR the immediate parent directory is exactly 'podman'; a docker socket under a path merely containing 'podman' elsewhere (e.g. a user home) still doesn't match.
…podman MOAT_RUNTIME=podman with DOCKER_HOST at a non-podman engine fails hard, but the mirror case — MOAT_RUNTIME=docker with DOCKER_HOST at a podman engine — was accepted with no identity check at all. Keep it accepted (an explicit DOCKER_HOST is the user's deliberate endpoint choice, and 'docker' also names the client implementation actually in use, which works unmodified against podman's compat API), but surface the mismatch with a warning that points at --runtime podman. The asymmetry is deliberate: 'podman' is purely an identity claim, so a mismatch there is a contradiction and still fails. The safety concern is backstopped by the unverified-gVisor warn-once and by engine-side creation failure when runsc is absent. Identity detection is best-effort: if IsPodmanEngine errors, say nothing rather than speculate, matching doctor's unknown-identity stance.
'podman runtime requested ... but the podman socket was found but unusable' read badly; now: 'a podman socket was found but is unusable'.
Live verification on Homebrew podman 6.0.1_1 contradicted the flat 'Podman 6.x defaults to the libkrun provider' claim: podman machine init came up applehv/vfkit first try. The pitfall was observed on 6.0.0, so the default is build-dependent. Keep the if-you-see-this-error guidance; state the provider default as conditional.
Two lint failures in code this branch adds — both would fail CI's lint job (upstream main lints clean at 0 issues): - govet shadow in newPodmanRuntimeWithPing: the Ping error shadowed the outer err from NewDockerRuntime. Renamed to pingErr, matching the pingErr convention already used elsewhere in detect.go. - gosec G112 in the fake Docker API test server: no ReadHeaderTimeout. Set to 10s, matching the rest of the codebase. (_test.go files have govet disabled but not gosec, so this one fires in tests.)
The podman changes carried roughly twice the commentary the surrounding code does: 44% of added non-test lines were comments against a 19.5% repo baseline, and 15.9% in tests against 7.8%. Now 32.6% and 12.1%, within the range of recent feature work in this repo. What went: rationale that belongs in a PR description rather than the source — the design argument for the forced-runtime asymmetry, the narration of which files a future ctx refactor would touch, and test docs written as review responses. The DockerHost field carried a 17-line essay where its neighbour Runtime gets two. What stayed: the non-obvious why. FromEnv-before-WithHost ordering and what breaks if reversed, the basename-vs-parent socket match and the custom-machine path that motivates it, why a matching socket path isn't proof of engine identity, and the test seams that defeat a live dockerd on CI runners. Comments only — no code lines touched.
TestStopBenignNotFoundWhenEndpointRecorded set DockerHost to the real docker socket. That routes through GetDockerAt, which builds a live client for the endpoint and so bypasses the injected flexibleRuntime entirely — stopFn was never called. The test passed only because a real daemon answered "no such container", and failed outright anywhere without Docker. CI has a daemon, so it would have stayed green forever while asserting nothing. Seed the pool with the stub for the pinned endpoint (NewRuntimePoolWithDockerHost) and point the run at a path nothing listens on, so resolution has to come from the seam. Assert StopContainer actually ran, so the test can't go vacuous again.
The new loud failure fired whenever a docker-type run had no recorded endpoint and its container came back not-found. Every run created before endpoint recording existed has no docker_host and nothing backfills it, so any pre-existing run whose container was removed out of band (docker rm, a prune, a daemon reset) started hard-erroring instead of stopping cleanly — a Docker-path regression landing on users who will never install podman. Gate it on a podman socket actually being present, which is the only situation where "gone" and "running on the other engine" are genuinely ambiguous. Hosts without podman keep the previous behavior. The check stats the filesystem rather than dialing, to keep an error path cheap; a stale socket file can still trigger the loud fail, which errs toward the safe side.
--force meant one thing: skip the volume-mode extraction-snapshot guard. It had quietly acquired a second, sharper meaning — tear down a still- running run without stopping it — so anyone passing --force for the data-loss guard silently also lost the running-run guard. It was also the documented escape hatch for Stop's loud not-found, leaving the two changes propping each other up. Running-run teardown moves to --force-running. The flags are independent in both directions, with a test that pins that.
…tically podmanSocketCandidates globs $TMPDIR/podman/*-api.sock, and filepath.Glob returns sorted results — so with machines "dev" and "podman-machine-default" both running, dev-api.sock won on alphabetical order alone. IsPodmanEngine can't disambiguate, because both really are podman. Follow what podman itself would talk to: CONTAINER_CONNECTION when set, otherwise the default connection recorded in podman-connections.json. Non-default machines stay in the list as fallbacks, and an absent, unreadable, or unmatched default leaves the order untouched. Machine sockets now carry their machine name in the candidate description. Verified on macOS by cross-compiling the package's test binary and running it natively — the darwin-only paths never execute in CI's Linux runner.
…t order podmanGvisorWarnOnce is process-global, so the warning could only ever be seen by whichever test ran first — and nothing asserted it at all. Extract the warn into warnPodmanGvisorUnverified, add a reset helper, and cover both halves of the contract: the text names the cause and the escape hatch, and it fires exactly once however many runtimes are built. A second test guards the reset seam itself, so the first can't quietly stop asserting.
The README's "macOS 15+" for Apple containers is stale — runtime detection itself reports 26+ — but that is not a podman change and does not belong in this branch. Reverted here and carried on its own branch (fix/readme-apple-macos-version) so it can be reviewed on its own merits. The podman additions to the same lines stay.
…cision Three different stories were in play: the CHANGELOG asserted a flat "Requires Podman >= 4.1", the installation guide said macOS auto-detection needs the 5.x socket layout, and the PR described testing on 6.0.0. State each precisely and separate the functional floor (4.1+ for host-gateway) from the macOS auto-detection floor (5.x layout) from what was actually exercised (6.0.0 and 6.0.1 on macOS arm64). Also documents why "podman" exists as a runtime value when it isn't a separate runtime — auto-detection only probes alternatives when the default Docker socket is unreachable, so on a host running both engines it always picks Docker, and the value additionally asserts engine identity where a mis-set DOCKER_HOST would silently run on Docker — plus the new --force-running flag and macOS machine selection.
Six package-level mutable vars had accumulated purely so tests could redirect them — podmanRootfulSocket, newDefaultDockerRuntime, xdgRuntimeDirFallback and podmanConnectionsPath among them. That put test scaffolding in the shipped binary and left the package unsafe for t.Parallel() without saying so. Collapse them into a single unexported detectEnviron value with an export_test.go swap/restore helper, and state the parallelism constraint where it can be seen. Defaults resolve exactly as before. The pool gets three fixes in the same pass. Its negative cache for a failed endpoint had no TTL, reasoning by analogy to the unavailable map — but that map caches runtime *types*, which don't come and go, whereas a podman machine restarting mid-session is routine and moat has a long-lived daemon; entries now expire after 30s against an injectable clock. Close() and ForEachAvailable deduped only values that asserted to *DockerRuntime, so a runtime seeded into both maps was closed twice and visited twice; both now dedupe by identity. And ForEachAvailable's comment claimed it made podman visible to clean/status, which overstates it: the map is populated lazily by GetDockerAt, so an engine with no persisted runs is still invisible, and the ordering that makes it work at all is an unenforced cross-package contract. Say that instead. Finally, tryDockerSocketCandidates had three hand-placed cancel() calls and handed the verify callback a context the ping had already spent. Extract the per-candidate body so defer can own the lifetime, give verify its own timeout, and close the runtime on the rejection paths it was leaking.
Forcing MOAT_RUNTIME=docker onto a podman socket used to pay a blocking ServerVersion call on every startup where DOCKER_HOST was set, purely so it could maybe print a warning — a tax on the common remote-Docker and Rancher Desktop paths for a message they never see. Meanwhile the same mismatch behaved three different ways depending on how it was reached: hard error one way, warning another, silence on auto-detection. Settle on two rules. Asking for podman and not getting it is an error, because "podman" names the engine and getting Docker instead is simply wrong. Every other identity fact is reported rather than warned about — list and status show the engine per run, doctor reports the engine behind the endpoint it can reach. Add EngineName as a thin cached wrapper over IsPodmanEngine so identity costs one probe per runtime whenever something actually needs it, and drop the eager one. The gVisor caveat gets teeth. Podman's compat API lists every OCI runtime in containers.conf whether or not the binary exists, so a reported runsc proves nothing — moat can't verify the isolation it promises. Warning about that is right, but the old text left the user nowhere to go. It now names the check (podman machine ssh -- which runsc on macOS, which runsc on Linux), and a container creation that fails under runsc on a podman engine is wrapped with the likely cause instead of surfacing a raw OCI error. The wrap preserves the original through %w so errdefs classification still works, and it reuses the cached identity rather than putting a network probe on an error path.
Because a podman run still records its Runtime as "docker", the display label was recovered afterwards by sniffing the socket path — checking whether path.Base(DockerHost) contained "podman", or its parent directory was named "podman". That is a heuristic, applied to a URL, using path functions, to recover something moat could simply have asked. It mislabels a machine whose name lacks "podman" and podman over tcp://, and it left list and status quietly disagreeing with doctor, which does ask the engine. Ask once, at creation, while a live connection is in hand: EngineName returns an authoritative "docker" or "podman" and it is persisted alongside the run. Engine is display-only and DockerHost keeps its own job of reconnecting to the right endpoint; neither is derived from the other. Identification is bounded and never fails Create — a run that can't be labelled is still a run. A run recorded before the field existed has no Engine and renders as plain "docker". Empty means unknown, not "not podman", so nothing falls back to sniffing the path again.
The ambiguous-not-found guard fired whenever a podman socket file existed on disk. On any host with podman installed that turned the ordinary cases into errors: a container removed by docker system prune, lost to a reboot, started with --rm, or deleted by hand would fail moat stop with a paragraph telling the user to run moat destroy --force-running. The container wasn't orphaned, it was just gone. The guard is protecting against a real bug — recording a run stopped while its container runs on another engine — but it was catching the common case to reach the rare one. Ambiguity needs two engines, so require one: a podman endpoint that answers a ping, identifies as podman, and is not the endpoint just queried. That last clause matters on its own — where podman is the only engine there is nothing to confuse it with, and the old condition would have hard-failed that too. Distinctness is decided by filesystem identity rather than by comparing endpoint strings. With the podman-docker package installed, /run/docker.sock is a symlink to /run/podman/podman.sock, so string comparison sees two endpoints where one engine exists — and /var/run is commonly a symlink to /run besides. A stat failure never skips a candidate: guessing wrong in that direction hides an orphan.
The 4.1 floor was wrong. Podman accepts the host-gateway sentinel in --add-host from 4.7.0, not 4.1, so every host between those versions was being told it was supported. The floor is also Linux-only: synthHostStrategy emits that sentinel just for a Docker-API runtime on a Linux host, and macOS reaches the host through MOAT_EXTRA_HOSTS instead — so the macOS rows were citing a constraint that never applied to them. macOS is gated by the machine socket layout alone. Three claims contradicted the code. The changelog told users to recover with moat destroy --force, which stopped being true once the running-run teardown got its own flag; destroy.go says plainly that the two are independent. It also described moat stop as failing whenever a run has no recorded endpoint, which is no longer the trigger. And both the changelog and the runtimes doc said the recorded engine shows up in moat doctor — doctor live-probes the endpoint it can reach and has no per-run display at all, so that conflated two different things. The runtimes doc claimed forcing podman only errors against a confirmed non-podman engine; it also errors when DOCKER_HOST is unreachable, when the engine can't be identified, when a socket is found but unusable, and when no socket is found at all. Also trims the two podman changelog bullets back toward the length of their neighbours, moving the detail they were carrying into the reference docs, and records what auto-detection does not cover: Windows has no podman candidates, so nothing finds it there.
Both entries still linked to majorcontext#435, the earlier attempt that was closed before review. Repoint them at majorcontext#444, which is where this work actually lands.
grinnellian
added a commit
to grinnellian/moat
that referenced
this pull request
Aug 2, 2026
…ontext#444 PR majorcontext#435 is closed; work continues on feat/podman-rebase as upstream PR majorcontext#444. This fork-meta plan stands as a historical record. Co-Authored-By: Claude <noreply@anthropic.com>
grinnellian
added a commit
to grinnellian/moat
that referenced
this pull request
Aug 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This reopens the work from #435, opened by an over-eager Claude. Now deeply reviewed by human eyes; sorry about the prior.
Four questions first, because these are the parts I don't think I should decide on my own.
1. gVisor under podman — is a warning enough?
Podman's compat API reports every OCI runtime listed in
containers.confas available whether or not the binary is installed, so a reportedrunscproves nothing. That means moat cannot verify the isolation it promises when it runs on podman. This PR warns once, names the command to check, and turns a creation failure underrunscinto a targeted diagnostic instead of a raw OCI error — but it still proceeds. For a tool whose whole pitch is the sandbox, that may be the wrong default.I can see three answers: require an explicit acknowledgement for podman + sandbox, verify
runscout of band, or leave it and document the caveat. I didn't want to pick unilaterally, and I'd rather drop podman from the supported matrix than have it quietly weaken the guarantee. Happy to implement whichever you prefer.2. Should
podmanexist as a runtime value at all?It isn't a separate
Runtimeimplementation but ratherDockerRuntimepointed at a podman socket, and a run still recordsRuntime: "docker". There's value in asserting engine identity: a mis-setDOCKER_HOSTwould otherwise run on Docker silently. But if you'd rather this were justDOCKER_HOSTplus documentation, that's a much smaller change and I'd understand the call.3. Would you rather this were split?
It's roughly 1,200 lines across three separable pieces: per-run engine pinning (which fixes Rancher Desktop independently of podman), podman detection and labeling, and the
moat stopbehavior change. I kept it as one PR because the first piece doesn't fully justify itself without the second but they should separately reviewable, and I'll split it on request.4. Is the
moat stopchange in scope here?It changes behavior for non-podman users too. Previously a not-found container only warned and still recorded the run stopped, which can mark a run stopped while its container keeps running on another engine. It now fails loudly — but only when a different, live podman endpoint is reachable, so a container that is simply gone still stops cleanly. If you'd rather that were its own PR, or an issue first, I'll pull it out.
What this does
Podman's Docker-compatible API works with moat's existing Docker runtime unmodified, so this adds podman without a new
Runtimeimplementation:DOCKER_HOSTis unset.--runtime podman,MOAT_RUNTIME=podman, orruntime: podman. Forcing podman errors if a usable podman engine can't be reached, so it can't silently succeed against real Docker.moat list/moat statuslabel the engine without inferring it from a socket path.What the self-review changed
The things worth calling out, since they were wrong before:
--add-host host-gatewaylanded in podman 4.7.0, and the requirement is Linux-only —synthHostStrategyemits that sentinel only for a Docker-API runtime on a Linux host, so the macOS rows were citing a constraint that never applied to them.moat destroy --forceafter that teardown had been given its own flag.moat stopguard was too broad — it fired whenever a podman socket file existed, sodocker system pruneon a podman-equipped host brokemoat stop. It now requires a reachable, distinct engine, compared by filesystem identity rather than by path string (withpodman-dockerinstalled,/run/docker.sockis a symlink to the podman socket, so string comparison sees two engines where one exists).Known gaps
destroyrunning-run guard is unit-level only. I didn't add an e2e case I couldn't run.DOCKER_HOSTisn't blocked by anything in moat, but I haven't verified it end to end and haven't claimed it works.Notes
internal/providers/claude.TestResolveClaudeCredential_SkipsMigrationInReadOnlyDirfails in any root container (root bypasses the0500permission it relies on). It fails identically onmain— flagging so it isn't mistaken for fallout from this branch.Verified on the branch: unit suite green under
-race,go vetclean including under thee2ebuild tag, andgolangci-lintat exactly the same nine pre-existingpreallocfindings asmain— no new lint.As with #435, and to keep that disclosure attached to this one: this branch was written by an AI agent (Claude) working for me, and I've reviewed (and actually run) it before submitting. Happy to answer for any of it.