Files
paperclip-adapter-claude-k8s/src/server/job-manifest.ts
T
Gandalf the Greybeard 346f5cc1df fix: prevent UTF-8 corruption when RTK truncation splits multi-byte codepoints (FAR-19)
The trunc function in the RTK filter script now walks back from the
truncation point past continuation bytes and checks whether the full
codepoint fits, avoiding replacement characters from mid-codepoint slicing.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 23:28:28 +00:00

631 lines
25 KiB
TypeScript

import type * as k8s from "@kubernetes/client-node";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import {
asString,
asNumber,
asBoolean,
asStringArray,
parseObject,
buildPaperclipEnv,
renderTemplate,
} from "@paperclipai/adapter-utils/server-utils";
import { createHash } from "node:crypto";
import type { ClaudePromptBundle } from "./prompt-cache.js";
/**
* Build the shell command prefix that installs a native Node.js PostToolUse
* hook into Claude Code's settings. The hook truncates oversized tool outputs
* before they reach the model — replacing the RTK binary init-container
* approach with a self-contained Node.js implementation.
*
* Both scripts are base64-encoded so they can be embedded in a sh -c command
* string without any quoting or escaping issues.
*
* @param maxOutputBytes Byte threshold above which tool output is truncated.
* @returns A shell command string (suitable for "&&"-chaining
* before the claude invocation).
*/
export function buildRtkSetupCommands(maxOutputBytes: number): string {
// --- Filter script ----------------------------------------------------------
// This script runs as the PostToolUse hook inside every K8s Job pod.
// Claude Code writes the hook event as JSON to the script's stdin; the script
// truncates the tool_response/tool_result content when it exceeds the
// threshold and writes the (possibly modified) JSON to stdout.
//
// Field-name coverage:
// • tool_response — documented hook event format for PostToolUse
// • tool_result — alternative name seen in some Claude Code versions
// Content may be a plain string or an array of typed blocks (text/image/…).
const filterScript = [
`const c=[];`,
`process.stdin.on('data',d=>c.push(d));`,
`process.stdin.on('end',()=>{`,
`const raw=Buffer.concat(c).toString('utf-8');`,
`let o;try{o=JSON.parse(raw);}catch{process.stdout.write(raw);return;}`,
`const MAX=${maxOutputBytes};`,
`function trunc(s){`,
`if(typeof s!=='string')return s;`,
`const b=Buffer.from(s,'utf-8');`,
`if(b.length<=MAX)return s;`,
`let e=MAX;if(e>0){let p=e-1;while(p>0&&(b[p]&0xC0)===0x80)p--;const l=b[p];let n=1;if((l&0xE0)===0xC0)n=2;else if((l&0xF0)===0xE0)n=3;else if((l&0xF8)===0xF0)n=4;if(p+n>e)e=p;}`,
`return b.slice(0,e).toString('utf-8')+'\\n[...'+(b.length-e)+' bytes truncated by paperclip-rtk]';`,
`}`,
`const tr=o&&(o.tool_response||o.tool_result);`,
`if(tr){`,
`if(typeof tr.content==='string'){tr.content=trunc(tr.content);}`,
`else if(Array.isArray(tr.content)){`,
`tr.content=tr.content.map(function(b){`,
`if(b&&typeof b==='object'&&typeof b.text==='string'){`,
`return Object.assign({},b,{text:trunc(b.text)});`,
`}return b;`,
`});`,
`}`,
`}`,
`process.stdout.write(JSON.stringify(o));`,
`});`,
].join("");
// --- Settings script --------------------------------------------------------
// Reads the existing ~/.claude/settings.json (if any), merges in the RTK
// PostToolUse hook, and writes the file back. All other settings sections
// are preserved; only PostToolUse is replaced so we own the full hook list
// for this run.
const settingsScript = [
`const fs=require('fs'),pt=require('path');`,
`const p=pt.join(process.env.HOME,'.claude','settings.json');`,
`let s={};try{s=JSON.parse(fs.readFileSync(p,'utf-8'));}catch(e){}`,
`s.hooks=s.hooks||{};`,
`s.hooks.PostToolUse=[{matcher:'.*',hooks:[{type:'command',command:'node /tmp/.rtk-filter.js'}]}];`,
`fs.mkdirSync(pt.dirname(p),{recursive:true});`,
`fs.writeFileSync(p,JSON.stringify(s));`,
].join("");
// Encode as base64 so the strings can be embedded directly in a shell command
// without any quoting concerns (base64 alphabet: A-Za-z0-9+/=).
const filterB64 = Buffer.from(filterScript, "utf-8").toString("base64");
const settingsB64 = Buffer.from(settingsScript, "utf-8").toString("base64");
return [
// Write the filter script
`node -e "require('fs').writeFileSync('/tmp/.rtk-filter.js',Buffer.from('${filterB64}','base64').toString('utf-8'))"`,
// Install the Claude Code PostToolUse hook (merge into existing settings)
`node -e "eval(Buffer.from('${settingsB64}','base64').toString('utf-8'))"`,
].join(" && ");
}
/** Prompts above this size (bytes) are staged via a Secret instead of an
* init container env var, protecting against the ~1 MiB PodSpec limit. */
const LARGE_PROMPT_THRESHOLD_BYTES = 256 * 1024;
// Inline prompt assembly — these functions are not yet in the published adapter-utils
function joinPromptSections(sections: string[], separator = "\n\n"): string {
return sections.filter((s) => s.trim().length > 0).join(separator);
}
function stringifyPaperclipWakePayload(wake: unknown): string | null {
if (!wake || typeof wake !== "object") return null;
try {
const json = JSON.stringify(wake);
return json === "{}" ? null : json;
} catch {
return null;
}
}
function renderPaperclipWakePrompt(wake: unknown, _opts?: { resumedSession?: boolean }): string {
if (!wake || typeof wake !== "object") return "";
const w = wake as Record<string, unknown>;
const reason = typeof w.reason === "string" ? w.reason.trim() : "";
const comments = Array.isArray(w.comments) ? w.comments : [];
if (!reason && comments.length === 0) return "";
const parts: string[] = [];
if (reason) parts.push(`Wake reason: ${reason}`);
for (const c of comments) {
if (typeof c === "object" && c !== null) {
const comment = c as Record<string, unknown>;
const body = typeof comment.body === "string" ? comment.body.trim() : "";
if (body) parts.push(`Comment: ${body}`);
}
}
return parts.join("\n\n");
}
import type { SelfPodInfo } from "./k8s-client.js";
/**
* Parse a config value that may be either a JSON object or multiline
* `key=value` text (one pair per line). This fixes the config-hint
* parity issue where textarea hints promise `key=value` per line but
* `parseObject` only handles JSON.
*/
function parseKeyValueConfig(raw: unknown): Record<string, string> {
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
// Already an object (JSON was parsed upstream)
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
if (typeof v === "string") result[k] = v;
}
return result;
}
if (typeof raw !== "string" || !raw.trim()) return {};
// Try JSON parse first
try {
const parsed = JSON.parse(raw);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === "string") result[k] = v;
}
return result;
}
} catch {
// Not JSON — fall through to key=value parsing
}
// Parse key=value lines
const result: Record<string, string> = {};
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx <= 0) continue;
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim();
if (key) result[key] = value;
}
return result;
}
export interface JobBuildInput {
ctx: AdapterExecutionContext;
selfPod: SelfPodInfo;
/** Prepared prompt bundle (skills + instructions). When provided, --add-dir and --append-system-prompt-file use bundle paths. */
promptBundle?: ClaudePromptBundle | null;
}
/** When the prompt exceeds the env-var size limit, the manifest uses a
* Secret-backed volume instead of the init container's PROMPT_CONTENT env.
* The caller must create this Secret before the Job and clean it up after. */
export interface PromptSecret {
name: string;
namespace: string;
data: Record<string, string>;
}
export interface JobBuildResult {
job: k8s.V1Job;
jobName: string;
namespace: string;
prompt: string;
claudeArgs: string[];
promptMetrics: Record<string, number>;
/** 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;
}
function sanitizeForK8sName(value: string, maxLen = 16): string {
return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, maxLen);
}
/**
* Sanitize a string for use as a Kubernetes label value (RFC 1123 subset:
* `[a-zA-Z0-9]([-_.a-zA-Z0-9]*[a-zA-Z0-9])?`, max 63 chars). Returns `null`
* when no usable characters remain — the caller should omit the label.
*/
export function sanitizeLabelValue(value: string, maxLen = 63): string | null {
const cleaned = value.replace(/[^a-zA-Z0-9._-]/g, "").slice(0, maxLen);
const trimmed = cleaned.replace(/^[^a-zA-Z0-9]+/, "").replace(/[^a-zA-Z0-9]+$/, "");
return trimmed.length > 0 ? trimmed : null;
}
/**
* Build a short deterministic hash suffix from the raw inputs to avoid
* collisions when sanitized slugs happen to be identical.
*/
function shortHash(input: string, len = 6): string {
return createHash("sha256").update(input).digest("hex").slice(0, len);
}
function buildEnvVars(
ctx: AdapterExecutionContext,
selfPod: SelfPodInfo,
config: Record<string, unknown>,
): k8s.V1EnvVar[] {
const { runId, agent, context } = ctx;
const envConfig = parseObject(config.env);
// Layer 1: PAPERCLIP_* base vars
const paperclipEnv = buildPaperclipEnv(agent);
// Layer 2: Context vars (run, wake, workspace — same as claude_local)
paperclipEnv.PAPERCLIP_RUN_ID = runId;
const setIfPresent = (envKey: string, value: unknown) => {
if (typeof value === "string" && value.trim().length > 0) {
paperclipEnv[envKey] = value.trim();
}
};
setIfPresent("PAPERCLIP_TASK_ID", context.taskId ?? context.issueId);
setIfPresent("PAPERCLIP_WAKE_REASON", context.wakeReason);
setIfPresent("PAPERCLIP_WAKE_COMMENT_ID", context.wakeCommentId ?? context.commentId);
setIfPresent("PAPERCLIP_APPROVAL_ID", context.approvalId);
setIfPresent("PAPERCLIP_APPROVAL_STATUS", context.approvalStatus);
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
if (wakePayloadJson) {
paperclipEnv.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
}
const workspaceContext = parseObject(context.paperclipWorkspace);
setIfPresent("PAPERCLIP_WORKSPACE_CWD", workspaceContext.cwd);
setIfPresent("PAPERCLIP_WORKSPACE_SOURCE", workspaceContext.source);
setIfPresent("PAPERCLIP_WORKSPACE_STRATEGY", workspaceContext.strategy);
setIfPresent("PAPERCLIP_WORKSPACE_ID", workspaceContext.workspaceId);
setIfPresent("PAPERCLIP_WORKSPACE_REPO_URL", workspaceContext.repoUrl);
setIfPresent("PAPERCLIP_WORKSPACE_REPO_REF", workspaceContext.repoRef);
setIfPresent("PAPERCLIP_WORKSPACE_BRANCH", workspaceContext.branchName);
setIfPresent("PAPERCLIP_WORKSPACE_WORKTREE_PATH", workspaceContext.worktreePath);
setIfPresent("AGENT_HOME", workspaceContext.agentHome);
const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((v): v is string => typeof v === "string" && v.trim().length > 0)
: [];
if (linkedIssueIds.length > 0) {
paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
}
if (Array.isArray(context.paperclipWorkspaces) && context.paperclipWorkspaces.length > 0) {
paperclipEnv.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(context.paperclipWorkspaces);
}
if (Array.isArray(context.paperclipRuntimeServiceIntents) && context.paperclipRuntimeServiceIntents.length > 0) {
paperclipEnv.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(context.paperclipRuntimeServiceIntents);
}
if (Array.isArray(context.paperclipRuntimeServices) && context.paperclipRuntimeServices.length > 0) {
paperclipEnv.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(context.paperclipRuntimeServices);
}
setIfPresent("PAPERCLIP_RUNTIME_PRIMARY_URL", context.paperclipRuntimePrimaryUrl);
// Auth token for agent callback to Paperclip API
if (ctx.authToken) {
paperclipEnv.PAPERCLIP_API_KEY = ctx.authToken;
}
// PAPERCLIP_API_URL is inherited from the Deployment env via selfPod.inheritedEnv.
// buildPaperclipEnv() sets a localhost value which is wrong for Job pods —
// the inherited value (set in the infra repo) points to the in-cluster service.
if (selfPod.inheritedEnv.PAPERCLIP_API_URL) {
paperclipEnv.PAPERCLIP_API_URL = selfPod.inheritedEnv.PAPERCLIP_API_URL;
}
// Layer 3: Inherited from Deployment (Bedrock, API keys, etc.)
const merged: Record<string, string> = {
...selfPod.inheritedEnv,
...paperclipEnv,
};
// Layer 4: User-defined overrides from adapterConfig.env (wins over everything)
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") merged[key] = value;
}
// HOME must be /paperclip to match PVC mount and enable session resume
merged.HOME = "/paperclip";
// Convert literal env to V1EnvVar array
const envVars: k8s.V1EnvVar[] = Object.entries(merged).map(([name, value]) => ({
name,
value,
}));
// Append valueFrom entries from the Deployment container (secretKeyRef,
// configMapKeyRef, fieldRef, etc.). Skip any whose name was already set
// by a literal value — the literal value wins (same precedence as above).
const literalNames = new Set(Object.keys(merged));
for (const entry of selfPod.inheritedEnvValueFrom) {
if (!literalNames.has(entry.name)) {
envVars.push(entry);
}
}
return envVars;
}
export function buildJobManifest(input: JobBuildInput): JobBuildResult {
const { ctx, selfPod, promptBundle } = input;
const { runId, agent, runtime, config: rawConfig, context } = ctx;
const config = parseObject(rawConfig);
// Resolve config values
const namespace = asString(config.namespace, "") || selfPod.namespace;
const image = asString(config.image, "") || selfPod.image;
const model = asString(config.model, "");
const effort = asString(config.effort, "");
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
// K8s Job pods are always unattended — no one to approve permission prompts
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
const extraArgs = asStringArray(config.extraArgs);
const timeoutSec = asNumber(config.timeoutSec, 0);
const ttlSeconds = asNumber(config.ttlSecondsAfterFinished, 300);
const nodeSelector = parseKeyValueConfig(config.nodeSelector);
const tolerations = Array.isArray(config.tolerations) ? config.tolerations : [];
const extraLabels = parseKeyValueConfig(config.labels);
const enableRtk = asBoolean(config.enableRtk, false);
const rtkMaxOutputBytes = asNumber(config.rtkMaxOutputBytes, 50000);
// Resolve working directory — use workspace cwd, fall back to /paperclip
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const configuredCwd = asString(config.cwd, "");
const workingDir = workspaceCwd || configuredCwd || "/paperclip";
// Build a deterministic, collision-resistant job name within the 63-char
// DNS label limit. Layout: "ac-{agentSlug}-{runSlug}-{hash}" where the
// hash is derived from the raw (un-truncated) agent+run IDs.
const agentSlug = sanitizeForK8sName(agent.id, 16);
const runSlug = sanitizeForK8sName(runId, 16);
const hash = shortHash(`${agent.id}:${runId}`);
const jobName = `ac-${agentSlug}-${runSlug}-${hash}`;
// Build prompt (same logic as claude_local)
const promptTemplate = asString(
config.promptTemplate,
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
);
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
};
const renderedBootstrapPrompt =
!runtimeSessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(runtimeSessionId) });
const shouldUseResumeDeltaPrompt = Boolean(runtimeSessionId) && wakePrompt.length > 0;
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
renderedBootstrapPrompt,
wakePrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
// Build Claude CLI args
// Prefer the bundle's materialized instructions file over the raw config path.
// Never inject --append-system-prompt-file on session resumes — the instructions
// are already in the session cache and re-injecting wastes tokens.
const rawInstructionsFilePath = asString(config.instructionsFilePath, "").trim();
const effectiveInstructionsFilePath =
promptBundle?.instructionsFilePath ?? (rawInstructionsFilePath || null);
const claudeArgs = ["--print", "-", "--output-format", "stream-json", "--verbose"];
if (runtimeSessionId) claudeArgs.push("--resume", runtimeSessionId);
if (dangerouslySkipPermissions) claudeArgs.push("--dangerously-skip-permissions");
if (model) claudeArgs.push("--model", model);
if (effort) claudeArgs.push("--effort", effort);
if (maxTurns > 0) claudeArgs.push("--max-turns", String(maxTurns));
if (effectiveInstructionsFilePath && !runtimeSessionId) {
claudeArgs.push("--append-system-prompt-file", effectiveInstructionsFilePath);
}
if (promptBundle) claudeArgs.push("--add-dir", promptBundle.addDir);
if (extraArgs.length > 0) claudeArgs.push(...extraArgs);
// Build env vars
const envVars = buildEnvVars(ctx, selfPod, config);
// Resource defaults — UI stores dotted keys (e.g. "resources.requests.cpu")
// as flat config entries, so read them directly from config with the dotted key.
const containerResources: k8s.V1ResourceRequirements = {
requests: {
cpu: asString(config["resources.requests.cpu"], "1000m"),
memory: asString(config["resources.requests.memory"], "2Gi"),
},
limits: {
cpu: asString(config["resources.limits.cpu"], "4000m"),
memory: asString(config["resources.limits.memory"], "8Gi"),
},
};
// Labels
const labels: Record<string, string> = {
"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/adapter-type": "claude_k8s",
};
// Reattach-target labels: let a future execute() identify this Job as the
// continuation of the same logical unit of work (same task + same resume
// session) so it can attach to the running pod across a Paperclip restart
// instead of deleting it and starting over (FAR-124).
const taskIdRaw = asString(context.taskId, "") || asString(context.issueId, "");
const taskLabel = taskIdRaw ? sanitizeLabelValue(taskIdRaw) : null;
if (taskLabel) labels["paperclip.io/task-id"] = taskLabel;
const sessionLabel = runtimeSessionId ? sanitizeLabelValue(runtimeSessionId) : null;
if (sessionLabel) labels["paperclip.io/session-id"] = sessionLabel;
for (const [key, value] of Object.entries(extraLabels)) {
labels[key] = value;
}
// Volumes
const volumes: k8s.V1Volume[] = [
{
name: "prompt",
emptyDir: {},
},
];
const volumeMounts: k8s.V1VolumeMount[] = [
{
name: "prompt",
mountPath: "/tmp/prompt",
},
];
// Mount shared PVC for /paperclip (session state, workspaces, data)
if (selfPod.pvcClaimName) {
volumes.push({
name: "data",
persistentVolumeClaim: { claimName: selfPod.pvcClaimName },
});
volumeMounts.push({
name: "data",
mountPath: "/paperclip",
});
}
// Mount secret volumes inherited from the Deployment pod
for (const sv of selfPod.secretVolumes) {
volumes.push({
name: sv.volumeName,
secret: { secretName: sv.secretName, defaultMode: sv.defaultMode, optional: true },
});
volumeMounts.push({
name: sv.volumeName,
mountPath: sv.mountPath,
readOnly: true,
});
}
// Security context matching the main Deployment
const securityContext: k8s.V1SecurityContext = {
capabilities: { drop: ["ALL"] },
readOnlyRootFilesystem: false,
runAsNonRoot: true,
runAsUser: 1000,
allowPrivilegeEscalation: false,
};
const podSecurityContext: k8s.V1PodSecurityContext = {
runAsNonRoot: true,
runAsUser: 1000,
runAsGroup: 1000,
fsGroup: 1000,
fsGroupChangePolicy: "OnRootMismatch",
};
// Build the claude command string for the main container
const claudeArgsEscaped = claudeArgs.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
const claudeInvocation = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped}`;
// When RTK output filtering is enabled, prepend the Node.js hook setup.
// This writes a filter script and a Claude Code settings file that installs
// it as a PostToolUse hook — no external binary or init container required.
const mainCommand = enableRtk
? `${buildRtkSetupCommands(rtkMaxOutputBytes)} && ${claudeInvocation}`
: claudeInvocation;
// Decide prompt delivery strategy: env var (small) or Secret volume (large).
const promptBytes = Buffer.byteLength(prompt, "utf-8");
const useLargePromptPath = promptBytes > LARGE_PROMPT_THRESHOLD_BYTES;
let promptSecret: PromptSecret | null = null;
const promptSecretName = `${jobName}-prompt`;
if (useLargePromptPath) {
// Stage prompt as a Secret; the init container copies from the mounted
// secret volume to the emptyDir so the main container reads it the
// same way regardless of prompt size.
promptSecret = {
name: promptSecretName,
namespace,
data: { "prompt.txt": prompt },
};
volumes.push({
name: "prompt-secret",
secret: { secretName: promptSecretName, optional: false },
});
}
const initContainer: k8s.V1Container = useLargePromptPath
? {
name: "write-prompt",
image: "busybox:1.36",
imagePullPolicy: "IfNotPresent",
command: ["sh", "-c", "cp /tmp/prompt-secret/prompt.txt /tmp/prompt/prompt.txt"],
volumeMounts: [
{ name: "prompt", mountPath: "/tmp/prompt" },
{ name: "prompt-secret", mountPath: "/tmp/prompt-secret", readOnly: true },
],
securityContext,
resources: {
requests: { cpu: "10m", memory: "16Mi" },
limits: { cpu: "100m", memory: "64Mi" },
},
}
: {
name: "write-prompt",
image: "busybox:1.36",
imagePullPolicy: "IfNotPresent",
command: ["sh", "-c", "printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"],
env: [{ name: "PROMPT_CONTENT", value: prompt }],
volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }],
securityContext,
resources: {
requests: { cpu: "10m", memory: "16Mi" },
limits: { cpu: "100m", memory: "64Mi" },
},
};
const job: k8s.V1Job = {
apiVersion: "batch/v1",
kind: "Job",
metadata: {
name: jobName,
namespace,
labels,
annotations: {
"paperclip.io/adapter-type": "claude_k8s",
"paperclip.io/agent-name": agent.name,
},
},
spec: {
backoffLimit: 0,
...(timeoutSec > 0 ? { activeDeadlineSeconds: timeoutSec } : {}),
ttlSecondsAfterFinished: ttlSeconds,
template: {
metadata: { labels },
spec: {
restartPolicy: "Never",
serviceAccountName: asString(config.serviceAccountName, "") || undefined,
securityContext: podSecurityContext,
...(selfPod.imagePullSecrets.length > 0 ? { imagePullSecrets: selfPod.imagePullSecrets } : {}),
...(selfPod.dnsConfig ? { dnsConfig: selfPod.dnsConfig } : {}),
...(Object.keys(nodeSelector).length > 0 ? { nodeSelector } : {}),
...(tolerations.length > 0 ? { tolerations: tolerations as k8s.V1Toleration[] } : {}),
initContainers: [initContainer],
containers: [
{
name: "claude",
image,
imagePullPolicy: asString(config.imagePullPolicy, "IfNotPresent"),
workingDir,
command: ["sh", "-c", mainCommand],
env: envVars,
...(selfPod.inheritedEnvFrom.length > 0 ? { envFrom: selfPod.inheritedEnvFrom } : {}),
volumeMounts,
securityContext,
resources: containerResources,
},
],
volumes,
},
},
},
};
return { job, jobName, namespace, prompt, claudeArgs, promptMetrics, promptSecret };
}