gsd-pi 2.69.0 → 2.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/resources/extensions/gsd/bootstrap/system-context.js +6 -2
  2. package/dist/resources/extensions/gsd/commands-cmux.js +30 -1
  3. package/dist/resources/extensions/gsd/workflow-mcp.js +53 -6
  4. package/dist/web/standalone/.next/BUILD_ID +1 -1
  5. package/dist/web/standalone/.next/app-path-routes-manifest.json +12 -12
  6. package/dist/web/standalone/.next/build-manifest.json +2 -2
  7. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  8. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  9. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  10. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  12. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  13. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  14. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  15. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  17. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/api/onboarding/route.js +1 -1
  25. package/dist/web/standalone/.next/server/app/index.html +1 -1
  26. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app-paths-manifest.json +12 -12
  33. package/dist/web/standalone/.next/server/chunks/6897.js +3 -3
  34. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  35. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  36. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  37. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  38. package/package.json +1 -1
  39. package/packages/daemon/src/orchestrator.ts +9 -84
  40. package/packages/mcp-server/README.md +25 -3
  41. package/packages/mcp-server/dist/cli.d.ts +0 -1
  42. package/packages/mcp-server/dist/cli.d.ts.map +1 -1
  43. package/packages/mcp-server/dist/cli.js +4 -2
  44. package/packages/mcp-server/dist/cli.js.map +1 -1
  45. package/packages/mcp-server/dist/server.d.ts +32 -1
  46. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  47. package/packages/mcp-server/dist/server.js +118 -1
  48. package/packages/mcp-server/dist/server.js.map +1 -1
  49. package/packages/mcp-server/dist/tool-credentials.d.ts +6 -0
  50. package/packages/mcp-server/dist/tool-credentials.d.ts.map +1 -0
  51. package/packages/mcp-server/dist/tool-credentials.js +90 -0
  52. package/packages/mcp-server/dist/tool-credentials.js.map +1 -0
  53. package/packages/mcp-server/dist/workflow-tools.d.ts +1 -0
  54. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  55. package/packages/mcp-server/dist/workflow-tools.js +274 -2
  56. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  57. package/packages/mcp-server/src/cli.ts +5 -3
  58. package/packages/mcp-server/src/mcp-server.test.ts +85 -1
  59. package/packages/mcp-server/src/server.ts +188 -1
  60. package/packages/mcp-server/src/tool-credentials.test.ts +95 -0
  61. package/packages/mcp-server/src/tool-credentials.ts +97 -0
  62. package/packages/mcp-server/src/workflow-tools.test.ts +32 -25
  63. package/packages/mcp-server/src/workflow-tools.ts +365 -2
  64. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  65. package/packages/pi-ai/dist/providers/anthropic.js +1 -23
  66. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  67. package/packages/pi-ai/dist/utils/oauth/index.d.ts +3 -2
  68. package/packages/pi-ai/dist/utils/oauth/index.d.ts.map +1 -1
  69. package/packages/pi-ai/dist/utils/oauth/index.js +3 -5
  70. package/packages/pi-ai/dist/utils/oauth/index.js.map +1 -1
  71. package/packages/pi-ai/src/providers/anthropic.ts +1 -31
  72. package/packages/pi-ai/src/utils/oauth/index.ts +3 -5
  73. package/packages/pi-coding-agent/package.json +1 -1
  74. package/pkg/package.json +1 -1
  75. package/src/resources/extensions/gsd/bootstrap/system-context.ts +9 -5
  76. package/src/resources/extensions/gsd/commands-cmux.ts +32 -1
  77. package/src/resources/extensions/gsd/tests/cmux.test.ts +67 -1
  78. package/src/resources/extensions/gsd/tests/mcp-project-config.test.ts +6 -2
  79. package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +23 -7
  80. package/src/resources/extensions/gsd/workflow-mcp.ts +59 -5
  81. package/packages/pi-ai/dist/utils/oauth/anthropic.d.ts +0 -17
  82. package/packages/pi-ai/dist/utils/oauth/anthropic.d.ts.map +0 -1
  83. package/packages/pi-ai/dist/utils/oauth/anthropic.js +0 -106
  84. package/packages/pi-ai/dist/utils/oauth/anthropic.js.map +0 -1
  85. package/packages/pi-ai/src/utils/oauth/anthropic.ts +0 -140
  86. /package/dist/web/standalone/.next/static/{DrWdzskk28E5Qz-Wjw1mj → Nl6lg7zP5dNgNBV1107v1}/_buildManifest.js +0 -0
  87. /package/dist/web/standalone/.next/static/{DrWdzskk28E5Qz-Wjw1mj → Nl6lg7zP5dNgNBV1107v1}/_ssgManifest.js +0 -0
