disclosure-bureau/web/lib/chat/claude-code.ts
Luiz Gustavo 55cac8a395
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 1m30s
CI / Scripts — Python smoke (push) Failing after 32s
CI / Web — npm audit (push) Failing after 37s
W0+W1+W1.2: security hardening, observability, autocomplete, glitchtip, forgejo CI
W0 — security hardening (5 fixes verified live on disclosure.top)
- middleware: gate /api/admin/* same as /admin/* (F1)
- imgproxy: tighten LOCAL_FILESYSTEM_ROOT from / to /var/lib/storage (F2)
- studio: real basic-auth label (bcrypt hash, middleware reference) (F3)
- relations: ENABLE ROW LEVEL SECURITY + public SELECT policy (F4)
- migration 0003: fold is_searchable + hybrid_search update into canonical (TD#2)

W1 — observability + resilience + autocomplete
- studio: HOSTNAME=0.0.0.0 so Next.js binds on loopback for healthcheck
- compose: PG_POOL_MAX=20, CLAUDE_CODE_OAUTH_TOKEN gated by separate env
- claude-code.ts: subprocess timeout configurable (CLAUDE_CODE_TIMEOUT_MS)
- openrouter.ts: retry with exponential backoff + Retry-After + in-memory
  circuit breaker (promotes FALLBACK after CB_THRESHOLD failures)
- lib/logger.ts: pino logger (NDJSON prod / pretty dev) + withRequest helper
- middleware: mints correlation_id, stamps x-correlation-id response header,
  emits structured http_request log per /api/* call
- messages/route.ts: switch to structured logger
- 60_meili_index.py: push documents + chunks into Meilisearch
- /api/search/autocomplete: parallel meili search (docs + chunks), 5-8ms p50
- search-autocomplete.tsx: debounced dropdown wired into search-panel

W1.2 — Glitchtip + Forgejo self-hosted
- compose: glitchtip-redis + glitchtip-web + glitchtip-worker (v4.2)
- compose: forgejo + forgejo-runner (server v9, runner v6) with group_add=988
- @sentry/nextjs SDK wired (instrumentation.ts + sentry.{client,server}.config.ts)
- /api/admin/throw smoke endpoint (gated by W0-F1 middleware)
- Synthetic event ingestion verified at glitchtip.disclosure.top
- forgejo.disclosure.top up, repo discadmin/disclosure-bureau created,
  runner registered (labels: ubuntu-latest, docker)
- .forgejo/workflows/ci.yml: typecheck + lint + build + npm audit + python
  syntax + compose validation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:18:42 -03:00

109 lines
3.9 KiB
TypeScript

/**
* Claude Code provider — OAuth via CLAUDE_CODE_OAUTH_TOKEN.
*
* Spawns the `claude` CLI as subprocess (same pattern as scripts/02-vision-page.py).
* The CLI reads CLAUDE_CODE_OAUTH_TOKEN from env and uses it instead of the
* credentials file. NEVER uses ANTHROPIC_API_KEY — this project forbids it.
*
* Requires the `claude` binary in PATH. In Docker, install with:
* RUN curl -fsSL https://claude.ai/install.sh | bash
*/
import { spawn } from "node:child_process";
import type { ChatProvider, ChatRequest, ChatResponse } from "./types";
const MODEL = process.env.CLAUDE_CODE_MODEL || "haiku";
// W1-TD#30: subprocess timeout is now configurable. Default 90s matches the
// previous hard-coded value. Lower it (e.g. 60s) when the provider should bail
// out of slow generations sooner; raise it (e.g. 180s) when running heavier
// models like opus on long contexts.
const TIMEOUT_MS = Number(process.env.CLAUDE_CODE_TIMEOUT_MS || 90_000);
function buildPrompt(req: ChatRequest): string {
// Single-shot prompt: collapse history into a structured transcript.
const parts: string[] = [];
parts.push(req.system);
parts.push("\n\n# CONVERSATION HISTORY\n");
const recent = req.messages.slice(-20);
for (const m of recent.slice(0, -1)) {
parts.push(`${m.role.toUpperCase()}: ${m.content}`);
}
const last = recent[recent.length - 1];
if (last) parts.push(`\nUSER ASKS NOW: ${last.content}`);
return parts.join("\n");
}
export const claudeCodeProvider: ChatProvider = {
name: "claude-code",
isAvailable: () => Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN),
async send(req: ChatRequest): Promise<ChatResponse> {
const prompt = buildPrompt(req);
const t0 = Date.now();
const text = await new Promise<{ result: string; durationMs: number; tokensIn?: number; tokensOut?: number; costUsd?: number }>((resolve, reject) => {
const child = spawn(
"claude",
[
"-p",
"--model", MODEL,
"--output-format", "json",
"--max-turns", "3",
"--",
prompt,
],
{
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env }, // forwards CLAUDE_CODE_OAUTH_TOKEN
},
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (c) => (stdout += c.toString()));
child.stderr.on("data", (c) => (stderr += c.toString()));
const t = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`claude CLI timeout > ${TIMEOUT_MS}ms`));
}, TIMEOUT_MS);
child.on("error", (e) => {
clearTimeout(t);
reject(new Error(`claude CLI spawn failed: ${e.message}`));
});
child.on("close", (code) => {
clearTimeout(t);
if (code !== 0) {
// Detect rate limit shape — surface a typed error
const msg = stderr.slice(-500) || `rc=${code}`;
const err = new Error(`claude-code rc=${code}: ${msg}`);
if (/usage limit|rate.?limit|429/i.test(stderr)) {
(err as Error & { isRateLimit?: boolean }).isRateLimit = true;
}
return reject(err);
}
try {
const cli = JSON.parse(stdout);
if (cli.is_error) {
return reject(new Error(`claude-code reported error: ${cli.result?.slice(0, 300)}`));
}
resolve({
result: cli.result || "",
durationMs: cli.duration_ms || Date.now() - t0,
tokensIn: cli.usage?.input_tokens,
tokensOut: cli.usage?.output_tokens,
costUsd: cli.total_cost_usd,
});
} catch (e) {
reject(new Error(`claude-code stdout parse: ${e instanceof Error ? e.message : String(e)}`));
}
});
});
return {
provider: "claude-code",
model: MODEL,
content: text.result,
tokensIn: text.tokensIn,
tokensOut: text.tokensOut,
costUsd: text.costUsd,
durationMs: text.durationMs,
};
},
};