Container staging (and its live-token env-file) lands in the Windows profile root; make it platform-correct, hidden, and per-host overridable #1160

Open
opened 2026-07-13 03:52:29 +00:00 by coilyco-ops · 2 comments
Member

Symptom

On Kai's Windows host, ward drops per-run container asset dirs straight into the user profile root:

C:\Users\firem\ward-container-assets-1922319142

That is the wrong place, it is not hidden, and it is not overridable per host. All three are the same root cause.

Root cause

launchStagingDir() (cmd/ward/container.go:430) returns bare $HOME on every platform:

func launchStagingDir() string {
	home, err := os.UserHomeDir()
	if err != nil || home == "" {
		return os.TempDir()
	}
	return home
}

writeContainerAssets then calls os.MkdirTemp(root, containerAssetsPrefix+"*"), so the dir lands as a sibling of the user's profile folders, not inside any app-state directory.

The reason it is $HOME at all is a Linux-specific constraint: a snap-packaged Docker is confined and cannot bind-mount out of /tmp, so staging was moved to home (ward#569, ward#574). That constraint was applied unconditionally to every OS. Windows has no snap, so it inherits the workaround and none of the reason.

Three defects, one fix

1. Wrong location on Windows. Windows app state belongs under %LOCALAPPDATA% (C:\Users\<user>\AppData\Local\ward\). Nothing that is not a user document belongs in the profile root. Resolve the staging root per platform:

  • Windows - %LOCALAPPDATA%\ward\staging (os.UserCacheDir() returns %LocalAppData% on Windows and is the idiomatic Go call).
  • Linux - keep it under $HOME because the snap-docker constraint is real, but put it under $HOME/.ward/staging rather than loose in $HOME. Verify a snap docker can still bind-mount from a dotdir under home before shipping, since that is the whole reason this code exists. If it cannot, say so in the issue rather than silently reverting.
  • macOS - ~/.ward/staging is fine and consistent with the existing ~/.ward/ surface.

2. Not hidden. Even after the move, no bare ward-* entry should be visible in a user's home or profile root. Nesting under .ward/ (Unix) or AppData\Local (Windows) solves this by construction. On Windows, also consider setting the hidden attribute if any path remains in the profile root.

3. No per-host override. Kai wants staging on the X: drive on her machine. Per the config-placement split in agentic-os AGENTS.md, "where this one host stages files" is operator-local preference (per-host, hand-edited, not embedded), so it belongs in ~/.ward/config.yaml, which ward already reads (wardGlobalConfig, cmd/ward/agent_director.go:510). Add a key such as:

container:
  staging-dir: X:\ward\staging

Precedence: explicit config value, then a WARD_STAGING_DIR env override if that fits ward's existing flag conventions, then the per-platform default. Do not put this in ward-kdl - that is the embedded fleet-tuning axis, and this is per-host.

The part that matters most: live tokens are in the profile root

launchStagingDir() is also where ward writes the docker --env-file (cmd/ward/container.go:357, prefix ward-forgejo-env-). ward's own comment on sweepStaleLaunchEnvFiles says:

$HOME is never OS-reaped and each orphan holds a live 0600 token (ward#569).

So live Forgejo credentials are being written into C:\Users\firem\, unhidden. 0600 is a POSIX mode and does not map onto NTFS ACLs the way that comment assumes - Go's os.WriteFile perm bits are largely advisory on Windows, and the file inherits the parent directory's ACL. Treat this as the priority: the env-file must move with the staging dir, and on Windows its permissions should be asserted rather than assumed. Confirm what the resulting ACL actually is rather than trusting the mode argument.

Do not break

  • Both stale sweeps key off the staging root (sweepStaleContainerAssets, sweepStaleLaunchEnvFiles). They must follow the new root, or orphaned asset dirs and token-bearing env files will accumulate forever, unreclaimed.
  • Migration: existing leftovers in $HOME / the profile root will never be swept once the root moves. Sweep the old location once on startup (or at minimum surface what is there), so today's orphans - including the token-bearing ward-forgejo-env-* files - do not linger indefinitely.
  • Docker Desktop drive sharing: a bind-mount from X: only works if that drive is shared with Docker Desktop. If the configured staging dir cannot be bind-mounted, fail loudly at bring-up with a message naming the drive-sharing setting. A container that silently launches without its assets mounted is the bad outcome here.
  • container_test.go asserts the sweep-recognizable prefix on the assets dir. Keep that contract.

Done condition

On Windows, no ward-* entry appears in the profile root, staging honors container.staging-dir from ~/.ward/config.yaml (verified against X:), the env-file lands beside the assets under that root, the stale sweeps still reclaim both, and old profile-root leftovers are cleaned up once.

## Symptom On Kai's Windows host, ward drops per-run container asset dirs straight into the user profile root: ```text C:\Users\firem\ward-container-assets-1922319142 ``` That is the wrong place, it is not hidden, and it is not overridable per host. All three are the same root cause. ## Root cause `launchStagingDir()` (`cmd/ward/container.go:430`) returns bare `$HOME` on every platform: ```go func launchStagingDir() string { home, err := os.UserHomeDir() if err != nil || home == "" { return os.TempDir() } return home } ``` `writeContainerAssets` then calls `os.MkdirTemp(root, containerAssetsPrefix+"*")`, so the dir lands as a **sibling of the user's profile folders**, not inside any app-state directory. The reason it is `$HOME` at all is a **Linux-specific** constraint: a snap-packaged Docker is confined and cannot bind-mount out of `/tmp`, so staging was moved to home (ward#569, ward#574). That constraint was applied unconditionally to every OS. Windows has no snap, so it inherits the workaround and none of the reason. ## Three defects, one fix **1. Wrong location on Windows.** Windows app state belongs under `%LOCALAPPDATA%` (`C:\Users\<user>\AppData\Local\ward\`). Nothing that is not a user document belongs in the profile root. Resolve the staging root per platform: * Windows - `%LOCALAPPDATA%\ward\staging` (`os.UserCacheDir()` returns `%LocalAppData%` on Windows and is the idiomatic Go call). * Linux - keep it under `$HOME` because the snap-docker constraint is real, but put it under `$HOME/.ward/staging` rather than loose in `$HOME`. Verify a snap docker can still bind-mount from a dotdir under home before shipping, since that is the whole reason this code exists. If it cannot, say so in the issue rather than silently reverting. * macOS - `~/.ward/staging` is fine and consistent with the existing `~/.ward/` surface. **2. Not hidden.** Even after the move, no bare `ward-*` entry should be visible in a user's home or profile root. Nesting under `.ward/` (Unix) or `AppData\Local` (Windows) solves this by construction. On Windows, also consider setting the hidden attribute if any path remains in the profile root. **3. No per-host override.** Kai wants staging on the **X: drive** on her machine. Per the config-placement split in agentic-os AGENTS.md, "where this one host stages files" is **operator-local preference** (per-host, hand-edited, not embedded), so it belongs in **`~/.ward/config.yaml`**, which ward already reads (`wardGlobalConfig`, `cmd/ward/agent_director.go:510`). Add a key such as: ```yaml container: staging-dir: X:\ward\staging ``` Precedence: explicit config value, then a `WARD_STAGING_DIR` env override if that fits ward's existing flag conventions, then the per-platform default. Do **not** put this in ward-kdl - that is the embedded fleet-tuning axis, and this is per-host. ## The part that matters most: live tokens are in the profile root `launchStagingDir()` is **also** where ward writes the docker `--env-file` (`cmd/ward/container.go:357`, prefix `ward-forgejo-env-`). ward's own comment on `sweepStaleLaunchEnvFiles` says: > `$HOME` is never OS-reaped and each orphan holds a live 0600 token (ward#569). So live Forgejo credentials are being written into `C:\Users\firem\`, unhidden. **`0600` is a POSIX mode and does not map onto NTFS ACLs** the way that comment assumes - Go's `os.WriteFile` perm bits are largely advisory on Windows, and the file inherits the parent directory's ACL. Treat this as the priority: the env-file must move with the staging dir, and on Windows its permissions should be asserted rather than assumed. Confirm what the resulting ACL actually is rather than trusting the mode argument. ## Do not break * **Both stale sweeps key off the staging root** (`sweepStaleContainerAssets`, `sweepStaleLaunchEnvFiles`). They must follow the new root, or orphaned asset dirs and token-bearing env files will accumulate forever, unreclaimed. * **Migration**: existing leftovers in `$HOME` / the profile root will never be swept once the root moves. Sweep the old location once on startup (or at minimum surface what is there), so today's orphans - including the token-bearing `ward-forgejo-env-*` files - do not linger indefinitely. * **Docker Desktop drive sharing**: a bind-mount from `X:` only works if that drive is shared with Docker Desktop. If the configured staging dir cannot be bind-mounted, **fail loudly at bring-up** with a message naming the drive-sharing setting. A container that silently launches without its assets mounted is the bad outcome here. * `container_test.go` asserts the sweep-recognizable prefix on the assets dir. Keep that contract. ## Done condition On Windows, no `ward-*` entry appears in the profile root, staging honors `container.staging-dir` from `~/.ward/config.yaml` (verified against `X:`), the env-file lands beside the assets under that root, the stale sweeps still reclaim both, and old profile-root leftovers are cleaned up once.
Owner

my specific staging dir on windows should be X:\.ward

my specific staging dir on windows should be `X:\.ward`
Owner

that should NOT be present in this repo, it should only show up in aos

that should NOT be present in this repo, it should only show up in aos
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
coilyco-flight-deck/ward#1160
No description provided.