fix: close PI core chat review gaps

This commit is contained in:
2026-08-24 01:28:06 +08:00
parent 612135f911
commit bec93d0918
15 changed files with 1094 additions and 122 deletions

View File

@@ -81,7 +81,18 @@ export function createCodingComposition(
});
const extensionHost = new PiManagedExtensionHost();
extensionHost.configureProductTools(productTools);
const registry = new PiSessionRegistry({ projectStore });
const conversationStores = new Map<string, ReturnType<typeof createCodingConversationStore>>();
const conversationStoreForProject = (projectPath: string) => {
const existing = conversationStores.get(projectPath);
if (existing) return existing;
const created = createCodingConversationStore(projectPath);
conversationStores.set(projectPath, created);
return created;
};
const registry = new PiSessionRegistry({
projectStore,
createConversationStore: conversationStoreForProject,
});
const revisions = new PiManagedInputRevisionCoordinator();
const processBudget = new PiProcessBudget();
const loadProviderInput = async () => ({
@@ -145,15 +156,16 @@ export function createCodingComposition(
})),
});
const projects = new CodingProjectService(projectStore, {
createConversationStore: conversationStoreForProject,
onResourcesChanged: async (project) => {
runtime.markResourcesStale();
const conversations = await createCodingConversationStore(project.path).read()
const conversations = await conversationStoreForProject(project.path).read()
.then((file) => file.conversations)
.catch(() => []);
for (const conversation of conversations) registry.forget(conversation.id);
},
onProjectDeactivated: async (project) => {
const conversations = await createCodingConversationStore(project.path).read()
const conversations = await conversationStoreForProject(project.path).read()
.then((file) => file.conversations)
.catch(() => []);
await Promise.allSettled([

View File

@@ -3,6 +3,7 @@ import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
const ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
const SUPPORTED_IMAGE_MIMES = new Set([
'image/png',
'image/jpeg',
@@ -10,6 +11,37 @@ const SUPPORTED_IMAGE_MIMES = new Set([
'image/gif',
]);
function matchesImageSignature(data: Uint8Array, mime: string): boolean {
if (mime === 'image/png') {
return data.length >= 8
&& data[0] === 0x89
&& data[1] === 0x50
&& data[2] === 0x4e
&& data[3] === 0x47
&& data[4] === 0x0d
&& data[5] === 0x0a
&& data[6] === 0x1a
&& data[7] === 0x0a;
}
if (mime === 'image/jpeg') {
return data.length >= 3
&& data[0] === 0xff
&& data[1] === 0xd8
&& data[2] === 0xff;
}
if (mime === 'image/gif') {
if (data.length < 6) return false;
const header = Buffer.from(data.subarray(0, 6)).toString('ascii');
return header === 'GIF87a' || header === 'GIF89a';
}
if (mime === 'image/webp') {
return data.length >= 12
&& Buffer.from(data.subarray(0, 4)).toString('ascii') === 'RIFF'
&& Buffer.from(data.subarray(8, 12)).toString('ascii') === 'WEBP';
}
return false;
}
function contentType(req: IncomingMessage): string {
const value = req.headers['content-type'];
return (Array.isArray(value) ? value[0] : value)?.split(';', 1)[0]?.trim().toLowerCase() ?? '';
@@ -17,7 +49,7 @@ function contentType(req: IncomingMessage): string {
async function readBoundedBody(req: IncomingMessage): Promise<Uint8Array> {
const declared = Number(req.headers['content-length']);
if (Number.isFinite(declared) && (declared <= 0 || declared > MAX_ATTACHMENT_BYTES)) {
if (Number.isFinite(declared) && declared > MAX_ATTACHMENT_BYTES) {
throw Object.assign(new Error('Attachment size is invalid'), { status: 413 });
}
const chunks: Buffer[] = [];
@@ -86,12 +118,24 @@ export async function handleCodingAttachmentRoutes(
if (!SUPPORTED_IMAGE_MIMES.has(mime)) {
throw Object.assign(new Error('Attachment MIME is invalid'), { status: 400 });
}
const attachment = await attachments.put(await readBoundedBody(req), mime);
const body = await readBoundedBody(req);
if (!matchesImageSignature(body, mime)) {
throw Object.assign(new Error('Attachment body is not the declared image type'), { status: 400 });
}
const attachment = await attachments.put(body, mime);
sendJson(res, 201, attachment);
return true;
}
const attachmentId = decodeURIComponent(contentMatch?.[1] ?? '');
let attachmentId: string;
try {
attachmentId = decodeURIComponent(contentMatch?.[1] ?? '');
} catch {
throw Object.assign(new Error('Attachment id is invalid'), { status: 400 });
}
if (!ATTACHMENT_ID_PATTERN.test(attachmentId)) {
throw Object.assign(new Error('Attachment id is invalid'), { status: 400 });
}
const record = await attachments.read(attachmentId);
if (!SUPPORTED_IMAGE_MIMES.has(record.mime)) {
throw Object.assign(new Error('Attachment MIME is invalid'), { status: 400 });

View File

@@ -56,9 +56,12 @@ export function startHostApiServer(ctx: HostApiContext, port = getPort('NIANCODE
}
// ── Content-Type gate (anti-CSRF) ──────────────────────────
// Mutation requests must use application/json to force a CORS
// preflight, preventing "simple request" CSRF attacks.
if (!requireJsonContentType(req)) {
// Mutation requests use application/json, except the exact authenticated
// image-byte upload route. Its supported image MIME types also force a
// browser preflight and are validated before storage.
const isCodingAttachmentUpload = req.method === 'POST'
&& requestUrl.pathname === '/api/coding/attachments';
if (!isCodingAttachmentUpload && !requireJsonContentType(req)) {
sendJson(res, 415, { success: false, error: 'Content-Type must be application/json' });
return;
}

View File

@@ -56,6 +56,7 @@ export interface CodingProjectConfigSnapshot {
export interface CodingProjectServiceOptions {
onResourcesChanged?(project: CodingProject): Promise<void> | void;
onProjectDeactivated?(project: CodingProject): Promise<void> | void;
createConversationStore?: typeof createCodingConversationStore;
writeConfig?: typeof writeCodingProjectConfigV2;
}
@@ -299,7 +300,7 @@ export class CodingProjectService {
conversationStore(projectPath: string): ReturnType<typeof createCodingConversationStore> {
const existing = this.conversationStores.get(projectPath);
if (existing) return existing;
const created = createCodingConversationStore(projectPath);
const created = (this.options.createConversationStore ?? createCodingConversationStore)(projectPath);
this.conversationStores.set(projectPath, created);
return created;
}