diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 111230fcaa..439ac9b93e 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -543,6 +543,15 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null }) } for (const file of output.artifact.files) { + /* `inherited` files are unchanged passthroughs of inputs the caller + * already owns (skill files, prior session inputs, inherited + * .dirkeep markers). Skip post-processing: re-downloading with the + * user's session key 403s when the file is entity-scoped, and the + * input is already persisted at its origin. They remain available + * to subsequent calls via primeInvokedSkills / session inheritance. */ + if (file.inherited) { + continue; + } const { id, name } = file; artifactPromises.push( (async () => { @@ -760,6 +769,15 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) } for (const file of output.artifact.files) { + /* `inherited` files are unchanged passthroughs of inputs the caller + * already owns (skill files, prior session inputs, inherited + * .dirkeep markers). Skip post-processing: re-downloading with the + * user's session key 403s when the file is entity-scoped, and the + * input is already persisted at its origin. They remain available + * to subsequent calls via primeInvokedSkills / session inheritance. */ + if (file.inherited) { + continue; + } const { id, name } = file; artifactPromises.push( (async () => { diff --git a/api/server/controllers/tools.js b/api/server/controllers/tools.js index c173e2981e..8124894584 100644 --- a/api/server/controllers/tools.js +++ b/api/server/controllers/tools.js @@ -180,6 +180,15 @@ const callTool = async (req, res) => { const artifactPromises = []; for (const file of artifact.files) { + /* Files flagged `inherited` by codeapi are unchanged passthroughs of + * inputs the caller already owns (skill files, prior downloaded inputs, + * inherited .dirkeep markers). Re-downloading them is wasted work and + * 403s when the file is scoped to a different entity (e.g. skill + * entity_id) than the user's session key. They remain available for + * subsequent tool calls via primeInvokedSkills / session inheritance. */ + if (file.inherited) { + continue; + } const { id, name } = file; artifactPromises.push( (async () => { diff --git a/api/server/services/Files/Code/crud.js b/api/server/services/Files/Code/crud.js index 8130f4f095..fdbedb56eb 100644 --- a/api/server/services/Files/Code/crud.js +++ b/api/server/services/Files/Code/crud.js @@ -112,15 +112,24 @@ async function uploadCodeEnvFile({ req, stream, filename, entity_id = '' }) { * @param {import('express').Request & { user: { id: string } }} params.req - The request object. * @param {Array<{ stream: NodeJS.ReadableStream; filename: string }>} params.files - Files to upload. * @param {string} [params.entity_id] - Optional entity ID. + * @param {boolean} [params.read_only] - When true, codeapi tags every file in + * the batch as infrastructure (e.g. skill files). The flag is persisted as + * MinIO object metadata (`X-Amz-Meta-Read-Only`) and travels with the file + * through subsequent download/walk passes — sandboxed-code modifications + * are dropped on the floor and the original ref is echoed back as + * `inherited: true`, never as a generated artifact. * @returns {Promise<{ session_id: string; files: Array<{ fileId: string; filename: string }> }>} * @throws {Error} If the batch upload fails entirely. */ -async function batchUploadCodeEnvFiles({ req, files, entity_id = '' }) { +async function batchUploadCodeEnvFiles({ req, files, entity_id = '', read_only = false }) { try { const form = new FormData(); if (entity_id.length > 0) { form.append('entity_id', entity_id); } + if (read_only) { + form.append('read_only', 'true'); + } for (const file of files) { form.append('file', file.stream, file.filename); } diff --git a/packages/api/src/agents/skillFiles.spec.ts b/packages/api/src/agents/skillFiles.spec.ts index 6c44d3c7f7..4303a69589 100644 --- a/packages/api/src/agents/skillFiles.spec.ts +++ b/packages/api/src/agents/skillFiles.spec.ts @@ -106,6 +106,10 @@ describe('primeInvokedSkills — execute_code capability gate', () => { auth internally. */ expect(uploadArgs).not.toHaveProperty('apiKey'); expect(uploadArgs.entity_id).toBe(SKILL_ID.toString()); + /* Skill files are infrastructure inputs; the read_only flag tells codeapi + to seal them so any sandboxed-code modifications are dropped instead + of surfaced as ghost generated artifacts. */ + expect(uploadArgs.read_only).toBe(true); /* One uploaded file per `fileRecords` entry plus the synthetic SKILL.md that `primeSkillFiles` always prepends. */ expect(uploadArgs.files).toHaveLength(fileRecords.length + 1); diff --git a/packages/api/src/agents/skillFiles.ts b/packages/api/src/agents/skillFiles.ts index a1467f75e7..989e27f384 100644 --- a/packages/api/src/agents/skillFiles.ts +++ b/packages/api/src/agents/skillFiles.ts @@ -27,6 +27,10 @@ export interface PrimeSkillFilesParams { req: ServerRequest; files: Array<{ stream: NodeJS.ReadableStream; filename: string }>; entity_id?: string; + /** When true, codeapi tags every file in the batch as infrastructure + * (read-only inputs that must never surface as generated artifacts, + * even if sandboxed code mutates the bytes on disk). */ + read_only?: boolean; }) => Promise<{ session_id: string; files: Array<{ fileId: string; filename: string }>; @@ -161,6 +165,14 @@ export async function primeSkillFiles( req, files: filesToUpload, entity_id: entityId, + /* Skill files are infrastructure: SKILL.md + bundled scripts/schemas/ + * docs that the agent reads but should never edit. Tag the upload as + * read-only so codeapi seals the inputs (chmod 444 in-sandbox) and + * walker echoes the original refs as `inherited: true` even if some + * sandboxed code path mutates bytes on disk. Without this, modified + * skill files surface as ghost generated artifacts the user has no + * authority to download. */ + read_only: true, }); // Exclude SKILL.md from the returned files array — it is uploaded to disk // for bash access but has no codeEnvIdentifier (cannot be cached). Omitting