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

@alexminza

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions