5c28e6c191
Busybox echo interprets escape sequences by default (\c, \n, \t, \0NNN, etc.). If the prompt contains \c (common in file paths or shell references), echo silently stops output at that point, truncating the prompt file. This can leave Claude CLI with an empty or garbled stdin, causing it to hang with zero output — manifesting as endless keepalive messages in the UI. printf '%s' passes content through verbatim, avoiding the issue. Co-Authored-By: Paperclip <noreply@paperclip.ing>
393 lines
14 KiB
TypeScript
393 lines
14 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";
|
|
|
|
// 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";
|
|
|
|
export interface JobBuildInput {
|
|
ctx: AdapterExecutionContext;
|
|
selfPod: SelfPodInfo;
|
|
}
|
|
|
|
export interface JobBuildResult {
|
|
job: k8s.V1Job;
|
|
jobName: string;
|
|
namespace: string;
|
|
prompt: string;
|
|
claudeArgs: string[];
|
|
promptMetrics: Record<string, number>;
|
|
}
|
|
|
|
function sanitizeForK8sName(value: string): string {
|
|
return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 8);
|
|
}
|
|
|
|
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 to V1EnvVar array
|
|
const envVars: k8s.V1EnvVar[] = Object.entries(merged).map(([name, value]) => ({
|
|
name,
|
|
value,
|
|
}));
|
|
|
|
return envVars;
|
|
}
|
|
|
|
export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
|
const { ctx, selfPod } = 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 resources = parseObject(config.resources);
|
|
const nodeSelector = parseObject(config.nodeSelector);
|
|
const tolerations = Array.isArray(config.tolerations) ? config.tolerations : [];
|
|
const extraLabels = parseObject(config.labels);
|
|
|
|
// 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";
|
|
|
|
const agentSlug = sanitizeForK8sName(agent.id);
|
|
const runSlug = sanitizeForK8sName(runId);
|
|
const jobName = `agent-claude-${agentSlug}-${runSlug}`;
|
|
|
|
// 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
|
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
|
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 (instructionsFilePath) claudeArgs.push("--append-system-prompt-file", instructionsFilePath);
|
|
if (extraArgs.length > 0) claudeArgs.push(...extraArgs);
|
|
|
|
// Build env vars
|
|
const envVars = buildEnvVars(ctx, selfPod, config);
|
|
|
|
// Resource defaults
|
|
const resourceRequests = parseObject(resources.requests);
|
|
const resourceLimits = parseObject(resources.limits);
|
|
const containerResources: k8s.V1ResourceRequirements = {
|
|
requests: {
|
|
cpu: asString(resourceRequests.cpu, "1000m"),
|
|
memory: asString(resourceRequests.memory, "2Gi"),
|
|
},
|
|
limits: {
|
|
cpu: asString(resourceLimits.cpu, "4000m"),
|
|
memory: asString(resourceLimits.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",
|
|
};
|
|
for (const [key, value] of Object.entries(extraLabels)) {
|
|
if (typeof value === "string") 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 mainCommand = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped}`;
|
|
|
|
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: nodeSelector as Record<string, string> } : {}),
|
|
...(tolerations.length > 0 ? { tolerations: tolerations as k8s.V1Toleration[] } : {}),
|
|
initContainers: [
|
|
{
|
|
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" },
|
|
},
|
|
},
|
|
],
|
|
containers: [
|
|
{
|
|
name: "claude",
|
|
image,
|
|
imagePullPolicy: asString(config.imagePullPolicy, "IfNotPresent"),
|
|
workingDir,
|
|
command: ["sh", "-c", mainCommand],
|
|
env: envVars,
|
|
volumeMounts,
|
|
securityContext,
|
|
resources: containerResources,
|
|
},
|
|
],
|
|
volumes,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
return { job, jobName, namespace, prompt, claudeArgs, promptMetrics };
|
|
}
|