Merge pull request #2439 from jeremymcs/fix/remote-token-hydration-on-startup

fix(remote-questions): hydrate remote channel tokens from auth.json on startup
This commit is contained in:
TÂCHES 2026-03-25 10:10:00 -06:00 committed by GitHub
commit 9490f2c45c
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";