mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 10:21:41 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The restart-chaos harness runs real agent tasks while it injects infrastructure faults > - `--storage s3` already puts a fault proxy between the server and a loopback MinIO (ST1 outage, ST2 latency, ST3 errors) > - Cloud deployments use the default AWS credential chain, and keys can expire or rotate during a run > - The harness had no fault for a rejected or rotated key, so this failure class was untested > - This pull request adds ST5 (a window of 403 credential rejections from the proxy) and ST5R (a real key rotation at MinIO) > - The benefit is that waves can prove runs wait out credential problems and pick up rotated keys ## Linked Issues or Issue Description **What existing behavior does this improve?** The restart-chaos storage faults cover outages, latency and 5xx errors only. **Subsystem affected** tests/restart-chaos (storage chaos). **Current behavior** No wave can reject the server's S3 credentials or rotate the store's key. **Proposed behavior** `--storage-faults ST5` answers every request in a window with 403 `ExpiredToken`, `InvalidAccessKeyId` or `SignatureDoesNotMatch`. `--storage-faults ST5R` restarts MinIO with a new root key (the old key stops working) and gives the server the new key through its credential source only when the window ends. **Reason and benefit** The first local ST5 wave (w-20261110T041556-s501) shows the gap: a 41 s `ExpiredToken` window failed 4 of 6 tasks with `adapter_failed`, and the automatic retries failed within one second. The product fix is a separate PR. ## What Changed - `tests/restart-chaos/storage-credentials.ts` (new): ST5 and ST5R rule planning, a wave-owned `AWS_CONFIG_FILE` profile whose `credential_process` prints the current key, MinIO restart with a new root key on the same loopback port, and a proxy wrapper that holds traffic during the restart and publishes the new key when the window ends. - `storage-proxy.ts`: the `expired_credentials` fault takes an S3 error code; a `credential_rotation` marker passes traffic through. - `storage-chaos.ts`, `types.ts`, `config.ts`, `main.ts`: registry lines for ST5/ST5R and the rotation wiring. - README: ST5/ST5R rows and the credential-source description. ## Verification - `PAPERCLIP_IN_WORKTREE=false npx vitest run --config tests/restart-chaos/vitest.config.ts` (28 files, 328 tests pass). The new `storage-credentials.test.ts` fails before this change (the kinds do not exist) and passes after. It includes a real MinIO test: after a rotation the old key gets 403 `InvalidAccessKeyId`, a client with a cached key fails, and the same client succeeds after it re-resolves its profile. - `node scripts/typecheck-test-projects.mjs tests/restart-chaos` passes. - Live wave: `--env local --workloads A1,C1 --faults K1,K2 --tasks 6 --storage s3 --storage-faults ST5,ST5R --storage-fault-seconds 30-90` ran and injected 62 credential 403s (storage-proxy.json). ## Risks - Test harness only. No product code changes. - ST5R restarts MinIO. Requests are held for up to 5 s during the restart, so a slow restart can also show as a short 502 blip. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
107 lines
5.1 KiB
TypeScript
107 lines
5.1 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { randomBytes } from "node:crypto";
|
|
import { existsSync } from "node:fs";
|
|
import { mkdir } from "node:fs/promises";
|
|
import { createRequire } from "node:module";
|
|
import { createServer } from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
|
|
export const MINIO_RELEASE = "RELEASE.2025-04-22T22-12-26Z";
|
|
export const DEFAULT_MINIO_BINARY = path.join(os.homedir(), ".cache", "paperclip", "tools", "minio", "minio");
|
|
|
|
export function minioBinary(environment: NodeJS.ProcessEnv = process.env): string | null {
|
|
const candidate = environment.RESTART_CHAOS_MINIO_BIN || DEFAULT_MINIO_BINARY;
|
|
return existsSync(candidate) ? candidate : null;
|
|
}
|
|
|
|
export async function freeLoopbackPort(): Promise<number> {
|
|
const server = createServer();
|
|
await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
|
|
const port = (server.address() as { port: number }).port;
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
return port;
|
|
}
|
|
|
|
export async function launchMinio(input: { binary: string; dataDir: string; address: string; home: string;
|
|
accessKeyId: string; secretAccessKey: string; readyTimeoutMs?: number }): Promise<() => Promise<void>> {
|
|
const consolePort = await freeLoopbackPort();
|
|
const child: ChildProcess = spawn(input.binary, ["server", input.dataDir, "--address", input.address, "--console-address", `127.0.0.1:${consolePort}`, "--quiet"], {
|
|
env: { PATH: process.env.PATH ?? "", HOME: input.home, MINIO_ROOT_USER: input.accessKeyId, MINIO_ROOT_PASSWORD: input.secretAccessKey,
|
|
MINIO_BROWSER: "off", MINIO_UPDATE: "off" },
|
|
stdio: ["ignore", "ignore", "pipe"],
|
|
});
|
|
let stderr = "";
|
|
let spawnError: Error | null = null;
|
|
child.on("error", error => { spawnError = error; });
|
|
child.stderr?.on("data", chunk => { stderr = `${stderr}${chunk}`.slice(-4_000); });
|
|
const stop = async () => {
|
|
if (spawnError || child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
|
|
const exited = new Promise<void>(resolve => child.once("exit", () => resolve()));
|
|
child.kill("SIGTERM");
|
|
await Promise.race([exited, delay(10_000).then(() => { child.kill("SIGKILL"); return exited; })]);
|
|
};
|
|
const endpoint = `http://${input.address}`;
|
|
const deadline = Date.now() + (input.readyTimeoutMs ?? 30_000);
|
|
for (;;) {
|
|
if (spawnError) throw new Error(`MinIO could not start from ${input.binary}: ${(spawnError as Error).message}`);
|
|
if (child.exitCode !== null) throw new Error(`MinIO exited before it was ready (${child.exitCode}): ${stderr}`);
|
|
const attempt = AbortSignal.timeout(Math.max(1, Math.min(2_000, deadline - Date.now())));
|
|
if (await fetch(`${endpoint}/minio/health/live`, { signal: attempt }).then(response => response.ok).catch(() => false)) return stop;
|
|
if (Date.now() > deadline) { await stop(); throw new Error(`MinIO did not become ready: ${stderr}`); }
|
|
await delay(200);
|
|
}
|
|
}
|
|
|
|
export interface ObjectStore {
|
|
endpoint: string;
|
|
bucket: string;
|
|
region: string;
|
|
accessKeyId: string;
|
|
secretAccessKey: string;
|
|
dataDir: string;
|
|
stop(): Promise<void>;
|
|
}
|
|
|
|
export function objectStoreServerEnvironment(store: Pick<ObjectStore, "bucket" | "region" | "accessKeyId" | "secretAccessKey">,
|
|
endpoint: string, prefix: string): Record<string, string> {
|
|
return {
|
|
PAPERCLIP_STORAGE_PROVIDER: "s3",
|
|
PAPERCLIP_STORAGE_S3_BUCKET: store.bucket,
|
|
PAPERCLIP_STORAGE_S3_REGION: store.region,
|
|
PAPERCLIP_STORAGE_S3_ENDPOINT: endpoint,
|
|
PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE: "true",
|
|
PAPERCLIP_STORAGE_S3_PREFIX: prefix,
|
|
AWS_ACCESS_KEY_ID: store.accessKeyId,
|
|
AWS_SECRET_ACCESS_KEY: store.secretAccessKey,
|
|
AWS_SESSION_TOKEN: "",
|
|
AWS_CREDENTIAL_EXPIRATION: "",
|
|
AWS_EC2_METADATA_DISABLED: "true",
|
|
};
|
|
}
|
|
|
|
export async function startMinio(input: { binary: string; root: string; bucket?: string; readyTimeoutMs?: number }): Promise<ObjectStore> {
|
|
const dataDir = path.join(input.root, "object-store");
|
|
await mkdir(dataDir, { recursive: true, mode: 0o700 });
|
|
const port = await freeLoopbackPort();
|
|
const accessKeyId = `chaos${randomBytes(6).toString("hex")}`;
|
|
const secretAccessKey = randomBytes(24).toString("hex");
|
|
const endpoint = `http://127.0.0.1:${port}`;
|
|
const stop = await launchMinio({ binary: input.binary, dataDir, address: `127.0.0.1:${port}`, home: input.root, accessKeyId, secretAccessKey,
|
|
...(input.readyTimeoutMs ? { readyTimeoutMs: input.readyTimeoutMs } : {}) });
|
|
const bucket = input.bucket ?? `chaos-${randomBytes(4).toString("hex")}`;
|
|
const region = "us-east-1";
|
|
const { S3Client, CreateBucketCommand } = createRequire(new URL("../../server/package.json", import.meta.url))("@aws-sdk/client-s3");
|
|
const client = new S3Client({ endpoint, region, forcePathStyle: true, credentials: { accessKeyId, secretAccessKey } });
|
|
try {
|
|
await client.send(new CreateBucketCommand({ Bucket: bucket }));
|
|
} catch (error) {
|
|
await stop();
|
|
throw error;
|
|
} finally {
|
|
client.destroy();
|
|
}
|
|
return { endpoint, bucket, region, accessKeyId, secretAccessKey, dataDir, stop };
|
|
}
|