Skip to content

Feature: Add GOG_HOME (and per-kind GOG_*_DIR) env vars and matching CLI flags to control state paths independently of XDG #622

Description

@alexminza

Summary

Even with full XDG Base Directory Spec support (gogcli#621), gogcli's path resolution remains coupled to operator control over $XDG_*_HOME vars. In environments where the parent process can't or won't set XDG vars portably — agent sandboxes, container runtimes, CI runners — gogcli ends up with the wrong paths.

Add a gogcli-specific override layer above XDG: a primary GOG_HOME umbrella env var (matching CARGO_HOME, RUSTUP_HOME, GOPATH) and per-XDG-kind overrides (GOG_CONFIG_DIR, GOG_DATA_DIR, GOG_STATE_DIR, GOG_CACHE_DIR), plus a --home <path> global CLI flag.

Why GOG_* and not GOGCLI_*

Empirical verification at tag v0.17.0: every env var gogcli currently reads uses the GOG_* prefix — GOG_KEYRING_BACKEND, GOG_KEYRING_PASSWORD, GOG_KEYRING_SERVICE_NAME, GOG_ACCOUNT, GOG_HELP, GOG_COLOR, GOG_TIMEZONE, GOG_AUTH_MODE, GOG_*_API_KEY, GOG_*_BASE_URL. Zero GOGCLI_* env vars exist in source. The GOG_* namespace is gogcli's established convention; this proposal matches it.

(This differs from sibling mcporter's MCPORTER_* convention, but matching gogcli's own internal convention is the right principle — pushing GOGCLI_* would force gogcli to break its own namespace.)

Why a *_HOME umbrella, not just a *_CONFIG_DIR

config.Dir() in internal/config/paths.go is the single function controlling every persisted-state location in gogcli today — config, keyring, credentials, drive downloads, gmail attachments, service-account keys, gmail-watch state, tracking config. After XDG conformance lands, that single function splits into per-kind resolvers (DataDir, StateDir, CacheDir, etc.). The override layer needs to match that shape: one umbrella (GOG_HOME — analogous to CARGO_HOME, RUSTUP_HOME) plus per-kind overrides for operators who want to split lifetimes (durable data on a backed-up volume, cache on tmpfs).

A single GOG_CONFIG_DIR would only cover one of the four+ resolvers, defeating the purpose. Matches the more established CLI convention (cargo, rustup, gh).

Current behavior (after XDG-conformance fix)

Once gogcli#621 lands, the path-resolution chain for each kind is:

  1. $XDG_<KIND>_HOME env var, if set
  2. $HOME/<spec-default>/gogcli (XDG fallback)

There's no gogcli-specific override above XDG. If the parent process strips or rewrites the XDG vars, the operator has no recourse.

Why this matters

gogcli's deployability remains coupled to upstream/sandbox XDG handling. Concrete pain point we hit:

OpenClaw strips XDG_CONFIG_HOME from the inherited environment of subprocesses it spawns, due to a policy oversight where XDG_CONFIG_HOME is listed in blockedOverrideOnlyKeys without a matching entry in allowedInheritedOverrideOnlyKeys (see host-env-security-policy.json). Filed upstream as openclaw#84854.

Even once that bug is fixed, other sandboxes / container runtimes / CI environments may also restrict or rewrite XDG vars. A tool-specific override is the standard escape hatch — it's portable, it doesn't collide with broader sandbox policies that target XDG vars, and it makes operator intent explicit ("I want gogcli's state here") rather than implicit via a shared env var.

mcporter#184 illustrates the inverse failure mode: embedders sometimes set XDG_CONFIG_HOME for an unrelated downstream tool, accidentally redirecting other XDG-aware tools too. A tool-specific override insulates gogcli from this kind of cross-tool XDG collision.

Precedent: mcporter (sibling project in openclaw/) exposes MCPORTER_CONFIG for exactly this reason — to override the XDG-derived config path when XDG resolution is unavailable or undesirable (see path-discovery.ts at v0.11.1).

Proposed solution

Scope: this proposal covers the four XDG Base Directory kinds (config, data, state, cache). The fifth kind from gogcli#621downloads — falls under a separate freedesktop spec (xdg-user-dirs) and is intentionally not part of GOG_HOME's umbrella: downloads are user-visible content with their own routing semantics (operators expect ~/Downloads/-style paths, not state-dir paths). Operators wanting downloads under GOG_HOME should set XDG_DOWNLOAD_DIR explicitly. A symmetric GOG_DOWNLOAD_DIR per-kind override could be added if the maintainer prefers — flagging as an open question.

Per-XDG-kind precedence chain (first match wins, most-specific to least-specific):

  1. GOG_<KIND>_DIR env var (where <KIND> is CONFIG / DATA / STATE / CACHE) — direct directory path, used as-is with no gogcli subdir append. Highest precedence; lets operators split lifetimes (e.g. data on a backed-up volume, cache on tmpfs).
  2. --home <path> CLI flag — per-invocation umbrella. Resolves to <path>/<kind>/ for each kind (flat XDG-kind sub-layout). Wins over GOG_HOME per the cobra-flag-overrides-env convention. Global, persistent across all subcommands.
  3. GOG_HOME env var — durable umbrella with the same flat XDG-kind sub-layout ($GOG_HOME/{config,data,state,cache}/). The intended shape for container entrypoints / systemd units / shell profiles.
  4. $XDG_<KIND>_HOME/gogcli — spec-conformant XDG path, from gogcli#621.
  5. $HOME/<XDG-default>/gogcli — XDG default fallback (e.g. ~/.config/gogcli, ~/.local/share/gogcli, ~/.local/state/gogcli, ~/.cache/gogcli).

Rank 1 (GOG_<KIND>_DIR) wins over both umbrella forms because per-kind overrides represent the most specific operator intent.

Why flat XDG-kind sub-layout under GOG_HOME (not $HOME-style dotted sub-layout): operators setting GOG_HOME=/persist expect to find gogcli's state directly under /persist/, not under /persist/.local/share/gogcli/. Cargo/Rustup precedent backs this — CARGO_HOME contains registry/, git/, bin/ directly (not .local/share/cargo/). The .local/share/-style nesting is meaningful only in the XDG default case where the resolver synthesizes a path under $HOME; once the operator has explicitly named a directory via GOG_HOME, that directory is the gogcli root, with kinds as immediate children.

Sketch (extending the resolver from gogcli#621):

package config

// kindName is introduced by this proposal (not part of gogcli#621).
// gogcli#621 defines xdgDefault() for the XDG-default fallback case;
// kindName() is the flat-layout equivalent for use under $GOG_HOME.
//
// Different from xdgDefault() (gogcli#621): xdgDefault() returns the XDG-spec
// default path under $HOME ("config", ".local/share", ".local/state", ".cache"),
// suitable for the fallback case. kindName() returns the short kind name
// ("config", "data", "state", "cache") for use under operator-named $GOG_HOME,
// matching the CARGO_HOME / RUSTUP_HOME convention of flat sub-layouts.
func kindName(kind XdgKind) string {
    switch kind {
    case KindConfig: return "config"
    case KindData:   return "data"
    case KindState:  return "state"
    case KindCache:  return "cache"
    }
    return ""
}

// kindDir returns the gogcli directory for a given XDG kind.
//
// Resolution order (most specific to least):
//   1. GOG_<KIND>_DIR env var — operator-supplied absolute path; used as-is
//   2. $GOG_HOME/<kindName> — umbrella override, flat XDG-kind sub-layout
//   3. $XDG_<KIND>_HOME/gogcli — spec-conformant XDG path
//   4. $HOME/<XDG-default>/gogcli — XDG default fallback
//
// The --home CLI flag is resolved at the cobra layer by overwriting GOG_HOME
// in the process env before kindDir runs, so it doesn't need its own branch
// here — it appears as rank 2 to the operator but is implemented as a flag
// → env coercion above this resolver.
func kindDir(kind XdgKind) (string, error) {
    // Per-kind direct override
    if perKind := strings.TrimSpace(os.Getenv(gogKindEnvVar(kind))); perKind != "" {
        return perKind, nil  // operator-supplied absolute path; no AppName join
    }
    // Umbrella GOG_HOME — flat XDG-kind sub-layout
    if home := strings.TrimSpace(os.Getenv("GOG_HOME")); home != "" {
        return filepath.Join(home, kindName(kind)), nil
        // e.g. GOG_HOME=/persist → /persist/data for the data kind
        // (or operator overrides per-kind via GOG_DATA_DIR for full control)
    }
    // XDG layer (from gogcli#621)
    if xdg := strings.TrimSpace(os.Getenv(xdgEnvVar(kind))); xdg != "" {
        return filepath.Join(xdg, AppName), nil
    }
    // XDG default
    home, err := os.UserHomeDir()
    if err != nil {
        return "", fmt.Errorf("resolve user home dir: %w", err)
    }
    return filepath.Join(home, xdgDefault(kind), AppName), nil
}

func gogKindEnvVar(kind XdgKind) string {
    switch kind {
    case KindConfig: return "GOG_CONFIG_DIR"
    case KindData:   return "GOG_DATA_DIR"
    case KindState:  return "GOG_STATE_DIR"
    case KindCache:  return "GOG_CACHE_DIR"
    }
    return ""
}

CLI: --home <path> as a persistent global flag on the root cobra command, equivalent to setting GOG_HOME for the duration of the invocation.

Interaction with gogcli#621's public API: the public wrappers added by gogcli#621 (Dir(), DataDir(), StateDir(), CacheDir()) are preserved — they delegate to kindDir(kind) exactly as before, gaining the new override layer transparently. No call-site changes beyond what gogcli#621 already specifies.

Open design questions for the maintainer

  1. Per-kind override semantics — strip the gogcli suffix?: GOG_DATA_DIR=/foo/bar should resolve to /foo/bar (no gogcli join) — same as MCPORTER_CONFIG doesn't auto-join. Convention from other tools (CARGO_HOME, RUSTUP_HOME, GOPATH) is "no auto-join, operator supplies the literal directory." The sketch above implements this; flagging in case maintainer prefers an auto-join variant.
  2. CLI flag scope: --home as a persistent global flag on the root command, matching --config / --profile conventions in most cobra CLIs. Per-subcommand override flags (--config-dir, etc.) are likely overkill — env vars cover the use case.
  3. Validation: should the per-kind override reject non-absolute paths (defensive) or accept them (let CWD context apply)? mcporter rejects non-absolute via path.isAbsolute check. Recommended: reject non-absolute, fail-fast with a clear error.

(Note: the prior draft included an open question about GOG_HOME flat vs. XDG-default sub-layout. The sketch above commits to flat XDG-kind sub-layout per the rationale paragraph in "Proposed solution"; flagging here in case the maintainer prefers a different shape.)

Alternatives considered

  • Symlink workaround at the deployment layer (e.g. ln -sfn /persist/gogcli ~/.config/gogcli) — works as break-glass but couples deployment to an internal gogcli path convention that could change. Also doesn't compose with the XDG-kind split.
  • Wrapper script exporting XDG vars before invoking gogcli — works but only when the wrapper has control over how gogcli is spawned. Doesn't help when gogcli is spawned by another tool (an agent, a skill runner) that already sanitized the env.
  • Fix only the parent sandbox (OpenClaw, in our case) — necessary, and being pursued separately (openclaw#84854). But this leaves gogcli brittle in every other environment that does similar XDG sanitization.
  • Single GOG_CONFIG_DIR only (no umbrella, no per-kind variants) — covers only one resolver; defeats the kind-aware separation that XDG conformance just introduced. This was the first-pass shape of this proposal before the kind-aware framing made the broader override necessary.

Impact

  • Affected: gogcli users in containerized / sandboxed / agent-spawned environments where the parent process can't or won't set XDG vars portably.
  • Severity: medium — has workarounds (symlinks, wrapper scripts) but each is a small papercut accumulating across deployments. Without this knob, every deployer of gogcli has to learn gogcli's XDG dependency and engineer around it.
  • Frequency: affects every deployment of the affected class; one-time learning curve per deployer.
  • Consequence: lost or misplaced OAuth refresh tokens, broken keyring continuity, persistent-state drift between container restarts when the surrounding stack is XDG-hostile.

Evidence / examples

  • gogcli env-var inventory (grepped from source at tag v0.17.0): all use GOG_* prefix, zero GOGCLI_* exist. The proposed naming matches the established convention.
  • mcporter precedent for tool-specific path override: src/config/path-discovery.ts reads MCPORTER_CONFIG env var as the highest-precedence escape hatch (at v0.11.1).
  • Conventional precedent for *_HOME umbrella vars: CARGO_HOME, RUSTUP_HOME, GOPATH, npm_config_prefix, PIP_CONFIG_FILE, GH_CONFIG_DIR, KUBECONFIG, DOCKER_CONFIG — every mature CLI exposes a tool-specific path override above the XDG default.

Additional information

Backward compatibility: fully preserved. The new env vars and flag are additional precedence above the XDG layer; existing users see no behavior change unless they opt in.

Migration path once both this and the XDG-conformance proposal ship: set GOG_HOME=/persist/gogcli (or per-kind variants for split-lifetime layouts — durable data on a backed-up volume, cache on tmpfs) in the container entrypoint. Retires the dependency on XDG_CONFIG_HOME (and the other XDG vars) reaching gogcli through the parent sandbox's env sanitizer, and gets the right per-kind separation.

Related references

Companion proposal (must-land-first):

  • gogcli#621 — "Honor XDG Base Directory Spec for credentials, vault, state, cache, and download paths" (filed 2026-05-21, OPEN). Introduces the per-kind resolvers this proposal layers overrides on top of. Should land first; this proposal is incoherent without it.

Concrete trigger:

  • openclaw#84854 — filed 2026-05-21: OpenClaw's host-env sanitizer strips XDG_CONFIG_HOME and XDG_CONFIG_DIRS from inherited subprocess env, contrary to the XDG Base Directory Spec. Fixing that bug closes the immediate failure mode for the OpenClaw-spawned case; this feature request is the defense-in-depth so gogcli isn't tied to a single env-var-name's policy treatment in every container/sandbox/runner above it.

Sibling-project precedent:

  • openclaw/mcporter#155 ("Honor XDG Base Directory Spec for config, vault, cache, and daemon paths") — closed as completed, shipped in mcporter v0.10.0. mcporter additionally exposes MCPORTER_CONFIG in src/config/path-discovery.ts as a tool-specific path override above the XDG default — exactly the pattern proposed here for GOG_HOME + per-kind overrides.
  • openclaw/mcporter#184 — illustrates why a tool-specific override is valuable even when XDG support exists: embedders sometimes set XDG_CONFIG_HOME for an unrelated downstream tool, accidentally redirecting other XDG-aware tools too. A GOG_* override insulates gogcli from this kind of cross-tool XDG collision.

Conventional precedent in other CLIs:

  • CARGO_HOME, RUSTUP_HOME, GOPATH, npm_config_prefix, PIP_CONFIG_FILE, GH_CONFIG_DIR, KUBECONFIG, DOCKER_CONFIG — every mature CLI exposes a tool-specific path override above the XDG default.

XDG Base Directory Specification:

  • freedesktop.org spec, latest revision — current XDG behavior in gogcli already follows the spec for one kind (config); the companion XDG-conformance proposal extends that to all four kinds, and this proposal adds the operator-override layer above that.

gogcli source code referenced:

  • internal/config/paths.go at tag v0.17.0 — the central path-resolution that this proposal extends. Today's AppName = "gogcli" constant and Dir() shape are reused; the new kindDir(kind) resolver from the XDG-conformance proposal gains the GOG_<KIND>_DIR / GOG_HOME precedence layer described above.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions