From 69b52fd8190119b71447f89ef744a6049f0cae97 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 00:30:22 +0800 Subject: [PATCH] Preserve frozen AI admission requests and expose receipt lookup --- src/app/api/foundry/[...path]/route.ts | 9 +++++---- src/lib/canvas-service/foundry.ts | 5 +++-- tests/headless/proxy.integration.mjs | 11 +++++++++++ tests/headless/service.test.mjs | 10 ++++++++++ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/app/api/foundry/[...path]/route.ts b/src/app/api/foundry/[...path]/route.ts index cd2412b..f3e7511 100644 --- a/src/app/api/foundry/[...path]/route.ts +++ b/src/app/api/foundry/[...path]/route.ts @@ -4,7 +4,7 @@ import { NextRequest, NextResponse } from "next/server"; const allowed = /^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials(?:\/[a-zA-Z0-9_-]+(?:\/canvas)?)?|search))?)?)$/; -const canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:model(?:\/(?:migrate|operations(?:\/[a-zA-Z0-9_-]+)?))?|operations(?:\/[a-zA-Z0-9_-]+)?|connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/; +const canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:model(?:\/(?:migrate|operations(?:\/[a-zA-Z0-9_-]+)?))?|operations(?:\/[a-zA-Z0-9_-]+)?|connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|operations\/[a-zA-Z0-9_-]+|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/; const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/(?:operations\/[a-zA-Z0-9_-]+|[a-zA-Z0-9_-]+))?$/; const bodyLimit = 1024 * 1024; @@ -57,10 +57,11 @@ async function handler( const asset = assetRoute.test(path) && !assetReceipt; const canvas = canvasRoute.test(path); const canonical = canvas && /\/canvas\/model(?:\/|$)/.test(path); + const frozenAI = canvas && /\/canvas\/ai\/runs(?:\/|$)/.test(path); let requestBody: unknown; if (req.method !== "GET") { try { - requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : canonical || path.endsWith("/canvas") || path.endsWith("/canvas/operations") ? 5 * 1024 * 1024 : bodyLimit, asset || canonical); + requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : canonical || path.endsWith("/canvas") || path.endsWith("/canvas/operations") ? 5 * 1024 * 1024 : bodyLimit, asset || canonical || frozenAI); } catch (error) { const tooLarge = error instanceof Error && error.message === "body_too_large"; return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400); @@ -83,8 +84,8 @@ async function handler( if (values.length === 1 && values[0].length < 300) query.set(key, values[0]); } const encodingStarted = performance.now(); - // Frozen canonical requests cross the proxy byte-for-byte; do not rebuild their JSON. - const outboundBody = req.method === "GET" ? undefined : asset || canonical ? requestBody as BodyInit : JSON.stringify(requestBody || {}); + // Frozen document and AI requests cross the proxy byte-for-byte. + const outboundBody = req.method === "GET" ? undefined : asset || canonical || frozenAI ? requestBody as BodyInit : JSON.stringify(requestBody || {}); const encodeMs = performance.now() - encodingStarted; const started = performance.now(); try { diff --git a/src/lib/canvas-service/foundry.ts b/src/lib/canvas-service/foundry.ts index 78e4203..52a2261 100644 --- a/src/lib/canvas-service/foundry.ts +++ b/src/lib/canvas-service/foundry.ts @@ -5,7 +5,7 @@ import type { EngineSnapshot, PersistencePort } from '../canvas-runtime/contract import type { FrozenRequest } from '../canvas-runtime/ledger.ts'; import type { OperationStatus } from '../canvas-runtime/scheduler.ts'; -export type ServiceRequest = { readonly path: string; readonly method: 'GET' | 'POST'; readonly requestId?: Id; readonly body?: string | FormData; readonly resourceId?: Id; readonly signal?: AbortSignal }; +export type ServiceRequest = { readonly path: string; readonly method: 'GET' | 'POST'; readonly requestId?: Id; readonly expectedVersion?: number; readonly body?: string | FormData; readonly resourceId?: Id; readonly signal?: AbortSignal }; export type ServiceResponse = { readonly status: number; readonly body: unknown; readonly retryAfterMs?: number }; export type ServiceTransport = (request: ServiceRequest) => Promise; const uuid = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i; @@ -25,8 +25,9 @@ export function createFoundryTransport(options: { accountId: Id; isCurrentIdenti const requestFetch = options.fetch ?? globalThis.fetch; return async input => { if (!options.isCurrentIdentity()) throw fault('identity_changed'); + if (input.expectedVersion !== undefined && (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1)) throw fault('invalid_server_version'); const response = await requestFetch(`${options.basePath ?? '/api/foundry/'}${input.path}`, { method: input.method, body: input.body, signal: input.signal, credentials: 'same-origin', cache: 'no-store', redirect: 'error', headers: { - Accept: 'application/json', ...(input.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...(input.resourceId ? { 'X-Resource-Id': input.resourceId } : {}), 'X-Expected-Account': options.accountId, ...(input.requestId ? { 'Idempotency-Key': input.requestId } : {}), + Accept: 'application/json', ...(input.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...(input.resourceId ? { 'X-Resource-Id': input.resourceId } : {}), 'X-Expected-Account': options.accountId, ...(input.requestId ? { 'Idempotency-Key': input.requestId } : {}), ...(input.expectedVersion !== undefined ? { 'If-Match': `"${input.expectedVersion}"` } : {}), } }); if (!options.isCurrentIdentity()) { await response.body?.cancel(); throw fault('identity_changed'); } let body: unknown; diff --git a/tests/headless/proxy.integration.mjs b/tests/headless/proxy.integration.mjs index 92e356d..e80e2ab 100644 --- a/tests/headless/proxy.integration.mjs +++ b/tests/headless/proxy.integration.mjs @@ -49,6 +49,17 @@ try{ const uploaded=await fetch(`http://127.0.0.1:${port}/api/foundry/${assetPath}`,{method:'POST',body:multipart,headers:{'X-Resource-Id':resourceId,'Idempotency-Key':key,'X-Expected-Account':account}});await uploaded.text();assert.equal(uploaded.status,200); const upload=received.find(item=>item.path.endsWith('/assets'));assert.equal(upload.headers['x-resource-id'],resourceId);assert.equal(upload.headers['idempotency-key'],key);assert.match(upload.body.toString(),/immutable file/);assert.match(upload.headers['content-type'],/^multipart\/form-data; boundary=/); console.log('PASS asset receipt JSON and multipart resource identity cross the actual Next proxy'); + const aiPath=path.replace('/model/operations','/ai/runs'); + const frozenAI=' {\n "protocol_version":2,"request_id":"'+key+'","prompt":"保留请求字节" } '; + const aiResponse=await fetch(`http://127.0.0.1:${port}/api/foundry/${aiPath}`,{method:'POST',body:frozenAI,headers:{'Content-Type':'application/json','Idempotency-Key':key,'If-Match':'"7"','X-Expected-Account':account,Cookie:'foundry_session=fixture-session'}}); + await aiResponse.text();assert.equal(aiResponse.status,200); + const ai=received.find(item=>item.path.endsWith('/ai/runs')); + assert.equal(ai.body.toString(),frozenAI);assert.equal(ai.headers['if-match'],'"7"');assert.equal(ai.headers['idempotency-key'],key);assert.equal(ai.headers['x-expected-account'],account);assert.equal(ai.headers.authorization,'Bearer fixture-session');assert.equal(ai.headers.accept,'application/vnd.foundry.canvas+json;version=2'); + const aiReceipt=await fetch(`http://127.0.0.1:${port}/api/foundry/${aiPath.replace('/runs','/operations')}/${key}`);await aiReceipt.text();assert.equal(aiReceipt.status,200); + assert.ok(received.some(item=>item.path.endsWith('/ai/operations/'+key))); + const aiEvents=await fetch(`http://127.0.0.1:${port}/api/foundry/${aiPath}/${key}?after_seq=41&limit=100`);await aiEvents.text();assert.equal(aiEvents.status,200); + assert.ok(received.some(item=>item.path.endsWith('/ai/runs/'+key+'?limit=100&after_seq=41'))); + console.log('PASS frozen AI bytes, identity, version header, admission receipt and event cursor cross the actual Next proxy'); const before=received.length,invalid=await fetch(`http://127.0.0.1:${port}/api/foundry/${path}/unexpected/extra`,{signal:AbortSignal.timeout(10000)});await invalid.text();assert.equal(invalid.status,404);assert.equal(received.length,before); console.log('PASS actual Next proxy preserves frozen request bytes/key/account and raw terminal receipt above 1 MiB'); console.log('PASS canonical route allowlist rejects unexpected paths without contacting the upstream'); diff --git a/tests/headless/service.test.mjs b/tests/headless/service.test.mjs index 8362f6e..691577d 100644 --- a/tests/headless/service.test.mjs +++ b/tests/headless/service.test.mjs @@ -88,3 +88,13 @@ test('browser transport fences identity changes and never rebuilds a frozen body const late=createFoundryTransport({accountId:account,isCurrentIdentity:()=>current,fetch:async()=>{current=false;return new Response('{}')}});current=true; await assert.rejects(()=>late({path:'fixture',method:'GET'}),/identity_changed/); }); + + +test('shared service transport binds the immutable AI admission version header',async()=>{ + const calls=[]; + const transport=createFoundryTransport({accountId:account,isCurrentIdentity:()=>true,fetch:async(url,input)=>{calls.push({url,input});return new Response('{}',{status:200,headers:{'Content-Type':'application/json'}})}}); + await transport({method:'POST',path:'fixture',body:' {"frozen": true} ',requestId,expectedVersion:7}); + assert.equal(calls[0].input.headers['If-Match'],'"7"');assert.equal(calls[0].input.headers['Idempotency-Key'],requestId);assert.equal(calls[0].input.body,' {"frozen": true} '); + for(const value of [0,-1,1.5,NaN,Number.MAX_SAFE_INTEGER+1])await assert.rejects(()=>transport({method:'POST',path:'fixture',expectedVersion:value}),/invalid_server_version/); + assert.equal(calls.length,1); +});