From f1433b05a60dc81fe0da7660a3f3fef974ad7487 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 23 Apr 2026 23:54:15 +0000 Subject: [PATCH] fix: reserve paperclip.io/ and app.kubernetes.io/ label prefixes (N2) Co-Authored-By: Claude Sonnet Co-Authored-By: Paperclip --- src/server/execute.ts | 3 +++ src/server/job-manifest.test.ts | 35 +++++++++++++++++++++++++++++++++ src/server/job-manifest.ts | 11 +++++++++-- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/server/execute.ts b/src/server/execute.ts index c65a43f..53a1e3b 100644 --- a/src/server/execute.ts +++ b/src/server/execute.ts @@ -700,6 +700,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { + await onLog("stderr", `[paperclip] Warning: skipped ${built.skippedLabels.length} extra label(s) with reserved prefix: ${built.skippedLabels.join(", ")}\n`); + } // Report invocation metadata if (onMeta) { diff --git a/src/server/job-manifest.test.ts b/src/server/job-manifest.test.ts index 36bb852..c189c68 100644 --- a/src/server/job-manifest.test.ts +++ b/src/server/job-manifest.test.ts @@ -166,6 +166,41 @@ describe("buildJobManifest", () => { expect(job.metadata?.labels?.["paperclip.io/task-id"]).toBeUndefined(); expect(job.metadata?.labels?.["paperclip.io/session-id"]).toBeUndefined(); }); + + it("drops user label with paperclip.io/ prefix", () => { + ctx.config = { labels: { "paperclip.io/run-id": "hijacked" } }; + const { job, skippedLabels } = buildJobManifest({ ctx, selfPod }); + expect(job.metadata?.labels?.["paperclip.io/run-id"]).not.toBe("hijacked"); + expect(skippedLabels).toContain("paperclip.io/run-id"); + }); + + it("drops user label with app.kubernetes.io/ prefix", () => { + ctx.config = { labels: { "app.kubernetes.io/managed-by": "attacker" } }; + const { job, skippedLabels } = buildJobManifest({ ctx, selfPod }); + expect(job.metadata?.labels?.["app.kubernetes.io/managed-by"]).toBe("paperclip"); + expect(skippedLabels).toContain("app.kubernetes.io/managed-by"); + }); + + it("passes through user label without reserved prefix", () => { + ctx.config = { labels: { "custom.io/team": "platform" } }; + const { job, skippedLabels } = buildJobManifest({ ctx, selfPod }); + expect(job.metadata?.labels?.["custom.io/team"]).toBe("platform"); + expect(skippedLabels).not.toContain("custom.io/team"); + }); + + it("populates skippedLabels with all dropped keys", () => { + ctx.config = { + labels: { + "paperclip.io/agent-id": "x", + "app.kubernetes.io/component": "y", + "safe": "z", + }, + }; + const { skippedLabels } = buildJobManifest({ ctx, selfPod }); + expect(skippedLabels).toHaveLength(2); + expect(skippedLabels).toContain("paperclip.io/agent-id"); + expect(skippedLabels).toContain("app.kubernetes.io/component"); + }); }); describe("annotations", () => { diff --git a/src/server/job-manifest.ts b/src/server/job-manifest.ts index f56c8b8..01f7bf8 100644 --- a/src/server/job-manifest.ts +++ b/src/server/job-manifest.ts @@ -200,6 +200,8 @@ export interface JobBuildResult { /** Non-null when the prompt is too large for an env var and must be * staged as a K8s Secret before creating the Job. */ promptSecret: PromptSecret | null; + /** User-supplied extra labels that were dropped because they used a reserved prefix. */ + skippedLabels: string[]; } function sanitizeForK8sName(value: string, maxLen = 16): string { @@ -460,8 +462,13 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { if (taskLabel) labels["paperclip.io/task-id"] = taskLabel; const sessionLabel = runtimeSessionId ? sanitizeLabelValue(runtimeSessionId) : null; if (sessionLabel) labels["paperclip.io/session-id"] = sessionLabel; + const skippedLabels: string[] = []; for (const [key, value] of Object.entries(extraLabels)) { - labels[key] = value; + if (key.startsWith("paperclip.io/") || key.startsWith("app.kubernetes.io/")) { + skippedLabels.push(key); + } else { + labels[key] = value; + } } // Volumes @@ -628,5 +635,5 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { }, }; - return { job, jobName, namespace, prompt, claudeArgs, promptMetrics, promptSecret }; + return { job, jobName, namespace, prompt, claudeArgs, promptMetrics, promptSecret, skippedLabels }; }