@@ -2,8 +2,9 @@
2
2
  * MCP Server — registers GSD orchestration, project-state, and workflow tools.
3
3
  *
4
4
  * Session tools (6): gsd_execute, gsd_status, gsd_result, gsd_cancel, gsd_query, gsd_resolve_blocker
5
+ * Interactive tools (1): ask_user_questions via MCP form elicitation
5
6
  * Read-only tools (6): gsd_progress, gsd_roadmap, gsd_history, gsd_doctor, gsd_captures, gsd_knowledge
6
- * Workflow tools (17): planning, replanning, completion, validation, reassessment, gate result, and milestone status tools
7
+ * Workflow tools (29): headless-safe planning, metadata persistence, replanning, completion, validation, reassessment, gate result, status, and journal tools
7
8
  *
8
9
  * Uses dynamic imports for @modelcontextprotocol/sdk because TS Node16
9
10
  * cannot resolve the SDK's subpath exports statically (same pattern as
@@ -44,6 +45,11 @@ function errorContent(message: string): { isError: true; content: Array<{ type:
44
45
  return { isError: true, content: [{ type: 'text' as const, text: message }] };
45
46
  }
46
47
 
48
+ /** Return raw text content without JSON wrapping. */
49
+ function textContent(text: string): { content: Array<{ type: 'text'; text: string }> } {
50
+ return { content: [{ type: 'text' as const, text }] };
51
+ }
52
+
47
53
  // ---------------------------------------------------------------------------
48
54
  // gsd_query filesystem reader
49
55
  // ---------------------------------------------------------------------------
@@ -108,10 +114,155 @@ async function fileExists(path: string): Promise<boolean> {
108
114
 
109
115
  interface McpServerInstance {
110
116
  tool(name: string, description: string, params: Record<string, unknown>, handler: (args: Record<string, unknown>) => Promise<unknown>): unknown;
117
+ server: {
118
+ elicitInput(
119
+ params: AskUserQuestionsElicitRequest,
120
+ options?: unknown,
121
+ ): Promise<AskUserQuestionsElicitResult>;
122
+ };
111
123
  connect(transport: unknown): Promise<void>;
112
124
  close(): Promise<void>;
113
125
  }
114
126
 
127
+ interface AskUserQuestionOption {
128
+ label: string;
129
+ description: string;
130
+ }
131
+
132
+ interface AskUserQuestion {
133
+ id: string;
134
+ header: string;
135
+ question: string;
136
+ options: AskUserQuestionOption[];
137
+ allowMultiple?: boolean;
138
+ }
139
+
140
+ interface AskUserQuestionsParams {
141
+ questions: AskUserQuestion[];
142
+ }
143
+
144
+ type AskUserQuestionsContentValue = string | number | boolean | string[];
145
+
146
+ interface AskUserQuestionsElicitResult {
147
+ action: 'accept' | 'decline' | 'cancel';
148
+ content?: Record<string, AskUserQuestionsContentValue>;
149
+ }
150
+
151
+ interface AskUserQuestionsElicitRequest {
152
+ mode: 'form';
153
+ message: string;
154
+ requestedSchema: {
155
+ type: 'object';
156
+ properties: Record<string, Record<string, unknown>>;
157
+ required?: string[];
158
+ };
159
+ }
160
+
161
+ const OTHER_OPTION_LABEL = 'None of the above';
162
+
163
+ function normalizeAskUserQuestionsNote(value: AskUserQuestionsContentValue | undefined): string {
164
+ return typeof value === 'string' ? value.trim() : '';
165
+ }
166
+
167
+ function normalizeAskUserQuestionsAnswers(
168
+ value: AskUserQuestionsContentValue | undefined,
169
+ allowMultiple: boolean,
170
+ ): string[] {
171
+ if (allowMultiple) {
172
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
173
+ }
174
+
175
+ return typeof value === 'string' && value.length > 0 ? [value] : [];
176
+ }
177
+
178
+ function validateAskUserQuestionsPayload(questions: AskUserQuestion[]): string | null {
179
+ if (questions.length === 0 || questions.length > 3) {
180
+ return 'Error: questions must contain 1-3 items';
181
+ }
182
+
183
+ for (const question of questions) {
184
+ if (!question.options || question.options.length === 0) {
185
+ return `Error: ask_user_questions requires non-empty options for every question (question "${question.id}" has none)`;
186
+ }
187
+ }
188
+
189
+ return null;
190
+ }
191
+
192
+ export function buildAskUserQuestionsElicitRequest(questions: AskUserQuestion[]): AskUserQuestionsElicitRequest {
193
+ const properties: Record<string, Record<string, unknown>> = {};
194
+ const required = questions.map((question) => question.id);
195
+
196
+ for (const question of questions) {
197
+ if (question.allowMultiple) {
198
+ properties[question.id] = {
199
+ type: 'array',
200
+ title: question.header,
201
+ description: question.question,
202
+ minItems: 1,
203
+ maxItems: question.options.length,
204
+ items: {
205
+ anyOf: question.options.map((option) => ({
206
+ const: option.label,
207
+ title: option.label,
208
+ })),
209
+ },
210
+ };
211
+ continue;
212
+ }
213
+
214
+ properties[question.id] = {
215
+ type: 'string',
216
+ title: question.header,
217
+ description: question.question,
218
+ oneOf: [...question.options, { label: OTHER_OPTION_LABEL, description: 'Choose this when the listed options do not fit.' }].map((option) => ({
219
+ const: option.label,
220
+ title: option.label,
221
+ })),
222
+ };
223
+
224
+ properties[`${question.id}__note`] = {
225
+ type: 'string',
226
+ title: `${question.header} Note`,
227
+ description: `Optional note for "${OTHER_OPTION_LABEL}".`,
228
+ maxLength: 500,
229
+ };
230
+ }
231
+
232
+ return {
233
+ mode: 'form',
234
+ message: 'Please answer the following question(s). For single-select questions, choose "None of the above" and add a note if the provided options do not fit.',
235
+ requestedSchema: {
236
+ type: 'object',
237
+ properties,
238
+ required,
239
+ },
240
+ };
241
+ }
242
+
243
+ export function formatAskUserQuestionsElicitResult(
244
+ questions: AskUserQuestion[],
245
+ result: AskUserQuestionsElicitResult,
246
+ ): string {
247
+ const answers: Record<string, { answers: string[] }> = {};
248
+ const content = result.content ?? {};
249
+
250
+ for (const question of questions) {
251
+ const answerList = normalizeAskUserQuestionsAnswers(content[question.id], !!question.allowMultiple);
252
+
253
+ if (!question.allowMultiple && answerList[0] === OTHER_OPTION_LABEL) {
254
+ const note = normalizeAskUserQuestionsNote(content[`${question.id}__note`]);
255
+ if (note) {
256
+ answerList.push(`user_note: ${note}`);
257
+ }
258
+ }
259
+
260
+ answers[question.id] = { answers: answerList };
261
+ }
262
+
263
+ return JSON.stringify({ answers });
264
+ }
265
+
115
266
  // ---------------------------------------------------------------------------
116
267
  // createMcpServer
117
268
  // ---------------------------------------------------------------------------
@@ -285,6 +436,42 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
285
436
  },
286
437
  );
