From d834d7be417e9e3d59d32832009476fe563c0c7a Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Wed, 18 Mar 2026 09:32:46 -0500 Subject: [PATCH] fix: pause auto-mode when env variables needed instead of blocking (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: pause auto-mode instead of blocking when env variables needed (#1146) When gsd auto encounters pending secrets in the SECRETS.md manifest, it now pauses the session with a clear notification listing the missing keys, instead of blocking the entire auto loop with an interactive TUI prompt. On resume (/gsd auto), secrets are re-collected via the TUI — if all are skipped, the session re-pauses to prevent broken task runs. * feat: notify remote channels (Slack/Discord/Telegram) on secrets pause Sends a one-way notification to the configured remote channel when auto-mode pauses for missing env variables. The notification directs the user back to the terminal — secrets are never collected through remote channels for security reasons. --- src/resources/extensions/gsd/auto-start.ts | 37 ++++-- src/resources/extensions/gsd/auto.ts | 32 +++++ src/resources/extensions/gsd/auto/session.ts | 2 + .../gsd/tests/auto-secrets-gate.test.ts | 110 +++++++++++++++++- .../extensions/remote-questions/mod.ts | 1 + .../extensions/remote-questions/notify.ts | 91 +++++++++++++++ 6 files changed, 259 insertions(+), 14 deletions(-) create mode 100644 src/resources/extensions/remote-questions/notify.ts diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 8eb91177b..d9a94fe3e 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -16,7 +16,8 @@ import type { import { deriveState } from "./state.js"; import { loadFile, getManifestStatus } from "./files.js"; import { loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, getIsolationMode } from "./preferences.js"; -import { collectSecretsFromManifest } from "../get-secrets-from-user.js"; +import { sendDesktopNotification } from "./notifications.js"; +import { sendRemoteNotification } from "../remote-questions/notify.js"; import { gsdRoot, resolveMilestoneFile, @@ -409,24 +410,36 @@ export async function bootstrapAutoSession( // Write initial lock file writeLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown", 0); - // Secrets collection gate + // Secrets collection gate — pause instead of blocking (#1146) const mid = state.activeMilestone!.id; try { const manifestStatus = await getManifestStatus(base, mid); if (manifestStatus && manifestStatus.pending.length > 0) { - const result = await collectSecretsFromManifest(base, mid, ctx); - if (result && result.applied && result.skipped && result.existingSkipped) { - ctx.ui.notify( - `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, - "info", - ); - } else { - ctx.ui.notify("Secrets collection skipped.", "info"); - } + const pendingKeys = manifestStatus.pending; + const keyList = pendingKeys.map((k: string) => ` • ${k}`).join("\n"); + s.paused = true; + s.pausedForSecrets = true; + ctx.ui.notify( + `Auto-mode paused: ${pendingKeys.length} env variable${pendingKeys.length > 1 ? "s" : ""} needed for ${mid}.\n${keyList}\n\nCollect them with /gsd secrets, then resume with /gsd auto.`, + "warning", + ); + ctx.ui.setStatus("gsd-auto", "paused"); + sendDesktopNotification( + "GSD — Secrets Required", + `${pendingKeys.length} env variable(s) needed for ${mid}. Run /gsd secrets to provide them.`, + "warning", + "attention", + ); + // Notify remote channel if configured (one-way — never collect secrets via remote) + sendRemoteNotification( + "GSD — Secrets Required", + `Auto-mode paused: ${pendingKeys.length} env variable(s) needed for ${mid}.\n${keyList}\n\nReturn to the terminal and run /gsd secrets to provide them securely.`, + ).catch(() => {}); // fire-and-forget + return false; } } catch (err) { ctx.ui.notify( - `Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`, + `Secrets check error: ${err instanceof Error ? err.message : String(err)}. Continuing without secrets.`, "warning", ); } diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 4342b3e7e..e9016543f 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -667,6 +667,38 @@ export async function startAuto( s.pausedSessionFile = null; } + // If resuming from a secrets pause, re-collect before dispatching (#1146) + if (s.pausedForSecrets && s.currentMilestoneId) { + try { + const manifestStatus = await getManifestStatus(s.basePath, s.currentMilestoneId); + if (manifestStatus && manifestStatus.pending.length > 0) { + const result = await collectSecretsFromManifest(s.basePath, s.currentMilestoneId, ctx); + if (result && result.applied.length > 0) { + ctx.ui.notify( + `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, + "info", + ); + } else if (result && result.applied.length === 0 && result.skipped.length > 0) { + // All keys were skipped — still pending, re-pause + s.paused = true; + s.active = false; + ctx.ui.notify( + `All env variables were skipped. Auto-mode remains paused.\nCollect them with /gsd secrets, then resume with /gsd auto.`, + "warning", + ); + ctx.ui.setStatus("gsd-auto", "paused"); + return; + } + } + } catch (err) { + ctx.ui.notify( + `Secrets check error: ${err instanceof Error ? err.message : String(err)}. Continuing without secrets.`, + "warning", + ); + } + s.pausedForSecrets = false; + } + writeLock(lockBase(), "resuming", s.currentMilestoneId ?? "unknown", s.completedUnits.length); await dispatchNextUnit(ctx, pi); diff --git a/src/resources/extensions/gsd/auto/session.ts b/src/resources/extensions/gsd/auto/session.ts index 70c6e8b65..12d7e54f8 100644 --- a/src/resources/extensions/gsd/auto/session.ts +++ b/src/resources/extensions/gsd/auto/session.ts @@ -69,6 +69,7 @@ export class AutoSession { // ── Lifecycle ──────────────────────────────────────────────────────────── active = false; paused = false; + pausedForSecrets = false; stepMode = false; verbose = false; cmdCtx: ExtensionCommandContext | null = null; @@ -162,6 +163,7 @@ export class AutoSession { // Lifecycle this.active = false; this.paused = false; + this.pausedForSecrets = false; this.stepMode = false; this.verbose = false; this.cmdCtx = null; diff --git a/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts b/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts index e73fe849c..c4913d987 100644 --- a/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +++ b/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts @@ -2,10 +2,11 @@ * Integration tests for the secrets collection gate in startAuto(). * * Exercises getManifestStatus() → collectSecretsFromManifest() composition - * end-to-end using real filesystem state. Proves the three gate paths: + * end-to-end using real filesystem state. Proves the gate paths: * 1. No manifest exists — gate skips silently - * 2. Pending keys exist — gate triggers collection + * 2. Pending keys exist — gate triggers collection (direct call) * 3. No pending keys — gate skips silently + * 4. Pending keys in auto-mode — session pauses instead of blocking (#1146) * * Uses temp directories with real .gsd/milestones/M001/ structure, mirroring * the pattern from manifest-status.test.ts. @@ -18,6 +19,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { getManifestStatus } from '../files.ts'; import { collectSecretsFromManifest } from '../../get-secrets-from-user.ts'; +import { AutoSession } from '../auto/session.ts'; function makeTempDir(prefix: string): string { const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -146,6 +148,110 @@ test('secrets gate: pending keys exist — gate triggers collection, manifest up // ─── Scenario 3: No pending keys — all collected or in env ────────────────── +// ─── Scenario 4: Pending keys pause AutoSession instead of blocking (#1146) ── + +test('secrets gate: pending keys set pausedForSecrets on AutoSession', async () => { + const tmp = makeTempDir('gate-pause-session'); + try { + // Ensure pending keys are NOT in env + delete process.env.GSD_PAUSE_TEST_KEY_A; + delete process.env.GSD_PAUSE_TEST_KEY_B; + + writeManifest(tmp, `# Secrets Manifest + +**Milestone:** M001 +**Generated:** 2025-06-20T10:00:00Z + +### GSD_PAUSE_TEST_KEY_A + +**Service:** ServiceA +**Status:** pending +**Destination:** dotenv + +1. Get key A from dashboard + +### GSD_PAUSE_TEST_KEY_B + +**Service:** ServiceB +**Status:** pending +**Destination:** dotenv + +1. Get key B from dashboard +`); + + // Verify manifest has pending keys + const status = await getManifestStatus(tmp, 'M001'); + assert.notStrictEqual(status, null, 'manifest should exist'); + assert.deepStrictEqual(status!.pending, ['GSD_PAUSE_TEST_KEY_A', 'GSD_PAUSE_TEST_KEY_B']); + + // Simulate what auto-start.ts now does: set pause flags on session + const session = new AutoSession(); + session.active = true; + session.currentMilestoneId = 'M001'; + + // The new gate logic: if pending keys exist, pause instead of collecting + if (status!.pending.length > 0) { + session.paused = true; + session.pausedForSecrets = true; + } + + assert.strictEqual(session.paused, true, 'session should be paused'); + assert.strictEqual(session.pausedForSecrets, true, 'pausedForSecrets flag should be set'); + + // Verify reset() clears pausedForSecrets + session.reset(); + assert.strictEqual(session.pausedForSecrets, false, 'reset() should clear pausedForSecrets'); + } finally { + delete process.env.GSD_PAUSE_TEST_KEY_A; + delete process.env.GSD_PAUSE_TEST_KEY_B; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('secrets gate: no pending keys do not set pausedForSecrets', async () => { + const tmp = makeTempDir('gate-no-pause'); + const savedKey = process.env.GSD_NO_PAUSE_TEST_KEY; + try { + process.env.GSD_NO_PAUSE_TEST_KEY = 'already-set'; + + writeManifest(tmp, `# Secrets Manifest + +**Milestone:** M001 +**Generated:** 2025-06-20T10:00:00Z + +### GSD_NO_PAUSE_TEST_KEY + +**Service:** ServiceX +**Status:** pending +**Destination:** dotenv + +1. Already in env +`); + + const status = await getManifestStatus(tmp, 'M001'); + assert.notStrictEqual(status, null, 'manifest should exist'); + assert.deepStrictEqual(status!.pending, [], 'no pending keys — already in env'); + + // Simulate gate logic — no pending keys, no pause + const session = new AutoSession(); + session.active = true; + + if (status!.pending.length > 0) { + session.paused = true; + session.pausedForSecrets = true; + } + + assert.strictEqual(session.paused, false, 'session should NOT be paused'); + assert.strictEqual(session.pausedForSecrets, false, 'pausedForSecrets should NOT be set'); + } finally { + delete process.env.GSD_NO_PAUSE_TEST_KEY; + if (savedKey !== undefined) process.env.GSD_NO_PAUSE_TEST_KEY = savedKey; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── Scenario 3: No pending keys — all collected or in env ────────────────── + test('secrets gate: no pending keys — getManifestStatus shows pending.length === 0', async () => { const tmp = makeTempDir('gate-no-pending'); const savedKey = process.env.GSD_GATE_TEST_ENVKEY; diff --git a/src/resources/extensions/remote-questions/mod.ts b/src/resources/extensions/remote-questions/mod.ts index 547f18098..4e4758cd0 100644 --- a/src/resources/extensions/remote-questions/mod.ts +++ b/src/resources/extensions/remote-questions/mod.ts @@ -13,3 +13,4 @@ export { parseTelegramResponse, } from "./format.js"; export { resolveRemoteConfig, isValidChannelId } from "./config.js"; +export { sendRemoteNotification } from "./notify.js"; diff --git a/src/resources/extensions/remote-questions/notify.ts b/src/resources/extensions/remote-questions/notify.ts new file mode 100644 index 000000000..761ac881a --- /dev/null +++ b/src/resources/extensions/remote-questions/notify.ts @@ -0,0 +1,91 @@ +/** + * Remote Notifications — one-way alert delivery to configured channels. + * + * Sends informational messages to Slack/Discord/Telegram without expecting + * a reply. Used for auto-mode events like secrets-required pauses where + * the user needs to be notified but should NOT send sensitive data back + * through the channel. + */ + +import { resolveRemoteConfig } from "./config.js"; +import type { ResolvedConfig } from "./config.js"; + +const PER_REQUEST_TIMEOUT_MS = 15_000; + +/** + * Send a one-way notification to the configured remote channel. + * Non-blocking, non-fatal — failures are silently ignored. + * + * SECURITY: This is intentionally one-way. Never use remote channels + * to collect secrets or sensitive values. + */ +export async function sendRemoteNotification(title: string, message: string): Promise { + let config: ResolvedConfig | null; + try { + config = resolveRemoteConfig(); + } catch { + return; // Remote not configured — skip silently + } + if (!config) return; + + try { + switch (config.channel) { + case "slack": + await sendSlackNotification(config, title, message); + break; + case "discord": + await sendDiscordNotification(config, title, message); + break; + case "telegram": + await sendTelegramNotification(config, title, message); + break; + } + } catch { + // Non-fatal — remote notifications are best-effort + } +} + +async function sendSlackNotification(config: ResolvedConfig, title: string, message: string): Promise { + const response = await fetch(`https://slack.com/api/chat.postMessage`, { + method: "POST", + headers: { + Authorization: `Bearer ${config.token}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + channel: config.channelId, + text: `⚠️ *${title}*\n${message}`, + }), + signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`Slack HTTP ${response.status}`); +} + +async function sendDiscordNotification(config: ResolvedConfig, title: string, message: string): Promise { + const response = await fetch(`https://discord.com/api/v10/channels/${config.channelId}/messages`, { + method: "POST", + headers: { + Authorization: `Bot ${config.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: `⚠️ **${title}**\n${message}`, + }), + signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`Discord HTTP ${response.status}`); +} + +async function sendTelegramNotification(config: ResolvedConfig, title: string, message: string): Promise { + const response = await fetch(`https://api.telegram.org/bot${config.token}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: config.channelId, + text: `⚠️ *${title}*\n${message}`, + parse_mode: "Markdown", + }), + signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`Telegram HTTP ${response.status}`); +}