Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/secure-input.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 },
Expand Down Expand Up @@ -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.<id>.enabled`, `plugins.<id>.settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab |
| Keyboard shortcuts | `shortcuts.<actionId>` | — | 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 |
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/client/src/api.ts
Original file line number Diff line number Diff line change
@@ -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";
28 changes: 27 additions & 1 deletion src/client/src/api/clients.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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" }];
Expand Down
18 changes: 17 additions & 1 deletion src/client/src/api/clients.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -35,6 +35,8 @@ import {
parseReloaded,
parseRestored,
parseSavedAttachments,
parseSecureInputReceipt,
parseSecureInputStatusResponse,
parseSessionBulkArchiveResponse,
parseSessionBulkDeleteArchivedResponse,
parseSessionCleanupExecuteResponse,
Expand Down Expand Up @@ -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<ArrayBuffer>): Promise<SecureInputReceipt> => 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}`;
Expand Down Expand Up @@ -323,6 +338,7 @@ export const api = {
...machinesApi,
...configApi,
...pluginsApi,
...secureInputApi,
...piPackagesApi,
...activityApi,
...projectsApi,
Expand Down
9 changes: 8 additions & 1 deletion src/client/src/api/parsers.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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",
Expand Down
Loading