fix(remote-questions): hydrate remote channel tokens from auth.json on startup

Token saved via `/gsd remote discord` (or `/gsd keys add discord_bot`) is
persisted to auth.json but was not being restored to process.env on the next
launch. resolveRemoteConfig() and getRemoteConfigStatus() both read only from
process.env, so the token appeared missing on every fresh session, triggering
the 'DISCORD_BOT_TOKEN not set — remote questions disabled' warning.

Fix: add hydrateRemoteTokensFromAuth() that reads discord_bot, slack_bot, and
telegram_bot API keys from auth.json and populates the corresponding env vars
(DISCORD_BOT_TOKEN, SLACK_BOT_TOKEN, TELEGRAM_BOT_TOKEN) before the env check,
but only when the vars are not already set. Called at the top of both public
functions so hydration fires regardless of which codepath triggers config
resolution.

- Silently no-ops if auth.json is absent or AuthStorage is unavailable
- Does not overwrite env vars already set (env takes precedence)
- Uses require() so AuthStorage failures don't crash the extension

Tests: 5 new source-level and behavioral assertions covering hydration call
ordering, provider map coverage, skip-when-set guard, and null-config path.
This commit is contained in:
Jeremy McSpadden 2026-03-24 21:54:42 -05:00
parent 51519e6cda
commit 64e2604782
2 changed files with 129 additions and 0 deletions

View file

@ -640,3 +640,87 @@ test("DiscordAdapter source-level: sendPrompt sets threadUrl in ref", () => {
"sendPrompt should set threadUrl to the constructed message URL",
);
});
// ═══════════════════════════════════════════════════════════════════════════
// Auth.json Token Hydration Tests
// ═══════════════════════════════════════════════════════════════════════════
test("config source-level: hydrateRemoteTokensFromAuth is called before env check in resolveRemoteConfig", () => {
const configSrc = readFileSync(
join(__dirname, "..", "..", "remote-questions", "config.ts"),
"utf-8",
);
// Find the body of resolveRemoteConfig by slicing from its declaration to the next export function.
const resolveStart = configSrc.indexOf("export function resolveRemoteConfig()");
const resolveEnd = configSrc.indexOf("\nexport function", resolveStart + 1);
const resolveFnBody = configSrc.slice(resolveStart, resolveEnd);
const hydrationIdx = resolveFnBody.indexOf("hydrateRemoteTokensFromAuth()");
const envCheckIdx = resolveFnBody.indexOf("process.env[ENV_KEYS[");
assert.ok(hydrationIdx !== -1, "hydrateRemoteTokensFromAuth() should be called inside resolveRemoteConfig");
assert.ok(envCheckIdx !== -1, "process.env[ENV_KEYS[ lookup should exist inside resolveRemoteConfig");
assert.ok(hydrationIdx < envCheckIdx, "hydration call should appear before the process.env env-key lookup");
});
test("config source-level: hydrateRemoteTokensFromAuth is called in getRemoteConfigStatus", () => {
const configSrc = readFileSync(
join(__dirname, "..", "..", "remote-questions", "config.ts"),
"utf-8",
);
const statusFnIdx = configSrc.indexOf("export function getRemoteConfigStatus()");
const hydrationInStatus = configSrc.indexOf("hydrateRemoteTokensFromAuth()", statusFnIdx);
assert.ok(hydrationInStatus > statusFnIdx, "hydrateRemoteTokensFromAuth should be called inside getRemoteConfigStatus");
});
test("config source-level: AUTH_PROVIDER_ENV_MAP covers all three remote channels", () => {
const configSrc = readFileSync(
join(__dirname, "..", "..", "remote-questions", "config.ts"),
"utf-8",
);
assert.ok(configSrc.includes("discord_bot"), "AUTH_PROVIDER_ENV_MAP should include discord_bot");
assert.ok(configSrc.includes("slack_bot"), "AUTH_PROVIDER_ENV_MAP should include slack_bot");
assert.ok(configSrc.includes("telegram_bot"), "AUTH_PROVIDER_ENV_MAP should include telegram_bot");
assert.ok(configSrc.includes("DISCORD_BOT_TOKEN"), "should map discord_bot to DISCORD_BOT_TOKEN");
assert.ok(configSrc.includes("SLACK_BOT_TOKEN"), "should map slack_bot to SLACK_BOT_TOKEN");
assert.ok(configSrc.includes("TELEGRAM_BOT_TOKEN"), "should map telegram_bot to TELEGRAM_BOT_TOKEN");
});
test("config source-level: hydration skips env vars already set", () => {
const configSrc = readFileSync(
join(__dirname, "..", "..", "remote-questions", "config.ts"),
"utf-8",
);
// The guard that skips already-set vars must be present.
assert.ok(
configSrc.includes("!process.env[envVar]"),
"hydrateRemoteTokensFromAuth should skip env vars that are already populated",
);
});
test("resolveRemoteConfig returns null when preferences are absent (no env side-effects)", () => {
// Guard: ensure that with no prefs configured, resolveRemoteConfig returns null cleanly.
// This exercises the hydration path without auth.json present (it should no-op silently).
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
const savedDiscord = process.env.DISCORD_BOT_TOKEN;
const savedSlack = process.env.SLACK_BOT_TOKEN;
const savedTelegram = process.env.TELEGRAM_BOT_TOKEN;
try {
// Point HOME to a nonexistent dir so auth.json lookup finds nothing.
process.env.HOME = "/tmp/gsd-no-such-home-for-test";
process.env.USERPROFILE = "/tmp/gsd-no-such-home-for-test";
delete process.env.DISCORD_BOT_TOKEN;
delete process.env.SLACK_BOT_TOKEN;
delete process.env.TELEGRAM_BOT_TOKEN;
const result = resolveRemoteConfig();
// With no prefs file, result is null — not an exception.
assert.equal(result, null, "resolveRemoteConfig should return null when no preferences are configured");
} finally {
process.env.HOME = savedHome;
process.env.USERPROFILE = savedUserProfile;
if (savedDiscord !== undefined) process.env.DISCORD_BOT_TOKEN = savedDiscord;
if (savedSlack !== undefined) process.env.SLACK_BOT_TOKEN = savedSlack;
if (savedTelegram !== undefined) process.env.TELEGRAM_BOT_TOKEN = savedTelegram;
}
});

