import { describe, it, expect, beforeEach } from "vitest"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; import { buildJobManifest, buildRtkSetupCommands, sanitizeLabelValue } from "./job-manifest.js"; import type { SelfPodInfo } from "./k8s-client.js"; function makeCtx(overrides: Partial = {}): AdapterExecutionContext { return { runId: "run-abc12345", agent: { id: "agent-abc", companyId: "co1", name: "Test Agent", adapterType: "claude_k8s", adapterConfig: {} }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, config: {}, context: {}, onLog: async () => {}, ...overrides, }; } function makeSelfPod(overrides: Partial = {}): SelfPodInfo { return { namespace: "paperclip", image: "paperclipai/paperclip:latest", imagePullSecrets: [{ name: "regcred" }], dnsConfig: undefined, pvcClaimName: "paperclip-data", secretVolumes: [], inheritedEnv: {}, inheritedEnvValueFrom: [], inheritedEnvFrom: [], ...overrides, }; } describe("buildJobManifest", () => { let ctx: AdapterExecutionContext; let selfPod: SelfPodInfo; beforeEach(() => { ctx = makeCtx(); selfPod = makeSelfPod(); }); describe("job naming", () => { it("uses ac- prefix", () => { const { jobName } = buildJobManifest({ ctx, selfPod }); expect(jobName).toMatch(/^ac-/); }); it("includes sanitized agent id slug (up to 16 chars)", () => { ctx.agent.id = "Agent-ABC!@#"; const { jobName } = buildJobManifest({ ctx, selfPod }); // sanitizeForK8sName: lowercase, strip non-alphanumeric (not dashes), slice 0-16 expect(jobName).toContain("agent-abc"); }); it("includes sanitized run id slug (up to 16 chars)", () => { ctx.runId = "RUN-ABC-12345"; const { jobName } = buildJobManifest({ ctx, selfPod }); expect(jobName).toContain("run-abc-12345"); }); it("includes a deterministic hash suffix", () => { const result1 = buildJobManifest({ ctx, selfPod }); const result2 = buildJobManifest({ ctx, selfPod }); expect(result1.jobName).toBe(result2.jobName); // Hash suffix is 6 hex chars at the end expect(result1.jobName).toMatch(/-[0-9a-f]{6}$/); }); it("different agent+run pairs produce different names", () => { const result1 = buildJobManifest({ ctx, selfPod }); ctx.runId = "run-different"; const result2 = buildJobManifest({ ctx, selfPod }); expect(result1.jobName).not.toBe(result2.jobName); }); it("stays within 63-char DNS label limit", () => { ctx.agent.id = "a".repeat(100); ctx.runId = "r".repeat(100); const { jobName } = buildJobManifest({ ctx, selfPod }); expect(jobName.length).toBeLessThanOrEqual(63); }); }); describe("job spec", () => { it("sets backoffLimit to 0 for fail-fast", () => { const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.backoffLimit).toBe(0); }); it("sets activeDeadlineSeconds when timeoutSec > 0", () => { ctx.config = { timeoutSec: 300 }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.activeDeadlineSeconds).toBe(300); }); it("omits activeDeadlineSeconds when timeoutSec is 0", () => { ctx.config = { timeoutSec: 0 }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.activeDeadlineSeconds).toBeUndefined(); }); it("sets ttlSecondsAfterFinished default 300", () => { const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.ttlSecondsAfterFinished).toBe(300); }); it("uses configured ttlSecondsAfterFinished", () => { ctx.config = { ttlSecondsAfterFinished: 600 }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.ttlSecondsAfterFinished).toBe(600); }); }); describe("labels", () => { it("includes required paperclip labels", () => { const { job } = buildJobManifest({ ctx, selfPod }); const labels = job.metadata?.labels ?? {}; expect(labels["app.kubernetes.io/managed-by"]).toBe("paperclip"); expect(labels["app.kubernetes.io/component"]).toBe("agent-job"); expect(labels["paperclip.io/agent-id"]).toBe("agent-abc"); expect(labels["paperclip.io/run-id"]).toBe("run-abc12345"); expect(labels["paperclip.io/company-id"]).toBe("co1"); expect(labels["paperclip.io/adapter-type"]).toBe("claude_k8s"); }); it("includes extra labels from config", () => { ctx.config = { labels: { "env": "prod", "team": "platform" } }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.metadata?.labels?.env).toBe("prod"); expect(job.metadata?.labels?.team).toBe("platform"); }); it("merges extra labels with required ones", () => { ctx.config = { labels: { "env": "prod" } }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.metadata?.labels?.env).toBe("prod"); expect(job.metadata?.labels?.["paperclip.io/adapter-type"]).toBe("claude_k8s"); }); it("adds task-id label when context provides taskId", () => { ctx.context = { taskId: "task-xyz-789" }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.metadata?.labels?.["paperclip.io/task-id"]).toBe("task-xyz-789"); }); it("falls back to issueId when taskId absent", () => { ctx.context = { issueId: "issue-42" }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.metadata?.labels?.["paperclip.io/task-id"]).toBe("issue-42"); }); it("adds session-id label when runtime provides sessionId", () => { ctx.runtime = { ...ctx.runtime, sessionId: "sess-abc-1234" }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.metadata?.labels?.["paperclip.io/session-id"]).toBe("sess-abc-1234"); }); it("reads sessionId from runtime.sessionParams when sessionId prop missing", () => { ctx.runtime = { ...ctx.runtime, sessionParams: { sessionId: "sess-from-params" } }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.metadata?.labels?.["paperclip.io/session-id"]).toBe("sess-from-params"); }); it("omits task-id and session-id labels when neither is provided", () => { const { job } = buildJobManifest({ ctx, selfPod }); 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("system label sanitization (N4)", () => { it("sanitizes agent.id with @ to a valid RFC 1123 label", () => { ctx.agent.id = "user@example.com"; const { job } = buildJobManifest({ ctx, selfPod }); const label = job.metadata?.labels?.["paperclip.io/agent-id"]; expect(label).toMatch(/^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/); expect(label).not.toContain("@"); }); it("sanitizes agent.id with spaces to a valid RFC 1123 label", () => { ctx.agent.id = "my agent id"; const { job } = buildJobManifest({ ctx, selfPod }); const label = job.metadata?.labels?.["paperclip.io/agent-id"]; expect(label).toMatch(/^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/); }); it("omits paperclip.io/run-id when sanitized value is null (all-invalid runId)", () => { // inject an all-special-chars runId via context override — buildJobManifest // uses ctx.runId directly const badCtx = makeCtx({ runId: "@@@" }); const { job, skippedLabels } = buildJobManifest({ ctx: badCtx, selfPod }); expect(job.metadata?.labels?.["paperclip.io/run-id"]).toBeUndefined(); expect(skippedLabels).toContain("paperclip.io/run-id"); }); it("selector matches sanitized agent-id label", () => { ctx.agent.id = "Agent@Test"; const { job } = buildJobManifest({ ctx, selfPod }); const agentLabel = job.metadata?.labels?.["paperclip.io/agent-id"]; // the label should equal what sanitizeLabelValue produces expect(agentLabel).toBe("AgentTest"); }); }); describe("annotations", () => { it("includes adapter type and agent name annotations", () => { const { job } = buildJobManifest({ ctx, selfPod }); expect(job.metadata?.annotations?.["paperclip.io/adapter-type"]).toBe("claude_k8s"); expect(job.metadata?.annotations?.["paperclip.io/agent-name"]).toBe("Test Agent"); }); }); describe("pod spec", () => { it("sets restartPolicy to Never", () => { const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.restartPolicy).toBe("Never"); }); it("sets fsGroupChangePolicy to OnRootMismatch", () => { const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.securityContext?.fsGroupChangePolicy).toBe("OnRootMismatch"); }); it("sets fsGroup, runAsNonRoot, runAsUser, runAsGroup", () => { const { job } = buildJobManifest({ ctx, selfPod }); const sc = job.spec?.template?.spec?.securityContext; expect(sc?.runAsNonRoot).toBe(true); expect(sc?.runAsUser).toBe(1000); expect(sc?.runAsGroup).toBe(1000); expect(sc?.fsGroup).toBe(1000); }); it("includes imagePullSecrets from selfPod", () => { const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.imagePullSecrets).toEqual([{ name: "regcred" }]); }); it("omits imagePullSecrets when empty", () => { selfPod.imagePullSecrets = []; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.imagePullSecrets).toBeUndefined(); }); it("includes dnsConfig from selfPod when present", () => { selfPod.dnsConfig = { nameservers: ["8.8.8.8"], searches: ["svc.cluster.local"] }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.dnsConfig).toEqual({ nameservers: ["8.8.8.8"], searches: ["svc.cluster.local"] }); }); it("omits dnsConfig when not present", () => { selfPod.dnsConfig = undefined; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.dnsConfig).toBeUndefined(); }); }); describe("init containers", () => { it("has write-prompt init container with busybox image", () => { const { job } = buildJobManifest({ ctx, selfPod }); const init = job.spec?.template?.spec?.initContainers?.[0]; expect(init?.name).toBe("write-prompt"); expect(init?.image).toBe("busybox:1.36"); expect(init?.imagePullPolicy).toBe("IfNotPresent"); }); it("write-prompt writes PROMPT_CONTENT to /tmp/prompt/prompt.txt", () => { const { job } = buildJobManifest({ ctx, selfPod }); const init = job.spec?.template?.spec?.initContainers?.[0]; expect(init?.command).toEqual(["sh", "-c", "printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"]); }); it("write-prompt mounts prompt volume", () => { const { job } = buildJobManifest({ ctx, selfPod }); const init = job.spec?.template?.spec?.initContainers?.[0]; expect(init?.volumeMounts).toContainEqual({ name: "prompt", mountPath: "/tmp/prompt" }); }); it("prompt env var contains rendered prompt text", () => { const { job, prompt } = buildJobManifest({ ctx, selfPod }); const init = job.spec?.template?.spec?.initContainers?.[0]; const promptEnv = init?.env?.find((e: { name: string }) => e.name === "PROMPT_CONTENT"); expect(promptEnv?.value).toBe(prompt); }); }); describe("claude container", () => { it("names container 'claude'", () => { const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.containers[0]?.name).toBe("claude"); }); it("uses selfPod image by default", () => { const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.containers[0]?.image).toBe("paperclipai/paperclip:latest"); }); it("uses configured image override", () => { ctx.config = { image: "my-image:v2" }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.containers[0]?.image).toBe("my-image:v2"); }); it("sets imagePullPolicy from config", () => { ctx.config = { imagePullPolicy: "Always" }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.containers[0]?.imagePullPolicy).toBe("Always"); }); it("defaults imagePullPolicy to IfNotPresent", () => { const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.containers[0]?.imagePullPolicy).toBe("IfNotPresent"); }); it("sets workingDir to /paperclip by default", () => { const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.containers[0]?.workingDir).toBe("/paperclip"); }); it("uses workspace cwd when available", () => { ctx.context = { paperclipWorkspace: { cwd: "/workspace/myproject" } }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.containers[0]?.workingDir).toBe("/workspace/myproject"); }); it("prefers workspace cwd over configured cwd", () => { ctx.config = { cwd: "/custom/path" }; ctx.context = { paperclipWorkspace: { cwd: "/workspace/myproject" } }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.containers[0]?.workingDir).toBe("/workspace/myproject"); }); }); describe("volumes", () => { it("creates prompt emptyDir volume", () => { const { job } = buildJobManifest({ ctx, selfPod }); const promptVol = job.spec?.template?.spec?.volumes?.find((v) => v.name === "prompt"); expect(promptVol?.emptyDir).toEqual({}); }); it("mounts data PVC at /paperclip when pvcClaimName is set", () => { const { job } = buildJobManifest({ ctx, selfPod }); const dataVol = job.spec?.template?.spec?.volumes?.find((v) => v.name === "data"); expect(dataVol?.persistentVolumeClaim?.claimName).toBe("paperclip-data"); const dataMount = job.spec?.template?.spec?.containers[0]?.volumeMounts?.find((vm) => vm.mountPath === "/paperclip"); expect(dataMount?.name).toBe("data"); }); it("omits data volume when no PVC", () => { selfPod.pvcClaimName = null; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.volumes?.find((v) => v.name === "data")).toBeUndefined(); }); it("mounts secret volumes", () => { selfPod.secretVolumes = [{ volumeName: "my-secret", secretName: "app-secret", mountPath: "/secrets/app", defaultMode: 420, }]; const { job } = buildJobManifest({ ctx, selfPod }); const secretVol = job.spec?.template?.spec?.volumes?.find((v) => v.name === "my-secret"); expect(secretVol?.secret?.secretName).toBe("app-secret"); const secretMount = job.spec?.template?.spec?.containers[0]?.volumeMounts?.find((vm) => vm.mountPath === "/secrets/app"); expect(secretMount?.readOnly).toBe(true); }); }); describe("environment variables", () => { it("sets HOME to /paperclip", () => { const { job } = buildJobManifest({ ctx, selfPod }); const home = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "HOME"); expect(home?.value).toBe("/paperclip"); }); it("inherits env vars from selfPod", () => { selfPod.inheritedEnv = { ANTHROPIC_API_KEY: "sk-abc", AWS_REGION: "us-east-1" }; const { job } = buildJobManifest({ ctx, selfPod }); const envNames = job.spec?.template?.spec?.containers[0]?.env?.map((e) => e.name) ?? []; expect(envNames).toContain("ANTHROPIC_API_KEY"); expect(envNames).toContain("AWS_REGION"); }); it("inherits ANTHROPIC_AUTH_TOKEN from selfPod for API auth", () => { selfPod.inheritedEnv = { ANTHROPIC_AUTH_TOKEN: "sk-test" }; const { job } = buildJobManifest({ ctx, selfPod }); const envNames = job.spec?.template?.spec?.containers[0]?.env?.map((e) => e.name) ?? []; expect(envNames).toContain("ANTHROPIC_AUTH_TOKEN"); }); it("user env config overrides inherited env", () => { selfPod.inheritedEnv = { AWS_REGION: "us-east-1" }; ctx.config = { env: { AWS_REGION: "us-west-2" } }; const { job } = buildJobManifest({ ctx, selfPod }); const awsRegion = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "AWS_REGION"); expect(awsRegion?.value).toBe("us-west-2"); }); it("sets PAPERCLIP_RUN_ID", () => { const { job } = buildJobManifest({ ctx, selfPod }); const runId = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "PAPERCLIP_RUN_ID"); expect(runId?.value).toBe("run-abc12345"); }); it("sets PAPERCLIP_API_KEY from authToken", () => { ctx.authToken = "pk_abc123"; const { job } = buildJobManifest({ ctx, selfPod }); const apiKey = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "PAPERCLIP_API_KEY"); expect(apiKey?.value).toBe("pk_abc123"); }); it("inherited PAPERCLIP_API_URL from selfPod takes precedence", () => { ctx.authToken = "pk_abc"; selfPod.inheritedEnv = { PAPERCLIP_API_URL: "http://paperclip:8080" }; const { job } = buildJobManifest({ ctx, selfPod }); const apiUrl = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "PAPERCLIP_API_URL"); expect(apiUrl?.value).toBe("http://paperclip:8080"); }); it("includes valueFrom env vars from selfPod", () => { selfPod.inheritedEnvValueFrom = [ { name: "ANTHROPIC_API_KEY", valueFrom: { secretKeyRef: { name: "api-keys", key: "anthropic" } } }, ]; const { job } = buildJobManifest({ ctx, selfPod }); const envList = job.spec?.template?.spec?.containers[0]?.env ?? []; const apiKeyEntry = envList.find((e) => e.name === "ANTHROPIC_API_KEY"); expect(apiKeyEntry?.valueFrom?.secretKeyRef?.name).toBe("api-keys"); expect(apiKeyEntry?.valueFrom?.secretKeyRef?.key).toBe("anthropic"); expect(apiKeyEntry?.value).toBeUndefined(); }); it("literal env overrides valueFrom with the same name", () => { selfPod.inheritedEnv = { MY_VAR: "literal-value" }; selfPod.inheritedEnvValueFrom = [ { name: "MY_VAR", valueFrom: { secretKeyRef: { name: "sec", key: "k" } } }, ]; const { job } = buildJobManifest({ ctx, selfPod }); const envList = job.spec?.template?.spec?.containers[0]?.env ?? []; const myVar = envList.filter((e) => e.name === "MY_VAR"); expect(myVar).toHaveLength(1); expect(myVar[0]?.value).toBe("literal-value"); expect(myVar[0]?.valueFrom).toBeUndefined(); }); it("includes envFrom sources from selfPod on the container", () => { selfPod.inheritedEnvFrom = [ { secretRef: { name: "api-secrets" } }, { configMapRef: { name: "app-config" } }, ]; const { job } = buildJobManifest({ ctx, selfPod }); const container = job.spec?.template?.spec?.containers[0]; expect(container?.envFrom).toHaveLength(2); expect(container?.envFrom?.[0]?.secretRef?.name).toBe("api-secrets"); expect(container?.envFrom?.[1]?.configMapRef?.name).toBe("app-config"); }); it("omits envFrom when selfPod has none", () => { selfPod.inheritedEnvFrom = []; const { job } = buildJobManifest({ ctx, selfPod }); const container = job.spec?.template?.spec?.containers[0]; expect(container?.envFrom).toBeUndefined(); }); }); describe("resources", () => { it("sets default resource requests and limits", () => { const { job } = buildJobManifest({ ctx, selfPod }); const resources = job.spec?.template?.spec?.containers[0]?.resources; expect(resources?.requests).toEqual({ cpu: "1000m", memory: "2Gi" }); expect(resources?.limits).toEqual({ cpu: "4000m", memory: "8Gi" }); }); it("uses configured resource overrides", () => { ctx.config = { "resources.requests.cpu": "500m", "resources.requests.memory": "1Gi", "resources.limits.cpu": "2000m", "resources.limits.memory": "4Gi", }; const { job } = buildJobManifest({ ctx, selfPod }); const resources = job.spec?.template?.spec?.containers[0]?.resources; expect(resources?.requests).toEqual({ cpu: "500m", memory: "1Gi" }); expect(resources?.limits).toEqual({ cpu: "2000m", memory: "4Gi" }); }); }); describe("nodeSelector and tolerations", () => { it("applies nodeSelector from config", () => { ctx.config = { nodeSelector: { "topology.kubernetes.io/zone": "us-east-1a" } }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.nodeSelector).toEqual({ "topology.kubernetes.io/zone": "us-east-1a" }); }); it("applies tolerations from config", () => { ctx.config = { tolerations: [{ key: "disk", operator: "Equal", value: "ssd", effect: "NoSchedule" }] }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.tolerations).toHaveLength(1); }); it("omits nodeSelector when empty", () => { const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.nodeSelector).toBeUndefined(); }); it("omits tolerations when empty", () => { const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.tolerations).toBeUndefined(); }); }); describe("claude args", () => { it("builds --print - - --output-format stream-json --verbose", () => { const { claudeArgs } = buildJobManifest({ ctx, selfPod }); expect(claudeArgs).toContain("--print"); expect(claudeArgs).toContain("-"); expect(claudeArgs).toContain("--output-format"); expect(claudeArgs).toContain("stream-json"); expect(claudeArgs).toContain("--verbose"); }); it("adds --model when configured", () => { ctx.config = { model: "claude-opus-4-6" }; const { claudeArgs } = buildJobManifest({ ctx, selfPod }); expect(claudeArgs).toContain("--model"); expect(claudeArgs).toContain("claude-opus-4-6"); }); it("adds --effort when configured", () => { ctx.config = { effort: "high" }; const { claudeArgs } = buildJobManifest({ ctx, selfPod }); expect(claudeArgs).toContain("--effort"); expect(claudeArgs).toContain("high"); }); it("adds --max-turns when configured", () => { ctx.config = { maxTurnsPerRun: 10 }; const { claudeArgs } = buildJobManifest({ ctx, selfPod }); expect(claudeArgs).toContain("--max-turns"); expect(claudeArgs).toContain("10"); }); it("adds --resume when sessionId present", () => { ctx.runtime.sessionId = "sess_abc"; const { claudeArgs } = buildJobManifest({ ctx, selfPod }); expect(claudeArgs).toContain("--resume"); expect(claudeArgs).toContain("sess_abc"); }); it("adds --dangerously-skip-permissions by default", () => { const { claudeArgs } = buildJobManifest({ ctx, selfPod }); expect(claudeArgs).toContain("--dangerously-skip-permissions"); }); it("adds --append-system-prompt-file (config fallback) when instructionsFilePath set and no session", () => { ctx.config = { instructionsFilePath: "/paperclip/instructions.md" }; const { claudeArgs } = buildJobManifest({ ctx, selfPod }); expect(claudeArgs).toContain("--append-system-prompt-file"); expect(claudeArgs).toContain("/paperclip/instructions.md"); }); it("omits --append-system-prompt-file on session resume (avoids token waste)", () => { ctx.config = { instructionsFilePath: "/paperclip/instructions.md" }; ctx.runtime.sessionId = "sess_existing"; const { claudeArgs } = buildJobManifest({ ctx, selfPod }); expect(claudeArgs).not.toContain("--append-system-prompt-file"); }); it("adds --add-dir when promptBundle is provided", () => { const promptBundle = { bundleKey: "abc123", rootDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", addDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", instructionsFilePath: null, }; const { claudeArgs } = buildJobManifest({ ctx, selfPod, promptBundle }); expect(claudeArgs).toContain("--add-dir"); expect(claudeArgs).toContain(promptBundle.addDir); }); it("uses bundle instructionsFilePath for --append-system-prompt-file when promptBundle provided", () => { const promptBundle = { bundleKey: "abc123", rootDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", addDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", instructionsFilePath: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123/agent-instructions.md", }; ctx.config = { instructionsFilePath: "/raw/path/AGENTS.md" }; const { claudeArgs } = buildJobManifest({ ctx, selfPod, promptBundle }); expect(claudeArgs).toContain("--append-system-prompt-file"); const idx = claudeArgs.indexOf("--append-system-prompt-file"); expect(claudeArgs[idx + 1]).toBe(promptBundle.instructionsFilePath); expect(claudeArgs).not.toContain("/raw/path/AGENTS.md"); }); it("omits --append-system-prompt-file from bundle on session resume", () => { const promptBundle = { bundleKey: "abc123", rootDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", addDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", instructionsFilePath: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123/agent-instructions.md", }; ctx.runtime.sessionId = "sess_existing"; const { claudeArgs } = buildJobManifest({ ctx, selfPod, promptBundle }); expect(claudeArgs).not.toContain("--append-system-prompt-file"); // --add-dir must still be present even on resume expect(claudeArgs).toContain("--add-dir"); }); it("omits --add-dir when no promptBundle", () => { const { claudeArgs } = buildJobManifest({ ctx, selfPod }); expect(claudeArgs).not.toContain("--add-dir"); }); it("appends extraArgs when configured", () => { ctx.config = { extraArgs: ["--no-input", "--verbose"] }; const { claudeArgs } = buildJobManifest({ ctx, selfPod }); expect(claudeArgs).toContain("--no-input"); expect(claudeArgs).toContain("--verbose"); }); }); describe("prompt rendering", () => { it("includes agent name in default prompt template", () => { const { prompt } = buildJobManifest({ ctx, selfPod }); expect(prompt).toContain("Test Agent"); }); it("uses custom promptTemplate when set", () => { ctx.config = { promptTemplate: "You are a helpful assistant." }; const { prompt } = buildJobManifest({ ctx, selfPod }); expect(prompt).toBe("You are a helpful assistant."); }); it("includes workspace context in prompt when available", () => { ctx.context = { paperclipWorkspace: { cwd: "/project", strategy: "read-only", workspaceId: "ws1", repoUrl: "https://github.com/org/repo", branchName: "main", }, }; const { prompt } = buildJobManifest({ ctx, selfPod }); expect(prompt).toContain("Test Agent"); }); it("returns promptMetrics with char counts", () => { const { promptMetrics } = buildJobManifest({ ctx, selfPod }); expect(promptMetrics.promptChars).toBeGreaterThan(0); expect(typeof promptMetrics.promptChars).toBe("number"); }); }); describe("serviceAccountName", () => { it("sets custom serviceAccountName when configured", () => { ctx.config = { serviceAccountName: "paperclip-agent" }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.serviceAccountName).toBe("paperclip-agent"); }); it("omits serviceAccountName when not configured", () => { const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.serviceAccountName).toBeUndefined(); }); }); describe("namespace", () => { it("uses selfPod namespace by default", () => { const { namespace } = buildJobManifest({ ctx, selfPod }); expect(namespace).toBe("paperclip"); }); it("uses configured namespace override", () => { ctx.config = { namespace: "agents" }; const { namespace, job } = buildJobManifest({ ctx, selfPod }); expect(namespace).toBe("agents"); expect(job.metadata?.namespace).toBe("agents"); }); }); describe("return value", () => { it("returns job, jobName, namespace, prompt, claudeArgs, promptMetrics, promptSecret", () => { const result = buildJobManifest({ ctx, selfPod }); expect(result.job).toBeDefined(); expect(result.jobName).toBeDefined(); expect(result.namespace).toBeDefined(); expect(result.prompt).toBeDefined(); expect(result.claudeArgs).toBeDefined(); expect(result.promptMetrics).toBeDefined(); expect(result.promptSecret).toBeNull(); }); }); describe("nodeSelector key=value parsing", () => { it("parses key=value multiline text", () => { ctx.config = { nodeSelector: "disktype=ssd\ntopology.kubernetes.io/zone=us-east-1a" }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.nodeSelector).toEqual({ disktype: "ssd", "topology.kubernetes.io/zone": "us-east-1a", }); }); it("still accepts JSON objects", () => { ctx.config = { nodeSelector: { disktype: "ssd" } }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.nodeSelector).toEqual({ disktype: "ssd" }); }); it("parses JSON string format", () => { ctx.config = { nodeSelector: '{"disktype":"ssd"}' }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.nodeSelector).toEqual({ disktype: "ssd" }); }); it("skips comment lines and blank lines", () => { ctx.config = { nodeSelector: "# comment\n\ndisktype=ssd\n" }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.spec?.template?.spec?.nodeSelector).toEqual({ disktype: "ssd" }); }); }); describe("labels key=value parsing", () => { it("parses key=value multiline text for extra labels", () => { ctx.config = { labels: "env=prod\nteam=platform" }; const { job } = buildJobManifest({ ctx, selfPod }); expect(job.metadata?.labels?.env).toBe("prod"); expect(job.metadata?.labels?.team).toBe("platform"); }); }); describe("large prompt Secret fallback", () => { it("returns null promptSecret for small prompts", () => { const { promptSecret } = buildJobManifest({ ctx, selfPod }); expect(promptSecret).toBeNull(); }); it("returns promptSecret for prompts >256 KiB", () => { // Build a prompt >256 KiB via a custom template const largePrompt = "x".repeat(300 * 1024); ctx.config = { promptTemplate: largePrompt }; const { promptSecret, job } = buildJobManifest({ ctx, selfPod }); expect(promptSecret).not.toBeNull(); expect(promptSecret!.data["prompt.txt"]).toBe(largePrompt); // Init container should copy from secret volume, not use PROMPT_CONTENT env const init = job.spec?.template?.spec?.initContainers?.[0]; expect(init?.command).toContainEqual(expect.stringContaining("cp")); expect(init?.env).toBeUndefined(); // Should have prompt-secret volume const secretVol = job.spec?.template?.spec?.volumes?.find((v) => v.name === "prompt-secret"); expect(secretVol?.secret?.secretName).toBe(promptSecret!.name); }); it("uses env var init container for small prompts", () => { const { job } = buildJobManifest({ ctx, selfPod }); const init = job.spec?.template?.spec?.initContainers?.[0]; expect(init?.env?.[0]?.name).toBe("PROMPT_CONTENT"); }); }); describe("rtk output filtering", () => { it("does not modify main command when enableRtk is false (default)", () => { const { job } = buildJobManifest({ ctx, selfPod }); const cmd = job.spec?.template?.spec?.containers[0]?.command; // Command should be the plain `cat ... | claude ...` form with no rtk setup expect(cmd?.[2]).toMatch(/^cat \/tmp\/prompt\/prompt\.txt \| claude /); expect(cmd?.[2]).not.toContain("rtk-filter"); }); it("prepends RTK setup commands when enableRtk is true", () => { ctx.config = { enableRtk: true }; const { job } = buildJobManifest({ ctx, selfPod }); const cmd = job.spec?.template?.spec?.containers[0]?.command; expect(cmd?.[2]).toContain(".rtk-filter.js"); expect(cmd?.[2]).toContain("cat /tmp/prompt/prompt.txt | claude"); }); it("RTK setup runs before claude invocation", () => { ctx.config = { enableRtk: true }; const { job } = buildJobManifest({ ctx, selfPod }); const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? ""; const rtkIdx = cmd.indexOf(".rtk-filter.js"); const claudeIdx = cmd.indexOf("cat /tmp/prompt/prompt.txt | claude"); expect(rtkIdx).toBeGreaterThanOrEqual(0); expect(claudeIdx).toBeGreaterThan(rtkIdx); }); it("RTK setup uses node (no external binaries)", () => { ctx.config = { enableRtk: true }; const { job } = buildJobManifest({ ctx, selfPod }); const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? ""; // Should only use `node` — no curl, wget, apt, pip, etc. expect(cmd).not.toMatch(/\b(curl|wget|apt|yum|pip|gem|cargo|go\s+get)\b/); expect(cmd).toContain("node "); }); it("uses default 50000 byte threshold when rtkMaxOutputBytes not set", () => { ctx.config = { enableRtk: true }; const setup = buildRtkSetupCommands(50000); // The filter script base64 should decode to contain the MAX constant const b64Match = setup.match(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/); expect(b64Match).not.toBeNull(); const decoded = Buffer.from(b64Match![1], "base64").toString("utf-8"); expect(decoded).toContain("50000"); }); it("respects custom rtkMaxOutputBytes", () => { ctx.config = { enableRtk: true, rtkMaxOutputBytes: 100000 }; const { job } = buildJobManifest({ ctx, selfPod }); const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? ""; // The custom threshold should appear in the base64-encoded filter script const b64Matches = [...cmd.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)]; const decoded = b64Matches.map((m) => Buffer.from(m[1], "base64").toString("utf-8")).join("\n"); expect(decoded).toContain("100000"); }); it("RTK setup installs a PostToolUse hook in claude settings", () => { const setup = buildRtkSetupCommands(50000); // The settings script (second base64 block) should reference PostToolUse const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)]; expect(b64Matches.length).toBeGreaterThanOrEqual(2); const settingsScript = Buffer.from(b64Matches[1]![1], "base64").toString("utf-8"); expect(settingsScript).toContain("PostToolUse"); expect(settingsScript).toContain("settings.json"); }); it("filter script handles string content truncation", () => { // Decode the filter script and verify it truncates string content const setup = buildRtkSetupCommands(1000); const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)]; const filterScript = Buffer.from(b64Matches[0]![1], "base64").toString("utf-8"); expect(filterScript).toContain("MAX=1000"); expect(filterScript).toContain("truncated by paperclip-rtk"); expect(filterScript).toContain("tool_response"); expect(filterScript).toContain("tool_result"); }); it("filter script truncates without corrupting multi-byte UTF-8", () => { // "中" is U+4E2D, 3 bytes in UTF-8: E4 B8 AD // With MAX=5, two "中" (6 bytes) should truncate to one (3 bytes), not // produce a replacement character from slicing mid-codepoint. const setup = buildRtkSetupCommands(5); const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)]; const filterScript = Buffer.from(b64Matches[0]![1], "base64").toString("utf-8"); // Extract the trunc function from the filter script and evaluate it const fnMatch = filterScript.match(/(function trunc\(s\)\{.*\})(?=const tr=)/); expect(fnMatch).toBeTruthy(); // eslint-disable-next-line no-eval const trunc = eval(`(()=>{const MAX=5;${fnMatch![1]};return trunc;})()`); const result = trunc("中中"); expect(result).not.toContain("�"); expect(result).toContain("中"); expect(result).toContain("truncated by paperclip-rtk"); // Should report bytes from the actual truncation point, not MAX expect(result).toContain("3 bytes truncated"); }); it("filter script handles array content (block format)", () => { const setup = buildRtkSetupCommands(50000); const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)]; const filterScript = Buffer.from(b64Matches[0]![1], "base64").toString("utf-8"); // Should handle array content blocks (text field on each block) expect(filterScript).toContain("Array.isArray"); expect(filterScript).toContain("b.text"); }); }); }); describe("sanitizeLabelValue", () => { it("passes through already-valid UUIDs and slugs", () => { expect(sanitizeLabelValue("abc-123-def")).toBe("abc-123-def"); expect(sanitizeLabelValue("0d8b4472-c42c-4052-aab1-e32897909afa")).toBe("0d8b4472-c42c-4052-aab1-e32897909afa"); }); it("strips characters outside [a-zA-Z0-9._-]", () => { expect(sanitizeLabelValue("task:xyz/123")).toBe("taskxyz123"); expect(sanitizeLabelValue("abc 123")).toBe("abc123"); }); it("trims leading/trailing non-alphanumeric characters", () => { expect(sanitizeLabelValue("--abc--")).toBe("abc"); expect(sanitizeLabelValue("...123...")).toBe("123"); }); it("truncates to the configured maxLen", () => { const long = "a".repeat(200); const out = sanitizeLabelValue(long, 63); expect(out?.length).toBe(63); }); it("returns null when no alphanumeric characters remain", () => { expect(sanitizeLabelValue("---")).toBeNull(); expect(sanitizeLabelValue("")).toBeNull(); expect(sanitizeLabelValue(" ")).toBeNull(); }); });