diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts index f5cb815cb..6d0550a32 100644 --- a/src/resources/extensions/gsd/tests/remote-questions.test.ts +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -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; + } +}); diff --git a/src/resources/extensions/remote-questions/config.ts b/src/resources/extensions/remote-questions/config.ts index 7e977e458..7aa95fa3e 100644 --- a/src/resources/extensions/remote-questions/config.ts +++ b/src/resources/extensions/remote-questions/config.ts @@ -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 = { + 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";