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>
109 lines
3.9 KiB
TypeScript
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,
|
|
};
|
|
},
|
|
};
|