Files
PaperClipAI/tests/restart-chaos/object-store.ts
T
DottaandClaude Opus 5.5 2444658c6f test(restart-chaos): add ST5 credential rejection and ST5R key rotation storage faults (#20455)
## 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>
2026-11-10 00:16:28 -06:00

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 };
}