forked from farhoodlabs/paperclip
fix(plugin): address kubernetes greptile follow-up
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -63,6 +63,7 @@ Common optional fields:
|
||||
| `backend` | `"sandbox-cr"` | `sandbox-cr` (alpha, requires agent-sandbox controller) or `job` (stable, one-shot entrypoint). |
|
||||
| `adapterType` | `"claude_local"` | One of the supported adapter types (claude_local, codex_local, gemini_local, cursor_local, opencode_local, acpx_local, pi_local). Determines runtime image + env keys + egress allow-list. |
|
||||
| `namespacePrefix` | `"paperclip-"` | Prefix for the per-company tenant namespace. |
|
||||
| `paperclipServerNamespace` | `"paperclip"` | Namespace where paperclip-server pods run. Generated egress policies use this so agent pods can call back to the server. |
|
||||
| `companySlug` | derived from companyId | Override the auto-derived company slug. |
|
||||
| `imageRegistry` | (none) | Override the default registry for agent runtime images. |
|
||||
| `imageAllowList` | `[]` | Glob patterns of allowed `target.imageOverride` values. Empty = no override permitted. |
|
||||
|
||||
@@ -43,6 +43,13 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
maxLength: 20,
|
||||
description: "Prefix for the per-company tenant namespace (default: paperclip-).",
|
||||
},
|
||||
paperclipServerNamespace: {
|
||||
type: "string",
|
||||
maxLength: 63,
|
||||
pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
|
||||
description:
|
||||
"Namespace where paperclip-server pods run. Used by generated egress policies so agent pods can call back to the server (default: paperclip).",
|
||||
},
|
||||
companySlug: {
|
||||
type: "string",
|
||||
maxLength: 43,
|
||||
|
||||
@@ -38,11 +38,6 @@ import {
|
||||
paperclipLabels,
|
||||
} from "./utils.js";
|
||||
|
||||
// The namespace paperclip-server itself runs in. Used when building
|
||||
// NetworkPolicy manifests so the tenant namespace allows inbound traffic
|
||||
// from the server pod.
|
||||
const PAPERCLIP_SERVER_NAMESPACE = "paperclip";
|
||||
|
||||
// Name of the ServiceAccount created inside each tenant namespace by ensureTenant.
|
||||
const TENANT_SERVICE_ACCOUNT = "paperclip-tenant-sa";
|
||||
|
||||
@@ -80,7 +75,7 @@ export function extractAdapterEnvFromProcess(
|
||||
const missing: string[] = [];
|
||||
for (const k of envKeys) {
|
||||
const v = process.env[k];
|
||||
if (v) {
|
||||
if (v !== undefined) {
|
||||
out[k] = v;
|
||||
} else {
|
||||
missing.push(k);
|
||||
@@ -94,6 +89,20 @@ export function extractAdapterEnvFromProcess(
|
||||
return out;
|
||||
}
|
||||
|
||||
function shellQuoteArg(arg: string): string {
|
||||
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
export function buildSandboxExecShellCommand(
|
||||
params: Pick<PluginEnvironmentExecuteParams, "args" | "command">,
|
||||
): string {
|
||||
if (typeof params.command === "string" && params.command.trim().length > 0) {
|
||||
return params.command;
|
||||
}
|
||||
|
||||
return params.args?.map(shellQuoteArg).join(" ") ?? "";
|
||||
}
|
||||
|
||||
function generateBootstrapToken(): string {
|
||||
// TODO: paperclip-server's actual callback auth scheme is separate and is
|
||||
// out of M4b scope. This per-run random token is stored in the per-run
|
||||
@@ -217,7 +226,7 @@ const plugin = definePlugin({
|
||||
await ensureTenant(clients, {
|
||||
namespace,
|
||||
companyId: params.companyId,
|
||||
paperclipServerNamespace: PAPERCLIP_SERVER_NAMESPACE,
|
||||
paperclipServerNamespace: config.paperclipServerNamespace,
|
||||
serviceAccountAnnotations: config.serviceAccountAnnotations,
|
||||
egressMode: config.egressMode,
|
||||
egressAllowFqdns: [...adapterDefaults.allowFqdns, ...config.egressAllowFqdns],
|
||||
@@ -481,10 +490,7 @@ const plugin = definePlugin({
|
||||
|
||||
// Build the command to exec. If params.command is provided use it;
|
||||
// otherwise wrap in a login shell so profile scripts run.
|
||||
const rawCommand =
|
||||
typeof params.command === "string" && params.command.trim().length > 0
|
||||
? params.command
|
||||
: params.args?.join(" ") ?? "";
|
||||
const rawCommand = buildSandboxExecShellCommand(params);
|
||||
|
||||
const execCommand = rawCommand.length > 0
|
||||
? ["/bin/sh", "-lc", rawCommand]
|
||||
|
||||
@@ -29,6 +29,12 @@ export const kubernetesProviderConfigSchema = z
|
||||
kubeconfig: z.string().optional(),
|
||||
|
||||
namespacePrefix: z.string().regex(/^[a-z0-9-]{1,20}$/).default("paperclip-"),
|
||||
paperclipServerNamespace: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(63)
|
||||
.regex(/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/)
|
||||
.default("paperclip"),
|
||||
companySlug: z.string().regex(/^[a-z0-9-]{1,43}$/).optional(),
|
||||
|
||||
imageRegistry: z.string().url().optional(),
|
||||
|
||||
@@ -12,6 +12,10 @@ describe("manifest", () => {
|
||||
|
||||
it("keeps namespace inputs within the Kubernetes DNS label length limit", () => {
|
||||
expect(configSchema.properties.namespacePrefix.maxLength).toBe(20);
|
||||
expect(configSchema.properties.paperclipServerNamespace.maxLength).toBe(63);
|
||||
expect(configSchema.properties.paperclipServerNamespace.pattern).toBe(
|
||||
"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
|
||||
);
|
||||
expect(configSchema.properties.companySlug.maxLength).toBe(43);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import plugin, { extractAdapterEnvFromProcess } from "../../src/plugin.js";
|
||||
import plugin, {
|
||||
buildSandboxExecShellCommand,
|
||||
extractAdapterEnvFromProcess,
|
||||
} from "../../src/plugin.js";
|
||||
|
||||
describe("plugin", () => {
|
||||
it("exports the kubernetes driver", () => {
|
||||
@@ -34,6 +37,7 @@ describe("plugin", () => {
|
||||
expect.objectContaining({
|
||||
namespacePrefix: "paperclip-",
|
||||
egressMode: "standard",
|
||||
paperclipServerNamespace: "paperclip",
|
||||
jobTtlSecondsAfterFinished: 900,
|
||||
podActivityDeadlineSec: 3600,
|
||||
adapterType: "claude_local",
|
||||
@@ -120,4 +124,41 @@ describe("plugin", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves intentionally empty adapter env values", () => {
|
||||
const warnMessages: string[] = [];
|
||||
const originalValue = process.env.PAPERCLIP_TEST_EMPTY_KEY;
|
||||
process.env.PAPERCLIP_TEST_EMPTY_KEY = "";
|
||||
try {
|
||||
const result = extractAdapterEnvFromProcess(
|
||||
["PAPERCLIP_TEST_EMPTY_KEY"],
|
||||
(message) => warnMessages.push(message),
|
||||
);
|
||||
expect(result).toEqual({ PAPERCLIP_TEST_EMPTY_KEY: "" });
|
||||
expect(warnMessages).toHaveLength(0);
|
||||
} finally {
|
||||
if (originalValue === undefined) {
|
||||
delete process.env.PAPERCLIP_TEST_EMPTY_KEY;
|
||||
} else {
|
||||
process.env.PAPERCLIP_TEST_EMPTY_KEY = originalValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("quotes args before passing them to /bin/sh -lc", () => {
|
||||
expect(
|
||||
buildSandboxExecShellCommand({
|
||||
args: ["git", "commit", "-m", "feat: add feature", "it's ready"],
|
||||
}),
|
||||
).toBe("'git' 'commit' '-m' 'feat: add feature' 'it'\\''s ready'");
|
||||
});
|
||||
|
||||
it("uses command verbatim when command is provided", () => {
|
||||
expect(
|
||||
buildSandboxExecShellCommand({
|
||||
command: "pnpm test -- --runInBand",
|
||||
args: ["ignored"],
|
||||
}),
|
||||
).toBe("pnpm test -- --runInBand");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ describe("kubernetesProviderConfigSchema", () => {
|
||||
const parsed = parseKubernetesProviderConfig({ inCluster: true });
|
||||
expect(parsed.inCluster).toBe(true);
|
||||
expect(parsed.namespacePrefix).toBe("paperclip-");
|
||||
expect(parsed.paperclipServerNamespace).toBe("paperclip");
|
||||
expect(parsed.imageAllowList).toEqual([]);
|
||||
expect(parsed.egressMode).toBe("standard");
|
||||
expect(parsed.jobTtlSecondsAfterFinished).toBe(900);
|
||||
@@ -40,6 +41,25 @@ describe("kubernetesProviderConfigSchema", () => {
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts a custom paperclip-server namespace", () => {
|
||||
const parsed = parseKubernetesProviderConfig({
|
||||
inCluster: true,
|
||||
paperclipServerNamespace: "paperclip-prod",
|
||||
});
|
||||
expect(parsed.paperclipServerNamespace).toBe("paperclip-prod");
|
||||
});
|
||||
|
||||
it("rejects invalid paperclip-server namespace values", () => {
|
||||
for (const namespace of ["Paperclip", "paperclip_", "-paperclip", "paperclip-"]) {
|
||||
expect(() =>
|
||||
parseKubernetesProviderConfig({
|
||||
inCluster: true,
|
||||
paperclipServerNamespace: namespace,
|
||||
}),
|
||||
).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects whitespace-only kubeconfig", () => {
|
||||
expect(() =>
|
||||
parseKubernetesProviderConfig({ inCluster: false, kubeconfig: " " }),
|
||||
|
||||
Reference in New Issue
Block a user