Archived
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:
+129
-6
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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 } : {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user