fix: pause auto-mode when env variables needed instead of blocking (#1147)

* 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.
This commit is contained in:
Jeremy McSpadden 2026-03-18 09:32:46 -05:00 committed by GitHub
parent ea42db9a1f
commit d834d7be41
6 changed files with 259 additions and 14 deletions

View file

@ -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",
);
}

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -13,3 +13,4 @@ export {
parseTelegramResponse,
} from "./format.js";
export { resolveRemoteConfig, isValidChannelId } from "./config.js";
export { sendRemoteNotification } from "./notify.js";

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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}`);
}