287
438
 
439
+ // -----------------------------------------------------------------------
440
+ // ask_user_questions — structured user input via MCP form elicitation
441
+ // -----------------------------------------------------------------------
442
+ server.tool(
443
+ 'ask_user_questions',
444
+ 'Request user input for one to three short questions and wait for the response. Single-select questions include a free-form "None of the above" path. Multi-select questions allow multiple choices.',
445
+ {
446
+ questions: z.array(z.object({
447
+ id: z.string().describe('Stable identifier for mapping answers (snake_case)'),
448
+ header: z.string().describe('Short header label shown in the UI (12 or fewer chars)'),
449
+ question: z.string().describe('Single-sentence prompt shown to the user'),
450
+ options: z.array(z.object({
451
+ label: z.string().describe('User-facing label (1-5 words)'),
452
+ description: z.string().describe('One short sentence explaining impact/tradeoff if selected'),
453
+ })).describe('Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with "(Recommended)". Do not include an "Other" option for single-select questions.'),
454
+ allowMultiple: z.boolean().optional().describe('If true, the user can select multiple options. No "None of the above" option is added.'),
455
+ })).describe('Questions to show the user. Prefer 1 and do not exceed 3.'),
456
+ },
457
+ async (args: Record<string, unknown>) => {
458
+ const { questions } = args as unknown as AskUserQuestionsParams;
459
+ try {
460
+ const validationError = validateAskUserQuestionsPayload(questions);
461
+ if (validationError) return errorContent(validationError);
462
+
463
+ const elicitation = await server.server.elicitInput(buildAskUserQuestionsElicitRequest(questions));
464
+ if (elicitation.action !== 'accept' || !elicitation.content) {
465
+ return textContent('ask_user_questions was cancelled before receiving a response');
466
+ }
467
+
468
+ return textContent(formatAskUserQuestionsElicitResult(questions, elicitation));
469
+ } catch (err) {
470
+ return errorContent(err instanceof Error ? err.message : String(err));
471
+ }
472
+ },
473
+ );
474
+
288
475
  // =======================================================================
