fix: clarify gog token export flow

This commit is contained in:
iHildy 2026-01-26 19:28:41 -06:00
parent 3a41e2e8bd
commit a7bfe30d88
2 changed files with 74 additions and 136 deletions

View File

@ -73,7 +73,7 @@ If you already use `gog` for Google Workspace, you can reuse its OAuth client +
} }
``` ```
3) Ensure `gog` can access its keyring on the gateway host. 3) Ensure `gog` can access its keyring on the gateway host.
- `gog` stores refresh tokens in the system keychain by default. citeturn6view0 - `gog` stores refresh tokens in the system keychain by default (not inside `credentials.json`). citeturn6view0
- For headless systems (systemd, SSH-only), switch to file keyring + password (see `gog` docs). citeturn6view0 - For headless systems (systemd, SSH-only), switch to file keyring + password (see `gog` docs). citeturn6view0
- Set `GOG_KEYRING_BACKEND=file` and `GOG_KEYRING_PASSWORD=...` for the gateway service. - Set `GOG_KEYRING_BACKEND=file` and `GOG_KEYRING_PASSWORD=...` for the gateway service.
- The file keyring lives under your gog config directory (for example `~/.config/gogcli/keyring/`). - The file keyring lives under your gog config directory (for example `~/.config/gogcli/keyring/`).
@ -81,7 +81,7 @@ If you already use `gog` for Google Workspace, you can reuse its OAuth client +
```bash ```bash
gog auth tokens list --json gog auth tokens list --json
``` ```
If this fails, install `gog` on the gateway host and ensure the keyring is accessible. This lists token keys only (no secrets). If this fails, install `gog` on the gateway host and ensure the keyring is accessible.
For non-interactive services, set `GOG_KEYRING_PASSWORD` in the gateway environment so `gog` can unlock the keyring. For non-interactive services, set `GOG_KEYRING_PASSWORD` in the gateway environment so `gog` can unlock the keyring.
Clawdbot reads `gog` OAuth client files from: Clawdbot reads `gog` OAuth client files from:
@ -89,7 +89,7 @@ Clawdbot reads `gog` OAuth client files from:
- `~/.config/gogcli/credentials-<client>.json` - `~/.config/gogcli/credentials-<client>.json`
- `~/.config/gogcli/credentials-<domain>.json` (or macOS equivalent) citeturn9view0 - `~/.config/gogcli/credentials-<domain>.json` (or macOS equivalent) citeturn9view0
Clawdbot queries `gog auth tokens list --json` (and falls back to `gog auth tokens export --json`) to reuse the stored refresh token. If this fails, set `oauthRefreshToken` manually. Clawdbot queries `gog auth tokens list --json` to discover which account to use, then runs `gog auth tokens export <email> --out <tmp>` to read the refresh token. If you have multiple gog accounts, set `gogAccount` (or `GOG_ACCOUNT`) to pick the right one. If this fails, set `oauthRefreshToken` manually.
### Option B: Manual OAuth ### Option B: Manual OAuth
1) Configure OAuth consent + create OAuth client credentials in your Google Cloud project (desktop app recommended). citeturn6view0 1) Configure OAuth consent + create OAuth client credentials in your Google Cloud project (desktop app recommended). citeturn6view0

View File