View file

@ -2,6 +2,7 @@
* Remote Questions configuration resolution and validation
*/
import { join } from "node:path";
import { loadEffectiveGSDPreferences, type RemoteQuestionsConfig } from "../gsd/preferences.js";
import type { RemoteChannel } from "./types.js";
@ -33,7 +34,50 @@ const MAX_TIMEOUT_MINUTES = 30;
const MIN_POLL_INTERVAL_SECONDS = 2;
const MAX_POLL_INTERVAL_SECONDS = 30;
// Provider IDs in auth.json that correspond to remote channel env vars.
const AUTH_PROVIDER_ENV_MAP: Record<string, string> = {
discord_bot: "DISCORD_BOT_TOKEN",
slack_bot: "SLACK_BOT_TOKEN",
telegram_bot: "TELEGRAM_BOT_TOKEN",
};
/**
* Populate remote channel env vars from auth.json when they are not already
* set in the environment. Called before every config resolution so that tokens
* saved via `/gsd remote discord` (or `/gsd keys add discord_bot`) survive
* process restarts without requiring the user to export env vars manually.
*
* Silently no-ops if auth.json is absent, unreadable, or malformed.
*/
function hydrateRemoteTokensFromAuth(): void {
const needed = Object.entries(AUTH_PROVIDER_ENV_MAP).filter(([, envVar]) => !process.env[envVar]);
if (needed.length === 0) return;
try {
const { AuthStorage } = require("@gsd/pi-coding-agent") as typeof import("@gsd/pi-coding-agent");
const authPath = join(process.env.HOME ?? "~", ".gsd", "agent", "auth.json");
const auth = AuthStorage.create(authPath);
for (const [providerId, envVar] of needed) {
try {
const creds = auth.getCredentialsForProvider(providerId);
const apiKeyCred = creds.find((c: { type: string }) => c.type === "api_key") as
| { type: "api_key"; key: string }
| undefined;
if (apiKeyCred?.key) {
process.env[envVar] = apiKeyCred.key;
}
} catch {
// Per-provider failure is non-fatal — skip and move on.
}
}
} catch {
// AuthStorage unavailable (unit tests, stripped build) — skip silently.
}
}
export function resolveRemoteConfig(): ResolvedConfig | null {
hydrateRemoteTokensFromAuth();
const prefs = loadEffectiveGSDPreferences();
const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions;
if (!rq || !rq.channel || !rq.channel_id) return null;
@ -58,6 +102,7 @@ export function resolveRemoteConfig(): ResolvedConfig | null {
}
export function getRemoteConfigStatus(): string {
hydrateRemoteTokensFromAuth();
const prefs = loadEffectiveGSDPreferences();
const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions;
if (!rq || !rq.channel || !rq.channel_id) return "Remote questions: not configured";