From 04d4f1dd68a798d12e976ddb5a24166b1a1f1dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ph=C3=A1t=20kana?= Date: Wed, 15 Jul 2026 00:06:58 +0700 Subject: [PATCH 1/2] feat: add local secure input receiver --- .changeset/secure-input.md | 5 + docs/config.md | 29 ++++ src/client/src/api.ts | 4 +- src/client/src/api/clients.test.ts | 28 +++- src/client/src/api/clients.ts | 18 ++- src/client/src/api/parsers.test.ts | 9 +- src/client/src/api/parsers.ts | 21 ++- .../components/PiWebApp.secureInput.test.ts | 72 ++++++++++ src/client/src/components/PiWebApp.ts | 40 +++++- .../src/components/SecureInputDialog.test.ts | 81 +++++++++++ .../src/components/SecureInputDialog.ts | 107 ++++++++++++++ .../src/components/appShell/AppContextBar.ts | 25 +++- .../components/appShell/AppNavigationPanel.ts | 3 + src/config.test.ts | 28 +++- src/config.ts | 61 ++++++++ src/server/app.ts | 3 + src/server/secureInputRoutes.test.ts | 135 ++++++++++++++++++ src/server/secureInputRoutes.ts | 127 ++++++++++++++++ src/shared/apiTypes.ts | 12 ++ 19 files changed, 796 insertions(+), 12 deletions(-) create mode 100644 .changeset/secure-input.md create mode 100644 src/client/src/components/PiWebApp.secureInput.test.ts create mode 100644 src/client/src/components/SecureInputDialog.test.ts create mode 100644 src/client/src/components/SecureInputDialog.ts create mode 100644 src/server/secureInputRoutes.test.ts create mode 100644 src/server/secureInputRoutes.ts diff --git a/.changeset/secure-input.md b/.changeset/secure-input.md new file mode 100644 index 00000000..d9993688 --- /dev/null +++ b/.changeset/secure-input.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": minor +--- + +Add an opt-in, machine-local secure input button that sends masked browser input directly to a fixed server-side stdin receiver without entering chat, agent prompts, sessions, or transcripts. diff --git a/docs/config.md b/docs/config.md index 8819de04..1fdf5220 100644 --- a/docs/config.md +++ b/docs/config.md @@ -45,6 +45,7 @@ Process restarts depend on the key: - `plugins`: reload the browser tab after changing PI WEB plugin enablement. - Pi package install/remove/update: not a PI WEB config key; after a mutation, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required. - `shortcuts`: saved settings apply in the browser after config refresh/save. +- `secureInput`: read by the web/API process on each status/submission request; reload the browser to show or hide the button after changing config. ## Global config example @@ -65,6 +66,10 @@ Process restarts depend on the key: }, "spawnSessions": true, "subsessions": false, + "secureInput": { + "command": ["/usr/local/bin/store-secret", "--stdin"], + "label": "Secret" + }, "plugins": { "workspace-tasks": { "enabled": true }, "updates": { "enabled": true }, @@ -116,6 +121,7 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file | Agent profile state directory | `agent.dir` | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and package-backed plugins | | Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine | | Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon on that machine | +| Secure input receiver | `secureInput.command`, `secureInput.label`, `secureInput.maxBytes`, `secureInput.timeoutMs` | — | Global web/API process | Not supported locally or through machine federation | Browser refresh after config change | | Plugin enablement/settings | `plugins..enabled`, `plugins..settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab | | Keyboard shortcuts | `shortcuts.` | — | Global | Not supported locally | Applies after settings save/config refresh | | Project config version | `version` | — | Project | Project-local only; must be `1` when present | Next project-config read | @@ -134,6 +140,29 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file ## Key details +### Secure input receiver + +`secureInput` enables a native **Secret** button for sending sensitive UTF-8 input directly to a command running on the same machine as the PI WEB web/API process: + +```json +{ + "secureInput": { + "command": ["/usr/local/bin/store-secret", "--stdin"], + "label": "Secret", + "maxBytes": 4096, + "timeoutMs": 10000 + } +} +``` + +The feature is disabled when `secureInput` is absent. `command` is a fixed argv array: the first entry must be a safe bare executable name or host-absolute executable path, PI WEB never invokes a shell, and the submitted bytes are written only to the command's standard input. Standard output and standard error are discarded. `label` defaults to `Secret`, `maxBytes` defaults to and cannot exceed `4096`, and `timeoutMs` defaults to `10000` with a maximum of `60000`. + +The browser renders a native `type="password"` field. The value does not use the chat composer, session daemon, agent prompt, session state, or transcript. The web/API returns only opaque receipt metadata and never returns receiver output. Requests and responses use `Cache-Control: no-store`; submissions require a non-simple same-origin request header and content type; only one receiver invocation may run at a time. Configure the receiver itself to store or consume stdin safely. + +This is deliberately machine-local. PI WEB does not proxy secure input through Fleet/machine federation, and the button is hidden while a remote machine is selected. The public config API also omits the receiver command and arguments. Configure `secureInput` directly in the global config file on the machine that will receive it; do not place it in project-local config. Reload the browser after changing the setting. + +Transport security remains the deployer's responsibility. Use an authenticated HTTPS reverse proxy for non-loopback access, protect the host and browser session, and do not assume password masking protects against a compromised browser, device, operating system, receiver executable, or screen capture. + ### Managed data directory `PI_WEB_DATA_DIR` sets the root for PI WEB-managed runtime state and defaults to `~/.pi-web`. Unless a more specific path override is configured, PI WEB stores its project and machine registries, locally discovered plugins, default session-daemon socket, and session archives beneath this root. diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 3b68515e..be6f858c 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,5 +1,5 @@ -export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; +export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, secureInputApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads"; export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads"; -export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; +export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SecureInputReceipt, SecureInputStatusResponse, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 4e6b4ac6..6490b105 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import type { PiWebConfigValues, TerminalCommandRun, Workspace } from "../../../shared/apiTypes"; -import { configApi, filesApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; +import { configApi, filesApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, secureInputApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; const workspace: Workspace = { id: "w/1", @@ -134,6 +134,32 @@ describe("settings config and plugin APIs", () => { }); }); +describe("secure input API", () => { + it("uses a local-only non-simple request with an opaque binary body", async () => { + const receipt = { accepted: true, receiptId: "receipt-1", acceptedAt: "2026-07-14T00:00:00.000Z" }; + const fetchMock = stubSequenceFetch([ + jsonResponse({ enabled: true, label: "Secret", maxBytes: 4096 }), + jsonResponse(receipt), + ]); + const input = new TextEncoder().encode("do-not-serialize"); + + await expect(secureInputApi.status()).resolves.toEqual({ enabled: true, label: "Secret", maxBytes: 4096 }); + await expect(secureInputApi.submit(input)).resolves.toEqual(receipt); + + expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/secure-input"); + expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store"); + const [url, init] = fetchCall(fetchMock, 1); + expect(url).toBe("https://pi.example.test/api/secure-input"); + expect(init?.method).toBe("POST"); + expect(init?.cache).toBe("no-store"); + expect(new Headers(init?.headers).get("content-type")).toBe("application/vnd.pi-web.secure-input"); + expect(new Headers(init?.headers).get("x-pi-web-secure-input")).toBe("1"); + expect(init?.body).toBeInstanceOf(ArrayBuffer); + if (!(init?.body instanceof ArrayBuffer)) throw new Error("Expected secure input ArrayBuffer body"); + expect(new Uint8Array(init.body)).toEqual(input); + }); +}); + describe("Pi package API", () => { it("preserves the legacy local Pi package-management routes by default", async () => { const packages = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }]; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 493b5063..4e68cda6 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -1,4 +1,4 @@ -import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes"; +import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SecureInputReceipt, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes"; import { resolveAppUrl } from "../appUrl"; import { request } from "./http"; import { @@ -35,6 +35,8 @@ import { parseReloaded, parseRestored, parseSavedAttachments, + parseSecureInputReceipt, + parseSecureInputStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, @@ -135,6 +137,19 @@ export const pluginsApi = { plugins: (machineId?: string) => request(pluginsPath(machineId), parsePiWebPluginsResponse), }; +export const secureInputApi = { + status: () => request("api/secure-input", parseSecureInputStatusResponse, { cache: "no-store" }), + submit: (input: Uint8Array): Promise => request("api/secure-input", parseSecureInputReceipt, { + method: "POST", + body: input.buffer, + cache: "no-store", + headers: { + "Content-Type": "application/vnd.pi-web.secure-input", + "X-Pi-Web-Secure-Input": "1", + }, + }), +}; + function piPackagePath(endpoint = "", machineId?: string): string { const basePath = machineId === undefined ? "api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`; return endpoint === "" ? basePath : `${basePath}/${endpoint}`; @@ -323,6 +338,7 @@ export const api = { ...machinesApi, ...configApi, ...pluginsApi, + ...secureInputApi, ...piPackagesApi, ...activityApi, ...projectsApi, diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 5b687a74..45249bc9 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; +import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parseSecureInputReceipt, parseSecureInputStatusResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; describe("API parsers", () => { it("parses PI WEB config responses", () => { @@ -19,6 +19,13 @@ describe("API parsers", () => { }); }); + it("parses secure input status and opaque receipts", () => { + expect(parseSecureInputStatusResponse({ enabled: true, label: "Secret", maxBytes: 4096 })).toEqual({ enabled: true, label: "Secret", maxBytes: 4096 }); + expect(parseSecureInputStatusResponse({ enabled: false })).toEqual({ enabled: false }); + expect(parseSecureInputReceipt({ accepted: true, receiptId: "receipt-1", acceptedAt: "2026-07-14T00:00:00.000Z" })).toEqual({ accepted: true, receiptId: "receipt-1", acceptedAt: "2026-07-14T00:00:00.000Z" }); + expect(() => parseSecureInputReceipt({ accepted: false })).toThrow("Invalid secure input receipt"); + }); + it("parses PI WEB runtime responses including the daemon-owned active profile", () => { expect(parsePiWebRuntimeResponse({ packageName: "@jmfederico/pi-web", diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index c2508a60..4e635c7e 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,4 +1,4 @@ -import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; +import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SecureInputReceipt, SecureInputStatusResponse, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes"; import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile"; import { parseKnownPiWebCapabilities } from "../../../shared/capabilities"; @@ -541,6 +541,25 @@ export function parseWorkspaceActivityResponse(value: unknown): WorkspaceActivit return { workspaces: arrayOf(parseWorkspaceActivity)(record["workspaces"]), generatedAt: requireString(record, "generatedAt") }; } +export function parseSecureInputStatusResponse(value: unknown): SecureInputStatusResponse { + const record = requireRecord(value); + return { + enabled: requireBoolean(record, "enabled"), + ...optionalField("label", optionalString(record, "label")), + ...optionalField("maxBytes", optionalNumber(record, "maxBytes")), + }; +} + +export function parseSecureInputReceipt(value: unknown): SecureInputReceipt { + const record = requireRecord(value); + if (record["accepted"] !== true) throw new Error("Invalid secure input receipt"); + return { + accepted: true, + receiptId: requireString(record, "receiptId"), + acceptedAt: requireString(record, "acceptedAt"), + }; +} + export function parsePiWebConfigResponse(value: unknown): PiWebConfigResponse { const record = requireRecord(value); return { diff --git a/src/client/src/components/PiWebApp.secureInput.test.ts b/src/client/src/components/PiWebApp.secureInput.test.ts new file mode 100644 index 00000000..54a8ccbd --- /dev/null +++ b/src/client/src/components/PiWebApp.secureInput.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AppAction } from "../actions"; +import { initialAppState } from "../appState"; +import { PiWebApp } from "./PiWebApp"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("PiWebApp secure input entry points", () => { + it("adds the configured local-only action and opens the isolated dialog", () => { + const app = createApp(); + setProperty(app, "secureInputStatus", { enabled: true, label: "Secret", maxBytes: 1024 }); + + const action = secureInputActions(app)[0]; + expect(action).toMatchObject({ id: "app.secure-input", title: "Secret" }); + + if (action === undefined) throw new Error("Expected secure input action"); + void Promise.resolve(action.run()); + expect(getProperty(app, "secureInputDialogOpen")).toBe(true); + expect(callStringMethod(app, "secureInputLabelForSelectedMachine")).toBe("Secret"); + }); + + it("does not expose secure input while a remote machine is selected", () => { + const app = createApp(); + setProperty(app, "secureInputStatus", { enabled: true, label: "Secret", maxBytes: 1024 }); + setProperty(app, "state", { + ...initialAppState(), + selectedMachine: { id: "remote-a", name: "Remote", kind: "remote", baseUrl: "https://remote.example.test", createdAt: "now", updatedAt: "now" }, + }); + + expect(secureInputActions(app)).toEqual([]); + expect(callStringMethod(app, "secureInputLabelForSelectedMachine")).toBeUndefined(); + }); +}); + +function createApp(): PiWebApp { + const storage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined }; + vi.stubGlobal("window", { location: { search: "" }, localStorage: storage }); + return new PiWebApp(); +} + +function secureInputActions(app: PiWebApp): AppAction[] { + const result = callMethod(app, "secureInputActions"); + if (!Array.isArray(result) || !result.every(isAppAction)) throw new Error("Expected secure input actions"); + return result; +} + +function callStringMethod(target: object, name: string): string | undefined { + const result = callMethod(target, name); + if (result !== undefined && typeof result !== "string") throw new Error(`Expected optional string from ${name}`); + return result; +} + +function callMethod(target: object, name: string): unknown { + const method: unknown = Reflect.get(target, name); + if (typeof method !== "function") throw new Error(`Expected method ${name}`); + return Reflect.apply(method, target, []); +} + +function isAppAction(value: unknown): value is AppAction { + return typeof value === "object" && value !== null && typeof Reflect.get(value, "id") === "string" && typeof Reflect.get(value, "run") === "function"; +} + +function setProperty(target: object, name: string, value: unknown): void { + if (!Reflect.set(target, name, value)) throw new Error(`Could not set ${name}`); +} + +function getProperty(target: object, name: string): unknown { + return Reflect.get(target, name); +} diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 737b36af..a242b401 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1,6 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; -import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api"; +import { configApi, effectiveWorkspaceUploadFolder, secureInputApi, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SecureInputStatusResponse, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api"; import type { AppAction } from "../actions"; import { initialAppState, type AppState } from "../appState"; import { isSessionActive } from "../../../shared/activity"; @@ -45,6 +45,7 @@ import "./ProjectList"; import "./WorkspaceList"; import "./SessionList"; import "./SessionCleanupDialog"; +import "./SecureInputDialog"; import "./ChatView"; import type { ChatView } from "./ChatView"; import "./PromptEditor"; @@ -193,6 +194,8 @@ export class PiWebApp extends LitElement { @state() private isRefreshingApp = false; @state() private sessionCleanupDialog: SessionCleanupDialogState | undefined; @state() private settingsSection: SettingsSection | undefined = readSettingsSection(); + @state() private secureInputStatus: SecureInputStatusResponse = { enabled: false }; + @state() private secureInputDialogOpen = false; @state() private shortcutConfig: PiWebShortcutConfig = {}; @state() private workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(undefined); private readonly onPopState = () => void this.withChatScrollTransition(async () => { @@ -234,6 +237,7 @@ export class PiWebApp extends LitElement { this.piWebStatusTimer = window.setInterval(() => { this.schedulePiWebStatusRefresh(); }, PI_WEB_STATUS_REFRESH_MS); void this.refreshWorkspaceActivity(); void this.loadClientConfig(); + void this.loadSecureInputStatus(); void this.ensureGatewayPluginsLoaded(); void this.loadProjectsAndRestoreRoute().finally(() => { this.schedulePiWebStatusRefresh(); }); } @@ -344,6 +348,15 @@ export class PiWebApp extends LitElement { this.workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(config); } + private async loadSecureInputStatus(): Promise { + try { + this.secureInputStatus = await secureInputApi.status(); + } catch (error) { + this.secureInputStatus = { enabled: false }; + console.warn("Failed to load secure input status", error); + } + } + private async refreshAppData(): Promise { if (this.isRefreshingApp) return; this.isRefreshingApp = true; @@ -352,6 +365,7 @@ export class PiWebApp extends LitElement { this.sessions.refreshSelectedSession(), this.refreshMachineActivities(), this.loadClientConfig(), + this.loadSecureInputStatus(), this.refreshWorkspaceDeletionRuns(), this.refreshCurrentWorkspaceSurface(), ]); @@ -1142,6 +1156,8 @@ export class PiWebApp extends LitElement { .sessionsCollapsed=${this.navigationSections.isCollapsed("sessions")} .workspaceLabelItems=${(workspace: Workspace) => this.workspaceLabelItems(workspace)} .refreshControl=${this.appShell.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : undefined} + .secureInputLabel=${this.secureInputLabelForSelectedMachine()} + .onSecureInput=${() => { this.secureInputDialogOpen = true; }} .onShowActions=${() => { this.setState({ actionPaletteOpen: true }); }} .onToggleProjects=${() => { this.navigationSections.toggle("projects"); }} .onToggleWorkspaces=${() => { this.navigationSections.toggle("workspaces"); }} @@ -1392,7 +1408,20 @@ export class PiWebApp extends LitElement { } private getDefaultActions(): AppAction[] { - return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.sessionActions(), ...this.navigationFocusActions(), ...this.panelLayoutActions()]; + return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.secureInputActions(), ...this.sessionActions(), ...this.navigationFocusActions(), ...this.panelLayoutActions()]; + } + + private secureInputActions(): AppAction[] { + const machine = this.state.selectedMachine; + if (!this.secureInputStatus.enabled || machine?.kind === "remote") return []; + const label = this.secureInputStatus.label ?? "Secret"; + return [{ + id: "app.secure-input", + title: label, + description: "Send sensitive input directly to this machine without adding it to chat or the session transcript", + group: "General", + run: () => { this.secureInputDialogOpen = true; }, + }]; } private sessionActions(): AppAction[] { @@ -1888,6 +1917,10 @@ export class PiWebApp extends LitElement { `; } + private secureInputLabelForSelectedMachine(): string | undefined { + return this.secureInputStatus.enabled && this.state.selectedMachine?.kind !== "remote" ? this.secureInputStatus.label ?? "Secret" : undefined; + } + private renderContextBar() { if (!this.appShell.isMobileNavigationLayout) return null; return html` @@ -1898,6 +1931,8 @@ export class PiWebApp extends LitElement { .workspace=${this.state.selectedWorkspace} .session=${this.state.selectedSession} .refreshControl=${this.appShell.shouldShowAppRefreshInContextBar() ? this.renderAppRefresh() : undefined} + .secureInputLabel=${this.secureInputLabelForSelectedMachine()} + .onSecureInput=${() => { this.secureInputDialogOpen = true; }} .onOpenSection=${(section: NavigationSection) => { this.openNavigationSection(section); }} .onShowActions=${() => { this.setState({ actionPaletteOpen: true }); }} > @@ -1960,6 +1995,7 @@ export class PiWebApp extends LitElement { ${state.actionPaletteOpen ? html` { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}>` : null} ${state.projectDialogOpen ? html` this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}>` : null} ${state.machineDialogOpen ? html` this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}>` : null} + ${this.secureInputDialogOpen ? html` { this.secureInputDialogOpen = false; }}>` : null} ${this.sessionCleanupDialog !== undefined ? html` { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}>` : null} ${state.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}>` : null} ${this.settingsSection !== undefined ? html` { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }} .onRefreshMachineRuntime=${(machineId: string) => this.machines.refreshMachineRuntime(machineId)}>` : null} diff --git a/src/client/src/components/SecureInputDialog.test.ts b/src/client/src/components/SecureInputDialog.test.ts new file mode 100644 index 00000000..04ab89ff --- /dev/null +++ b/src/client/src/components/SecureInputDialog.test.ts @@ -0,0 +1,81 @@ +import type { TemplateResult } from "lit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { secureInputApi } from "../api"; +import { SecureInputDialog } from "./SecureInputDialog"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("secure-input-dialog", () => { + it("renders a native password field without binding its value into component state", () => { + const dialog = new SecureInputDialog(); + dialog.label = "Vault Secret"; + dialog.maxBytes = 1024; + + const markup = flattenTemplate(dialog.render()); + + expect(markup).toContain('type="password"'); + expect(markup).toContain('autocomplete="off"'); + expect(markup).toContain('name="secure-input-value"'); + expect(markup).not.toContain(".value="); + expect(markup).toContain("will not be added to the chat or session transcript"); + expect(markup).not.toContain("prompt-editor"); + }); + + it("clears the password field before transport and zeroes transport bytes afterward", async () => { + const dialog = new SecureInputDialog(); + const input = { value: "thư bí mật 🔒", focus: vi.fn() }; + Object.defineProperty(dialog, "input", { value: input, configurable: true }); + let submittedBytes: Uint8Array | undefined; + vi.spyOn(secureInputApi, "submit").mockImplementation((bytes) => { + submittedBytes = bytes; + expect(input.value).toBe(""); + expect(new TextDecoder().decode(bytes)).toBe("thư bí mật 🔒"); + return Promise.resolve({ accepted: true, receiptId: "receipt-1", acceptedAt: "2026-07-14T00:00:00.000Z" }); + }); + + await callSubmit(dialog); + + expect(submittedBytes).toBeDefined(); + expect(submittedBytes?.every((byte) => byte === 0)).toBe(true); + expect(flattenTemplate(dialog.render())).not.toContain("thư bí mật"); + }); +}); + +async function callSubmit(dialog: SecureInputDialog): Promise { + const submit: unknown = Reflect.get(dialog, "submit"); + if (typeof submit !== "function") throw new Error("SecureInputDialog.submit was unavailable"); + await Reflect.apply(submit, dialog, []); +} + +function flattenTemplate(template: TemplateResult): string { + let output = templateStrings(template).join(""); + for (const value of templateValues(template)) { + if (isTemplateResult(value)) output += flattenTemplate(value); + else if (Array.isArray(value)) { + for (const item of value) if (isTemplateResult(item)) output += flattenTemplate(item); + } else if (typeof value === "string" || typeof value === "number") output += String(value); + } + return output; +} + +function templateStrings(template: TemplateResult): readonly string[] { + const strings = Reflect.get(template, "strings"); + if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); + return strings; +} + +function templateValues(template: TemplateResult): readonly unknown[] { + const values = Reflect.get(template, "values"); + if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable"); + return values; +} + +function isTemplateResult(value: unknown): value is TemplateResult { + return typeof value === "object" && value !== null && Array.isArray(Reflect.get(value, "strings")); +} + +function isStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} diff --git a/src/client/src/components/SecureInputDialog.ts b/src/client/src/components/SecureInputDialog.ts new file mode 100644 index 00000000..800ebb87 --- /dev/null +++ b/src/client/src/components/SecureInputDialog.ts @@ -0,0 +1,107 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { secureInputApi, type SecureInputReceipt } from "../api"; +import { commandPickerStyles } from "./shared"; + +@customElement("secure-input-dialog") +export class SecureInputDialog extends LitElement { + @property() label = "Secret"; + @property({ type: Number }) maxBytes = 4096; + @property({ attribute: false }) onClose?: () => void; + @query("input") private input?: HTMLInputElement; + @state() private submitting = false; + @state() private error = ""; + @state() private receipt?: SecureInputReceipt; + + override render() { + return html` +
{ if (!this.submitting) this.close(); }}> +
{ event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}> +
+ ${this.label} + +
+
{ event.preventDefault(); void this.submit(); }}> + ${this.receipt === undefined ? html` +

Send sensitive input directly to this PI WEB machine's configured receiver. It will not be added to the chat or session transcript.

+ + + Maximum ${this.maxBytes.toLocaleString()} UTF-8 bytes. + ${this.error === "" ? null : html``} +
+ + +
+ ` : html` +

Accepted by the configured receiver.

+
+
Receipt
${this.receipt.receiptId}
+
Accepted
${new Date(this.receipt.acceptedAt).toLocaleString()}
+
+
+ `} +
+
+
+ `; + } + + protected override firstUpdated(): void { + this.input?.focus(); + } + + private async submit(): Promise { + const input = this.input; + if (input === undefined || this.submitting) return; + const bytes = new TextEncoder().encode(input.value); + input.value = ""; + this.error = ""; + if (bytes.length === 0) { + this.error = `${this.label} cannot be empty.`; + return; + } + if (bytes.length > this.maxBytes) { + bytes.fill(0); + this.error = `${this.label} exceeds the ${this.maxBytes.toLocaleString()} byte limit.`; + return; + } + + this.submitting = true; + try { + this.receipt = await secureInputApi.submit(bytes); + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + } finally { + bytes.fill(0); + this.submitting = false; + if (this.receipt === undefined) await this.updateComplete.then(() => { this.input?.focus(); }); + } + } + + private handleKeyDown(event: KeyboardEvent): void { + if (event.key !== "Escape" || this.submitting) return; + event.preventDefault(); + this.close(); + } + + private close(): void { + if (this.input !== undefined) this.input.value = ""; + this.onClose?.(); + } + + static override styles = [commandPickerStyles, css` + form { display: grid; gap: 12px; padding: 14px; overflow: auto; } + p { margin: 0; color: var(--pi-text-secondary); } + label, small, dt { color: var(--pi-muted); } + input { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 9px 10px; } + input:focus { border-color: var(--pi-accent); outline: 2px solid color-mix(in srgb, var(--pi-accent) 30%, transparent); } + .actions { display: flex; justify-content: flex-end; gap: 8px; } + .actions button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; } + .actions button.primary { border-color: var(--pi-success-border); background: var(--pi-success-surface); color: var(--pi-success); } + .actions button:disabled { opacity: .6; cursor: wait; } + .error-text { color: var(--pi-danger); } + .success { color: var(--pi-success); } + dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 10px; margin: 0; } + dd { margin: 0; overflow-wrap: anywhere; } + `]; +} diff --git a/src/client/src/components/appShell/AppContextBar.ts b/src/client/src/components/appShell/AppContextBar.ts index a21c91d8..e4d0656e 100644 --- a/src/client/src/components/appShell/AppContextBar.ts +++ b/src/client/src/components/appShell/AppContextBar.ts @@ -12,8 +12,10 @@ export class AppContextBar extends LitElement { @property({ attribute: false }) workspace?: Workspace; @property({ attribute: false }) session?: SessionInfo; @property({ attribute: false }) refreshControl: unknown; + @property({ type: String }) secureInputLabel?: string; @property({ attribute: false }) onOpenSection?: (section: NavigationSection) => void; @property({ attribute: false }) onShowActions?: () => void; + @property({ attribute: false }) onSecureInput?: () => void; @query(".context-items") private contextItems?: HTMLElement | null; @state() private canScrollLeft = false; @state() private canScrollRight = false; @@ -74,11 +76,22 @@ export class AppContextBar extends LitElement { - ${this.hasContextActions() ? html`
${this.renderActionsButton()}${this.refreshControl}
` : null} + ${this.hasContextActions() ? html`
${this.renderSecureInputButton()}${this.renderActionsButton()}${this.refreshControl}
` : null} `; } + private renderSecureInputButton() { + if (this.secureInputLabel === undefined) return null; + return html` + + `; + } + private renderActionsButton() { if (this.onShowActions === undefined) return null; return html` @@ -93,14 +106,19 @@ export class AppContextBar extends LitElement { private contextBarClass(): string { const classes = ["context-bar"]; if (this.hasContextActions()) classes.push("has-context-actions"); - if (this.refreshControl !== undefined && this.onShowActions !== undefined) classes.push("has-context-actions-double"); + if (this.contextActionCount() > 1) classes.push("has-context-actions-double"); + if (this.contextActionCount() > 2) classes.push("has-context-actions-triple"); if (this.canScrollLeft) classes.push("can-scroll-left"); if (this.canScrollRight) classes.push("can-scroll-right"); return classes.join(" "); } private hasContextActions(): boolean { - return this.refreshControl !== undefined || this.onShowActions !== undefined; + return this.contextActionCount() > 0; + } + + private contextActionCount(): number { + return [this.refreshControl, this.onShowActions, this.secureInputLabel].filter((value) => value !== undefined).length; } private observeContextItems(): void { @@ -146,6 +164,7 @@ export class AppContextBar extends LitElement { .context-items { flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 5px; margin: 0; padding: 0 8px; list-style: none; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px; scrollbar-width: thin; } .context-bar.has-context-actions .context-items { padding-right: 58px; scroll-padding-inline: 8px 58px; } .context-bar.has-context-actions-double .context-items { padding-right: 102px; scroll-padding-inline: 8px 102px; } + .context-bar.has-context-actions-triple .context-items { padding-right: 146px; scroll-padding-inline: 8px 146px; } .context-item { flex: 0 0 auto; min-width: 0; display: flex; } .context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; gap: 6px; padding: 0 8px; background: var(--pi-bg); pointer-events: none; } .context-actions::before { content: ""; position: absolute; top: 0; bottom: 0; left: -24px; z-index: 0; width: 24px; background: linear-gradient(90deg, transparent, var(--pi-bg)); pointer-events: none; } diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 9029fa77..237d82a6 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -33,6 +33,7 @@ export class AppNavigationPanel extends LitElement { @property({ attribute: false }) deletingWorkspaceIds: string[] = []; @property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => []; @property({ attribute: false }) refreshControl: unknown; + @property({ type: String }) secureInputLabel?: string; @property({ type: Boolean, reflect: true }) collapsible = false; @property({ type: Boolean, reflect: true }) compact = false; @property({ type: Boolean }) machinesCollapsed = false; @@ -48,6 +49,7 @@ export class AppNavigationPanel extends LitElement { @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; @property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions."; @property({ attribute: false }) onShowActions?: () => void; + @property({ attribute: false }) onSecureInput?: () => void; @property({ attribute: false }) onToggleMachines?: () => void; @property({ attribute: false }) onToggleProjects?: () => void; @property({ attribute: false }) onToggleWorkspaces?: () => void; @@ -108,6 +110,7 @@ export class AppNavigationPanel extends LitElement { ` : null}
${this.refreshControl} + ${this.secureInputLabel === undefined ? null : html``}
diff --git a/src/config.test.ts b/src/config.test.ts index be5c0d15..65002ea6 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; +import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, loadSecureInputConfig, maxUploadBytes, parseSecureInputConfig, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; let tempDir: string; let configPath: string; @@ -69,6 +69,32 @@ describe("PI WEB config persistence", () => { expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "acme-agent", dir: "/opt/acme-agent/state" }); }); + it("loads secure input separately from browser-visible config", async () => { + const secureInput = { command: [process.execPath, "receiver.js", "--stdin"], label: "Vault Secret", maxBytes: 1024, timeoutMs: 5000 }; + await writeFile(configPath, `${JSON.stringify({ port: 9000, secureInput }, null, 2)}\n`, "utf8"); + + expect(loadPiWebConfig(testOptions()).config).toEqual({ port: 9000 }); + expect(loadSecureInputConfig(testOptions())).toEqual(secureInput); + expect(effectivePiWebConfig(testOptions()).config).not.toHaveProperty("secureInput"); + }); + + it("preserves secure input when browser-visible config is saved", async () => { + const secureInput = { command: [process.execPath, "receiver.js"], label: "Secret" }; + await writeFile(configPath, `${JSON.stringify({ port: 8504, secureInput }, null, 2)}\n`, "utf8"); + + savePiWebConfig({ port: 9000 }, testOptions()); + + expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ secureInput, port: 9000 }); + }); + + it("validates secure input without accepting shell or control-character injection", () => { + expect(parseSecureInputConfig({ command: [process.execPath, "--safe value"] }, "test").command).toEqual([process.execPath, "--safe value"]); + expect(() => parseSecureInputConfig({ command: ["node;other"] }, "test")).toThrow("safe bare name or host-absolute path"); + expect(() => parseSecureInputConfig({ command: [process.execPath, "bad\narg"] }, "test")).toThrow("without control characters"); + expect(() => parseSecureInputConfig({ command: [process.execPath], future: true }, "test")).toThrow('contains unknown key "future"'); + expect(() => parseSecureInputConfig({ command: [process.execPath], maxBytes: 4097 }, "test")).toThrow("secureInput.maxBytes"); + }); + it("defaults to the Pi agent directory only for canonical Pi companion names", () => { for (const command of ["pi", "pi.cmd"]) { expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command } })).toMatchObject({ diff --git a/src/config.ts b/src/config.ts index 8ad09209..ca232d7c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -31,6 +31,17 @@ export interface LoadOptions { cwd?: string; } +export interface SecureInputConfig { + command: [string, ...string[]]; + label: string; + maxBytes: number; + timeoutMs: number; +} + +export const DEFAULT_SECURE_INPUT_LABEL = "Secret"; +export const DEFAULT_SECURE_INPUT_MAX_BYTES = 4096; +export const DEFAULT_SECURE_INPUT_TIMEOUT_MS = 10_000; + export function defaultPiWebConfigPath(env: NodeJS.ProcessEnv = process.env): string { const xdgConfigHome = env["XDG_CONFIG_HOME"]; return join(xdgConfigHome !== undefined && xdgConfigHome !== "" ? xdgConfigHome : join(homedir(), ".config"), "pi-web", "config.json"); @@ -135,6 +146,17 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedEffective return resolveEffectivePiWebConfig(loadPiWebConfig(options), options); } +export function loadSecureInputConfig(options: LoadOptions = {}): SecureInputConfig | undefined { + const env = options.env ?? process.env; + const path = piWebConfigPath(env, options.cwd ?? process.cwd()); + if (!existsSync(path)) return undefined; + + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isRecord(parsed)) throw new Error(`PI WEB config must be a JSON object: ${path}`); + const value = parsed["secureInput"]; + return value === undefined ? undefined : parseSecureInputConfig(value, path); +} + export function resolveEffectivePiWebConfig(loaded: LoadedPiWebConfig, options: LoadOptions = {}): LoadedEffectivePiWebConfig { const env = options.env ?? process.env; const host = env["PI_WEB_HOST"]; @@ -224,6 +246,45 @@ function parsePiWebConfig(value: Record, path: string): PiWebCo }; } +export function parseSecureInputConfig(value: unknown, path: string): SecureInputConfig { + if (!isRecord(value)) throw new Error(`PI WEB config secureInput must be an object: ${path}`); + const allowedKeys = new Set(["command", "label", "maxBytes", "timeoutMs"]); + const unknownKey = Object.keys(value).find((key) => !allowedKeys.has(key)); + if (unknownKey !== undefined) throw new Error(`PI WEB config secureInput contains unknown key ${JSON.stringify(unknownKey)}: ${path}`); + + const command = value["command"]; + if (!Array.isArray(command) || command.length === 0 || command.length > 64 || !command.every(isSafeCommandArgument)) { + throw new Error(`PI WEB config secureInput.command must be an array of 1-64 strings without control characters: ${path}`); + } + const executable = command[0]; + if (executable === undefined || !isSafeAgentCommandForHost(executable)) { + throw new Error(`PI WEB config secureInput.command executable must be a safe bare name or host-absolute path: ${path}`); + } + + const label = value["label"] === undefined ? DEFAULT_SECURE_INPUT_LABEL : parseSecureInputLabel(value["label"], path); + const maxBytes = value["maxBytes"] === undefined ? DEFAULT_SECURE_INPUT_MAX_BYTES : parseBoundedPositiveInteger(value["maxBytes"], "secureInput.maxBytes", DEFAULT_SECURE_INPUT_MAX_BYTES, path); + const timeoutMs = value["timeoutMs"] === undefined ? DEFAULT_SECURE_INPUT_TIMEOUT_MS : parseBoundedPositiveInteger(value["timeoutMs"], "secureInput.timeoutMs", 60_000, path); + return { command: [executable, ...command.slice(1)], label, maxBytes, timeoutMs }; +} + +function isSafeCommandArgument(value: unknown): value is string { + return typeof value === "string" && !hasControlCharacter(value); +} + +function parseSecureInputLabel(value: unknown, path: string): string { + if (typeof value !== "string" || value.trim() === "" || value.length > 80 || hasControlCharacter(value)) { + throw new Error(`PI WEB config secureInput.label must be a non-empty string of at most 80 characters: ${path}`); + } + return value; +} + +function parseBoundedPositiveInteger(value: unknown, key: string, maximum: number, path: string): number { + if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > maximum) { + throw new Error(`PI WEB config ${key} must be an integer from 1 to ${String(maximum)}: ${path}`); + } + return value; +} + function parseMaxUploadBytes(value: unknown, key: string, path = "environment"): number { const bytes = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN; if (!Number.isInteger(bytes) || bytes < 1) throw new Error(`PI WEB config ${key} must be a positive integer: ${path}`); diff --git a/src/server/app.ts b/src/server/app.ts index 6bd2eee9..0d6150a6 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -35,6 +35,7 @@ import { MachineService } from "./machines/machineService.js"; import { registerMachineRoutes } from "./machines/machineRoutes.js"; import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js"; import { proxyMachinePluginAsset, registerMachinePluginProxyRoutes } from "./machines/machinePluginProxyRoutes.js"; +import { registerSecureInputRoutes, type SecureInputService } from "./secureInputRoutes.js"; import type { Project, Workspace } from "./types.js"; export interface AppDependencies { @@ -47,6 +48,7 @@ export interface AppDependencies { piPackages?: PiPackageService; piWebStatusCache?: PiWebStatusCache; config?: PiWebConfigService; + secureInput?: SecureInputService; clientDist?: string | false; logger?: FastifyServerOptions["logger"]; /** Maximum accepted HTTP request body size in bytes. */ @@ -214,6 +216,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise>; + +beforeEach(async () => { + submit = vi.fn(() => Promise.resolve({ accepted: true, receiptId: "receipt-1", acceptedAt: "2026-07-14T00:00:00.000Z" })); + app = Fastify({ logger: false }); + registerSecureInputRoutes(app, { status: () => ({ enabled: true, label: "Secret", maxBytes: 4096 }), submit }); + await app.ready(); +}); + +afterEach(async () => { + await app.close(); +}); + +describe("secure input routes", () => { + it("exposes only public receiver metadata", async () => { + const response = await app.inject({ method: "GET", url: "/api/secure-input" }); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBe("no-store"); + expect(response.json()).toEqual({ enabled: true, label: "Secret", maxBytes: 4096 }); + expect(response.body).not.toContain("command"); + }); + + it("requires the non-simple same-origin request header before invoking the receiver", async () => { + const response = await app.inject({ method: "POST", url: "/api/secure-input", headers: { "content-type": contentType }, payload: "do-not-forward" }); + + expect(response.statusCode).toBe(403); + expect(response.headers["cache-control"]).toBe("no-store"); + expect(response.body).not.toContain("do-not-forward"); + expect(submit).not.toHaveBeenCalled(); + }); + + it("passes exact UTF-8 bytes without echoing them and clears the request buffer", async () => { + const secret = "thư bí mật 🔒"; + let received: Buffer | undefined; + submit.mockImplementation((input) => { + received = input; + expect(Buffer.from(input)).toEqual(Buffer.from(secret, "utf8")); + return Promise.resolve({ accepted: true, receiptId: "receipt-1", acceptedAt: "2026-07-14T00:00:00.000Z" }); + }); + + const response = await app.inject({ method: "POST", url: "/api/secure-input", headers: requiredHeaders, payload: Buffer.from(secret, "utf8") }); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBe("no-store"); + expect(response.json()).toEqual({ accepted: true, receiptId: "receipt-1", acceptedAt: "2026-07-14T00:00:00.000Z" }); + expect(response.body).not.toContain(secret); + expect(received).toBeDefined(); + expect(received?.every((byte) => byte === 0)).toBe(true); + }); + + it("rejects oversized input before invoking the receiver", async () => { + const response = await app.inject({ method: "POST", url: "/api/secure-input", headers: requiredHeaders, payload: Buffer.alloc(4097, 97) }); + + expect(response.statusCode).toBe(413); + expect(response.headers["cache-control"]).toBe("no-store"); + expect(submit).not.toHaveBeenCalled(); + }); + + it("does not return receiver diagnostics or submitted input on failure", async () => { + submit.mockRejectedValue(new Error("receiver leaked do-not-return")); + + const response = await app.inject({ method: "POST", url: "/api/secure-input", headers: requiredHeaders, payload: "do-not-return" }); + + expect(response.statusCode).toBe(502); + expect(response.json()).toEqual({ error: "Secure input receiver failed" }); + expect(response.body).not.toContain("do-not-return"); + expect(response.body).not.toContain("receiver leaked"); + }); +}); + +describe("command secure input service", () => { + it("writes exact bytes to a fixed argv receiver through stdin", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-secure-input-")); + const output = join(tempDir, "received.bin"); + const script = "const fs=require('node:fs');const chunks=[];process.stdin.on('data',c=>chunks.push(c));process.stdin.on('end',()=>fs.writeFileSync(process.argv[1],Buffer.concat(chunks)));"; + const service = new CommandSecureInputService(() => ({ + command: [process.execPath, "-e", script, output], + label: "Secret", + maxBytes: 4096, + timeoutMs: 5_000, + })); + const input = Buffer.from("exact\u0000UTF-8: thư 🔒", "utf8"); + + try { + const receipt = await service.submit(input); + expect(receipt).toMatchObject({ accepted: true }); + expect(await readFile(output)).toEqual(input); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("rejects concurrent submissions while the receiver is active", async () => { + const service = new CommandSecureInputService(() => ({ + command: [process.execPath, "-e", "setTimeout(() => process.exit(0), 100)"], + label: "Secret", + maxBytes: 4096, + timeoutMs: 5_000, + })); + + const first = service.submit(Buffer.from("first")); + await expect(service.submit(Buffer.from("second"))).rejects.toThrow("Another secure input submission is in progress"); + await expect(first).resolves.toMatchObject({ accepted: true }); + }); + + it("terminates a receiver that exceeds its configured timeout", async () => { + const service = new CommandSecureInputService(() => ({ + command: [process.execPath, "-e", "setInterval(() => {}, 1000)"], + label: "Secret", + maxBytes: 4096, + timeoutMs: 25, + })); + + await expect(service.submit(Buffer.from("timeout"))).rejects.toThrow("Secure input receiver timed out"); + }); + + it("fails closed when not configured", async () => { + const service = new CommandSecureInputService(() => undefined); + + expect(service.status()).toEqual({ enabled: false }); + await expect(service.submit(Buffer.from("x"))).rejects.toThrow("Secure input is not configured"); + }); +}); diff --git a/src/server/secureInputRoutes.ts b/src/server/secureInputRoutes.ts new file mode 100644 index 00000000..92a507d8 --- /dev/null +++ b/src/server/secureInputRoutes.ts @@ -0,0 +1,127 @@ +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import type { FastifyInstance, FastifyReply } from "fastify"; +import { DEFAULT_SECURE_INPUT_MAX_BYTES, loadSecureInputConfig, type SecureInputConfig } from "../config.js"; +import type { SecureInputReceipt, SecureInputStatusResponse } from "../shared/apiTypes.js"; + +const SECURE_INPUT_CONTENT_TYPE = "application/vnd.pi-web.secure-input"; +const SECURE_INPUT_HEADER = "x-pi-web-secure-input"; + +export interface SecureInputService { + status: () => SecureInputStatusResponse | Promise; + submit: (input: Buffer) => SecureInputReceipt | Promise; +} + +export class CommandSecureInputService implements SecureInputService { + private active = false; + + constructor(private readonly readConfig: () => SecureInputConfig | undefined = () => loadSecureInputConfig()) {} + + status(): SecureInputStatusResponse { + const config = this.readConfig(); + if (config === undefined) return { enabled: false }; + return { enabled: true, label: config.label, maxBytes: config.maxBytes }; + } + + async submit(input: Buffer): Promise { + const config = this.readConfig(); + if (config === undefined) throw new SecureInputError(404, "Secure input is not configured"); + if (input.length === 0) throw new SecureInputError(400, "Secure input cannot be empty"); + if (input.length > config.maxBytes) throw new SecureInputError(413, `Secure input exceeds the ${String(config.maxBytes)} byte limit`); + if (this.active) throw new SecureInputError(409, "Another secure input submission is in progress"); + + this.active = true; + try { + await runReceiver(config, input); + return { accepted: true, receiptId: randomUUID(), acceptedAt: new Date().toISOString() }; + } finally { + this.active = false; + } + } +} + +export function registerSecureInputRoutes(app: FastifyInstance, service: SecureInputService = new CommandSecureInputService()): void { + try { + app.addContentTypeParser(SECURE_INPUT_CONTENT_TYPE, { parseAs: "buffer" }, (_request, body, done) => { done(null, body); }); + } catch { + // Route registration may be composed more than once in tests. + } + + app.get("/api/secure-input", async (_request, reply) => { + return noStore(reply).send(await service.status()); + }); + + app.post<{ Body: Buffer }>("/api/secure-input", { + bodyLimit: DEFAULT_SECURE_INPUT_MAX_BYTES, + onRequest: (_request, reply, done) => { noStore(reply); done(); }, + }, async (request, reply) => { + const body = request.body; + try { + if (request.headers[SECURE_INPUT_HEADER] !== "1") { + return await noStore(reply).code(403).send({ error: "Secure input request header is required" }); + } + if (!Buffer.isBuffer(body)) { + return await noStore(reply).code(415).send({ error: `Secure input requires ${SECURE_INPUT_CONTENT_TYPE}` }); + } + return await noStore(reply).send(await service.submit(body)); + } catch (error) { + const statusCode = error instanceof SecureInputError ? error.statusCode : 502; + const message = error instanceof SecureInputError ? error.message : "Secure input receiver failed"; + return await noStore(reply).code(statusCode).send({ error: message }); + } finally { + if (Buffer.isBuffer(body)) body.fill(0); + } + }); +} + +class SecureInputError extends Error { + constructor(readonly statusCode: number, message: string) { + super(message); + } +} + +function noStore(reply: FastifyReply): FastifyReply { + return reply.header("Cache-Control", "no-store").header("X-Content-Type-Options", "nosniff"); +} + +function runReceiver(config: SecureInputConfig, input: Buffer): Promise { + return new Promise((resolve, reject) => { + const [file, ...args] = config.command; + const child = spawn(file, args, { + shell: false, + stdio: ["pipe", "ignore", "ignore"], + windowsHide: true, + }); + let settled = false; + let pendingError: SecureInputError | undefined; + let forceKill: NodeJS.Timeout | undefined; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (forceKill !== undefined) clearTimeout(forceKill); + if (error === undefined) resolve(); + else reject(error); + }; + const terminate = (error: SecureInputError) => { + if (settled || pendingError !== undefined) return; + pendingError = error; + child.kill(); + forceKill = setTimeout(() => { child.kill("SIGKILL"); }, 1_000); + forceKill.unref(); + }; + const timeout = setTimeout(() => { + terminate(new SecureInputError(504, "Secure input receiver timed out")); + }, config.timeoutMs); + timeout.unref(); + + child.once("error", () => { finish(new SecureInputError(502, "Secure input receiver could not start")); }); + child.once("close", (code) => { + finish(pendingError ?? (code === 0 ? undefined : new SecureInputError(502, "Secure input receiver rejected the submission"))); + }); + child.stdin.once("error", () => { + terminate(new SecureInputError(502, "Secure input receiver closed before accepting the submission")); + }); + child.stdin.end(input); + }); +} diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 820b741d..f0749e90 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -74,6 +74,18 @@ export interface PiWebAgentConfig { dir?: string; } +export interface SecureInputStatusResponse { + enabled: boolean; + label?: string; + maxBytes?: number; +} + +export interface SecureInputReceipt { + accepted: true; + receiptId: string; + acceptedAt: string; +} + export interface PiWebConfigValues { host?: string; port?: number; From 4072d8413998c44b8732a5dca9b461a0166c11fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ph=C3=A1t=20kana?= Date: Wed, 15 Jul 2026 01:03:18 +0700 Subject: [PATCH 2/2] fix: harden secure input dialog controls --- .../src/components/SecureInputDialog.test.ts | 43 ++++++++++++++++++- .../src/components/SecureInputDialog.ts | 27 ++++++++++-- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/client/src/components/SecureInputDialog.test.ts b/src/client/src/components/SecureInputDialog.test.ts index 04ab89ff..0f9f9024 100644 --- a/src/client/src/components/SecureInputDialog.test.ts +++ b/src/client/src/components/SecureInputDialog.test.ts @@ -15,14 +15,43 @@ describe("secure-input-dialog", () => { const markup = flattenTemplate(dialog.render()); - expect(markup).toContain('type="password"'); + expect(markup).toContain("password"); expect(markup).toContain('autocomplete="off"'); expect(markup).toContain('name="secure-input-value"'); + expect(markup).toContain("Show Vault Secret"); + expect(markup).toContain("false"); expect(markup).not.toContain(".value="); expect(markup).toContain("will not be added to the chat or session transcript"); expect(markup).not.toContain("prompt-editor"); }); + it("reveals and hides the existing DOM value without copying it into component state", () => { + const dialog = new SecureInputDialog(); + const input = { value: "still-only-in-the-dom", focus: vi.fn() }; + Object.defineProperty(dialog, "input", { value: input, configurable: true }); + + callToggleReveal(dialog); + expect(flattenTemplate(dialog.render())).toContain("text"); + expect(flattenTemplate(dialog.render())).toContain("Hide Secret"); + expect(flattenTemplate(dialog.render())).not.toContain(input.value); + + callToggleReveal(dialog); + expect(flattenTemplate(dialog.render())).toContain("password"); + expect(flattenTemplate(dialog.render())).toContain("Show Secret"); + expect(flattenTemplate(dialog.render())).not.toContain(input.value); + }); + + it("closes from the receipt state after the input has left the DOM", () => { + const dialog = new SecureInputDialog(); + const onClose = vi.fn(); + dialog.onClose = onClose; + Object.defineProperty(dialog, "input", { value: null, configurable: true }); + + callClose(dialog); + + expect(onClose).toHaveBeenCalledOnce(); + }); + it("clears the password field before transport and zeroes transport bytes afterward", async () => { const dialog = new SecureInputDialog(); const input = { value: "thư bí mật 🔒", focus: vi.fn() }; @@ -43,6 +72,18 @@ describe("secure-input-dialog", () => { }); }); +function callToggleReveal(dialog: SecureInputDialog): void { + const toggle: unknown = Reflect.get(dialog, "toggleRevealInput"); + if (typeof toggle !== "function") throw new Error("SecureInputDialog.toggleRevealInput was unavailable"); + Reflect.apply(toggle, dialog, []); +} + +function callClose(dialog: SecureInputDialog): void { + const close: unknown = Reflect.get(dialog, "close"); + if (typeof close !== "function") throw new Error("SecureInputDialog.close was unavailable"); + Reflect.apply(close, dialog, []); +} + async function callSubmit(dialog: SecureInputDialog): Promise { const submit: unknown = Reflect.get(dialog, "submit"); if (typeof submit !== "function") throw new Error("SecureInputDialog.submit was unavailable"); diff --git a/src/client/src/components/SecureInputDialog.ts b/src/client/src/components/SecureInputDialog.ts index 800ebb87..5429fffa 100644 --- a/src/client/src/components/SecureInputDialog.ts +++ b/src/client/src/components/SecureInputDialog.ts @@ -10,6 +10,7 @@ export class SecureInputDialog extends LitElement { @property({ attribute: false }) onClose?: () => void; @query("input") private input?: HTMLInputElement; @state() private submitting = false; + @state() private revealInput = false; @state() private error = ""; @state() private receipt?: SecureInputReceipt; @@ -19,13 +20,18 @@ export class SecureInputDialog extends LitElement {
{ event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>
${this.label} - +
{ event.preventDefault(); void this.submit(); }}> ${this.receipt === undefined ? html`

Send sensitive input directly to this PI WEB machine's configured receiver. It will not be added to the chat or session transcript.

- +
+ + +
Maximum ${this.maxBytes.toLocaleString()} UTF-8 bytes. ${this.error === "" ? null : html``}
@@ -55,6 +61,7 @@ export class SecureInputDialog extends LitElement { if (input === undefined || this.submitting) return; const bytes = new TextEncoder().encode(input.value); input.value = ""; + this.revealInput = false; this.error = ""; if (bytes.length === 0) { this.error = `${this.label} cannot be empty.`; @@ -78,6 +85,12 @@ export class SecureInputDialog extends LitElement { } } + private toggleRevealInput(): void { + if (this.submitting) return; + this.revealInput = !this.revealInput; + void this.updateComplete.then(() => { this.input?.focus(); }); + } + private handleKeyDown(event: KeyboardEvent): void { if (event.key !== "Escape" || this.submitting) return; event.preventDefault(); @@ -85,7 +98,9 @@ export class SecureInputDialog extends LitElement { } private close(): void { - if (this.input !== undefined) this.input.value = ""; + const input = this.input; + if (input != null) input.value = ""; + this.revealInput = false; this.onClose?.(); } @@ -93,8 +108,12 @@ export class SecureInputDialog extends LitElement { form { display: grid; gap: 12px; padding: 14px; overflow: auto; } p { margin: 0; color: var(--pi-text-secondary); } label, small, dt { color: var(--pi-muted); } - input { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 9px 10px; } + .input-wrap { position: relative; } + input { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 9px 42px 9px 10px; } input:focus { border-color: var(--pi-accent); outline: 2px solid color-mix(in srgb, var(--pi-accent) 30%, transparent); } + .reveal { position: absolute; top: 50%; right: 5px; width: 32px; height: 32px; display: grid; place-items: center; transform: translateY(-50%); border: 0; border-radius: 6px; background: transparent; color: var(--pi-muted); padding: 6px; cursor: pointer; } + .reveal:hover, .reveal:focus-visible { background: var(--pi-surface-hover); color: var(--pi-text); } + .reveal svg { width: 20px; height: 20px; fill: currentColor; } .actions { display: flex; justify-content: flex-end; gap: 8px; } .actions button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; } .actions button.primary { border-color: var(--pi-success-border); background: var(--pi-success-surface); color: var(--pi-success); }