mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Chat connectors give each agent a customer-owned bot and task-backed conversations. > - Manual Slack setup requires app creation and copying durable credentials. > - Operators need a shorter setup that an assisting agent can use safely. > - This pull request creates the app through Slack's Manifest API and installs it through OAuth. > - Durable registration state supports recovery without creating another app. > - A four-screen wizard, automatic avatar upload, and OAuth account linking reduce setup work. > - Connector settings and per-turn tool guidance support daily use after installation. ## Linked Issues or Issue Description **Subsystem affected** Native Slack bot setup, company secret storage, chat connector management, and agent tool guidance. **Problem or motivation** New Slack bots require manual app creation and copying a signing secret and bot token. Interrupted setup can create duplicate apps. The setup and management screens contain unnecessary controls. Agents also need guidance for native questions, files, thread replies, and governed Slack actions. **Proposed solution** Use a temporary app-configuration access token to create a customer-owned app. Save durable secrets in the vault. Bind OAuth to the initiating actor, company, endpoint, registration revision, scopes, and configured origins. Preserve manual and existing-app recovery. Link the installing user's account, send a welcome DM, and advance from saved server evidence. Keep request-URL recovery instructions available if automatic connection detection waits. **Alternatives considered** The Slack CLI adds installation requirements. Socket Mode changes transport. A shared Paperclip-owned app changes app ownership. These alternatives are outside this change. **Roadmap alignment** This extends existing chat connectors and secrets capabilities. Related public work: #14037 and #13954 cover Slack MCP prerequisites and user OAuth. No duplicate bot-registration PR was found. ## What Changed - Share one reviewed manifest builder between automatic registration and manual setup. - Add replay-safe migration 0318 and company-bound registration state with vault references and uncertain-creation recovery. - Add registration, installation, callback, and resume APIs with short-lived, single-use OAuth state. - Save installation credentials before downstream checks and preserve bot identity constraints. - Reduce automatic setup to four screens. Keep advanced app details, manual recovery, and existing-app setup. - Upload the agent avatar with the Paperclip dark background. Link the OAuth installer's account and send setup DMs. - Show agent and connector-owner avatars. Simplify settings, access, and conversation screens. - Discover joined Slack channels and enable them by default. Start a task from a bare mention and admit same-thread follow-ups. - Refresh Slack tool guidance each turn. Add native-form, file, approval, and delivery regressions plus manual model probe definitions and sanitized acceptance records. - Update deployment/database docs, OpenAPI, redaction, removal cleanup, production Storybook stories, and provider browser tests. - Merge current master and move the registration migration after its latest migration without rewriting published commits. The completed Slack success view intentionally has a single centered **Done** action and no **Save & exit**, as explicitly requested by the product owner. `DESIGN.md` records this exception; unfinished setup steps retain the aligned wizard footer. ## Verification - Passed after the master merge: repository typecheck, full build, Storybook build, design-token gates, module-boundary gates, and migration generation. - Passed: all 352 focused Slack deterministic tests and all 14 affected provider browser tests. Browser tests use controlled provider fixtures and a separate throwaway instance. - Passed on current head `c5d01e0e2`: the complete GitHub test matrix (general server, chat, all workspaces, serialized server, and Runner), all eight browser shards, typecheck/release registry, build, canary dry run, security checks, and policy gates. There are 52 passing checks and no pending or failing checks. - Greptile completed on the exact current head with 5/5 and no actionable findings or open review threads. - Local repair verification passed 93 focused tests, including same-app reinstall after revocation and rejection of consent started before revocation, the AgentMail browser journey, and repository typecheck. Local build and Storybook build also passed. The redundant local full-suite rerun was stopped after the complete current-head CI matrix passed. - Real Slack setup and agent replies were exercised in the authorized isolated test drive during the setup iteration. - The ten additional model probes were attempted with legacy `codex_local`, `gpt-5.6-sol`: five passed, two failed, and three were partly verified. Native runtime is not qualified. See `server/src/services/connectors/slack/evals/2026-10-08-acceptance.md` for evidence and limits. - Passing model probes cover native forms, downloaded file bytes, bare mentions with thread replies, explicit posts/reactions, and saved approval denial. - The controlled uncertain-write probe found wrong delivery-check IDs. The canvas fallback attempt used an invented tool name. Search pagination/native search, a private-source denied-tool receipt, and distinct board/webhook origins remain unqualified. Reviewer path: enable Chat connectors, start Slack chat setup, select an agent, enter an app-configuration access token, and approve Slack installation. Send a message to the bot and confirm that setup advances to success. Inspect settings and allowed channels. See `doc/connections/SLACK-AUTOMATIC-SETUP.md` for deployment and recovery. ## Risks - Slack app creation has no provider idempotency guarantee. A timeout after dispatch stays uncertain until the operator checks Slack. - OAuth needs a stable public HTTPS board origin. Webhook ingress may use a separate configured HTTPS origin. Workspace policy can delay installation. - Migration 0318 can replay safely on instances that applied the earlier development migration. - OAuth installation now links the installer to the initiating Paperclip user. Identity checks and company access rules still apply. - Joined channels now enable bot responses by default. Linked-user authorization and per-action approval rules still apply. - Model behavior has the documented delivery-check and canvas fallback failures. A passing CI run does not establish that every model probe passed. - Removing the connection does not delete the customer's Slack app. No new first-party telemetry is added. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository tools, code execution, and browser verification. The runtime does not expose a more specific authoring model ID or context-window size. The live bot probes used OpenAI `gpt-5.6-sol` through `codex_local` in legacy mode. ## 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 - [x] All Paperclip CI gates are green - [x] 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>
830 lines
32 KiB
TypeScript
830 lines
32 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.each([200, 400, 500])("keeps Slack setup credentials and provider echoes out of %i logs", async status => {
|
|
const canaries = ["configuration-canary", "signing-canary", "client-canary", "bot-canary", "oauth-code-canary", "provider-echo-canary"];
|
|
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.use((req, res, next) => {
|
|
if (status === 500) { next(new Error(canaries.join(" "))); return; }
|
|
res.status(status).json({ ok: status === 200 });
|
|
});
|
|
app.use(errorHandler);
|
|
const responses = [];
|
|
for (const suffix of ["registration", "install", "resume"]) {
|
|
responses.push(await request(app).post(`/api/chat-endpoints/endpoint/slack/${suffix}`).send({
|
|
credentials: { configurationToken: canaries[0], signingSecret: canaries[1], clientSecret: canaries[2], botToken: canaries[3] },
|
|
}));
|
|
}
|
|
responses.push(await request(app).get("/api/chat-slack/oauth/callback").query({ code: canaries[4], error_description: canaries[5] }));
|
|
for (const response of responses) expect(response.status).toBe(status);
|
|
const output = JSON.stringify({ logs: chunks, responses: responses.map(response => response.body) });
|
|
for (const canary of canaries) expect(output).not.toContain(canary);
|
|
});
|
|
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]",
|
|
});
|
|
},
|
|
);
|
|
});
|