@ -9,7 +9,6 @@ type GogTokenEntry = {
}; };
const tokenCache = new Map<string, string>(); const tokenCache = new Map<string, string>();
const jwtPattern = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
function resolveWildcardJsonFile( function resolveWildcardJsonFile(
dirs: string[], dirs: string[],
@ -76,52 +75,6 @@ function readJsonFile(pathname: string): unknown | null {
} }
} }
function tryParseJson(value: string): unknown | null {
try {
return JSON.parse(value);
} catch {
return null;
}
}
function decodeBase64Payload(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.includes(".")) return null;
const normalized = trimmed.replace(/-/g, "+").replace(/_/g, "/");
if (!/^[A-Za-z0-9+/=]+$/.test(normalized)) return null;
try {
const decoded = Buffer.from(normalized, "base64").toString("utf8");
return decoded.trim() ? decoded : null;
} catch {
return null;
}
}
function resolveGogKeyringFiles(params: {
gogClient?: string | null;
gogAccount?: string | null;
}): string[] {
const dirs = resolveConfigDirs().map((dir) => path.join(dir, "keyring"));
const files: string[] = [];
for (const dir of dirs) {
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
files.push(path.join(dir, entry.name));
}
} catch {
// Ignore missing/permission issues; we'll fall back to other sources.
}
}
const account = params.gogAccount?.trim();
if (account) {
const matches = files.filter((file) => file.includes(account));
if (matches.length > 0) return matches;
}
return files;
}
function resolveConfigDirs(): string[] { function resolveConfigDirs(): string[] {
const dirs: string[] = []; const dirs: string[] = [];
const xdg = process.env.XDG_CONFIG_HOME; const xdg = process.env.XDG_CONFIG_HOME;
@ -153,32 +106,14 @@ export function resolveGogCredentialsFile(params: {
return resolveGogJsonFile(params, "credentials"); return resolveGogJsonFile(params, "credentials");
} }
function resolveGogTokenFile(params: {
gogClient?: string | null;
gogAccount?: string | null;
}): string | null {
return resolveGogJsonFile(params, "tokens");
}
function looksLikeJwt(token: string): boolean {
return jwtPattern.test(token.trim());
}
function looksLikeRefreshToken(token: string): boolean { function looksLikeRefreshToken(token: string): boolean {
const trimmed = token.trim(); const trimmed = token.trim();
if (!trimmed) return false; if (!trimmed) return false;
if (trimmed.startsWith("ya29.")) return false; if (trimmed.startsWith("ya29.")) return false;
if (looksLikeJwt(trimmed)) return false;
if (trimmed.startsWith("1//")) return true; if (trimmed.startsWith("1//")) return true;
return trimmed.length > 30; return trimmed.length > 30;
} }
function collectTokensFromString(value: string, out: GogTokenEntry[]) {
const trimmed = value.trim();
if (!trimmed) return;
if (looksLikeRefreshToken(trimmed)) out.push({ refreshToken: trimmed });
}
function collectTokens(value: unknown, out: GogTokenEntry[]) { function collectTokens(value: unknown, out: GogTokenEntry[]) {
if (!value || typeof value !== "object") return; if (!value || typeof value !== "object") return;
if (Array.isArray(value)) { if (Array.isArray(value)) {
@ -208,34 +143,28 @@ function collectTokens(value: unknown, out: GogTokenEntry[]) {
} }
} }
function collectTokensFromRaw(value: string, out: GogTokenEntry[]) { function parseTokenEmails(value: unknown): string[] {
const trimmed = value.trim(); if (!value || typeof value !== "object") return [];
if (!trimmed) return; const record = value as Record<string, unknown>;
const keys = Array.isArray(record.keys)
const parsed = tryParseJson(trimmed); ? record.keys.filter((entry): entry is string => typeof entry === "string")
if (parsed) { : [];
if (typeof parsed === "string") { const emails = new Set<string>();
collectTokensFromString(parsed, out); for (const key of keys) {
} else { const email = parseTokenEmail(key);
collectTokens(parsed, out); if (email) emails.add(email);
}
return;
} }
return Array.from(emails);
}
const decoded = decodeBase64Payload(trimmed); function parseTokenEmail(key: string): string | null {
if (decoded) { const trimmed = key.trim();
const decodedParsed = tryParseJson(decoded); if (!trimmed) return null;
if (decodedParsed) { const parts = trimmed.split(":");
if (typeof decodedParsed === "string") { if (parts.length < 2) return null;
collectTokensFromString(decodedParsed, out); if (parts[0] !== "token") return null;
} else { if (parts.length === 2) return parts[1] || null;
collectTokens(decodedParsed, out); return parts[2] || null;
}
return;
}
}
collectTokensFromString(trimmed, out);
} }
export function readGogRefreshTokenSync(params: { export function readGogRefreshTokenSync(params: {
@ -246,25 +175,6 @@ export function readGogRefreshTokenSync(params: {
const cached = tokenCache.get(cacheKey); const cached = tokenCache.get(cacheKey);
if (cached) return cached; if (cached) return cached;
const tokens: GogTokenEntry[] = [];
const tokenFile = resolveGogTokenFile(params);
if (tokenFile) {
const parsed = readJsonFile(tokenFile);
if (parsed) collectTokens(parsed, tokens);
}
if (tokens.length === 0) {
const keyringFiles = resolveGogKeyringFiles(params);
for (const file of keyringFiles) {
try {
const raw = fs.readFileSync(file, "utf8");
collectTokensFromRaw(raw, tokens);
} catch {
// Ignore keyring read errors and keep trying other entries.
}
}
}
const env = { const env = {
...process.env, ...process.env,
...(params.gogAccount?.trim() ...(params.gogAccount?.trim()
@ -277,7 +187,7 @@ export function readGogRefreshTokenSync(params: {
const runGogJson = (args: string[]): unknown | null => { const runGogJson = (args: string[]): unknown | null => {
try { try {
const stdout = execFileSync("gog", ["--no-input", ...args], { const stdout = execFileSync("gog", ["--no-input", "--json", ...args], {
encoding: "utf8", encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
timeout: 3000, timeout: 3000,
@ -289,34 +199,62 @@ export function readGogRefreshTokenSync(params: {
} }
}; };
if (tokens.length === 0) { const explicitAccount = params.gogAccount?.trim();
const parsed = runGogJson(["auth", "tokens", "list", "--json"]); let account = explicitAccount;
if (parsed) collectTokens(parsed, tokens); if (!account) {
const parsed = runGogJson(["auth", "tokens", "list"]);
const emails = parseTokenEmails(parsed);
if (emails.length === 1) {
account = emails[0];
} else {
return null;
}
} }
if (tokens.length === 0) {
const exported = runGogJson(["auth", "tokens", "export", "--json"]);
if (exported) collectTokens(exported, tokens);
}
if (tokens.length === 0) return null;
const target = params.gogAccount?.trim().toLowerCase(); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-gog-"));
if (target) { const outPath = path.join(tmpDir, "token.json");
const match = tokens.find( try {
(entry) => entry.account?.trim().toLowerCase() === target, execFileSync(
"gog",
[
"--no-input",
"--json",
"auth",
"tokens",
"export",
account,
"--out",
outPath,
"--overwrite",
],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 5000,
env,
},
); );
if (match?.refreshToken) { } catch {
tokenCache.set(cacheKey, match.refreshToken); try {
return match.refreshToken; fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
} }
return null;
} }
if (tokens.length === 1) { const parsed = readJsonFile(outPath);
const only = tokens[0]?.refreshToken; try {
if (only) { fs.rmSync(tmpDir, { recursive: true, force: true });
tokenCache.set(cacheKey, only); } catch {
return only; // ignore cleanup errors
}
} }
return null; const tokens: GogTokenEntry[] = [];
if (parsed) collectTokens(parsed, tokens);
const token = tokens[0]?.refreshToken?.trim();
if (!token) return null;
tokenCache.set(cacheKey, token);
return token;
} }