mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Harden dynamic secret command tests
Restrict dynamic-command dry-runs to persisted secret bindings and run generators with a sanitized process environment. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
8 files changed
+282
-111
No files matched your search
@@ -30,15 +30,24 @@ describe("secret validators", () => {
|
||||
expect(parsed.staticArgv).toBeUndefined();
|
||||
});
|
||||
|
||||
it("validates a dynamic-command test payload and defaults argv to []", () => {
|
||||
it("validates a dynamic-command test payload for a saved secret binding", () => {
|
||||
const parsed = testDynamicSecretCommandSchema.parse({
|
||||
command: "/usr/local/bin/mint-github-token",
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
bindingId: "22222222-2222-2222-2222-222222222222",
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
bindingId: "22222222-2222-2222-2222-222222222222",
|
||||
});
|
||||
expect(parsed.staticArgv).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an empty command on the dynamic-command test payload", () => {
|
||||
expect(() => testDynamicSecretCommandSchema.parse({ command: " " })).toThrow();
|
||||
it("rejects arbitrary command text on the dynamic-command test payload", () => {
|
||||
expect(() =>
|
||||
testDynamicSecretCommandSchema.parse({
|
||||
command: "/usr/local/bin/mint-github-token",
|
||||
staticArgv: ["--installation", "123"],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("allows dynamic command secrets with host-command generator config", () => {
|
||||
|
||||
@@ -39,13 +39,12 @@ export const dynamicSecretCommandSchema = z.object({
|
||||
|
||||
export const staticArgvSchema = z.array(z.string().max(4096)).max(64).default([]);
|
||||
|
||||
// Operator dry-run of a dynamic (host-command) generator before saving a
|
||||
// secret or binding. The resolved value is never returned to the caller —
|
||||
// only a pass/fail signal plus non-sensitive metadata.
|
||||
// Operator dry-run of a saved dynamic (host-command) generator. The route
|
||||
// never accepts arbitrary command text; it reuses persisted secret + binding
|
||||
// configuration and returns only pass/fail metadata.
|
||||
export const testDynamicSecretCommandSchema = z.object({
|
||||
command: z.string().trim().min(1).max(2048),
|
||||
ttlSeconds: z.number().int().min(1).max(86_400).optional(),
|
||||
staticArgv: staticArgvSchema,
|
||||
secretId: z.string().uuid(),
|
||||
bindingId: z.string().uuid(),
|
||||
}).strict();
|
||||
|
||||
export type TestDynamicSecretCommand = z.infer<typeof testDynamicSecretCommandSchema>;
|
||||
|
||||
@@ -23,11 +23,16 @@ const mockSecretService = vi.hoisted(() => ({
|
||||
remove: vi.fn(),
|
||||
previewRemoteImport: vi.fn(),
|
||||
importRemoteSecrets: vi.fn(),
|
||||
testDynamicCommand: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
@@ -54,6 +59,8 @@ describe("secret routes", () => {
|
||||
for (const mock of Object.values(mockSecretService)) {
|
||||
mock.mockReset();
|
||||
}
|
||||
mockInstanceSettingsService.getExperimental.mockReset();
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableDynamicSecrets: true });
|
||||
mockLogActivity.mockReset();
|
||||
});
|
||||
|
||||
@@ -95,6 +102,53 @@ describe("secret routes", () => {
|
||||
expect(mockSecretService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects arbitrary dynamic command tests before calling the service", async () => {
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/secrets/dynamic-command/test")
|
||||
.send({
|
||||
command: process.execPath,
|
||||
staticArgv: ["./mint-token.js"],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockInstanceSettingsService.getExperimental).not.toHaveBeenCalled();
|
||||
expect(mockSecretService.testDynamicCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dry-runs only a saved dynamic command secret binding", async () => {
|
||||
mockSecretService.testDynamicCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
posture: "soft",
|
||||
bytes: 24,
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/secrets/dynamic-command/test")
|
||||
.send({
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
bindingId: "22222222-2222-2222-2222-222222222222",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true, posture: "soft", bytes: 24 });
|
||||
expect(mockSecretService.testDynamicCommand).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
bindingId: "22222222-2222-2222-2222-222222222222",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "secret.dynamic_command.tested",
|
||||
entityType: "secret",
|
||||
entityId: "11111111-1111-1111-1111-111111111111",
|
||||
details: {
|
||||
ok: true,
|
||||
bindingId: "22222222-2222-2222-2222-222222222222",
|
||||
errorCode: null,
|
||||
},
|
||||
}));
|
||||
expect(JSON.stringify(res.body)).not.toContain("minted");
|
||||
});
|
||||
|
||||
it("rejects provider vault routes for non-board actors", async () => {
|
||||
const res = await request(createApp({
|
||||
type: "agent",
|
||||
|
||||
@@ -308,6 +308,63 @@ console.log("minted-" + count + ":" + process.argv.slice(2).join("|"));
|
||||
expect(activityJson).not.toContain(scriptPath);
|
||||
});
|
||||
|
||||
it("runs dynamic command generators with a sanitized environment", async () => {
|
||||
const previousEnv = {
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
PAPERCLIP_API_KEY: process.env.PAPERCLIP_API_KEY,
|
||||
PAPERCLIP_TEST_TOKEN: process.env.PAPERCLIP_TEST_TOKEN,
|
||||
};
|
||||
process.env.DATABASE_URL = "postgres://paperclip:secret@example.test/paperclip";
|
||||
process.env.OPENAI_API_KEY = "sk-should-not-leak";
|
||||
process.env.PAPERCLIP_API_KEY = "paperclip-api-key";
|
||||
process.env.PAPERCLIP_TEST_TOKEN = "token-should-not-leak";
|
||||
try {
|
||||
const companyId = await seedCompany();
|
||||
await enableDynamicSecrets();
|
||||
const scriptPath = writeGeneratorScript("dynamic-env", `
|
||||
const sensitive = ["DATABASE_URL", "OPENAI_API_KEY", "PAPERCLIP_API_KEY", "PAPERCLIP_TEST_TOKEN"];
|
||||
const visible = sensitive.filter((key) => process.env[key]).join(",");
|
||||
console.log(JSON.stringify({ visible, path: typeof process.env.PATH === "string" && process.env.PATH.length > 0 }));
|
||||
`);
|
||||
const svc = secretService(db);
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `dynamic-env-${randomUUID()}`,
|
||||
provider: "host_command",
|
||||
managedMode: "dynamic_command",
|
||||
dynamicCommand: {
|
||||
provider: "host-command",
|
||||
command: process.execPath,
|
||||
ttlSeconds: 60,
|
||||
},
|
||||
});
|
||||
await svc.createBinding({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
targetType: "agent",
|
||||
targetId: "agent-env",
|
||||
configPath: "env.GITHUB_TOKEN",
|
||||
staticArgv: [scriptPath],
|
||||
});
|
||||
|
||||
const resolved = await svc.resolveAdapterConfigForRuntime(
|
||||
companyId,
|
||||
{ env: { GITHUB_TOKEN: { type: "secret_ref", secretId: secret.id } } },
|
||||
{ consumerType: "agent", consumerId: "agent-env" },
|
||||
);
|
||||
|
||||
expect(JSON.parse(resolved.config.env.GITHUB_TOKEN)).toEqual({ visible: "", path: true });
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed at runtime when dynamic secrets are disabled after creation", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await enableDynamicSecrets();
|
||||
@@ -415,7 +472,7 @@ console.log("should-not-run");
|
||||
expect(activities).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("dry-runs a dynamic generator without returning the value", async () => {
|
||||
it("dry-runs a saved dynamic generator binding without returning the value", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await enableDynamicSecrets();
|
||||
const svc = secretService(db);
|
||||
@@ -424,11 +481,26 @@ console.log("should-not-run");
|
||||
"console.log('minted:' + process.argv.slice(2).join('|'));",
|
||||
);
|
||||
|
||||
const ok = await svc.testDynamicCommand({
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `dynamic-test-${randomUUID()}`,
|
||||
provider: "host_command",
|
||||
managedMode: "dynamic_command",
|
||||
dynamicCommand: {
|
||||
provider: "host-command",
|
||||
command: process.execPath,
|
||||
ttlSeconds: 60,
|
||||
},
|
||||
});
|
||||
const binding = await svc.createBinding({
|
||||
companyId,
|
||||
command: process.execPath,
|
||||
secretId: secret.id,
|
||||
targetType: "agent",
|
||||
targetId: "agent-test",
|
||||
configPath: "env.GITHUB_TOKEN",
|
||||
staticArgv: [scriptPath, "--installation", "999"],
|
||||
});
|
||||
|
||||
const ok = await svc.testDynamicCommand({ companyId, secretId: secret.id, bindingId: binding.id });
|
||||
expect(ok.ok).toBe(true);
|
||||
expect(ok.posture).toBe("soft");
|
||||
expect(ok.bytes).toBe("minted:--installation|999".length);
|
||||
@@ -438,10 +510,28 @@ console.log("should-not-run");
|
||||
"dynamic-test-fail",
|
||||
"process.stderr.write('boom'); process.exit(2);",
|
||||
);
|
||||
const failingSecret = await svc.create(companyId, {
|
||||
name: `dynamic-test-fail-${randomUUID()}`,
|
||||
provider: "host_command",
|
||||
managedMode: "dynamic_command",
|
||||
dynamicCommand: {
|
||||
provider: "host-command",
|
||||
command: process.execPath,
|
||||
ttlSeconds: 60,
|
||||
},
|
||||
});
|
||||
const failingBinding = await svc.createBinding({
|
||||
companyId,
|
||||
secretId: failingSecret.id,
|
||||
targetType: "agent",
|
||||
targetId: "agent-test-fail",
|
||||
configPath: "env.GITHUB_TOKEN",
|
||||
staticArgv: [failing],
|
||||
});
|
||||
const failure = await svc.testDynamicCommand({
|
||||
companyId,
|
||||
command: process.execPath,
|
||||
staticArgv: [failing],
|
||||
secretId: failingSecret.id,
|
||||
bindingId: failingBinding.id,
|
||||
});
|
||||
expect(failure.ok).toBe(false);
|
||||
expect(failure.posture).toBe("soft");
|
||||
@@ -449,6 +539,51 @@ console.log("should-not-run");
|
||||
expect(failure.bytes).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects dynamic command dry-runs for bindings outside the secret company", async () => {
|
||||
const companyA = await seedCompany("A");
|
||||
const companyB = await seedCompany("B");
|
||||
await enableDynamicSecrets();
|
||||
const svc = secretService(db);
|
||||
const scriptPath = writeGeneratorScript("dynamic-test-foreign", "console.log('minted');");
|
||||
const secretA = await svc.create(companyA, {
|
||||
name: `dynamic-company-a-${randomUUID()}`,
|
||||
provider: "host_command",
|
||||
managedMode: "dynamic_command",
|
||||
dynamicCommand: {
|
||||
provider: "host-command",
|
||||
command: process.execPath,
|
||||
ttlSeconds: 60,
|
||||
},
|
||||
});
|
||||
const secretB = await svc.create(companyB, {
|
||||
name: `dynamic-company-b-${randomUUID()}`,
|
||||
provider: "host_command",
|
||||
managedMode: "dynamic_command",
|
||||
dynamicCommand: {
|
||||
provider: "host-command",
|
||||
command: process.execPath,
|
||||
ttlSeconds: 60,
|
||||
},
|
||||
});
|
||||
const foreignBinding = await svc.createBinding({
|
||||
companyId: companyB,
|
||||
secretId: secretB.id,
|
||||
targetType: "agent",
|
||||
targetId: "agent-foreign",
|
||||
configPath: "env.GITHUB_TOKEN",
|
||||
staticArgv: [scriptPath],
|
||||
});
|
||||
|
||||
const result = await svc.testDynamicCommand({
|
||||
companyId: companyA,
|
||||
secretId: secretA.id,
|
||||
bindingId: foreignBinding.id,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errorCode).toBe("binding_missing");
|
||||
});
|
||||
|
||||
it("threads operator static argv from env bindings into binding rows", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await enableDynamicSecrets();
|
||||
|
||||
@@ -307,9 +307,9 @@ export function secretRoutes(db: Db) {
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
// Operator-only dry-run of a dynamic (host-command) generator before saving.
|
||||
// Gated behind the experimental flag and the board scope. Returns only a
|
||||
// pass/fail signal and non-sensitive metadata — never the generated value.
|
||||
// Operator-only dry-run of a saved dynamic (host-command) generator. This
|
||||
// endpoint deliberately does not accept arbitrary command text; the service
|
||||
// resolves persisted secret + binding config after company ownership checks.
|
||||
router.post(
|
||||
"/companies/:companyId/secrets/dynamic-command/test",
|
||||
validate(testDynamicSecretCommandSchema),
|
||||
@@ -323,8 +323,8 @@ export function secretRoutes(db: Db) {
|
||||
}
|
||||
const result = await svc.testDynamicCommand({
|
||||
companyId,
|
||||
command: req.body.command,
|
||||
staticArgv: req.body.staticArgv,
|
||||
secretId: req.body.secretId,
|
||||
bindingId: req.body.bindingId,
|
||||
});
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
@@ -332,8 +332,8 @@ export function secretRoutes(db: Db) {
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret.dynamic_command.tested",
|
||||
entityType: "secret",
|
||||
entityId: "dynamic-command-test",
|
||||
details: { ok: result.ok, errorCode: result.errorCode ?? null },
|
||||
entityId: req.body.secretId,
|
||||
details: { ok: result.ok, bindingId: req.body.bindingId, errorCode: result.errorCode ?? null },
|
||||
});
|
||||
res.json(result);
|
||||
},
|
||||
|
||||
@@ -76,6 +76,15 @@ const EMPTY_STATIC_ARGV: string[] = [];
|
||||
const DEFAULT_DYNAMIC_SECRET_COMMAND_TIMEOUT_MS = 30_000;
|
||||
const REDACTED_DYNAMIC_SECRET_FIELD = "***REDACTED***";
|
||||
const DYNAMIC_SECRET_CONFIG_VERSION = 1;
|
||||
const DYNAMIC_SECRET_COMMAND_ENV_ALLOWLIST = new Set([
|
||||
"PATH",
|
||||
"HOME",
|
||||
"TMPDIR",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"SystemRoot",
|
||||
"ComSpec",
|
||||
]);
|
||||
const dynamicSecretCache = new Map<string, { value: string; expiresAt: number }>();
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type SecretBindingDb = Pick<Db | DbTransaction, "select" | "delete" | "insert">;
|
||||
@@ -394,6 +403,15 @@ function dynamicSecretCommandTimeoutMs() {
|
||||
: DEFAULT_DYNAMIC_SECRET_COMMAND_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function buildDynamicSecretCommandEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const key of DYNAMIC_SECRET_COMMAND_ENV_ALLOWLIST) {
|
||||
const value = source[key];
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
async function runDynamicSecretCommand(input: {
|
||||
command: string;
|
||||
staticArgv: string[];
|
||||
@@ -407,7 +425,7 @@ async function runDynamicSecretCommand(input: {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
env: buildDynamicSecretCommandEnv(),
|
||||
});
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
@@ -608,6 +626,14 @@ export function secretService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getBindingById(bindingId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
.where(eq(companySecretBindings.id, bindingId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function assertBindingContext(
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
@@ -2619,16 +2645,14 @@ export function secretService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
// Operator dry-run of a dynamic (host-command) generator. Runs the command
|
||||
// under Paperclip's privileged control exactly as injection would, but
|
||||
// never returns the resolved value — only a pass/fail signal plus
|
||||
// non-sensitive metadata (byte length, posture). Posture is "soft" because
|
||||
// an unbound generator is not yet attached to any hard-isolation
|
||||
// environment; the runtime resolver labels the bound posture per Stage 5.
|
||||
// Operator dry-run of a saved dynamic (host-command) generator. Runs the
|
||||
// persisted command and persisted binding argv under Paperclip's privileged
|
||||
// control exactly as injection would, but never accepts arbitrary command
|
||||
// text and never returns the resolved value.
|
||||
testDynamicCommand: async (input: {
|
||||
companyId: string;
|
||||
command: string;
|
||||
staticArgv?: string[];
|
||||
secretId: string;
|
||||
bindingId: string;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
posture: "hard" | "soft";
|
||||
@@ -2638,9 +2662,29 @@ export function secretService(db: Db) {
|
||||
}> => {
|
||||
try {
|
||||
await assertDynamicSecretsEnabled();
|
||||
const secret = await assertSecretInCompany(input.companyId, input.secretId);
|
||||
if (secret.managedMode !== "dynamic_command" || secret.provider !== "host_command") {
|
||||
throw unprocessable("Secret is not a dynamic command secret", {
|
||||
code: "dynamic_secret_command_failed",
|
||||
});
|
||||
}
|
||||
const binding = await getBindingById(input.bindingId);
|
||||
if (!binding) throw notFound("Secret binding not found");
|
||||
if (binding.companyId !== input.companyId || binding.secretId !== secret.id) {
|
||||
throw unprocessable("Secret binding must belong to same company and secret", {
|
||||
code: "binding_missing",
|
||||
});
|
||||
}
|
||||
const dynamicCommand = await resolveDynamicCommandConfig({
|
||||
id: secret.id,
|
||||
companyId: secret.companyId,
|
||||
key: secret.key,
|
||||
latestVersion: secret.latestVersion,
|
||||
});
|
||||
const staticArgv = await resolveBindingStaticArgv(binding);
|
||||
const value = await runDynamicSecretCommand({
|
||||
command: input.command,
|
||||
staticArgv: normalizeStaticArgv(input.staticArgv ?? EMPTY_STATIC_ARGV),
|
||||
command: dynamicCommand.command,
|
||||
staticArgv,
|
||||
});
|
||||
return { ok: true, posture: "soft", bytes: Buffer.byteLength(value) };
|
||||
} catch (err) {
|
||||
|
||||
@@ -36,9 +36,8 @@ export interface CreateSecretInput {
|
||||
}
|
||||
|
||||
export interface TestDynamicCommandInput {
|
||||
command: string;
|
||||
ttlSeconds?: number;
|
||||
staticArgv?: string[];
|
||||
secretId: string;
|
||||
bindingId: string;
|
||||
}
|
||||
|
||||
export interface TestDynamicCommandResult {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
ShieldAlert,
|
||||
Clock,
|
||||
TerminalSquare,
|
||||
Play,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
@@ -50,7 +49,6 @@ import {
|
||||
type CreateSecretInput,
|
||||
type CreateSecretProviderConfigInput,
|
||||
type SecretProviderHealthResponse,
|
||||
type TestDynamicCommandResult,
|
||||
type UpdateSecretProviderConfigInput,
|
||||
} from "../api/secrets";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
@@ -496,7 +494,6 @@ export function Secrets() {
|
||||
ttlSeconds: String(DEFAULT_DYNAMIC_TTL_SECONDS),
|
||||
});
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<TestDynamicCommandResult | null>(null);
|
||||
const [rotateOpen, setRotateOpen] = useState(false);
|
||||
const [rotateValue, setRotateValue] = useState("");
|
||||
const [rotateExternalRef, setRotateExternalRef] = useState("");
|
||||
@@ -705,7 +702,6 @@ export function Secrets() {
|
||||
ttlSeconds: String(DEFAULT_DYNAMIC_TTL_SECONDS),
|
||||
});
|
||||
setCreateError(null);
|
||||
setTestResult(null);
|
||||
setSelectedSecretId(created.id);
|
||||
invalidateAll([created.id]);
|
||||
},
|
||||
@@ -714,24 +710,6 @@ export function Secrets() {
|
||||
},
|
||||
});
|
||||
|
||||
const testDynamicMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
secretsApi.testDynamicCommand(selectedCompanyId!, {
|
||||
command: createForm.command.trim(),
|
||||
ttlSeconds: dynamicTtlValid ? dynamicTtlSeconds : undefined,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
setTestResult(result);
|
||||
},
|
||||
onError: (error) => {
|
||||
setTestResult({
|
||||
ok: false,
|
||||
posture: "soft",
|
||||
message: error instanceof ApiError ? error.message : (error as Error).message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rotateMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!selectedSecret) throw new Error("Select a secret first");
|
||||
@@ -1334,10 +1312,6 @@ export function Secrets() {
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
if (!open) {
|
||||
setTestResult(null);
|
||||
testDynamicMutation.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
@@ -1353,7 +1327,6 @@ export function Secrets() {
|
||||
value={createMode}
|
||||
onValueChange={(value) => {
|
||||
setCreateMode(value as CreateMode);
|
||||
setTestResult(null);
|
||||
}}
|
||||
>
|
||||
<TabsList
|
||||
@@ -1539,7 +1512,6 @@ export function Secrets() {
|
||||
onChange={(event) => {
|
||||
const command = event.target.value;
|
||||
setCreateForm((current) => ({ ...current, command }));
|
||||
setTestResult(null);
|
||||
}}
|
||||
placeholder="/usr/local/bin/mint-github-token"
|
||||
className="pl-7 font-mono text-xs"
|
||||
@@ -1574,52 +1546,11 @@ export function Secrets() {
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<RuntimePostureExplainer posture={testResult?.posture ?? "soft"} />
|
||||
<div className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] font-medium">Test generator before saving</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTestResult(null);
|
||||
testDynamicMutation.mutate();
|
||||
}}
|
||||
disabled={testDynamicMutation.isPending || !createForm.command.trim()}
|
||||
>
|
||||
{testDynamicMutation.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
{testResult ? (
|
||||
testResult.ok ? (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-emerald-700 dark:text-emerald-300">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
|
||||
Generator produced a fresh value
|
||||
{typeof testResult.bytes === "number" ? ` (${testResult.bytes} bytes)` : ""}.
|
||||
The value is not shown.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Generator failed{testResult.errorCode ? ` (${testResult.errorCode})` : ""}.{" "}
|
||||
{testResult.message ?? "Check the command and try again."}
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
Runs the command once under Paperclip's control to confirm it returns a value.
|
||||
Nothing is saved.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<RuntimePostureExplainer posture="soft" />
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Saved generators are tested from an existing secret binding so Paperclip can reuse
|
||||
the persisted command and operator-fixed arguments.
|
||||
</p>
|
||||
<div className="flex items-start gap-1.5 rounded-md border border-amber-500/30 bg-amber-500/5 p-2 text-[11px] text-amber-700 dark:text-amber-300">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
|
||||
Reference in new issue
Block a user