Skip to content

Feature: ${VAR} env-var interpolation in credentials.json (parity with mcporter config) #599

@alexminza

Description

@alexminza

What

Let gog auth credentials set resolve ${VAR} / ${VAR:-fallback} placeholders against the process environment when parsing the input JSON. Today the parser does plain json.Unmarshal with no expansion — operators delivering credentials via env-var must shell-substitute on the writer side (e.g. jq -cn '{...env.VAR...}'), which adds a setup-time tool dependency and a hand-rolled escaping concern. Other openclaw projects (mcporter) already support universal ${VAR} interpolation in config; gogcli is the asymmetric outlier.

Why

Today's intake shape

gog auth credentials set <path|-> reads bytes from a file or stdin, then calls ParseGoogleOAuthClientJSON:

func ParseGoogleOAuthClientJSON(b []byte) (ClientCredentials, error) {
    var f googleCredentialsFile
    if err := json.Unmarshal(b, &f); err != nil {
        return ClientCredentials{}, fmt.Errorf("decode credentials json: %w", err)
    }
    // ... extract client_id + client_secret ...
}

Plain json.Unmarshal. Grepping the entire repo confirms:

  • grep -r "os.Expand\|ExpandEnv" / → 0 hits in the credentials path
  • No string-interpolation helper anywhere in internal/config/

So a credentials.json with "client_secret": "${MY_SECRET}" is taken literally — gogcli stores the literal string ${MY_SECRET} as the OAuth client secret, and the next API call against Google fails with invalid_client.

Project-side cost

