fix(runner): address skill loading review findings

This commit is contained in:
Dotta committed 2026-11-06 19:17:18 -06:00
1 parent 48789fb7e6
commit c9d04cba60
6 files changed
+143 -29

No files matched your search

+5 -2
View File
@@ -26,8 +26,11 @@ return an error instead of silently substituting different content.
Loads use the existing saved package reader, including repository skill wrappers Loads use the existing saved package reader, including repository skill wrappers
and supporting files. Text files above 16 MiB are rejected before body reads; and supporting files. Text files above 16 MiB are rejected before body reads;
accepted files are read in full for each page. Supporting paths use the saved accepted text is cached for the run with a 64 MiB budget and at most 64 files.
package limit of 4,096 characters plus the generated repository prefix. Later pages reuse cached text and still check current access. Evicted files are
read again. Repository reads use verified warm files or isolated recovery without
holding the company startup lock. Supporting paths use the saved package limit
of 4,096 characters plus the generated repository prefix.
Binary files and unsafe paths are rejected. Access is Binary files and unsafe paths are rejected. Access is
checked against the current run and the existing company and project source checked against the current run and the existing company and project source
boundaries, including after file I/O. Loaded content does not change persistent boundaries, including after file I/O. Loaded content does not change persistent
@@ -9,7 +9,8 @@ import { agents, companies, companySkills, companySkillVersions, companySkillSou
import { startRecoveryPostgres } from "../../__tests__/helpers/recovery-postgres.js"; import { startRecoveryPostgres } from "../../__tests__/helpers/recovery-postgres.js";
import { createDb } from "@paperclipai/db"; import { createDb } from "@paperclipai/db";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
import { skillPackageStore } from "../skill-package-store.js"; import { skillPackagePaths, skillPackageStore } from "../skill-package-store.js";
import { withSkillVersionCacheLock } from "../skill-version-cache.js";
import { getStorageService } from "../../storage/index.js"; import { getStorageService } from "../../storage/index.js";
describe("native company skill discovery", () => { describe("native company skill discovery", () => {
@@ -63,6 +64,16 @@ describe("native company skill discovery", () => {
expect((await db.select().from(agents).where(eq(agents.id, f.agentId)))[0]?.adapterConfig).toEqual({ provider: "codex" }); expect((await db.select().from(agents).where(eq(agents.id, f.agentId)))[0]?.adapterConfig).toEqual({ provider: "codex" });
}); });
it("keeps default inline file reads compatible with existing callers", async () => {
const f = await fixture(), s = await skill(f.companyId);
const store = skillPackageStore(db);
const version = await store.version(f.companyId, s.id, s.versionId);
expect(version).toBeTruthy();
expect(await store.readVersionFile(version!, "SKILL.md")).toMatchObject({ content: s.text });
expect(await store.readVersionFile(version!, "references/checks.md", false))
.toMatchObject({ content: "Check CI and the review result." });
});
it("bounds and paginates search results and treats SQL wildcard characters literally", async () => { it("bounds and paginates search results and treats SQL wildcard characters literally", async () => {
const f = await fixture(); const f = await fixture();
await skill(f.companyId, "review-a"); await skill(f.companyId, "review-b"); await skill(f.companyId, "review-c"); await skill(f.companyId, "review-a"); await skill(f.companyId, "review-b"); await skill(f.companyId, "review-c");
@@ -108,6 +119,65 @@ describe("native company skill discovery", () => {
finally { fault.mockRestore(); } finally { fault.mockRestore(); }
}); });
it.each([false, true])("reuses text between pages and still rechecks access (repository: %s)", async repository => {
const f = await fixture(), s = await skill(f.companyId);
const content = "x".repeat(16 * 1024 * 1024);
const packageId = await skillPackageStore(db).prepare(f.companyId, [
{ path: "SKILL.md", kind: "skill", content: "---\nname: review\ndescription: Review.\n---\n\nRead checks." },
{ path: "checks.md", kind: "reference", content },
]);
await db.update(companySkillVersions).set({ fileInventory: [], ...(repository
? { repositorySnapshotId: packageId, repositorySkillPath: "SKILL.md" }
: { packageSnapshotId: packageId }) }).where(eq(companySkillVersions.id, s.versionId));
const path = `${repository ? ".paperclip-repository/" : ""}checks.md`;
const open = vi.spyOn(fs, "open"), read = vi.spyOn(fs, "readFile");
const bodyReads = () => [...open.mock.calls, ...read.mock.calls].filter(args => String(args[0]).endsWith("/checks.md")).length;
try {
const first = await f.call("load_skill", { skill: s.id, path }) as any;
const reads = bodyReads();
expect(reads).toBeGreaterThan(0);
expect(first.byteSize).toBe(content.length);
expect(await f.call("load_skill", { skill: s.id, versionId: s.versionId, path, offset: first.nextOffset }))
.toMatchObject({ text: content.slice(6000, 12000), sha256: null });
expect(bodyReads()).toBe(reads);
const sourceId = randomUUID();
await db.insert(companySkillSources).values({ id: sourceId, companyId: f.companyId, repositoryUrl: "https://github.com/example/private", fullName: "example/private", trackingRef: "HEAD", accessScope: "projects" });
await db.update(companySkills).set({ accessSourceId: sourceId }).where(eq(companySkills.id, s.id));
expect(await f.call("load_skill", { skill: s.id, versionId: s.versionId, path, offset: 12000 }))
.toMatchObject({ outcome: "failed", code: "runner_skill_not_found" });
} finally { open.mockRestore(); read.mockRestore(); f.authority.close(); }
});
it.each([false, true])("reads repository files without waiting for the startup lock (cold: %s)", async cold => {
const f = await fixture(), s = await skill(f.companyId);
const store = skillPackageStore(db);
const packageId = await store.prepare(f.companyId, [
{ path: "SKILL.md", kind: "skill", content: "---\nname: review\ndescription: Review.\n---\n\nRead checks." },
{ path: "checks.md", kind: "reference", content: "Repository checks." },
]);
await db.update(companySkillVersions).set({ fileInventory: [], repositorySnapshotId: packageId, repositorySkillPath: "SKILL.md" })
.where(eq(companySkillVersions.id, s.versionId));
if (cold) {
await rm(skillPackagePaths(f.companyId, packageId).directory, { recursive: true, force: true });
}
let release!: () => void, acquired!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
const ready = new Promise<void>(resolve => { acquired = resolve; });
const lock = withSkillVersionCacheLock(db, f.companyId, async () => { acquired(); await held; });
await ready;
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
(async () => {
expect(await f.call("load_skill", { skill: s.id })).toMatchObject({ path: "SKILL.md" });
expect(await f.call("load_skill", { skill: s.id, versionId: s.versionId, path: ".paperclip-repository/checks.md" }))
.toMatchObject({ text: "Repository checks." });
})(),
new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error("Skill read waited for the startup lock")), 3000); }),
]);
} finally { clearTimeout(timeout); release(); await lock; f.authority.close(); }
});
it.each(["inline", "package", "repository"])("rejects oversized text before reading its body (%s)", async storage => { it.each(["inline", "package", "repository"])("rejects oversized text before reading its body (%s)", async storage => {
const f = await fixture(), s = await skill(f.companyId); const f = await fixture(), s = await skill(f.companyId);
const root = storage === "repository" ? "skills/review/" : ""; const root = storage === "repository" ? "skills/review/" : "";
@@ -12,6 +12,33 @@ import { REPOSITORY_CONTENT_DIR } from "../skill-repository-package.js";
import { settleRunnerBridgeRead } from "./runner-bridge-tools.js"; import { settleRunnerBridgeRead } from "./runner-bridge-tools.js";
const MAX_TEXT_FILE_BYTES = 16 * 1024 * 1024; const MAX_TEXT_FILE_BYTES = 16 * 1024 * 1024;
type SkillText = { text: string; sha256: string; byteSize: number };
/** Owned by one run authority. Retain at most 64 MiB of text and 64 entries. */
export class SkillLibraryReadCache {
#entries = new Map<string, SkillText>();
#bytes = 0;
#closed = false;
#size(key: string, value: SkillText) { return (key.length + value.text.length) * 2 + 128; }
#assertOpen() { if (this.#closed) throw new Error("runner_skill_scope_closed"); }
close() { this.#closed = true; this.#entries.clear(); this.#bytes = 0; }
get(key: string) {
this.#assertOpen();
const value = this.#entries.get(key);
if (value) { this.#entries.delete(key); this.#entries.set(key, value); }
return value;
}
set(key: string, value: SkillText) {
this.#assertOpen();
const prior = this.#entries.get(key);
if (prior) { this.#bytes -= this.#size(key, prior); this.#entries.delete(key); }
const bytes = this.#size(key, value);
while (this.#entries.size && (this.#entries.size >= 64 || this.#bytes + bytes > 64 * 1024 * 1024)) {
const oldest = this.#entries.keys().next().value!;
this.#bytes -= this.#size(oldest, this.#entries.get(oldest)!); this.#entries.delete(oldest);
}
if (bytes <= 64 * 1024 * 1024) { this.#entries.set(key, value); this.#bytes += bytes; }
}
}
const relativePath = z.string().min(1).max(MAX_SKILL_PACKAGE_PATH_LENGTH + REPOSITORY_CONTENT_DIR.length + 1).refine(value => { const relativePath = z.string().min(1).max(MAX_SKILL_PACKAGE_PATH_LENGTH + REPOSITORY_CONTENT_DIR.length + 1).refine(value => {
try { assertSkillSnapshotPath(value); return true; } catch { return false; } try { assertSkillSnapshotPath(value); return true; } catch { return false; }
}, "Use a relative skill file path without traversal"); }, "Use a relative skill file path without traversal");
@@ -45,7 +72,7 @@ const summaryColumns = {
const fail = (code: string, message: string) => ({ outcome: "failed", code, message }); const fail = (code: string, message: string) => ({ outcome: "failed", code, message });
export async function executeSkillLibraryTool(input: { export async function executeSkillLibraryTool(input: {
db: Db; companyId: string; tool: string; arguments: unknown; db: Db; companyId: string; tool: string; arguments: unknown; cache: SkillLibraryReadCache;
authorize: () => Promise<AuthorizationActor>; authorize: () => Promise<AuthorizationActor>;
}) { }) {
const actor = await input.authorize(); const actor = await input.authorize();
@@ -87,24 +114,30 @@ export async function executeSkillLibraryTool(input: {
const result = await settleRunnerBridgeRead(async () => { const result = await settleRunnerBridgeRead(async () => {
const runtime = skillPackageRuntime(input.db); const runtime = skillPackageRuntime(input.db);
const files = version.repositorySnapshotId ? await runtime.inventory(version) : await packages.versionInventory(version); const files = version.repositorySnapshotId ? await runtime.inventory(version) : await packages.versionInventory(version);
let file; const cacheKey = JSON.stringify([input.companyId, versionId, args.path]);
try { let content = input.cache.get(cacheKey);
file = version.repositorySnapshotId ? await runtime.readFile(version, args.path, MAX_TEXT_FILE_BYTES) if (!content) {
: await packages.readVersionFile(version, args.path, true, MAX_TEXT_FILE_BYTES); let file;
} catch (error) { try {
if (error instanceof SkillPackageFileTooLarge) return fail("runner_skill_file_size_limit", "This tool reads text files up to 16 MiB. Use a smaller supporting file."); file = version.repositorySnapshotId ? await runtime.readFile(version, args.path, MAX_TEXT_FILE_BYTES)
throw error; : await packages.readVersionFile(version, args.path, true, MAX_TEXT_FILE_BYTES);
} catch (error) {
if (error instanceof SkillPackageFileTooLarge) return fail("runner_skill_file_size_limit", "This tool reads text files up to 16 MiB. Use a smaller supporting file.");
throw error;
}
if (!file) return fail("runner_skill_file_not_found", "The requested file is not in this skill version.");
if (file.encoding === "base64") return fail("runner_skill_binary_file", "This tool reads UTF-8 text. The requested skill file is binary.");
content = { text: file.content, sha256: createHash("sha256").update(file.content).digest("hex"), byteSize: Buffer.byteLength(file.content) };
input.cache.set(cacheKey, content);
} }
if (!file) return fail("runner_skill_file_not_found", "The requested file is not in this skill version."); const { text } = content;
if (file.encoding === "base64") return fail("runner_skill_binary_file", "This tool reads UTF-8 text. The requested skill file is binary.");
const text = file.content;
const splitsCharacter = (offset: number) => offset > 0 && offset < text.length const splitsCharacter = (offset: number) => offset > 0 && offset < text.length
&& /[\uD800-\uDBFF]/u.test(text[offset - 1]!) && /[\uDC00-\uDFFF]/u.test(text[offset]!); && /[\uD800-\uDBFF]/u.test(text[offset - 1]!) && /[\uDC00-\uDFFF]/u.test(text[offset]!);
if (args.offset > text.length || splitsCharacter(args.offset)) return fail("runner_skill_invalid_offset", "Use nextOffset from the previous page."); if (args.offset > text.length || splitsCharacter(args.offset)) return fail("runner_skill_invalid_offset", "Use nextOffset from the previous page.");
let end = Math.min(args.offset + 6000, text.length); let end = Math.min(args.offset + 6000, text.length);
if (splitsCharacter(end)) end--; if (splitsCharacter(end)) end--;
return { ...skill, skillId: skill.id, versionId, path: args.path, text: text.slice(args.offset, end), return { ...skill, skillId: skill.id, versionId, path: args.path, text: text.slice(args.offset, end),
sha256: args.offset === 0 && args.fileOffset === 0 ? createHash("sha256").update(text).digest("hex") : null, byteSize: Buffer.byteLength(text), sha256: args.offset === 0 && args.fileOffset === 0 ? content.sha256 : null, byteSize: content.byteSize,
nextOffset: end < text.length ? end : null, nextOffset: end < text.length ? end : null,
files: files.slice(args.fileOffset, args.fileOffset + 50), fileCount: files.length, files: files.slice(args.fileOffset, args.fileOffset + 50), fileCount: files.length,
nextFileOffset: args.fileOffset + 50 < files.length ? args.fileOffset + 50 : null, nextFileOffset: args.fileOffset + 50 < files.length ? args.fileOffset + 50 : null,
@@ -14,7 +14,7 @@ import { executeNativeWorkflowTool } from "./native-workflow-tools.js";
import { assertNoCaseHandoffMutation } from "../heartbeat/case-handoff-lifecycle.js"; import { assertNoCaseHandoffMutation } from "../heartbeat/case-handoff-lifecycle.js";
import { handoffCaseSchema } from "@paperclipai/shared"; import { handoffCaseSchema } from "@paperclipai/shared";
import { skillAccessService } from "../skill-access.js"; import { skillAccessService } from "../skill-access.js";
import { SKILL_LIBRARY_SCHEMAS, SKILL_LIBRARY_TOOL_DEFINITIONS, executeSkillLibraryTool } from "./native-skill-library-tools.js"; import { SKILL_LIBRARY_SCHEMAS, SKILL_LIBRARY_TOOL_DEFINITIONS, SkillLibraryReadCache, executeSkillLibraryTool } from "./native-skill-library-tools.js";
import { withExternalExecutionAdmission, type ExternalAdmissionTransaction } from "../../modules/external-agents/index.js"; import { withExternalExecutionAdmission, type ExternalAdmissionTransaction } from "../../modules/external-agents/index.js";
import { callTaskWorkspaceTool, pendingTaskRepositoryContinuation } from "../task-workspace-tools.js"; import { callTaskWorkspaceTool, pendingTaskRepositoryContinuation } from "../task-workspace-tools.js";
import { TaskAttachmentReadCache, TASK_ATTACHMENT_DEFINITIONS, TASK_ATTACHMENT_SCHEMAS, listTaskAttachments, readTaskAttachment } from "./runner-task-attachments.js"; import { TaskAttachmentReadCache, TASK_ATTACHMENT_DEFINITIONS, TASK_ATTACHMENT_SCHEMAS, listTaskAttachments, readTaskAttachment } from "./runner-task-attachments.js";
@@ -314,7 +314,8 @@ function hireAgentResult(value: unknown, provider: unknown) {
export class PaperclipRunnerToolAuthority { export class PaperclipRunnerToolAuthority {
#taskAttachmentCache = new TaskAttachmentReadCache(); #taskAttachmentCache = new TaskAttachmentReadCache();
close() { this.#taskAttachmentCache.close(); } #skillLibraryCache = new SkillLibraryReadCache();
close() { this.#taskAttachmentCache.close(); this.#skillLibraryCache.close(); }
constructor(readonly db: Db, readonly binding: Binding) {} constructor(readonly db: Db, readonly binding: Binding) {}
definitions(): Array<Record<string, unknown>> { definitions(): Array<Record<string, unknown>> {
@@ -599,7 +600,7 @@ export class PaperclipRunnerToolAuthority {
} }
if (SKILL_LIBRARY_SCHEMAS.has(call.tool)) { if (SKILL_LIBRARY_SCHEMAS.has(call.tool)) {
if (!this.definitions().some(definition => definition.name === call.tool)) throw forbidden("Tool is not available to this run"); if (!this.definitions().some(definition => definition.name === call.tool)) throw forbidden("Tool is not available to this run");
return executeSkillLibraryTool({ db: this.db, companyId: this.binding.companyId, tool: call.tool, arguments: call.arguments, return executeSkillLibraryTool({ db: this.db, companyId: this.binding.companyId, tool: call.tool, arguments: call.arguments, cache: this.#skillLibraryCache,
authorize: async () => { const current = await this.#boundContext({ tool: call.tool }); return this.#privacyActor(current.run); } }); authorize: async () => { const current = await this.#boundContext({ tool: call.tool }); return this.#privacyActor(current.run); } });
} }
if (RUNNER_BRIDGE_SCHEMAS.has(call.tool)) { if (RUNNER_BRIDGE_SCHEMAS.has(call.tool)) {
+17 -10
View File
@@ -37,6 +37,12 @@ function originalFiles(version: StoredSkillVersion, manifest: SkillPackageManife
} }
export function skillPackageRuntime(db: Db) { export function skillPackageRuntime(db: Db) {
const store = skillPackageStore(db); const store = skillPackageStore(db);
function readOriginalFile(version: StoredSkillVersion, sourcePath: string, maxBytes?: number, projected?: ProjectedFile) {
// Use verified warm reads and isolated recovery, without the company-wide
// preparation lock. The projection has already resolved the source path.
return store.readVersionFile({ ...version, packageSnapshotId: version.repositorySnapshotId ?? version.packageSnapshotId,
packageFileMap: projected ? [{ path: sourcePath, sourcePath, kind: projected.kind, encoding: projected.encoding }] : null }, sourcePath, true, maxBytes);
}
async function inventory(version: StoredSkillVersion) { async function inventory(version: StoredSkillVersion) {
const id = version.repositorySnapshotId ?? version.packageSnapshotId; const id = version.repositorySnapshotId ?? version.packageSnapshotId;
if (!id) throw new Error("Skill version has no package"); if (!id) throw new Error("Skill version has no package");
@@ -63,13 +69,13 @@ export function skillPackageRuntime(db: Db) {
if (!version.repositorySkillPath) throw new Error("Repository skill entrypoint is missing"); if (!version.repositorySkillPath) throw new Error("Repository skill entrypoint is missing");
const wanted = new Set([version.repositorySkillPath, path.posix.join(path.posix.dirname(version.repositorySkillPath), "agents/openai.yaml")]); const wanted = new Set([version.repositorySkillPath, path.posix.join(path.posix.dirname(version.repositorySkillPath), "agents/openai.yaml")]);
for (const file of manifest.files.filter(file => wanted.has(file.path))) assertSkillPackageFileSize(file.size, maxBytes); for (const file of manifest.files.filter(file => wanted.has(file.path))) assertSkillPackageFileSize(file.size, maxBytes);
const generated = await store.withPackage(version.companyId, id, async (root, manifest) => { const selected: CompanySkillVersionFileInventoryEntry[] = [];
const selected: CompanySkillVersionFileInventoryEntry[] = []; for (const file of manifest.files.filter(file => wanted.has(file.path))) {
for (const file of manifest.files.filter(file => wanted.has(file.path))) selected.push({ const original = await readOriginalFile(version, file.path, maxBytes);
...file, content: (await fs.readFile(path.join(root, file.path))).toString(file.encoding), if (!original) throw new Error("Repository skill file is missing");
}); selected.push(original);
return repositorySkillProjection(selected, version.repositorySkillPath!).filter(file => !file.path.startsWith(`${REPOSITORY_CONTENT_DIR}/`)); }
}); const generated = repositorySkillProjection(selected, version.repositorySkillPath!).filter(file => !file.path.startsWith(`${REPOSITORY_CONTENT_DIR}/`));
for (const file of generated) { for (const file of generated) {
const bytes = skillFileBytes(file); const bytes = skillFileBytes(file);
generatedFiles.push({ path: file.path, kind: file.kind, encoding: file.encoding ?? "utf8", executable: Boolean(file.executable), generatedFiles.push({ path: file.path, kind: file.kind, encoding: file.encoding ?? "utf8", executable: Boolean(file.executable),
@@ -118,9 +124,10 @@ export function skillPackageRuntime(db: Db) {
?? (await projection(version, maxBytes)).find(file => file.path === relative); ?? (await projection(version, maxBytes)).find(file => file.path === relative);
if (!file) return null; if (!file) return null;
assertSkillPackageFileSize(file.size, maxBytes); assertSkillPackageFileSize(file.size, maxBytes);
const bytes = file.generated ?? await store.withPackage(version.companyId, const content = file.generated ? file.generated.toString(file.encoding)
(version.repositorySnapshotId ?? version.packageSnapshotId)!, root => fs.readFile(path.join(root, file.sourcePath!))); : (await readOriginalFile(version, file.sourcePath!, maxBytes, file))?.content;
return { path: file.path, kind: file.kind, content: bytes.toString(file.encoding), encoding: file.encoding, executable: file.executable }; if (content === undefined) return null;
return { path: file.path, kind: file.kind, content, encoding: file.encoding, executable: file.executable };
} }
return { inventory, materialize, readFile }; return { inventory, materialize, readFile };
} }
+1 -1
View File
@@ -649,7 +649,7 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService, cach
// an oversized inline body never crosses into the server process. // an oversized inline body never crosses into the server process.
const [row] = await db.select({ ...skillVersionMetadataColumns, const [row] = await db.select({ ...skillVersionMetadataColumns,
file: sql<(CompanySkillVersionFileInventoryEntry & { tooLarge?: boolean }) | null>`( file: sql<(CompanySkillVersionFileInventoryEntry & { tooLarge?: boolean }) | null>`(
select case when octet_length(f->>'content') > ${maxBytes} select case when octet_length(f->>'content')::bigint > ${maxBytes}::bigint
then jsonb_build_object('tooLarge', true) else f end then jsonb_build_object('tooLarge', true) else f end
from jsonb_array_elements(${companySkillVersions.fileInventory}) f from jsonb_array_elements(${companySkillVersions.fileInventory}) f
where f->>'path' = ${relativePath} limit 1)` }) where f->>'path' = ${relativePath} limit 1)` })