From 74fee9ed486efdc8d49dab60e9e633ef87444c0f Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 11 Apr 2026 12:39:49 -0500 Subject: [PATCH 1/5] fix(interactive): keep MCP tool output ordered and restore secure prompt fallback --- .../src/core/chat-controller-ordering.test.ts | 68 +++++++++++++++++++ .../src/core/extensions/types.ts | 2 + .../controllers/chat-controller.ts | 45 ++++++++---- .../pi-coding-agent/src/modes/rpc/rpc-mode.ts | 2 +- .../src/modes/rpc/rpc-types.ts | 1 + .../claude-code-cli/stream-adapter.ts | 53 +++++++++------ .../extensions/get-secrets-from-user.ts | 25 ++++++- .../gsd/tests/secure-env-collect.test.ts | 45 ++++++++++++ web/components/gsd/chat-mode.tsx | 1 + web/components/gsd/focused-panel.tsx | 1 + web/lib/gsd-workspace-store.tsx | 2 +- 11 files changed, 207 insertions(+), 38 deletions(-) diff --git a/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts b/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts index 551ca13b6..23bba623d 100644 --- a/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +++ b/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts @@ -150,3 +150,71 @@ test("chat-controller keeps tool output ahead of delayed assistant text for exte assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent"); assert.equal(host.chatContainer.children[1]?.constructor?.name, "AssistantMessageComponent"); }); + +test("chat-controller keeps serverToolUse output ahead of assistant text when external results arrive", async () => { + (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = { + fg: (_key: string, text: string) => text, + bg: (_key: string, text: string) => text, + bold: (text: string) => text, + italic: (text: string) => text, + truncate: (text: string) => text, + }; + + const host = createHost(); + const toolId = "mcp-secure-1"; + const serverToolUse = { + type: "serverToolUse", + id: toolId, + name: "mcp__gsd-workflow__secure_env_collect", + input: { projectDir: "/tmp/project", keys: [{ key: "SECURE_PASSWORD" }], destination: "dotenv" }, + }; + + await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any); + + await handleAgentEvent( + host, + { + type: "message_update", + message: makeAssistant([serverToolUse]), + assistantMessageEvent: { + type: "server_tool_use", + contentIndex: 0, + partial: makeAssistant([serverToolUse]), + }, + } as any, + ); + + assert.equal(host.streamingComponent, undefined, "assistant content should stay deferred while only tool content streams"); + assert.equal(host.chatContainer.children.length, 1, "server tool block should render immediately"); + assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent"); + + host.getMarkdownThemeWithSettings = () => ({}); + const resultMessage = makeAssistant([ + { + ...serverToolUse, + externalResult: { + content: [{ type: "text", text: "secure_env_collect was cancelled by user." }], + details: {}, + isError: true, + }, + }, + { type: "text", text: "The secure password collection was cancelled." }, + ]); + + await handleAgentEvent( + host, + { + type: "message_update", + message: resultMessage, + assistantMessageEvent: { + type: "server_tool_use", + contentIndex: 0, + partial: resultMessage, + }, + } as any, + ); + + assert.equal(host.chatContainer.children.length, 2, "assistant text should render after existing server tool output"); + assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent"); + assert.equal(host.chatContainer.children[1]?.constructor?.name, "AssistantMessageComponent"); +}); diff --git a/packages/pi-coding-agent/src/core/extensions/types.ts b/packages/pi-coding-agent/src/core/extensions/types.ts index a1aad0dc9..5fea6389a 100644 --- a/packages/pi-coding-agent/src/core/extensions/types.ts +++ b/packages/pi-coding-agent/src/core/extensions/types.ts @@ -88,6 +88,8 @@ export interface ExtensionUIDialogOptions { timeout?: number; /** When true, the user can select multiple options. The return type becomes `string[]`. */ allowMultiple?: boolean; + /** When true, text input dialogs should hide typed characters if supported by the client surface. */ + secure?: boolean; } /** Placement for extension widgets. */ diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts index 8d72d683d..168bdaa45 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts @@ -129,19 +129,6 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { host.streamingMessage = event.message; const innerEvent = event.assistantMessageEvent; - if (!host.streamingComponent && hasVisibleAssistantContent(host.streamingMessage)) { - host.streamingComponent = new AssistantMessageComponent( - undefined, - host.hideThinkingBlock, - host.getMarkdownThemeWithSettings(), - host.settingsManager.getTimestampFormat(), - ); - host.chatContainer.addChild(host.streamingComponent); - } - if (host.streamingComponent) { - host.streamingComponent.updateContent(host.streamingMessage); - } - let externalToolResult: | { toolCallId: string; content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; details: Record; isError: boolean } | undefined; @@ -156,6 +143,18 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { isError: ext.isError ?? false, }; } + } else if (innerEvent.type === "server_tool_use") { + const idx = typeof innerEvent.contentIndex === "number" ? innerEvent.contentIndex : -1; + const block = idx >= 0 ? (host.streamingMessage.content[idx] as any) : undefined; + const ext = block?.externalResult; + if (block?.id && ext) { + externalToolResult = { + toolCallId: block.id, + content: ext.content ?? [{ type: "text", text: "" }], + details: ext.details ?? {}, + isError: ext.isError ?? false, + }; + } } const contentBlocks = host.streamingMessage.content; @@ -230,6 +229,26 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { } } + // Render assistant text/thinking after tool components so mixed + // streams keep chronological ordering in the chat container. + const hasToolBlocks = hasAssistantToolBlocks(host.streamingMessage); + if (!host.streamingComponent && hasVisibleAssistantContent(host.streamingMessage)) { + host.streamingComponent = new AssistantMessageComponent( + undefined, + host.hideThinkingBlock, + host.getMarkdownThemeWithSettings(), + host.settingsManager.getTimestampFormat(), + ); + host.chatContainer.addChild(host.streamingComponent); + } + if (host.streamingComponent) { + if (hasToolBlocks) { + host.chatContainer.removeChild(host.streamingComponent); + host.chatContainer.addChild(host.streamingComponent); + } + host.streamingComponent.updateContent(host.streamingMessage); + } + // Update index: fully processed blocks won't need re-scanning. // Keep the last block's index (it may still be accumulating data), // so we re-check it next time but skip all earlier ones. diff --git a/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts b/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts index f2f8fbe4c..7d36e563a 100644 --- a/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts @@ -224,7 +224,7 @@ export async function runRpcMode(session: AgentSession): Promise { ), input: (title, placeholder, opts) => - createDialogPromise(opts, undefined, { method: "input", title, placeholder, timeout: opts?.timeout }, (r) => + createDialogPromise(opts, undefined, { method: "input", title, placeholder, timeout: opts?.timeout, secure: opts?.secure }, (r) => "cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined, ), diff --git a/packages/pi-coding-agent/src/modes/rpc/rpc-types.ts b/packages/pi-coding-agent/src/modes/rpc/rpc-types.ts index 20d5c2c73..d6cd25bfc 100644 --- a/packages/pi-coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/pi-coding-agent/src/modes/rpc/rpc-types.ts @@ -291,6 +291,7 @@ export type RpcExtensionUIRequest = title: string; placeholder?: string; timeout?: number; + secure?: boolean; } | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string } | { diff --git a/src/resources/extensions/claude-code-cli/stream-adapter.ts b/src/resources/extensions/claude-code-cli/stream-adapter.ts index 5c97c9a4a..9e2541221 100644 --- a/src/resources/extensions/claude-code-cli/stream-adapter.ts +++ b/src/resources/extensions/claude-code-cli/stream-adapter.ts @@ -508,15 +508,15 @@ export function extractToolResultsFromSdkUserMessage(message: SDKUserMessage): A return extracted; } -function attachExternalResultsToToolCalls( - toolCalls: AssistantMessage["content"], +function attachExternalResultsToToolBlocks( + toolBlocks: AssistantMessage["content"], toolResultsById: ReadonlyMap, ): void { - for (const block of toolCalls) { - if (block.type !== "toolCall") continue; + for (const block of toolBlocks) { + if (block.type !== "toolCall" && block.type !== "serverToolUse") continue; const externalResult = toolResultsById.get(block.id); if (!externalResult) continue; - (block as ToolCallWithExternalResult).externalResult = externalResult; + (block as ToolCallWithExternalResult & { id: string }).externalResult = externalResult; } } @@ -554,8 +554,8 @@ async function pumpSdkMessages( /** Track the last text content seen across all assistant turns for the final message. */ let lastTextContent = ""; let lastThinkingContent = ""; - /** Collect tool calls from intermediate SDK turns for tool_execution events. */ - const intermediateToolCalls: AssistantMessage["content"] = []; + /** Collect tool blocks from intermediate SDK turns for tool execution rendering. */ + const intermediateToolBlocks: AssistantMessage["content"] = []; /** Preserve real external tool results from Claude Code's synthetic user messages. */ const toolResultsById = new Map(); @@ -666,9 +666,9 @@ async function pumpSdkMessages( lastTextContent = block.text; } else if (block.type === "thinking" && block.thinking) { lastThinkingContent = block.thinking; - } else if (block.type === "toolCall") { - // Collect tool calls for externalToolExecution rendering - intermediateToolCalls.push(block); + } else if (block.type === "toolCall" || block.type === "serverToolUse") { + // Collect tool blocks for externalToolExecution rendering + intermediateToolBlocks.push(block); } } } @@ -678,24 +678,33 @@ async function pumpSdkMessages( for (const { toolUseId, result } of extractToolResultsFromSdkUserMessage(msg as SDKUserMessage)) { toolResultsById.set(toolUseId, result); } - attachExternalResultsToToolCalls(intermediateToolCalls, toolResultsById); + attachExternalResultsToToolBlocks(intermediateToolBlocks, toolResultsById); // Push a synthetic toolcall_end for each tool call from this turn // so the TUI can render tool results in real-time during the SDK // session instead of waiting until the entire session completes. if (builder) { for (const block of builder.message.content) { - if (block.type !== "toolCall") continue; const extResult = (block as ToolCallWithExternalResult).externalResult; if (!extResult) continue; - // Push a toolcall_end with result attached so the chat-controller - // can call updateResult on the pending ToolExecutionComponent. - stream.push({ - type: "toolcall_end", - contentIndex: builder.message.content.indexOf(block), - toolCall: block, - partial: builder.message, - }); + const contentIndex = builder.message.content.indexOf(block); + if (contentIndex < 0) continue; + // Push synthetic completion events with result attached so the + // chat-controller can update pending ToolExecutionComponents. + if (block.type === "toolCall") { + stream.push({ + type: "toolcall_end", + contentIndex, + toolCall: block, + partial: builder.message, + }); + } else if (block.type === "serverToolUse") { + stream.push({ + type: "server_tool_use", + contentIndex, + partial: builder.message, + }); + } } } @@ -713,8 +722,8 @@ async function pumpSdkMessages( const finalContent: AssistantMessage["content"] = []; // Add tool calls from intermediate turns first (renders above text) - attachExternalResultsToToolCalls(intermediateToolCalls, toolResultsById); - finalContent.push(...intermediateToolCalls); + attachExternalResultsToToolBlocks(intermediateToolBlocks, toolResultsById); + finalContent.push(...intermediateToolBlocks); // Add text/thinking from the last turn if (builder && builder.message.content.length > 0) { diff --git a/src/resources/extensions/get-secrets-from-user.ts b/src/resources/extensions/get-secrets-from-user.ts index a8f1cfe36..967752048 100644 --- a/src/resources/extensions/get-secrets-from-user.ts +++ b/src/resources/extensions/get-secrets-from-user.ts @@ -126,7 +126,7 @@ async function collectOneSecret( ): Promise { if (!ctx.hasUI) return null; - return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => { + const customResult = await ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => { let value = ""; let cachedLines: string[] | undefined; @@ -223,6 +223,29 @@ async function collectOneSecret( handleInput, }; }); + + // RPC/web surfaces may not implement ctx.ui.custom(). Fall back to a + // standard input prompt so users can still provide the secret. + if (customResult !== undefined) { + return customResult; + } + + if (typeof ctx.ui?.input !== "function") { + return null; + } + + const inputTitle = `Secure value for ${keyName} (${pageIndex + 1}/${totalPages})`; + const inputPlaceholder = hint || "Enter secret value"; + const inputResult = await ctx.ui.input( + inputTitle, + inputPlaceholder, + { secure: true }, + ); + if (typeof inputResult !== "string") { + return null; + } + const trimmed = inputResult.trim(); + return trimmed.length > 0 ? trimmed : null; } /** diff --git a/src/resources/extensions/gsd/tests/secure-env-collect.test.ts b/src/resources/extensions/gsd/tests/secure-env-collect.test.ts index 18acf7dd4..3e1a5e109 100644 --- a/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +++ b/src/resources/extensions/gsd/tests/secure-env-collect.test.ts @@ -317,3 +317,48 @@ test("secure_env_collect #2997: null from ctx.ui.custom() is still treated as sk "Key returning null must NOT be in applied list", ); }); + +test("secure_env_collect: falls back to secure input prompt when custom UI is unavailable", async (t) => { + const { collectSecretsFromManifest } = await loadOrchestrator(); + + const tmp = makeTempDir("sec-input-fallback-test"); + t.after(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + const manifest = makeManifest([ + { key: "SECRET_FROM_INPUT_FALLBACK", status: "pending", formatHint: "starts with sk-" }, + ]); + await writeManifestFile(tmp, manifest); + + let callIndex = 0; + const inputCalls: Array<{ title: string; placeholder?: string; opts?: { secure?: boolean } }> = []; + const mockCtx = { + cwd: tmp, + hasUI: true, + ui: { + custom: async (_factory: any) => { + callIndex++; + if (callIndex <= 1) return null; // summary screen dismiss + return undefined; // collect screen unavailable on this surface + }, + input: async (title: string, placeholder?: string, opts?: { secure?: boolean }) => { + inputCalls.push({ title, placeholder, opts }); + return " sk-test-fallback-value "; + }, + }, + }; + + const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any); + + assert.ok( + result.applied.includes("SECRET_FROM_INPUT_FALLBACK"), + "Fallback input should collect and apply the key", + ); + assert.ok( + !result.skipped.includes("SECRET_FROM_INPUT_FALLBACK"), + "Fallback input should not mark the key as skipped", + ); + assert.equal(inputCalls.length, 1, "Fallback input should be requested once"); + assert.equal(inputCalls[0]?.opts?.secure, true, "Fallback input should request secure entry when supported"); +}); diff --git a/web/components/gsd/chat-mode.tsx b/web/components/gsd/chat-mode.tsx index f298f2754..81fefd1b3 100644 --- a/web/components/gsd/chat-mode.tsx +++ b/web/components/gsd/chat-mode.tsx @@ -1801,6 +1801,7 @@ function InlineInput({ return (
setValue(e.target.value)} diff --git a/web/components/gsd/focused-panel.tsx b/web/components/gsd/focused-panel.tsx index ee5c79156..1eb3bd2a1 100644 --- a/web/components/gsd/focused-panel.tsx +++ b/web/components/gsd/focused-panel.tsx @@ -180,6 +180,7 @@ function InputRenderer({ }} > setValue(e.target.value)} placeholder={request.placeholder || "Enter a value"} diff --git a/web/lib/gsd-workspace-store.tsx b/web/lib/gsd-workspace-store.tsx index adee496d6..ae67f5f28 100644 --- a/web/lib/gsd-workspace-store.tsx +++ b/web/lib/gsd-workspace-store.tsx @@ -380,7 +380,7 @@ export interface WorkspaceLiveState { export type ExtensionUiRequestEvent = | { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number; allowMultiple?: boolean } | { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number } - | { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string; timeout?: number } + | { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string; timeout?: number; secure?: boolean } | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string } | { type: "extension_ui_request"; id: string; method: "notify"; message: string; notifyType?: "info" | "warning" | "error" } | { type: "extension_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText: string | undefined } From 6b52f5df3f7e38d73addffd7b824633a4a217e26 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 11 Apr 2026 12:53:26 -0500 Subject: [PATCH 2/5] chore(pr): drop web-layer changes from MCP stream-order fix --- web/components/gsd/chat-mode.tsx | 1 - web/components/gsd/focused-panel.tsx | 1 - web/lib/gsd-workspace-store.tsx | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/web/components/gsd/chat-mode.tsx b/web/components/gsd/chat-mode.tsx index 81fefd1b3..f298f2754 100644 --- a/web/components/gsd/chat-mode.tsx +++ b/web/components/gsd/chat-mode.tsx @@ -1801,7 +1801,6 @@ function InlineInput({ return (
setValue(e.target.value)} diff --git a/web/components/gsd/focused-panel.tsx b/web/components/gsd/focused-panel.tsx index 1eb3bd2a1..ee5c79156 100644 --- a/web/components/gsd/focused-panel.tsx +++ b/web/components/gsd/focused-panel.tsx @@ -180,7 +180,6 @@ function InputRenderer({ }} > setValue(e.target.value)} placeholder={request.placeholder || "Enter a value"} diff --git a/web/lib/gsd-workspace-store.tsx b/web/lib/gsd-workspace-store.tsx index ae67f5f28..adee496d6 100644 --- a/web/lib/gsd-workspace-store.tsx +++ b/web/lib/gsd-workspace-store.tsx @@ -380,7 +380,7 @@ export interface WorkspaceLiveState { export type ExtensionUiRequestEvent = | { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number; allowMultiple?: boolean } | { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number } - | { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string; timeout?: number; secure?: boolean } + | { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string; timeout?: number } | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string } | { type: "extension_ui_request"; id: string; method: "notify"; message: string; notifyType?: "info" | "warning" | "error" } | { type: "extension_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText: string | undefined } From 1495e711e1cee5145e950df4e6d2508809edcfdb Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 11 Apr 2026 13:18:27 -0500 Subject: [PATCH 3/5] fix(claude-code): accept secure_env_collect MCP elicitation forms --- packages/mcp-server/src/server.ts | 3 + .../claude-code-cli/stream-adapter.ts | 135 ++++++++++++++++-- .../tests/stream-adapter.test.ts | 79 ++++++++++ 3 files changed, 208 insertions(+), 9 deletions(-) diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index d619ff0f6..1db1e6254 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -547,6 +547,9 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{ type: 'string', title: item.key, description: descParts.join('\n'), + format: 'password', + writeOnly: true, + 'x-sensitive': true, }; // Don't mark as required — empty string = skip } diff --git a/src/resources/extensions/claude-code-cli/stream-adapter.ts b/src/resources/extensions/claude-code-cli/stream-adapter.ts index 9e2541221..a1d08e7b4 100644 --- a/src/resources/extensions/claude-code-cli/stream-adapter.ts +++ b/src/resources/extensions/claude-code-cli/stream-adapter.ts @@ -60,6 +60,8 @@ interface SdkElicitationFieldSchema { type?: string; title?: string; description?: string; + format?: string; + writeOnly?: boolean; oneOf?: SdkElicitationRequestOption[]; items?: { anyOf?: SdkElicitationRequestOption[]; @@ -73,6 +75,7 @@ interface SdkElicitationRequest { requestedSchema?: { type?: string; properties?: Record; + required?: string[]; }; } @@ -85,7 +88,16 @@ interface ParsedElicitationQuestion extends Question { noteFieldId?: string; } +interface ParsedTextInputField { + id: string; + title: string; + description: string; + required: boolean; + secure: boolean; +} + const OTHER_OPTION_LABEL = "None of the above"; +const SENSITIVE_FIELD_PATTERN = /(password|passphrase|secret|token|api[_\s-]*key|private[_\s-]*key|credential)/i; // --------------------------------------------------------------------------- // Stream factory @@ -274,6 +286,60 @@ export function parseAskUserQuestionsElicitation( return questions.length > 0 ? questions : null; } +function isSecureElicitationField( + requestMessage: string, + fieldId: string, + field: SdkElicitationFieldSchema, +): boolean { + if (field.format === "password") return true; + if (field.writeOnly === true) return true; + + const rawField = field as Record; + if (rawField.sensitive === true || rawField["x-sensitive"] === true) return true; + + const haystack = [ + requestMessage, + fieldId.replace(/[_-]+/g, " "), + typeof field.title === "string" ? field.title : "", + typeof field.description === "string" ? field.description : "", + ] + .join(" ") + .toLowerCase(); + + return SENSITIVE_FIELD_PATTERN.test(haystack); +} + +export function parseTextInputElicitation( + request: Pick, +): ParsedTextInputField[] | null { + if (request.mode && request.mode !== "form") return null; + const properties = request.requestedSchema?.properties; + if (!properties || typeof properties !== "object") return null; + + const requiredSet = new Set( + Array.isArray(request.requestedSchema?.required) + ? request.requestedSchema.required.filter((value): value is string => typeof value === "string") + : [], + ); + + const fields: ParsedTextInputField[] = []; + for (const [fieldId, field] of Object.entries(properties)) { + if (!field || typeof field !== "object") return null; + if (field.type !== "string") return null; + if (Array.isArray(field.oneOf) && field.oneOf.length > 0) return null; + + fields.push({ + id: fieldId, + title: typeof field.title === "string" && field.title.length > 0 ? field.title : fieldId, + description: typeof field.description === "string" ? field.description : "", + required: requiredSet.has(fieldId), + secure: isSecureElicitationField(request.message, fieldId, field), + }); + } + + return fields.length > 0 ? fields : null; +} + export function roundResultToElicitationContent( questions: ParsedElicitationQuestion[], result: RoundResult, @@ -355,6 +421,52 @@ async function promptElicitationWithDialogs( return { action: "accept", content }; } +function buildTextInputPromptTitle(request: SdkElicitationRequest, field: ParsedTextInputField): string { + const parts = [ + request.serverName ? `[${request.serverName}]` : "", + field.title, + field.description, + ].filter((part) => typeof part === "string" && part.trim().length > 0); + return parts.join("\n\n"); +} + +function buildTextInputPlaceholder(field: ParsedTextInputField): string | undefined { + const desc = field.description.trim(); + if (!desc) return field.required ? "Required" : "Leave empty to skip"; + + const formatLine = desc + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => /^format:/i.test(line)); + + if (!formatLine) return field.required ? "Required" : "Leave empty to skip"; + const hint = formatLine.replace(/^format:\s*/i, "").trim(); + return hint.length > 0 ? hint : field.required ? "Required" : "Leave empty to skip"; +} + +async function promptTextInputElicitation( + request: SdkElicitationRequest, + fields: ParsedTextInputField[], + ui: ExtensionUIContext, + signal: AbortSignal, +): Promise { + const content: Record = {}; + + for (const field of fields) { + const value = await ui.input( + buildTextInputPromptTitle(request, field), + buildTextInputPlaceholder(field), + { signal, ...(field.secure ? { secure: true } : {}) }, + ); + if (value === undefined) { + return { action: "cancel" }; + } + content[field.id] = value; + } + + return { action: "accept", content }; +} + export function createClaudeCodeElicitationHandler( ui: ExtensionUIContext | undefined, ): ((request: SdkElicitationRequest, options: { signal: AbortSignal }) => Promise) | undefined { @@ -366,19 +478,24 @@ export function createClaudeCodeElicitationHandler( } const questions = parseAskUserQuestionsElicitation(request); - if (!questions) { - return { action: "decline" }; + if (questions) { + const interviewResult = await showInterviewRound(questions, { signal }, { ui } as any).catch(() => undefined); + if (interviewResult && Object.keys(interviewResult.answers).length > 0) { + return { + action: "accept", + content: roundResultToElicitationContent(questions, interviewResult), + }; + } + + return promptElicitationWithDialogs(request, questions, ui, signal); } - const interviewResult = await showInterviewRound(questions, { signal }, { ui } as any).catch(() => undefined); - if (interviewResult && Object.keys(interviewResult.answers).length > 0) { - return { - action: "accept", - content: roundResultToElicitationContent(questions, interviewResult), - }; + const textFields = parseTextInputElicitation(request); + if (textFields) { + return promptTextInputElicitation(request, textFields, ui, signal); } - return promptElicitationWithDialogs(request, questions, ui, signal); + return { action: "decline" }; }; } diff --git a/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts b/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts index 3616f239b..d0b7bed10 100644 --- a/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +++ b/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts @@ -11,6 +11,7 @@ import { extractToolResultsFromSdkUserMessage, getClaudeLookupCommand, parseAskUserQuestionsElicitation, + parseTextInputElicitation, parseClaudeLookupOutput, roundResultToElicitationContent, } from "../stream-adapter.ts"; @@ -514,6 +515,84 @@ describe("stream-adapter — MCP elicitation bridge", () => { }, }); }); + + test("parseTextInputElicitation recognizes secure free-text MCP forms", () => { + const request = { + serverName: "gsd-workflow", + message: "Enter values for environment variables.", + mode: "form" as const, + requestedSchema: { + type: "object" as const, + properties: { + TEST_PASSWORD: { + type: "string", + title: "TEST_PASSWORD", + description: "Format: min 8 characters\nLeave empty to skip.", + }, + PROJECT_NAME: { + type: "string", + title: "PROJECT_NAME", + description: "Human-readable project name.", + }, + }, + }, + }; + + const parsed = parseTextInputElicitation(request as any); + assert.deepEqual(parsed, [ + { + id: "TEST_PASSWORD", + title: "TEST_PASSWORD", + description: "Format: min 8 characters\nLeave empty to skip.", + required: false, + secure: true, + }, + { + id: "PROJECT_NAME", + title: "PROJECT_NAME", + description: "Human-readable project name.", + required: false, + secure: false, + }, + ]); + }); + + test("createClaudeCodeElicitationHandler collects secure_env_collect fields through input dialogs", async () => { + const secureRequest = { + serverName: "gsd-workflow", + message: "Enter values for environment variables.", + mode: "form" as const, + requestedSchema: { + type: "object" as const, + properties: { + TEST_PASSWORD: { + type: "string", + title: "TEST_PASSWORD", + description: "Format: Your secure testing password\nLeave empty to skip.", + }, + }, + }, + }; + + const inputCalls: Array<{ opts?: { secure?: boolean } }> = []; + const handler = createClaudeCodeElicitationHandler({ + input: async (_title: string, _placeholder?: string, opts?: { secure?: boolean }) => { + inputCalls.push({ opts }); + return "super-secret"; + }, + } as any); + assert.ok(handler); + + const result = await handler!(secureRequest as any, { signal: new AbortController().signal }); + assert.deepEqual(result, { + action: "accept", + content: { + TEST_PASSWORD: "super-secret", + }, + }); + assert.equal(inputCalls.length, 1); + assert.equal(inputCalls[0]?.opts?.secure, true, "secure_env_collect fields should request secure input"); + }); }); describe("stream-adapter — Windows Claude path lookup (#3770)", () => { From bf4bcfadde226d6b636b7fee20a964f5d4f957a1 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 11 Apr 2026 13:26:24 -0500 Subject: [PATCH 4/5] fix(claude-code): harden MCP elicitation schema handling --- packages/mcp-server/src/server.ts | 3 -- .../claude-code-cli/stream-adapter.ts | 19 +++++++---- .../tests/stream-adapter.test.ts | 33 +++++++++++++++++++ 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 1db1e6254..d619ff0f6 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -547,9 +547,6 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{ type: 'string', title: item.key, description: descParts.join('\n'), - format: 'password', - writeOnly: true, - 'x-sensitive': true, }; // Don't mark as required — empty string = skip } diff --git a/src/resources/extensions/claude-code-cli/stream-adapter.ts b/src/resources/extensions/claude-code-cli/stream-adapter.ts index a1d08e7b4..a6efa439a 100644 --- a/src/resources/extensions/claude-code-cli/stream-adapter.ts +++ b/src/resources/extensions/claude-code-cli/stream-adapter.ts @@ -313,8 +313,15 @@ export function parseTextInputElicitation( request: Pick, ): ParsedTextInputField[] | null { if (request.mode && request.mode !== "form") return null; - const properties = request.requestedSchema?.properties; - if (!properties || typeof properties !== "object") return null; + const schema = request.requestedSchema as + | ({ properties?: Record; keys?: Record } & Record) + | undefined; + const fieldsSource = schema?.properties && typeof schema.properties === "object" + ? schema.properties + : schema?.keys && typeof schema.keys === "object" + ? schema.keys + : undefined; + if (!fieldsSource) return null; const requiredSet = new Set( Array.isArray(request.requestedSchema?.required) @@ -323,10 +330,10 @@ export function parseTextInputElicitation( ); const fields: ParsedTextInputField[] = []; - for (const [fieldId, field] of Object.entries(properties)) { - if (!field || typeof field !== "object") return null; - if (field.type !== "string") return null; - if (Array.isArray(field.oneOf) && field.oneOf.length > 0) return null; + for (const [fieldId, field] of Object.entries(fieldsSource)) { + if (!field || typeof field !== "object") continue; + if (field.type !== "string") continue; + if (Array.isArray(field.oneOf) && field.oneOf.length > 0) continue; fields.push({ id: fieldId, diff --git a/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts b/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts index d0b7bed10..082b40da2 100644 --- a/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +++ b/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts @@ -557,6 +557,39 @@ describe("stream-adapter — MCP elicitation bridge", () => { ]); }); + test("parseTextInputElicitation accepts legacy keys schema and skips unsupported fields", () => { + const request = { + serverName: "gsd-workflow", + message: "Enter secure values", + mode: "form" as const, + requestedSchema: { + type: "object" as const, + keys: { + API_TOKEN: { + type: "string", + title: "API_TOKEN", + description: "Leave empty to skip.", + }, + META: { + type: "object", + title: "metadata", + }, + }, + }, + }; + + const parsed = parseTextInputElicitation(request as any); + assert.deepEqual(parsed, [ + { + id: "API_TOKEN", + title: "API_TOKEN", + description: "Leave empty to skip.", + required: false, + secure: true, + }, + ]); + }); + test("createClaudeCodeElicitationHandler collects secure_env_collect fields through input dialogs", async () => { const secureRequest = { serverName: "gsd-workflow", From 2d531720f7502a2034aa5d9d8d696ee85e4fcfd7 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 11 Apr 2026 13:28:17 -0500 Subject: [PATCH 5/5] fix(tui): mask secure extension input values in interactive mode --- .../modes/interactive/components/extension-input.ts | 2 ++ .../src/modes/interactive/interactive-mode.ts | 2 +- .../pi-tui/src/components/__tests__/input.test.ts | 11 +++++++++++ packages/pi-tui/src/components/input.ts | 11 +++++++---- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts b/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts index 525bcfc06..7634d154f 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts @@ -11,6 +11,7 @@ import { keyHint } from "./keybinding-hints.js"; export interface ExtensionInputOptions { tui?: TUI; timeout?: number; + secure?: boolean; } export class ExtensionInputComponent extends Container implements Focusable { @@ -61,6 +62,7 @@ export class ExtensionInputComponent extends Container implements Focusable { } this.input = new Input(); + this.input.secure = opts?.secure === true; if (placeholder) { this.input.placeholder = placeholder; } diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 85ba64d39..eb062ca41 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -1631,7 +1631,7 @@ export class InteractiveMode { this.hideExtensionInput(); resolve(undefined); }, - { tui: this.ui, timeout: opts?.timeout }, + { tui: this.ui, timeout: opts?.timeout, secure: opts?.secure }, ); this.editorContainer.clear(); diff --git a/packages/pi-tui/src/components/__tests__/input.test.ts b/packages/pi-tui/src/components/__tests__/input.test.ts index c47100492..581c2e14f 100644 --- a/packages/pi-tui/src/components/__tests__/input.test.ts +++ b/packages/pi-tui/src/components/__tests__/input.test.ts @@ -32,4 +32,15 @@ describe("Input", () => { input.focused = false; assert.equal(input.focused, false); }); + + it("secure mode obscures typed characters in render output", () => { + const input = new Input(); + input.secure = true; + input.focused = true; + input.handleInput("secret123"); + + const line = input.render(40)[0] ?? ""; + assert.ok(!line.includes("secret123"), "rendered line must not expose raw secret text"); + assert.ok(line.includes("*********"), "rendered line should include masked characters"); + }); }); diff --git a/packages/pi-tui/src/components/input.ts b/packages/pi-tui/src/components/input.ts index 627f3557c..78535ab3f 100644 --- a/packages/pi-tui/src/components/input.ts +++ b/packages/pi-tui/src/components/input.ts @@ -21,6 +21,8 @@ export class Input implements Component, Focusable { public onSubmit?: (value: string) => void; public onEscape?: () => void; public placeholder: string = ""; + /** When true, render obscured characters instead of the actual value. */ + public secure: boolean = false; /** Focusable interface - set by TUI when focus changes */ private _focused: boolean = false; @@ -446,6 +448,7 @@ export class Input implements Component, Focusable { // Calculate visible window const prompt = "> "; const availableWidth = width - prompt.length; + const renderValue = this.secure ? "*".repeat(this.value.length) : this.value; if (availableWidth <= 0) { return [prompt]; @@ -466,7 +469,7 @@ export class Input implements Component, Focusable { if (this.value.length < availableWidth) { // Everything fits (leave room for cursor at end) - visibleText = this.value; + visibleText = renderValue; } else { // Need horizontal scrolling // Reserve one character for cursor if it's at the end @@ -501,17 +504,17 @@ export class Input implements Component, Focusable { if (this.cursor < halfWidth) { // Cursor near start - visibleText = this.value.slice(0, findValidEnd(scrollWidth)); + visibleText = renderValue.slice(0, findValidEnd(scrollWidth)); cursorDisplay = this.cursor; } else if (this.cursor > this.value.length - halfWidth) { // Cursor near end const start = findValidStart(this.value.length - scrollWidth); - visibleText = this.value.slice(start); + visibleText = renderValue.slice(start); cursorDisplay = this.cursor - start; } else { // Cursor in middle const start = findValidStart(this.cursor - halfWidth); - visibleText = this.value.slice(start, findValidEnd(start + scrollWidth)); + visibleText = renderValue.slice(start, findValidEnd(start + scrollWidth)); cursorDisplay = halfWidth; } }