For headless / agentic deployments, the natural shape is "operator-side already has the secret in an env var; pass it through to gogcli." Today that requires:

  1. Shell substitution before invocationjq -cn or envsubst or printf with manual JSON-escaping. Adds a setup-time runtime dep beyond gogcli itself. (Verified: jq is the usual choice; it's pre-installed in many runtimes but not universally.)
  2. Escaping discipline. If the OAuth client_secret contains a backslash, a control character, or a double-quote, naive printf produces invalid JSON and the call fails opaquely. jq -n handles this; printf doesn't.
  3. Bigger init scripts. A non-trivial setup.sh per skill instead of a credentials.json template that gogcli reads directly.

Precedent: mcporter already does this

openclaw/mcporter (also maintained by @steipete) interpolates ${VAR} on every string-valued config field at request time, with ${VAR:-fallback} defaults. Documented at the mcporter config docs:

universal ${VAR} and ${VAR:-fallback} interpolation on every string-valued field on v0.11.0+

That's how mcporter.json templates work end-to-end: ship a config with ${VAR} placeholders, gogcli's sibling resolves them lazily against the process env. Skills shipping mcporter wrappers rely on this — there is no init script writing tokens to disk because mcporter does the interpolation.

The intake shape gogcli is missing is exactly the one mcporter has — @steipete's design taste from the sibling project, just not yet applied to credentials.json.

Suggested shape

Resolve ${VAR} and ${VAR:-fallback} against os.Environ() during credentials-JSON extraction inside ParseGoogleOAuthClientJSON. Apply only to string-valued positions — not to keys or non-string values. (Pre-parse byte substitution was considered and rejected; see § "Implementation sketch".)

# Today's verbose path
jq -cn '{web: {client_id: env.CID, client_secret: env.CSEC}}' \
  | gog auth credentials set - --client=mine

# Post-feature: a static template, env-var values
# (--expand-env shown explicitly per the opt-in-then-default rollout in
# § "Toggle" below; drop the flag if the rollout lands always-on.)
cat <<'EOF' | gog auth credentials set - --client=mine --expand-env
{
  "web": {
    "client_id":     "${GOG_CLIENT_ID}",
    "client_secret": "${GOG_CLIENT_SECRET}"
  }
}
EOF

Implementation sketch

Shape B (proposed) is below. Shape A is documented first as an alternative considered and rejected, so reviewers don't ask "why not pre-parse?"

Shape A (alternative — rejected): pre-parse byte sweep. Walk the raw bytes; replace ${VAR} / ${VAR:-fallback} patterns inline; pass the substituted bytes to json.Unmarshal. Doesn't match the strings-only contract — a raw byte sweep would also expand placeholders inside JSON keys, structural positions (commas, braces, arrays), and inside strings that happen to contain ${...} literally. Making it token-aware (so it expands only inside JSON-string values) reproduces most of json.Unmarshal's work, defeating the "simple inline replacement" advantage. Mentioned here only to preempt the "why not pre-parse?" review question; not the proposed implementation.

Shape B: expand during extraction. Inside ParseGoogleOAuthClientJSON (internal/config/credentials.go), json.Unmarshal into the existing googleCredentialsFile envelope first, then run os.Expand (with a custom mapper for the :- default syntax) on the two extracted string fields before returning the ClientCredentials:

func ParseGoogleOAuthClientJSON(b []byte) (ClientCredentials, error) {
    var f googleCredentialsFile
    if err := json.Unmarshal(b, &f); err != nil { /* ... */ }

    var clientID, clientSecret string
    if f.Installed != nil {
        clientID, clientSecret = f.Installed.ClientID, f.Installed.ClientSecret
    } else if f.Web != nil {
        clientID, clientSecret = f.Web.ClientID, f.Web.ClientSecret
    }

    // NEW: expand ${VAR} / ${VAR:-fallback} in the extracted string fields
    clientID = expandEnv(clientID)
    clientSecret = expandEnv(clientSecret)

    if clientID == "" || clientSecret == "" {
        return ClientCredentials{}, errInvalidCredentials
    }
    return ClientCredentials{ClientID: clientID, ClientSecret: clientSecret}, nil
}

Risks: doesn't catch interpolation that produces structural JSON (e.g. ${ARRAY_OF_DOMAINS} expanding to ["a", "b"]). Mitigation: don't promise that — strings only, by design.

Shape B enforces the strings-only contract structurally — json.Unmarshal does the JSON parsing; expansion runs on already-extracted string fields, so it cannot accidentally produce structural JSON or expand inside keys. Matches mcporter's "universal ${VAR} on every string-valued field" wording; smallest surgical change to the existing extraction path (two expandEnv calls between extract and check).

Strict-by-default

Missing env var without a :-fallback → parse error. Don't silently substitute empty string. Matches mcporter's semantics (mcporter v0.11.0+ env-resolution).

Toggle

Could be opt-in via flag (--expand-env) or always-on. Always-on is simpler and matches mcporter, but has a sharper back-compat edge: any existing credentials.json containing a literal ${...} substring (vanishingly unlikely in real Google-issued OAuth client JSON, but technically possible) would break.

Pragmatic answer: opt-in via flag for the first release, then flip to default after one release cycle once any edge cases shake out. Mirrors how a careful maintainer would land the change.

Scope

  • internal/config/credentials.go: add the env-expansion pass (Shape B above) inside or just before ParseGoogleOAuthClientJSON.
  • Optional new flag on gog auth credentials set (--expand-env).
  • No changes to storage shape — the resolved {client_id, client_secret} lands on disk exactly as today (or in the keyring once #596 merges).

Out of scope

  • Interpolation in other on-disk config files (config.json, paired.json, etc.). Useful, but each has its own threat model — credentials.json is the one that operators hand-write or feed via stdin during deployment automation; the others are gogcli-managed.
  • Expansion at read time instead of write time. Doing it at read time means env can change between auth credentials set and the next API call; surprising. Resolve once, persist the resolved value.

Context

  • Filed alongside #596 (move client_secret to keyring). The two issues are complementary — Feature: store Google OAuth client_secret in the keyring (parity with the Zoom path) #596 changes where the secret lives; this issue changes how it gets in. They don't conflict.
  • mcporter's interpolation feature shipped in v0.11.0+; the implementation is straightforward Go (~30 LoC for the helper + tests).
  • No existing issue, PR, or discussion proposes this — searched for env interpolation credentials, ${VAR} credentials.json, expand env auth, envsubst credentials; discussions are disabled on this repo. Filing as a clean new ask.

References

All references pinned to v0.17.0 (gogcli) and v0.11.1 (mcporter, as cited in third-party guides):

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