diff --git a/src/server/job-manifest.test.ts b/src/server/job-manifest.test.ts index b64f185..79c986d 100644 --- a/src/server/job-manifest.test.ts +++ b/src/server/job-manifest.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildJobManifest, type JobBuildInput } from "./job-manifest.js"; +import { buildJobManifest, sanitizeLabelValue, type JobBuildInput } from "./job-manifest.js"; const mockSelfPod: JobBuildInput["selfPod"] = { namespace: "paperclip", @@ -195,4 +195,98 @@ describe("buildJobManifest", () => { const container = result.job.spec?.template?.spec?.containers?.[0]; expect(container?.envFrom).toBeUndefined(); }); + + it("job name includes 6-char hash suffix", () => { + const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); + + // format: agent-opencode-{agentSlug}-{runSlug}-{6hexchars} + expect(result.jobName).toMatch(/^agent-opencode-[a-z0-9-]+-[a-f0-9]{6}$/); + }); + + it("job name has no trailing hyphens even when slug sanitization creates them", () => { + const ctx = { + ...mockCtx, + agent: { ...mockCtx.agent, id: "agent---" }, + runId: "run---", + }; + const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); + + expect(result.jobName).not.toMatch(/-$/); + }); + + it("label values are sanitized to [a-z0-9._-]", () => { + const ctx = { + ...mockCtx, + agent: { ...mockCtx.agent, id: "Agent_ID/123", companyId: "Co:456" }, + runId: "Run@789", + }; + const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); + + const labels = result.job.metadata?.labels ?? {}; + expect(labels["paperclip.io/agent-id"]).toMatch(/^[a-z0-9._-]*$/); + expect(labels["paperclip.io/run-id"]).toMatch(/^[a-z0-9._-]*$/); + expect(labels["paperclip.io/company-id"]).toMatch(/^[a-z0-9._-]*$/); + }); + + it("same agent+run always produces the same job name (deterministic hash)", () => { + const r1 = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); + const r2 = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); + + expect(r1.jobName).toBe(r2.jobName); + }); + + it("different runIds produce different job names", () => { + const ctx2 = { ...mockCtx, runId: "run-different-999" }; + const r1 = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); + const r2 = buildJobManifest({ ctx: ctx2, selfPod: mockSelfPod }); + + expect(r1.jobName).not.toBe(r2.jobName); + }); +}); + +describe("sanitizeLabelValue", () => { + it("passes through clean values unchanged", () => { + expect(sanitizeLabelValue("abc-123")).toBe("abc-123"); + expect(sanitizeLabelValue("foo.bar_baz")).toBe("foo.bar_baz"); + }); + + it("lowercases uppercase letters", () => { + expect(sanitizeLabelValue("MyAgent")).toBe("myagent"); + }); + + it("strips chars outside [a-z0-9._-]", () => { + expect(sanitizeLabelValue("agent/id:test@v1")).toBe("agentidtestv1"); + }); + + it("truncates to 63 chars", () => { + const long = "a".repeat(80); + expect(sanitizeLabelValue(long)).toHaveLength(63); + }); + + it("calls warn when chars are dropped", () => { + const warned: string[] = []; + sanitizeLabelValue("agent/id", (msg) => warned.push(msg)); + expect(warned.length).toBe(1); + expect(warned[0]).toContain("agent/id"); + }); + + it("does not call warn when nothing is dropped", () => { + const warned: string[] = []; + sanitizeLabelValue("clean-value.123", (msg) => warned.push(msg)); + expect(warned.length).toBe(0); + }); + + it("does not call warn when the only change is truncation to exactly 63", () => { + const warned: string[] = []; + // 63 chars all lowercase clean — no warn expected + sanitizeLabelValue("a".repeat(63), (msg) => warned.push(msg)); + expect(warned.length).toBe(0); + }); + + it("calls warn when value contains invalid chars even if truncated result looks clean", () => { + const warned: string[] = []; + // 'a/b' repeated — '/' is stripped, so sanitized differs from lower.slice(0,63) + sanitizeLabelValue("a/b".repeat(25), (msg) => warned.push(msg)); + expect(warned.length).toBe(1); + }); }); diff --git a/src/server/job-manifest.ts b/src/server/job-manifest.ts index c00002f..b43a2fe 100644 --- a/src/server/job-manifest.ts +++ b/src/server/job-manifest.ts @@ -1,3 +1,4 @@ +import { createHash } from "crypto"; import type * as k8s from "@kubernetes/client-node"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; import { @@ -80,8 +81,25 @@ function parseKeyValueOrObject(value: unknown): Record { return result; } -function sanitizeForK8sName(value: string): string { - return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 8); +function sanitizeForK8sName(value: string, maxLen = 16): string { + return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, maxLen); +} + +/** + * Strips chars outside [a-z0-9._-], lowercases, truncates to 63 chars (K8s label value limit). + * Emits a stderr warning via `warn` when chars are dropped. + */ +export function sanitizeLabelValue(value: string, warn?: (msg: string) => void): string { + const lower = value.toLowerCase(); + const sanitized = lower.replace(/[^a-z0-9._-]/g, "").slice(0, 63); + if (warn && sanitized !== lower.slice(0, 63)) { + warn(`[paperclip] sanitizeLabelValue: dropped chars from "${value}" -> "${sanitized}"\n`); + } + return sanitized; +} + +function nameHash(...parts: string[]): string { + return createHash("sha256").update(parts.join(":")).digest("hex").slice(0, 6); } function buildEnvVars( @@ -193,7 +211,8 @@ function buildRuntimeConfigJson(config: Record): string | null export function buildJobManifest(input: JobBuildInput): JobBuildResult { const { ctx, selfPod } = input; - const { runId, agent, runtime, config: rawConfig, context } = ctx; + const { runId, agent, runtime, config: rawConfig, context, onLog } = ctx; + const warnLabel = (msg: string) => void onLog("stderr", msg).catch(() => {}); const config = parseObject(rawConfig); const namespace = asString(config.namespace, "") || selfPod.namespace; @@ -214,10 +233,11 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { const configuredCwd = asString(config.cwd, ""); const workingDir = workspaceCwd || configuredCwd || "/paperclip"; - // Job naming + // Job naming: slug + 6-char hash for collision resistance; strip trailing hyphens const agentSlug = sanitizeForK8sName(agent.id); const runSlug = sanitizeForK8sName(runId); - const jobName = `agent-opencode-${agentSlug}-${runSlug}`; + const hash = nameHash(agent.id, runId); + const jobName = `agent-opencode-${agentSlug}-${runSlug}-${hash}`.replace(/-+$/, ""); // Build prompt const promptTemplate = asString( @@ -291,17 +311,17 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { }, }; - // Labels + // Labels: sanitize all values to [a-z0-9._-] max 63 chars (K8s RFC 1123) const labels: Record = { "app.kubernetes.io/managed-by": "paperclip", "app.kubernetes.io/component": "agent-job", - "paperclip.io/agent-id": agent.id, - "paperclip.io/run-id": runId, - "paperclip.io/company-id": agent.companyId, + "paperclip.io/agent-id": sanitizeLabelValue(agent.id, warnLabel), + "paperclip.io/run-id": sanitizeLabelValue(runId, warnLabel), + "paperclip.io/company-id": sanitizeLabelValue(agent.companyId, warnLabel), "paperclip.io/adapter-type": "opencode_k8s", }; for (const [key, value] of Object.entries(extraLabels)) { - if (typeof value === "string") labels[key] = value; + if (typeof value === "string") labels[key] = sanitizeLabelValue(value, warnLabel); } // Volumes