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:
parent
ea42db9a1f
commit
d834d7be41
6 changed files with 259 additions and 14 deletions
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -13,3 +13,4 @@ export {
|
|||
parseTelegramResponse,
|
||||
} from "./format.js";
|
||||
export { resolveRemoteConfig, isValidChannelId } from "./config.js";
|
||||
export { sendRemoteNotification } from "./notify.js";
|
||||
|
|
|
|||
91
src/resources/extensions/remote-questions/notify.ts
Normal file
91
src/resources/extensions/remote-questions/notify.ts
Normal 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}`);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue