Files
PaperClipAI/server/src/__tests__/http-log-redaction.test.ts
DottaandPaperclip b508a05c43 feat: add internal agent complaints and suggestions (#15367)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents use legacy skills or native runner tools to work on tasks.
> - Those agents can encounter friction that does not belong in the task
thread.
> - A complaint should preserve the raw reaction. A suggestion should
describe an improvement.
> - This pull request adds attributed local storage and both submission
paths.
> - Agents can submit feedback once and continue their primary work.

## Linked Issues or Issue Description

**Subsystem affected**

Server, database, shared contracts, runtime skills, and native runner
tools.

**Problem or motivation**

Agents have no default internal channel for incidental complaints and
suggestions. Sending this feedback through task comments adds noise and
can alter task workflows.

**Proposed solution**

Store free-form feedback in the current instance database. Derive agent,
run, company, and task attribution from active authority. Provide
default legacy skills and provider-neutral native actions. Keep the
instructions close to Warp's MIT-licensed originals.

**Alternatives considered**

Task comments and external Slack delivery add unwanted side effects.
Mandatory suggestion fields and short editorial limits would discard
useful feedback. This release has no listing API, UI, read tool,
automatic triage, or external forwarding.

**Roadmap alignment**

This is a maintainer-requested addition to the existing runtime skills
and runner tool paths. It does not duplicate a listed roadmap milestone.
Searches for complaint tooling, suggestion-box, and agent commentary
found no overlapping public PR or issue.

## What Changed

- Add the company-scoped `agent_commentary` table, shared validation,
and idempotent migration `0310`.
- Add one transactional service and the agent-only POST route. Validate
active authority before writes or replay. Redact known credentials.
Commit a content-free audit with each new record.
- Add `submit_complaint` and `submit_suggestion` to standard, ask, and
planning modes. Keep review, revocation, and completion restrictions.
Store replay identity on the commentary row.
- Mount `complain` and `suggestion-box` by default for legacy agents.
Bundle a dependency-free Node.js stdin helper in the operational skill
and allow its POST through the sandbox bridge.
- Preserve Warp's complaint voice and suggestion guidance, with
attribution and local transport adaptations. Keep source attribution and
MIT notices in each skill's LICENSE, outside runtime instructions.
- Document custom-runtime HTTP use and database inspection. Add
real-database tests and a repeatable live Codex smoke for local and
Daytona execution.
- Pin the lagging-source migration fixture before the identity-repair
migration so later migrations preserve its regression coverage.

## Verification

- Personally ran real Codex submissions in all four environments on
2026-10-06. Local runs passed at 20:35 UTC. Daytona native passed at
20:31 UTC; Daytona legacy passed at 20:33 UTC. Each stored exactly two
rows with company, agent, run, and task attribution, wrote the
continuation marker, exited zero, created no task comments, and left
task status unchanged. Each recorded two content-free activity entries.
- Daytona used production provider hooks, real remote execution and file
transfer, the legacy queue callback bridge, and native private WebSocket
ingress. The current Linux runner was built from `abf47b595`, staged,
and verified against controller contracts. Both sandboxes were confirmed
deleted. This is a focused feedback transport smoke; it does not claim
full Runner E2E catalog or browser qualification.
- The immutable base image and Linux binary digest are recorded in [the
verification
documentation](https://github.com/paperclipai/paperclip/blob/codex/agent-commentary/doc/agent-commentary.md#verification).
The smoke script can save content-free JSON evidence. No credentials or
feedback bodies are in these reports.

| Environment | Runner | Complaint row | Suggestion row |
| --- | --- | --- | --- |
| local | legacy Codex | `59413a00-1de2-4bb1-bcc6-9c4b54c64aa6` |
`3db2364d-3e15-4f47-846f-875d3902999d` |
| local | native Codex | `5da22b5f-41df-4de5-8ba0-d9345ab01267` |
`2d5abe17-dd41-403c-a5ee-4729f2d58921` |
| daytona | legacy Codex | `27c9d0aa-8477-409f-9da0-e8ffa48dee50` |
`209681c9-d1e9-4ce1-999e-48fa07692389` |
| daytona | native Codex | `6eb001bb-4bcf-43f7-8717-f662f53dc7c3` |
`77c383d8-a997-49e5-a33e-25c70e15c0b2` |

- Run the local check with `node cli/node_modules/tsx/dist/cli.mjs
server/scripts/verify-agent-commentary-live.ts`. The documentation gives
the Daytona invocation. Both use disposable instance databases and
normal Codex provider usage.
- Repository `pnpm -r typecheck` and `pnpm build` passed after the test
extension. The build includes runner generation, contracts, and replay
checks. The smoke scripts also passed a separate TypeScript check. The
lagging-source migration regression passed. All equivalent current-head
Vitest CI shards passed. The local monolithic `pnpm test:run` invocation
was stopped after CI supplied that coverage; it did not complete
locally.
- Focused tests cover company isolation, spoofing, revoked credentials,
stale ownership, post-finish rejection, concurrent replay, conflicting
keys, atomic rollback, and deletion through existing services. Boundary
tests cover empty text, Unicode, text beyond 8,000 characters, and the
524,288-character ceiling without truncation. Mounting tests cover
Codex, Claude, and sandbox staging. Helper tests cover standalone Node
execution, stdin, invalid UTF-8, redirects, HTTP failure, and its
deadline. Privacy and bridge tests cover successful and rejected
requests.
- Instructions were compared with Warp's originals. MIT notices and
source credits live only in LICENSE files. Native tools preserve
truthful disclosure when asked, without routine announcements.
- [Full
CI](https://github.com/paperclipai/paperclip/actions/runs/37508559190)
and Greptile 5/5 passed on the earlier feature commit `5209c3501`. The
later head found the migration-fixture assumption fixed in this update.
On `8a4965164`, all 55 check contexts passed after one browser shard
rerun. Its initial reviewer signoff failure also passed an isolated
local browser run (1 test). Greptile scored that head 5/5 and identified
one smoke cleanup gap. `6ecbafb0b` fixes failed-acquisition cleanup with
four passing tests and a passing smoke-script typecheck. Fresh CI is
pending for this final test-only fix. No commentary production code
changed during verification.

## Risks

- Feedback is internally attributed. It is not anonymous. Existing
redaction removes known credentials, but agents must still omit
sensitive content. Normal provider transcripts can include their
submitted arguments.
- Default skill availability changes for existing legacy agents. Runtime
policy filtering still applies. The helper uses the existing Node.js
runtime with no extra dependencies; custom runtimes can call the HTTP
endpoint.
- Feedback is removed with its run, agent, or company. Task deletion
clears only the issue pointer. Normal database backups include the
table.
- The migration is additive and has no backfill. Writes serialize on the
active run for replay consistency. No server suggestion quota is
imposed.

## Model Used

OpenAI `gpt-6-astra` through Codex, with `xhigh` reasoning effort and a
reported 258,400-token context window. Capabilities used: repository
inspection, code execution, and live runtime verification. No subagents
were used.

## 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-06 16:14:06 -05:00

807 lines
30 KiB
TypeScript

import { createServer, request as httpRequest } from "node:http";
import { Writable } from "node:stream";
import express from "express";
import pino from "pino";
import { pinoHttp } from "pino-http";
import request from "supertest";
import { describe, expect, it } from "vitest";
import { HttpError } from "../errors.js";
import { HTTP_LOG_REDACT_PATHS } from "../middleware/http-log-redaction.js";
import { errorHandler } from "../middleware/error-handler.js";
import { testAdapterEnvironmentSchema } from "@paperclipai/shared";
import { createHttpLogger } from "../middleware/logger.js";
describe("HTTP logger redaction", () => {
it("redacts inbound MCP OAuth codes, PKCE verifiers, refresh tokens and redirect credentials", async () => {
const chunks: string[] = [];
const stream = new Writable({ write(chunk, _encoding, callback) { chunks.push(chunk.toString()); callback(); } });
const app = express();
app.use(express.json());
app.use(createHttpLogger(pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream)));
app.post("/mcp/paperclip", (_req, res) => res.status(400).json({ error: "invalid_request" }));
app.post("/mcp/oauth/token", (_req, res) => res.status(400).json({ error: "invalid_grant" }));
app.get("/mcp/oauth/authorize", (_req, res) => res.redirect("https://client.example/callback?code=redirect-canary"));
await request(app).post("/mcp/oauth/token").send({ code: "code-canary", code_verifier: "pkce-canary", refresh_token: "refresh-canary" });
await request(app).get("/mcp/oauth/authorize?state=state-canary");
await request(app).post("/mcp/paperclip").send({ params: { delivery: { url: "https://receiver.example/callback-path-canary", secret: "whsec_callback-secret-canary" }, _meta: { "ai.paperclip/cloudAuthority": { token: "cloud-authority-canary" } } } });
expect(chunks.join("")).not.toMatch(/callback-path-canary|callback-secret-canary|cloud-authority-canary/);
expect(chunks.join("")).not.toMatch(/code-canary|pkce-canary|refresh-canary|redirect-canary|state-canary/);
});
it.each([[400, "/api/companies/company/agent-commentary"], [503, "/API/COMPANIES/company/AGENT-COMMENTARY"], [503, "http://localhost/api/companies/company/agent-commentary"]] as const)("keeps rejected commentary content out of %i diagnostics for %s", async (status, url) => {
const canary = "private-agent-commentary-canary";
const chunks: string[] = [];
const stream = new Writable({ write(chunk, _encoding, callback) { chunks.push(chunk.toString()); callback(); } });
const app = express();
app.use(createHttpLogger(pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream)));
app.use(express.json());
app.post("/api/companies/:companyId/agent-commentary", (_req, res) => {
if (status === 503) (res as any).err = new Error(`Driver echoed ${canary}`);
res.status(status).end();
});
const server = createServer(app);
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
try {
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing test listener");
await new Promise<void>((resolve, reject) => {
const client = httpRequest({ hostname: "127.0.0.1", port: address.port, method: "POST", path: url, headers: { "content-type": "application/json" } }, res => {
expect(res.statusCode).toBe(status);
res.resume(); res.on("end", resolve);
});
client.on("error", reject);
client.end(JSON.stringify({ body: canary, unexpected: canary }));
});
} finally {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
}
const output = chunks.join("");
expect(output).not.toContain(canary);
expect(JSON.parse(output.trim()).reqBody).toBe("[REDACTED]");
});
it.each([
{ method: "POST", path: "/api/routine-triggers/public/private-url-canary/fire" },
{ method: "PUT", path: "/api/routine-triggers/public/private-url-canary/fire" },
{
method: "POST",
path: "http://provider.invalid/api/chat-webhooks/../private-url-canary",
},
{ method: "GET", path: "/api/chat-webhooks/private-url-canary/slack/" },
{ method: "PUT", path: "/API/CHAT-WEBHOOKS/private-url-canary/SLACK" },
{ method: "PATCH", path: "/api/chat-webhooks//private-url-canary" },
{ method: "DELETE", path: "/api/chat-webhooks/private-url-canary/%XX" },
{
method: "POST",
path: "/api/chat-webhooks/private-url-canary/slack/extra",
},
{ method: "POST", path: "/api/chat-webhooks?payload=private-url-canary" },
{
method: "POST",
path: "http://provider.invalid/api/chat-webhooks/private-url-canary/slack?token=private-url-canary",
},
])(
"keeps malformed/rejected webhook $method requests content-free",
async ({ method, path }) => {
const privateText = "private-rejected-method-body-canary";
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
});
const app = express();
app.use(
createHttpLogger(pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream)),
);
app.use(express.raw({ type: "*/*" }));
app.use((_req, res) => {
(res as any).err = new Error(`SDK error echoed ${privateText}`);
res.setHeader("x-provider-prose", privateText);
res.status(405).end();
});
const server = createServer(app);
try {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string")
throw new Error("Fixture listener unavailable");
const body = JSON.stringify({ text: privateText });
await new Promise<void>((resolve, reject) => {
const client = httpRequest(
{
hostname: "127.0.0.1",
port: address.port,
method,
path,
headers: {
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
"x-provider-prose": privateText,
},
},
(res) => {
expect(res.statusCode).toBe(405);
res.resume();
res.on("end", resolve);
},
);
client.on("error", reject);
client.end(body);
});
} finally {
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
const output = chunks.join("");
expect(output).not.toContain(privateText);
expect(output).not.toContain("private-url-canary");
expect(output).not.toContain("provider.invalid");
const log = JSON.parse(output.trim());
expect(log.req).toMatchObject({
method,
url: path.includes("routine-triggers") ? "/api/routine-triggers/public/:publicId/fire" : "/api/chat-webhooks/:publicId/:provider",
});
expect(log.reqBody).toBe("[REDACTED]");
expect(log.err.message).toBe("Chat webhook request failed");
expect(log.res).toEqual({ statusCode: 405 });
expect(log.responseTime).toEqual(expect.any(Number));
},
);
it.each(
["raw-json", "raw-form", "raw-text", "parsed-json", "parsed-form"].flatMap(
(bodyKind) =>
["warning", "context-error", "bare-sdk-error"].flatMap((failureMode) =>
[false, true].map((mountedLogger) => ({
bodyKind,
failureMode,
mountedLogger,
})),
),
),
)(
"omits private webhook input: $bodyKind / $failureMode / mounted=$mountedLogger",
async ({ bodyKind, failureMode, mountedLogger }) => {
const canaries = {
text: "private-chat-text-canary-9024",
filename: "private-file-name-canary-7731.txt",
token: "private-webhook-token-canary-2342",
sdk: "private-sdk-prose-canary-1148",
};
const payload = {
text: canaries.text,
files: [{ name: canaries.filename }],
token: canaries.token,
};
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
});
const testLogger = pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream);
const app = express();
const routes = express.Router();
if (mountedLogger) routes.use(createHttpLogger(testLogger));
else app.use(createHttpLogger(testLogger));
routes.use(
bodyKind === "parsed-json"
? express.json()
: bodyKind === "parsed-form"
? express.urlencoded({ extended: false })
: bodyKind === "raw-text"
? express.text({ type: "*/*" })
: express.raw({ type: "*/*" }),
);
routes.post("/chat-webhooks/:publicId/:provider", (req, res) => {
res.setHeader("x-provider-diagnostic", canaries.sdk);
const sdkError = Object.assign(
new Error(`${canaries.sdk}: ${canaries.text} ${canaries.token}`),
{
name: canaries.filename,
request: { body: req.body },
response: { data: canaries.text },
},
);
if (failureMode === "context-error") {
(res as any).__errorContext = {
error: {
message: sdkError.message,
stack: sdkError.stack,
name: sdkError.name,
raw: sdkError,
},
reqBody: req.body,
reqParams: { ...req.params, private: canaries.filename },
reqQuery: { text: canaries.text },
};
}
if (failureMode !== "warning") (res as any).err = sdkError;
res.status(failureMode === "warning" ? 401 : 503).end();
});
app.use("/api", routes);
const contentType = bodyKind.includes("form")
? "application/x-www-form-urlencoded"
: bodyKind === "raw-text"
? "text/plain"
: "application/json";
const wireBody = bodyKind.includes("form")
? new URLSearchParams({ payload: JSON.stringify(payload) }).toString()
: JSON.stringify(payload);
const response = await request(app)
.post("/api/chat-webhooks/endpoint-1/slack")
.set("Content-Type", contentType)
.send(wireBody);
expect(response.status).toBe(failureMode === "warning" ? 401 : 503);
const output = chunks.join("");
const log = JSON.parse(output.trim());
// Structural absence catches Buffer's numeric-byte representation too;
// matching plaintext canaries alone would miss that encoding of the body.
expect(log.reqBody).toBe("[REDACTED]");
expect(log.reqParams).toBeUndefined();
expect(log.req.body).toBeUndefined();
expect(log.req.params).toBeUndefined();
expect(log.req.query).toBeUndefined();
expect(log.req.method).toBe("POST");
expect(log.res.statusCode).toBe(response.status);
expect(log.res.headers).toBeUndefined();
expect(log.responseTime).toEqual(expect.any(Number));
expect(log.level).toBe(failureMode === "warning" ? 40 : 50);
for (const canary of Object.values(canaries))
expect(output).not.toContain(canary);
if (failureMode !== "warning") {
expect(log.msg).toMatch(/503 — request failed$/);
expect(log.err.message).toBe("Chat webhook request failed");
expect(log.err.request).toBeUndefined();
expect(log.errorContext).toEqual({ name: "Error" });
}
},
);
it.each(["slack", "github", "discord", "telegram", "microsoft-teams"])(
"keeps %s webhook request serialization and error-handler SDK prose content-free",
async (provider) => {
const privateText = "private-serialized-webhook-text-canary-8124";
const privateFile = "private-serialized-webhook-file-canary-2443.png";
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
});
const app = express();
const routes = express.Router();
routes.use(express.raw({ type: "*/*" }));
routes.use(
createHttpLogger(pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream)),
);
routes.post("/chat-webhooks/:publicId/:provider", (req, _res, next) => {
req.params.extra = privateFile;
req.log.warn({ req }, "Webhook fixture rejected");
const error = Object.assign(new Error(`SDK echoed ${privateText}`), {
name: privateFile,
});
next(error);
});
app.use("/api", routes);
app.use(errorHandler);
const response = await request(app)
.post(`/api/chat-webhooks/endpoint-1/${provider}`)
.set("Content-Type", "application/json")
.send(JSON.stringify({ text: privateText, file: privateFile }));
expect(response.status).toBe(500);
const output = chunks.join("");
expect(output).not.toContain(privateText);
expect(output).not.toContain(privateFile);
const logs = output
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(logs).toHaveLength(2);
for (const log of logs) {
expect(log.req.method).toBe("POST");
expect(log.req.params).toBeUndefined();
expect(log.req.body).toBeUndefined();
}
expect(logs[1].res.statusCode).toBe(500);
expect(logs[1].reqBody).toBe("[REDACTED]");
expect(logs[1].errorContext).toEqual({ name: "Error" });
expect(logs[1].err.message).toBe("Chat webhook request failed");
},
);
it("preserves ordinary request diagnostics outside the exact webhook route", async () => {
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
});
const app = express();
app.use(express.json());
app.use(
createHttpLogger(pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream)),
);
app.post("/api/issues/:id", (req, res) => {
(res as any).__errorContext = {
error: { name: "Error", message: "ordinary diagnostic" },
reqBody: req.body,
reqParams: req.params,
};
res.status(422).end();
});
await request(app)
.post("/api/issues/issue-1")
.send({ title: "ordinary task", token: "redact-me" });
const log = JSON.parse(chunks.join("").trim());
expect(log.reqBody).toEqual({
title: "ordinary task",
token: "[REDACTED]",
});
expect(log.reqParams).toEqual({ id: "issue-1" });
expect(log.errorContext).toEqual({
name: "Error",
message: "ordinary diagnostic",
});
});
it("defines the HTTP auth and cookie header paths that must be redacted", () => {
expect(HTTP_LOG_REDACT_PATHS).toContain("req.headers.authorization");
expect(HTTP_LOG_REDACT_PATHS).toContain("req.headers.cookie");
expect(HTTP_LOG_REDACT_PATHS).toContain('req.headers["set-cookie"]');
expect(HTTP_LOG_REDACT_PATHS).toContain('res.headers["set-cookie"]');
expect(HTTP_LOG_REDACT_PATHS).toContain(
'req.headers["proxy-authorization"]',
);
expect(HTTP_LOG_REDACT_PATHS).toContain('req.headers["x-csrf-token"]');
expect(HTTP_LOG_REDACT_PATHS).toContain('req.headers["x-xsrf-token"]');
expect(HTTP_LOG_REDACT_PATHS).toContain('req.headers["x-api-key"]');
expect(HTTP_LOG_REDACT_PATHS).toContain(
'req.headers["x-telegram-bot-api-secret-token"]',
);
expect(HTTP_LOG_REDACT_PATHS).toContain("reqBody.credentials");
expect(HTTP_LOG_REDACT_PATHS).toContain("errorContext.details.credentials");
});
it("redacts request and response header secrets from pino-http output", async () => {
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
});
const logger = pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream);
const httpLogger = pinoHttp({ logger });
const server = createServer((req, res) => {
httpLogger(req, res);
res.setHeader("set-cookie", "sid=response-secret");
res.end("ok");
});
try {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Expected server to listen on an ephemeral TCP port");
}
await new Promise<void>((resolve, reject) => {
const client = httpRequest(
{
hostname: "127.0.0.1",
port: address.port,
path: "/api/chat-webhooks/endpoint-1/telegram",
headers: {
authorization: "Bearer auth-secret",
cookie: "sid=request-secret",
"set-cookie": "proxy-secret",
"x-telegram-bot-api-secret-token":
"telegram-webhook-canary-534c28",
},
},
(res) => {
res.resume();
res.on("end", resolve);
},
);
client.on("error", reject);
client.end();
});
await new Promise((resolve) => setImmediate(resolve));
} finally {
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
}
const output = chunks.join("");
expect(output).not.toMatch(
/auth-secret|request-secret|proxy-secret|response-secret|telegram-webhook-canary-534c28/,
);
const log = JSON.parse(output.trim()) as {
req: { headers: Record<string, string> };
res: { headers: Record<string, string> };
};
expect(log.req.headers.authorization).toBe("[Redacted]");
expect(log.req.headers.cookie).toBe("[Redacted]");
expect(log.req.headers["set-cookie"]).toBe("[Redacted]");
expect(log.req.headers["x-telegram-bot-api-secret-token"]).toBe(
"[Redacted]",
);
expect(log.res.headers["set-cookie"]).toBe("[Redacted]");
});
it.each([200, 403, 500])("redacts runtime GitHub capabilities from HTTP %i logs", async (status) => {
const capability = "runtime-github-capability-canary";
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
});
const app = express();
app.use(createHttpLogger(pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream)));
app.post("/runtime-tools/github/credentials", (_req, res) => {
res.status(status).json({ status });
});
await request(app)
.post("/runtime-tools/github/credentials")
.set("X-Paperclip-Github-Capability", capability)
.send({})
.expect(status);
const output = chunks.join("");
expect(output).not.toContain(capability);
const log = JSON.parse(output.trim());
expect(log.req.headers["x-paperclip-github-capability"]).toBe("[Redacted]");
expect(log.req.url).toBe("/runtime-tools/github/credentials");
expect(log.res.statusCode).toBe(status);
});
it.each([200, 403, 500])("redacts cloud credentials and assertions from HTTP %i logs", async (status) => {
const headers = {
"X-Paperclip-Cloud-Tenant-Token": "cloud-tenant-token-canary",
"X-Paperclip-Cloud-Session-Id": "cloud-session-id-canary",
"X-Paperclip-Cloud-Runtime-Identity": "cloud-runtime-identity-canary",
"X-Paperclip-Cloud-Control": "cloud-control-canary",
};
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
});
const app = express();
app.use(createHttpLogger(pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream)));
app.get("/api/companies", (_req, res, next) => {
if (status === 403) {
next(new HttpError(403, "Cloud tenant authentication required"));
return;
}
if (status === 500) {
next(new Error("Synthetic cloud request failure"));
return;
}
res.status(status).json({ status });
});
app.use(errorHandler);
const response = await request(app).get("/api/companies").set(headers).expect(status);
if (status === 403) {
expect(response.body).toEqual({ error: "Cloud tenant authentication required" });
} else if (status === 500) {
expect(response.body).toEqual({ error: "Internal server error" });
}
const output = chunks.join("");
const log = JSON.parse(output.trim());
for (const [header, secret] of Object.entries(headers)) {
expect(output).not.toContain(secret);
expect(log.req.headers[header.toLowerCase()]).toBe("[Redacted]");
}
expect(log.req.method).toBe("GET");
expect(log.req.url).toBe("/api/companies");
expect(log.res.statusCode).toBe(status);
expect(log.level).toBe(status === 500 ? 50 : status === 403 ? 40 : 30);
if (status === 500) {
expect(log.errorContext.message).toBe("Synthetic cloud request failure");
expect(log.err.message).toBe("Synthetic cloud request failure");
}
});
it("drops OAuth callback query data from the message and structured request", async () => {
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
});
const testLogger = pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream);
const app = express();
app.use(createHttpLogger(testLogger));
app.get("/api/tools/oauth/callback", (_req, res) => {
res.status(400).json({ error: "callback rejected" });
});
const authorizationCode = "oauth-code-canary-61a88f";
const providerProse = "provider-prose-canary-2087e2";
const providerUriCanary = "provider-uri-canary-d91ac4";
const response = await request(app)
.get("/api/tools/oauth/callback")
.query({
code: authorizationCode,
error_description: providerProse,
error_uri: `https://provider.example/error?detail=${providerUriCanary}`,
});
expect(response.status).toBe(400);
const output = chunks.join("");
expect(output).not.toMatch(
new RegExp(`${authorizationCode}|${providerProse}|${providerUriCanary}`),
);
const log = JSON.parse(output.trim()) as {
msg: string;
req: { method: string; url: string; query?: unknown };
reqQuery?: unknown;
};
expect(log.msg).toBe("GET /api/tools/oauth/callback 400");
expect(log.req).toMatchObject({
method: "GET",
url: "/api/tools/oauth/callback",
});
expect(log.req.query).toBeUndefined();
expect(log.reqQuery).toBeUndefined();
});
it("redacts failed secret payload values from structured request logs", async () => {
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
});
const testLogger = pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream);
const app = express();
app.use(express.json());
app.use(createHttpLogger(testLogger));
app.post("/api/companies/:companyId/secrets", (_req, res) => {
res.status(422).json({ error: "validation failed" });
});
const response = await request(app)
.post("/api/companies/company-1/secrets")
.send({
name: "OpenAI",
value: "value-canary-4c845d",
metadata: { token: "token-canary-902ffc" },
});
expect(response.status).toBe(422);
const output = chunks.join("");
expect(output).not.toMatch(/value-canary-4c845d|token-canary-902ffc/);
const log = JSON.parse(output.trim()) as {
reqBody: Record<string, unknown>;
};
expect(log.reqBody).toEqual({
name: "OpenAI",
value: "[REDACTED]",
metadata: { token: "[REDACTED]" },
});
});
it("redacts every credential from serialized chat setup 422 and 500 logs", async () => {
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
});
const testLogger = pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream);
const app = express();
app.use(express.json());
app.use(createHttpLogger(testLogger));
const routes = express.Router();
routes.post("/chat-endpoints/:endpointId/setup", (req, res, next) => {
const mode = req.header("x-test-mode");
if (mode === "generic-500") {
const error = new Error(
`synthetic provider failure echoed ${req.body.credentials.botToken}`,
);
error.name = `SecretName-${req.body.credentials.signingSecret}`;
next(error);
return;
}
if (mode === "http-500") {
next(
new HttpError(
500,
`synthetic HTTP failure echoed ${req.body.credentials.webhookSecret}`,
{ credentials: req.body.credentials },
),
);
return;
}
next(
new HttpError(
422,
`synthetic validation failure echoed ${req.body.credentials.privateKey}`,
{ credentials: req.body.credentials },
),
);
});
routes.post(
"/chat-endpoints/:endpointId/setup-secret",
(req, _res, next) => {
next(
new Error(`synthetic rotation failure echoed ${req.body.bot_token}`),
);
},
);
app.use("/api", routes);
app.use(errorHandler);
const credentials = {
botToken: "bot-token-canary-bf231a",
signingSecret: "signing-secret-canary-0f861d",
webhookSecret: "webhook-secret-canary-54c112",
privateKey: "private-key-canary-26ec43",
clientSecret: "client-secret-canary-944088",
arbitraryFutureCredential: "future-credential-canary-5e6941",
};
const outsideEnvelope = {
bot_token: "snake-bot-canary-512c31",
signing_secret: "snake-signing-canary-efb11f",
webhook_secret: "snake-webhook-canary-415b14",
secret_token: "snake-secret-token-canary-3ba19f",
app_secret: "snake-app-canary-fd29eb",
application_secret: "snake-application-canary-42bc91",
};
const canaries = [
...Object.values(credentials),
...Object.values(outsideEnvelope),
];
const validationResponse = await request(app)
.post("/api/chat-endpoints/endpoint-1/setup")
.send({ action: "configure", credentials, diagnostic: outsideEnvelope });
const genericCrashResponse = await request(app)
.post("/api/chat-endpoints/endpoint-1/setup")
.set("x-test-mode", "generic-500")
.send({ action: "configure", credentials, diagnostic: outsideEnvelope });
const httpCrashResponse = await request(app)
.post("/api/chat-endpoints/endpoint-1/setup")
.set("x-test-mode", "http-500")
.send({ action: "configure", credentials, diagnostic: outsideEnvelope });
const setupSecretCrashResponse = await request(app)
.post("/api/chat-endpoints/endpoint-1/setup-secret")
.send({ bot_token: outsideEnvelope.bot_token });
expect(validationResponse.status).toBe(422);
expect(genericCrashResponse.status).toBe(500);
expect(httpCrashResponse.status).toBe(500);
expect(setupSecretCrashResponse.status).toBe(500);
for (const response of [
validationResponse,
genericCrashResponse,
httpCrashResponse,
setupSecretCrashResponse,
]) {
for (const canary of canaries) {
expect(JSON.stringify(response.body)).not.toContain(canary);
}
}
expect(validationResponse.body).toMatchObject({
error: "synthetic validation failure echoed [REDACTED]",
details: { credentials: "[REDACTED]" },
});
expect(httpCrashResponse.body).toEqual({ error: "Internal server error" });
const output = chunks.join("");
for (const canary of canaries) {
expect(output).not.toContain(canary);
}
const logs = output
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line)) as Array<{
msg: string;
res: { statusCode: number };
req: { url: string };
reqBody: Record<string, unknown>;
errorContext?: Record<string, unknown>;
}>;
expect(logs).toHaveLength(4);
const setupLogs = logs.filter((log) => log.req.url.endsWith("/setup"));
expect(setupLogs).toHaveLength(3);
for (const log of setupLogs) {
expect(log.reqBody).toEqual({
action: "configure",
credentials: "[Redacted]",
diagnostic: Object.fromEntries(
Object.keys(outsideEnvelope).map((key) => [key, "[REDACTED]"]),
),
});
}
const crashLogs = logs.filter((log) => log.res.statusCode === 500);
expect(crashLogs).toHaveLength(3);
for (const log of crashLogs) {
expect(log.msg).toMatch(/ 500 — request failed$/);
expect(log.errorContext).toEqual({ name: "Error" });
}
expect(
logs.find((log) => log.req.url.endsWith("/setup-secret"))?.reqBody,
).toEqual({
bot_token: "[REDACTED]",
});
});
it.each([400, 500])(
"redacts the complete probe credential container on HTTP %s",
async (status) => {
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
});
const app = express();
app.use(express.json());
app.use(
createHttpLogger(pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream)),
);
app.post("/probe", (req, res) => {
if (status === 500) {
(res as any).__errorContext = {
error: { message: "probe failed" },
reqBody: req.body,
};
}
res.status(status).json({ error: "probe failed" });
});
const keys = Object.keys(
testAdapterEnvironmentSchema.shape.testCredentials.unwrap().shape,
);
const credentials = Object.fromEntries(
[...keys, "UNKNOWN_PROVIDER_KEY"].map((key) => [key, `canary-${key}`]),
);
await request(app)
.post("/probe")
.send({
adapterConfig: { model: "default" },
testCredentials: credentials,
});
const output = chunks.join("");
expect(output).not.toContain("canary-");
expect(JSON.parse(output.trim()).reqBody).toEqual({
adapterConfig: { model: "default" },
testCredentials: "[REDACTED]",
});
},
);
});