fix: close PI core chat review gaps
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user