Merge remote-tracking branch 'origin/main' into feat/docker-runtime-host-admin

# Conflicts:
#	src/client/src/api.ts
#	src/client/src/api/parsers.test.ts
#	src/shared/piWebStatusParsing.test.ts
This commit is contained in:
Pi Web Agent
2026-07-02 20:52:50 +00:00
155 changed files with 11092 additions and 1306 deletions
+129 -6
View File
@@ -3,9 +3,13 @@ export type MachineStatus = "unknown" | "online" | "offline" | "error";
export const PI_WEB_CAPABILITIES = {
sessionsDeleteArchived: "sessions.deleteArchived",
sessionsBulkMutations: "sessions.bulkMutations",
sessionsCleanup: "sessions.cleanup",
sessionsReload: "sessions.reload",
promptAttachments: "prompt.attachments",
workspaceFileSuggestions: "workspace.fileSuggestions",
piPackagesManage: "piPackages.manage",
selectedMachineSettings: "settings.selectedMachine",
} as const;
export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES];
@@ -98,6 +102,43 @@ export interface PiWebPluginsResponse {
plugins: PiWebPluginInfo[];
}
export type PiPackageScope = "user" | "project";
export interface PiPackageInfo {
source: string;
scope: PiPackageScope;
filtered: boolean;
installedPath?: string;
}
export interface PiPackagesResponse {
packages: PiPackageInfo[];
}
export interface PiPackageInstallRequest {
source: string;
}
export interface PiPackageRemoveRequest {
source: string;
/** Optional known scope from a listed package; not an install-location picker. */
scope?: PiPackageScope;
}
export interface PiPackageUpdateRequest {
/** Omit to update all configured Pi packages. */
source?: string;
}
export type PiPackageMutationAction = "install" | "remove" | "update";
export interface PiPackageMutationResponse extends PiPackagesResponse {
action: PiPackageMutationAction;
source?: string;
scope?: PiPackageScope;
removed?: boolean;
}
export interface PiWebConfigEnvOverrides {
host: boolean;
port: boolean;
@@ -145,6 +186,8 @@ export interface SessionRef {
export interface SessionInfo extends SessionRef {
path: string;
/** True when the server has verified a backing session file exists; false when known transient. */
persisted?: boolean;
name?: string;
created: string;
modified: string;
@@ -162,6 +205,72 @@ export interface ArchiveSessionsResponse {
skippedAlreadyArchivedCount?: number;
}
export interface SessionBulkMutationRef {
id: string;
cwd?: string;
}
export interface SessionBulkMutationRequest {
sessions: SessionBulkMutationRef[];
}
export interface SessionBulkFailure {
sessionId: string;
error: string;
}
export interface SessionBulkArchiveResponse {
archived: true;
archivedSessionIds: string[];
failures: SessionBulkFailure[];
generatedAt: string;
}
export interface SessionBulkDeleteArchivedResponse {
deleted: true;
deletedSessionIds: string[];
failures: SessionBulkFailure[];
generatedAt: string;
}
export interface SessionCleanupRequest {
/** Archive non-archived sessions whose modified time is older than this many days. Omit/null to disable. */
archiveIdleDays?: number | null;
/** Permanently delete archived sessions whose archivedAt time is older than this many days. Omit/null to disable. */
deleteArchivedDays?: number | null;
/** Stored cwd paths selected from a preview. Omit/null to include all discovered project/workspace paths. */
projectCwds?: string[] | null;
}
export interface SessionCleanupThresholds {
archiveIdleDays?: number;
deleteArchivedDays?: number;
}
export interface SessionCleanupProjectSummary {
cwd: string;
archiveCount: number;
deleteCount: number;
}
export interface SessionCleanupTotals {
archiveCount: number;
deleteCount: number;
}
export interface SessionCleanupPreviewResponse {
generatedAt: string;
thresholds: SessionCleanupThresholds;
projects: SessionCleanupProjectSummary[];
totals: SessionCleanupTotals;
skippedBusySessionIds?: string[];
}
export interface SessionCleanupExecuteResponse extends SessionCleanupPreviewResponse {
archivedSessionIds: string[];
deletedSessionIds: string[];
}
export interface SessionActivity {
sessionId: string;
phase: "active" | "idle" | "error";
@@ -176,14 +285,13 @@ export interface QueuedSessionMessage {
}
/**
* A binary attachment carried with a prompt. The wire format mirrors pi's own
* `ImageContent` shape (`{ type: "image", data, mimeType }`) so attachments are
* fully compatible with the underlying pi coding agent.
* A pi-native image attachment carried with a prompt. The wire format mirrors
* pi's own `ImageContent` shape (`{ type: "image", data, mimeType }`) so these
* attachments are compatible with native multimodal delivery after validation.
*/
export interface PromptAttachment {
/** Kind of attachment. Only images are supported by pi today. */
export interface PromptImageAttachment {
kind: "image";
/** IANA mime type (for example "image/png"). */
/** Supported image MIME type (image/png, image/jpeg, image/gif, or image/webp). */
mimeType: string;
/** Base64-encoded binary payload (no data: URL prefix). */
data: string;
@@ -191,6 +299,19 @@ export interface PromptAttachment {
name?: string;
}
/** A general file attachment that must be saved into the workspace before use. */
export interface PromptFileAttachment {
kind: "file";
/** Non-empty IANA MIME type (for example "application/pdf"). */
mimeType: string;
/** Base64-encoded binary payload (no data: URL prefix). Empty for zero-byte files. */
data: string;
/** Optional original filename, used for previews and folder-mode filenames. */
name?: string;
}
export type PromptAttachment = PromptImageAttachment | PromptFileAttachment;
/**
* How prompt attachments should be delivered to the session.
* - "inline": send the binary to pi as native image content (multimodal input).
@@ -261,6 +382,8 @@ export interface ThinkingLevelsResponse {
export interface SessionStatus {
sessionId: string;
/** True when the server has verified a backing session file exists; false when known transient. */
persisted?: boolean;
model?: SessionModel;
thinkingLevel?: string;
isStreaming: boolean;
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { effectivePiWebCapabilities, PI_WEB_CAPABILITIES, SESSIOND_RUNTIME_CAPABILITIES, WEB_RUNTIME_CAPABILITIES, parseKnownPiWebCapabilities } from "./capabilities";
describe("PI WEB capabilities", () => {
it("advertises web-only capabilities without requiring session daemon support", () => {
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.piPackagesManage);
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] },
sessiond: { available: false, capabilities: [] },
})).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
});
it("keeps only known string capabilities when parsing runtime data", () => {
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined();
});
});
+27 -2
View File
@@ -6,14 +6,34 @@ export type { PiWebCapability };
export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES);
const knownPiWebCapabilities: ReadonlySet<string> = new Set(KNOWN_PI_WEB_CAPABILITIES);
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.workspaceFileSuggestions] as const satisfies readonly PiWebCapability[];
export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
export const WEB_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsDeleteArchived,
PI_WEB_CAPABILITIES.sessionsBulkMutations,
PI_WEB_CAPABILITIES.sessionsCleanup,
PI_WEB_CAPABILITIES.sessionsReload,
PI_WEB_CAPABILITIES.promptAttachments,
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
PI_WEB_CAPABILITIES.piPackagesManage,
PI_WEB_CAPABILITIES.selectedMachineSettings,
] as const satisfies readonly PiWebCapability[];
export const SESSIOND_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsDeleteArchived,
PI_WEB_CAPABILITIES.sessionsBulkMutations,
PI_WEB_CAPABILITIES.sessionsCleanup,
PI_WEB_CAPABILITIES.sessionsReload,
PI_WEB_CAPABILITIES.promptAttachments,
] as const satisfies readonly PiWebCapability[];
const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
[PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsBulkMutations]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
[PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],
[PI_WEB_CAPABILITIES.selectedMachineSettings]: ["web"],
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
export function isPiWebCapability(value: unknown): value is PiWebCapability {
@@ -24,6 +44,11 @@ export function supportsPiWebCapability(source: { capabilities?: readonly PiWebC
return source?.capabilities?.includes(capability) === true;
}
export function parseKnownPiWebCapabilities(value: unknown): PiWebCapability[] | undefined {
if (!Array.isArray(value) || !value.every((capability) => typeof capability === "string")) return undefined;
return value.filter(isPiWebCapability);
}
export function effectivePiWebCapabilities(components: Partial<Record<PiWebServiceComponent, Pick<PiWebRuntimeComponent, "available" | "capabilities">>>): PiWebCapability[] {
return KNOWN_PI_WEB_CAPABILITIES.filter((capability) => {
const requiredComponents = EFFECTIVE_CAPABILITY_REQUIREMENTS[capability];
+14
View File
@@ -1,12 +1,22 @@
export type FederatedHttpMethod = "GET" | "POST" | "PUT" | "DELETE";
export const PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS = 5 * 60_000;
export interface FederatedHttpRouteSpec {
method: FederatedHttpMethod;
path: string;
timeoutMs?: number;
}
export const FEDERATED_HTTP_ROUTES = [
{ method: "GET", path: "/pi-web/status" },
{ method: "GET", path: "/config" },
{ method: "PUT", path: "/config" },
{ method: "GET", path: "/plugins" },
{ method: "GET", path: "/pi-packages" },
{ method: "POST", path: "/pi-packages/install", timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS },
{ method: "POST", path: "/pi-packages/remove", timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS },
{ method: "POST", path: "/pi-packages/update", timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS },
{ method: "GET", path: "/projects" },
{ method: "POST", path: "/projects" },
{ method: "DELETE", path: "/projects/:projectId" },
@@ -35,6 +45,10 @@ export const FEDERATED_HTTP_ROUTES = [
{ method: "GET", path: "/activity" },
{ method: "GET", path: "/sessions" },
{ method: "POST", path: "/sessions" },
{ method: "POST", path: "/sessions/cleanup/preview" },
{ method: "POST", path: "/sessions/cleanup" },
{ method: "POST", path: "/sessions/bulk/archive" },
{ method: "POST", path: "/sessions/bulk/delete-archived" },
{ method: "GET", path: "/sessions/:sessionId/messages" },
{ method: "GET", path: "/sessions/:sessionId/status" },
{ method: "GET", path: "/sessions/:sessionId/models" },
+33 -2
View File
@@ -1,7 +1,38 @@
import { describe, expect, it } from "vitest";
import { parsePiWebComponentStatus, parsePiWebInstallationInfo, parsePiWebVersionResponse } from "./piWebStatusParsing.js";
import { PI_WEB_CAPABILITIES } from "./capabilities";
import { parsePiWebComponentStatus, parsePiWebInstallationInfo, parsePiWebRuntimeResponse, parsePiWebVersionResponse } from "./piWebStatusParsing";
describe("PI WEB status parsing", () => {
it("parses known runtime capabilities and ignores unknown string capabilities", () => {
expect(parsePiWebRuntimeResponse({
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"] },
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: ["future.sessiondCapability"] },
},
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"],
})).toMatchObject({
components: {
web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] },
sessiond: { capabilities: [] },
},
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings],
});
});
it("rejects malformed capability arrays", () => {
expect(parsePiWebRuntimeResponse({
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "Web/UI", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, 1] },
sessiond: { component: "sessiond", label: "Session daemon", available: true, capabilities: [] },
},
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage],
})).toBeUndefined();
});
describe("PI WEB shared status parsing", () => {
it("parses Docker installation metadata", () => {
expect(parsePiWebInstallationInfo({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" })).toEqual({
kind: "docker",
+16 -8
View File
@@ -1,5 +1,5 @@
import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebRuntimeComponent, PiWebVersionResponse } from "./apiTypes.js";
import { isPiWebCapability } from "./capabilities.js";
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebVersionResponse } from "./apiTypes.js";
import { parseKnownPiWebCapabilities } from "./capabilities.js";
export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse | undefined {
if (!isRecord(value)) return undefined;
@@ -13,13 +13,26 @@ export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse
return { packageName, generatedAt, components: { web, sessiond } };
}
export function parsePiWebRuntimeResponse(value: unknown): PiWebRuntimeResponse | undefined {
if (!isRecord(value)) return undefined;
const packageName = value["packageName"];
const generatedAt = value["generatedAt"];
const components = value["components"];
const capabilities = parseKnownPiWebCapabilities(value["capabilities"]);
if (typeof packageName !== "string" || packageName === "" || typeof generatedAt !== "string" || generatedAt === "" || !isRecord(components) || capabilities === undefined) return undefined;
const web = parsePiWebRuntimeComponent(components["web"]);
const sessiond = parsePiWebRuntimeComponent(components["sessiond"]);
if (web === undefined || sessiond === undefined) return undefined;
return { packageName, generatedAt, components: { web, sessiond }, capabilities };
}
export function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponent | undefined {
if (!isRecord(value)) return undefined;
const component = value["component"];
const label = value["label"];
const runtimeVersion = value["runtimeVersion"];
const available = value["available"];
const capabilities = parsePiWebCapabilities(value["capabilities"]);
const capabilities = parseKnownPiWebCapabilities(value["capabilities"]);
const error = value["error"];
if (component !== "web" && component !== "sessiond") return undefined;
if (typeof label !== "string" || label === "" || typeof available !== "boolean" || capabilities === undefined) return undefined;
@@ -57,11 +70,6 @@ export function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus
};
}
function parsePiWebCapabilities(value: unknown): PiWebCapability[] | undefined {
if (!Array.isArray(value) || !value.every(isPiWebCapability)) return undefined;
return value;
}
export function parsePiWebInstallationInfo(value: unknown): PiWebInstallationInfo | undefined {
if (!isRecord(value)) return undefined;
const kind = value["kind"];
+25
View File
@@ -58,9 +58,34 @@ describe("parsePromptAttachments", () => {
it("rejects unsupported kinds and mime types", () => {
expect(() => parsePromptAttachments([{ kind: "video", mimeType: "image/png", data: tinyPngBase64 }])).toThrow(/unsupported kind/);
expect(() => parsePromptAttachments([{ kind: "file", mimeType: "application/pdf", data: tinyPngBase64 }])).toThrow(/unsupported kind/);
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }])).toThrow(/unsupported image type/);
});
it("accepts generic files only when file attachments are allowed", () => {
const result = parsePromptAttachments(
[{ kind: "file", mimeType: "application/pdf", data: "QUJD", name: "report.pdf" }],
{ allowFileAttachments: true },
);
expect(result).toEqual([{ kind: "file", mimeType: "application/pdf", data: "QUJD", name: "report.pdf" }]);
});
it("accepts zero-byte generic files", () => {
const result = parsePromptAttachments(
[{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" }],
{ allowFileAttachments: true },
);
expect(result).toEqual([{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" }]);
});
it("rejects generic files with empty mime types", () => {
expect(() => parsePromptAttachments([{ kind: "file", mimeType: "", data: "QUJD" }], { allowFileAttachments: true })).toThrow(/invalid file type/);
});
it("keeps image MIME validation when file attachments are allowed", () => {
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }], { allowFileAttachments: true })).toThrow(/unsupported image type/);
});
it("rejects invalid base64 data", () => {
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: "not base64!!!" }])).toThrow(/invalid base64/);
});
+39 -6
View File
@@ -1,4 +1,4 @@
import type { PromptAttachment } from "./apiTypes.js";
import type { PromptAttachment, PromptFileAttachment, PromptImageAttachment } from "./apiTypes.js";
/**
* Image mime types supported by the pi coding agent. Mirrors
@@ -44,13 +44,20 @@ export function base64ByteLength(data: string): number {
export interface AttachmentValidationOptions {
/** When true, enforce the per-image base64 size cap (inline delivery). */
enforceInlineSizeLimit?: boolean;
/** When true, accept general file attachments for save-to-folder delivery. */
allowFileAttachments?: boolean;
maxAttachments?: number;
}
type ImageOnlyAttachmentValidationOptions = AttachmentValidationOptions & { allowFileAttachments?: false | undefined };
type SaveAttachmentValidationOptions = AttachmentValidationOptions & { allowFileAttachments: true };
/**
* Validate and normalize untrusted prompt attachments. Throws on malformed,
* unsupported, or oversized input so routes can return a 400.
*/
export function parsePromptAttachments(value: unknown, options?: ImageOnlyAttachmentValidationOptions): PromptImageAttachment[];
export function parsePromptAttachments(value: unknown, options: SaveAttachmentValidationOptions): PromptAttachment[];
export function parsePromptAttachments(value: unknown, options: AttachmentValidationOptions = {}): PromptAttachment[] {
if (value === undefined) return [];
if (!Array.isArray(value)) throw new Error("attachments must be an array");
@@ -67,19 +74,45 @@ function parsePromptAttachment(value: unknown, index: number, options: Attachmen
if (!isRecord(value)) throw new Error(`attachment ${String(index)} must be an object`);
const record = value;
const kind = record["kind"];
if (kind !== "image") throw new Error(`attachment ${String(index)} has unsupported kind`);
if (kind === "image") return parseImageAttachment(record, index, options);
if (kind === "file" && options.allowFileAttachments === true) return parseFileAttachment(record, index);
throw new Error(`attachment ${String(index)} has unsupported kind`);
}
function parseImageAttachment(record: Record<string, unknown>, index: number, options: AttachmentValidationOptions): PromptImageAttachment {
const mimeType = record["mimeType"];
if (!isSupportedImageMimeType(mimeType)) throw new Error(`attachment ${String(index)} has unsupported image type`);
const data = record["data"];
if (typeof data !== "string" || data === "" || !base64Pattern.test(data)) throw new Error(`attachment ${String(index)} has invalid base64 data`);
const data = requireBase64Data(record["data"], index, { allowEmpty: false });
if (options.enforceInlineSizeLimit === true && base64ByteLength(data) > MAX_INLINE_IMAGE_BASE64_BYTES) {
throw new Error(`attachment ${String(index)} exceeds the inline image size limit`);
}
const name = record["name"];
return {
kind: "image",
mimeType,
data,
...(typeof name === "string" && name !== "" ? { name } : {}),
...attachmentName(record),
};
}
function parseFileAttachment(record: Record<string, unknown>, index: number): PromptFileAttachment {
const mimeType = record["mimeType"];
if (typeof mimeType !== "string" || mimeType.trim() === "") throw new Error(`attachment ${String(index)} has invalid file type`);
return {
kind: "file",
mimeType: mimeType.trim(),
data: requireBase64Data(record["data"], index, { allowEmpty: true }),
...attachmentName(record),
};
}
function requireBase64Data(value: unknown, index: number, options: { allowEmpty: boolean }): string {
if (typeof value !== "string" || (!options.allowEmpty && value === "") || !base64Pattern.test(value)) {
throw new Error(`attachment ${String(index)} has invalid base64 data`);
}
return value;
}
function attachmentName(record: Record<string, unknown>): { name?: string } {
const name = record["name"];
return typeof name === "string" && name !== "" ? { name } : {};
}