289
476
  // READ-ONLY TOOLS — no session required, pure filesystem reads
290
477
  // =======================================================================
@@ -0,0 +1,95 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ import { loadStoredCredentialEnvKeys, resolveAuthPath } from "./tool-credentials.js";
8
+
9
+ describe("tool credentials", () => {
10
+ it("hydrates supported model and tool keys from auth.json", () => {
11
+ const tempRoot = mkdtempSync(join(tmpdir(), "gsd-mcp-auth-"));
12
+ const authPath = join(tempRoot, "auth.json");
13
+ const env: NodeJS.ProcessEnv = {};
14
+
15
+ try {
16
+ writeFileSync(authPath, JSON.stringify({
17
+ anthropic: { type: "api_key", key: "sk-ant-secret" },
18
+ openai: { type: "api_key", key: "sk-openai-secret" },
19
+ tavily: { type: "api_key", key: "tvly-secret" },
20
+ context7: [{ type: "api_key", key: "ctx7-secret" }],
21
+ }));
22
+
23
+ const loaded = loadStoredCredentialEnvKeys({ authPath, env });
24
+ assert.deepEqual(loaded.sort(), [
25
+ "ANTHROPIC_API_KEY",
26
+ "CONTEXT7_API_KEY",
27
+ "OPENAI_API_KEY",
28
+ "TAVILY_API_KEY",
29
+ ]);
30
+ assert.equal(env.ANTHROPIC_API_KEY, "sk-ant-secret");
31
+ assert.equal(env.OPENAI_API_KEY, "sk-openai-secret");
32
+ assert.equal(env.TAVILY_API_KEY, "tvly-secret");
33
+ assert.equal(env.CONTEXT7_API_KEY, "ctx7-secret");
34
+ } finally {
35
+ rmSync(tempRoot, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ it("does not overwrite explicit environment variables", () => {
40
+ const tempRoot = mkdtempSync(join(tmpdir(), "gsd-mcp-auth-"));
41
+ const authPath = join(tempRoot, "auth.json");
42
+ const env: NodeJS.ProcessEnv = {
43
+ BRAVE_API_KEY: "already-set",
44
+ };
45
+
46
+ try {
47
+ writeFileSync(authPath, JSON.stringify({
48
+ brave: { type: "api_key", key: "from-auth-json" },
49
+ anthropic: { type: "api_key", key: "sk-ant-from-auth-json" },
50
+ }));
51
+
52
+ const loaded = loadStoredCredentialEnvKeys({ authPath, env });
53
+ assert.deepEqual(loaded, ["ANTHROPIC_API_KEY"]);
54
+ assert.equal(env.BRAVE_API_KEY, "already-set");
55
+ assert.equal(env.ANTHROPIC_API_KEY, "sk-ant-from-auth-json");
56
+ } finally {
57
+ rmSync(tempRoot, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ it("ignores oauth credentials because they are resolved through auth storage, not env hydration", () => {
62
+ const tempRoot = mkdtempSync(join(tmpdir(), "gsd-mcp-auth-"));
63
+ const authPath = join(tempRoot, "auth.json");
64
+ const env: NodeJS.ProcessEnv = {};
65
+
66
+ try {
67
+ writeFileSync(authPath, JSON.stringify({
68
+ openai: { type: "oauth", access: "oauth-access-token" },
69
+ "google-gemini-cli": { type: "oauth", token: "ya29.oauth-token" },
70
+ }));
71
+
72
+ const loaded = loadStoredCredentialEnvKeys({ authPath, env });
73
+ assert.deepEqual(loaded, []);
74
+ assert.equal(env.OPENAI_API_KEY, undefined);
75
+ assert.equal(env.GEMINI_API_KEY, undefined);
76
+ } finally {
77
+ rmSync(tempRoot, { recursive: true, force: true });
78
+ }
79
+ });
80
+
81
+ it("resolves auth.json from GSD_CODING_AGENT_DIR", () => {
82
+ const tempRoot = mkdtempSync(join(tmpdir(), "gsd-mcp-agent-dir-"));
83
+ const agentDir = join(tempRoot, "agent");
84
+ mkdirSync(agentDir, { recursive: true });
85
+
86
+ try {
87
+ assert.equal(
88
+ resolveAuthPath({ GSD_CODING_AGENT_DIR: agentDir }),
89
+ join(agentDir, "auth.json"),
90
+ );
91
+ } finally {
92
+ rmSync(tempRoot, { recursive: true, force: true });
93
+ }
94
+ });
95
+ });
@@ -0,0 +1,97 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ type AuthCredential =
6
+ | { type?: unknown; key?: unknown }
7
+ | Array<{ type?: unknown; key?: unknown }>;
8
+
9
+ type AuthStorageData = Record<string, AuthCredential>;
10
+
11
+ const AUTH_ENV_KEYS = [
12
+ ["anthropic", "ANTHROPIC_API_KEY"],
13
+ ["openai", "OPENAI_API_KEY"],
14
+ ["github-copilot", "GITHUB_TOKEN"],
15
+ ["google", "GEMINI_API_KEY"],
16
+ ["groq", "GROQ_API_KEY"],
17
+ ["xai", "XAI_API_KEY"],
18
+ ["openrouter", "OPENROUTER_API_KEY"],
19
+ ["mistral", "MISTRAL_API_KEY"],
20
+ ["ollama-cloud", "OLLAMA_API_KEY"],
21
+ ["custom-openai", "CUSTOM_OPENAI_API_KEY"],
22
+ ["cerebras", "CEREBRAS_API_KEY"],
23
+ ["azure-openai-responses", "AZURE_OPENAI_API_KEY"],
24
+ ["vercel-ai-gateway", "AI_GATEWAY_API_KEY"],
25
+ ["zai", "ZAI_API_KEY"],
26
+ ["minimax", "MINIMAX_API_KEY"],
27
+ ["minimax-cn", "MINIMAX_CN_API_KEY"],
28
+ ["huggingface", "HF_TOKEN"],
29
+ ["opencode", "OPENCODE_API_KEY"],
30
+ ["opencode-go", "OPENCODE_API_KEY"],
31
+ ["kimi-coding", "KIMI_API_KEY"],
32
+ ["alibaba-coding-plan", "ALIBABA_API_KEY"],
33
+ ["brave", "BRAVE_API_KEY"],
34
+ ["brave_answers", "BRAVE_ANSWERS_KEY"],
35
+ ["context7", "CONTEXT7_API_KEY"],
36
+ ["jina", "JINA_API_KEY"],
37
+ ["tavily", "TAVILY_API_KEY"],
38
+ ["slack_bot", "SLACK_BOT_TOKEN"],
39
+ ["discord_bot", "DISCORD_BOT_TOKEN"],
40
+ ["telegram_bot", "TELEGRAM_BOT_TOKEN"],
41
+ ] as const;
42
+
43
+ function expandHome(pathValue: string): string {
44
+ if (pathValue === "~") return homedir();
45
+ if (pathValue.startsWith("~/")) return join(homedir(), pathValue.slice(2));
46
+ return pathValue;
47
+ }
48
+
49
+ function getStoredApiKey(data: AuthStorageData, providerId: string): string | undefined {
50
+ const raw = data[providerId];
51
+ const credentials = Array.isArray(raw) ? raw : raw ? [raw] : [];
52
+
53
+ for (const credential of credentials) {
54
+ if (credential?.type !== "api_key") continue;
55
+ if (typeof credential.key !== "string") continue;
56
+ if (credential.key.trim().length === 0) continue;
57
+ return credential.key;
58
+ }
59
+
60
+ return undefined;
61
+ }
62
+
63
+ export function resolveAuthPath(env: NodeJS.ProcessEnv = process.env): string {
64
+ const agentDir = env.GSD_CODING_AGENT_DIR?.trim();
65
+ if (agentDir) return join(expandHome(agentDir), "auth.json");
66
+ return join(homedir(), ".gsd", "agent", "auth.json");
67
+ }
68
+
69
+ export function loadStoredCredentialEnvKeys(options: {
70
+ env?: NodeJS.ProcessEnv;
71
+ authPath?: string;
72
+ } = {}): string[] {
73
+ const env = options.env ?? process.env;
74
+ const authPath = options.authPath ?? resolveAuthPath(env);
75
+ if (!existsSync(authPath)) return [];
76
+
77
+ let parsed: AuthStorageData;
78
+ try {
79
+ const raw = readFileSync(authPath, "utf-8");
80
+ const data = JSON.parse(raw) as unknown;
81
+ if (!data || typeof data !== "object" || Array.isArray(data)) return [];
82
+ parsed = data as AuthStorageData;
83
+ } catch {
84
+ return [];
85
+ }
86
+
87
+ const loaded: string[] = [];
88
+ for (const [providerId, envVar] of AUTH_ENV_KEYS) {
89
+ if (env[envVar]) continue;
90
+ const key = getStoredApiKey(parsed, providerId);
91
+ if (!key) continue;
92
+ env[envVar] = key;
93
+ loaded.push(envVar);
94
+ }
95
+
96
+ return loaded;
97
+ }
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
6
6
  import { randomUUID } from "node:crypto";
7
7
 
8
8
  import { _getAdapter, closeDatabase } from "../../../src/resources/extensions/gsd/gsd-db.ts";
9
- import { registerWorkflowTools } from "./workflow-tools.ts";
9
+ import { registerWorkflowTools, WORKFLOW_TOOL_NAMES } from "./workflow-tools.ts";
10
10
 
11
11
  function makeTmpBase(): string {
12
12
  const base = join(tmpdir(), `gsd-mcp-workflow-${randomUUID()}`);
@@ -68,33 +68,12 @@ function makeMockServer() {
68
68
  }
69
69
 
70
70
  describe("workflow MCP tools", () => {
71
- it("registers the seventeen workflow tools", () => {
71
+ it("registers the full headless-safe workflow tool surface", () => {
72
72
  const server = makeMockServer();
73
73
  registerWorkflowTools(server as any);
74
74
 
75
- assert.equal(server.tools.length, 17);
76
- assert.deepEqual(
77
- server.tools.map((t) => t.name),
78
- [
79
- "gsd_plan_milestone",
80
- "gsd_plan_slice",
81
- "gsd_replan_slice",
82
- "gsd_slice_replan",
83
- "gsd_slice_complete",
84
- "gsd_complete_slice",
85
- "gsd_complete_milestone",
86
- "gsd_milestone_complete",
87
- "gsd_validate_milestone",
88
- "gsd_milestone_validate",
89
- "gsd_reassess_roadmap",
90
- "gsd_roadmap_reassess",
91
- "gsd_save_gate_result",
92
- "gsd_summary_save",
93
- "gsd_task_complete",
94
- "gsd_complete_task",
95
- "gsd_milestone_status",
96
- ],
97
- );
75
+ assert.equal(server.tools.length, WORKFLOW_TOOL_NAMES.length);
76
+ assert.deepEqual(server.tools.map((t) => t.name), [...WORKFLOW_TOOL_NAMES]);
98
77
  });
99
78
 
100
79
  it("gsd_summary_save writes artifact through the shared executor", async () => {
@@ -974,3 +953,31 @@ describe("workflow MCP tools", () => {
974
953
  }
975
954
  });
976
955
  });
956
+
957
+ describe("URL scheme regex — Windows drive letter safety", () => {
958
+ // This is the regex used in getWriteGateModuleCandidates() and
959
+ // getWorkflowExecutorModuleCandidates() to reject non-file URL schemes.
960
+ // It must NOT match single-letter Windows drive prefixes (C:, D:, etc.).
961
+ const urlSchemeRegex = /^[a-z]{2,}:/i;
962
+
963
+ it("rejects multi-letter URL schemes", () => {
964
+ assert.ok(urlSchemeRegex.test("http://example.com"), "http: should match");
965
+ assert.ok(urlSchemeRegex.test("https://example.com"), "https: should match");
966
+ assert.ok(urlSchemeRegex.test("ftp://files.example.com"), "ftp: should match");
967
+ assert.ok(urlSchemeRegex.test("file:///C:/Users"), "file: should match");
968
+ assert.ok(urlSchemeRegex.test("node:fs"), "node: should match");
969
+ });
970
+
971
+ it("allows single-letter Windows drive prefixes", () => {
972
+ assert.ok(!urlSchemeRegex.test("C:\\Users\\user\\project"), "C:\\ should not match");
973
+ assert.ok(!urlSchemeRegex.test("D:\\other\\path"), "D:\\ should not match");
974
+ assert.ok(!urlSchemeRegex.test("c:\\lowercase\\drive"), "c:\\ should not match");
975
+ assert.ok(!urlSchemeRegex.test("E:/forward/slash/path"), "E:/ should not match");
976
+ });
977
+
978
+ it("allows bare filesystem paths", () => {
979
+ assert.ok(!urlSchemeRegex.test("/usr/local/lib/module.js"), "unix absolute path should not match");
980
+ assert.ok(!urlSchemeRegex.test("./relative/path.js"), "relative path should not match");
981
+ assert.ok(!urlSchemeRegex.test("../parent/path.js"), "parent relative path should not match");
982
+ });
983
+ });