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:
commit
9490f2c45c
2 changed files with 129 additions and 0 deletions
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue