codehere 0.8.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +37 -184
  2. package/dist/application/use-cases/check-claimed-tests.js +81 -0
  3. package/dist/application/use-cases/detect-test-weakening.js +237 -0
  4. package/dist/application/use-cases/rerun-tests.js +329 -0
  5. package/dist/index.js +74 -1004
  6. package/dist/infrastructure/orchestration/artifacts-extractor.js +2 -265
  7. package/dist/presentation/cli/commands/check-command.js +251 -0
  8. package/dist/presentation/cli/commands/help-command.js +22 -286
  9. package/dist/ui.js +19 -438
  10. package/dist/utils/version.js +0 -12
  11. package/package.json +18 -27
  12. package/dist/application/use-cases/domain-trust.js +0 -110
  13. package/dist/application/use-cases/generate-final-report.js +0 -134
  14. package/dist/application/use-cases/generate-narration.js +0 -128
  15. package/dist/application/use-cases/generate-thread-title.js +0 -178
  16. package/dist/application/use-cases/narrator-prompt-template.js +0 -158
  17. package/dist/application/use-cases/thread-export.js +0 -155
  18. package/dist/application/use-cases/verify-claim.js +0 -274
  19. package/dist/application/use-cases/write-pass.js +0 -277
  20. package/dist/domain/entities/task.js +0 -61
  21. package/dist/domain/services/codebase-detector.js +0 -91
  22. package/dist/error-handler.js +0 -251
  23. package/dist/infrastructure/completion/bash-completion.js +0 -25
  24. package/dist/infrastructure/completion/fish-completion.js +0 -22
  25. package/dist/infrastructure/completion/zsh-completion.js +0 -30
  26. package/dist/infrastructure/config/config-manager.js +0 -173
  27. package/dist/infrastructure/config/product-context.js +0 -67
  28. package/dist/infrastructure/context/adapters/drawio-source.js +0 -157
  29. package/dist/infrastructure/context/adapters/figma-source.js +0 -163
  30. package/dist/infrastructure/context/adapters/github-issue-source.js +0 -159
  31. package/dist/infrastructure/context/adapters/linear-source.js +0 -190
  32. package/dist/infrastructure/context/context-resolver.js +0 -144
  33. package/dist/infrastructure/context/context-source.js +0 -114
  34. package/dist/infrastructure/context/oauth-flow.js +0 -207
  35. package/dist/infrastructure/context/oauth-store.js +0 -119
  36. package/dist/infrastructure/context/registry-bootstrap.js +0 -99
  37. package/dist/infrastructure/context/write-pass-resolver.js +0 -106
  38. package/dist/infrastructure/cost/budget-config.js +0 -113
  39. package/dist/infrastructure/cost/cost-router.js +0 -104
  40. package/dist/infrastructure/cost/spend-aggregator.js +0 -147
  41. package/dist/infrastructure/hooks/claude-sdk-hook.js +0 -888
  42. package/dist/infrastructure/hooks/hook-mode.js +0 -71
  43. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +0 -43
  44. package/dist/infrastructure/hooks/pre-prompt-scan.js +0 -122
  45. package/dist/infrastructure/hooks/session-analyzer.js +0 -177
  46. package/dist/infrastructure/observability/execution-tracer.js +0 -227
  47. package/dist/infrastructure/orchestration/agent-adapter.js +0 -13
  48. package/dist/infrastructure/orchestration/agent-installer.js +0 -101
  49. package/dist/infrastructure/orchestration/agents-config-seed.js +0 -56
  50. package/dist/infrastructure/orchestration/aider-adapter.js +0 -447
  51. package/dist/infrastructure/orchestration/cassette-recorder.js +0 -120
  52. package/dist/infrastructure/orchestration/claude-code-adapter.js +0 -478
  53. package/dist/infrastructure/orchestration/codex-adapter.js +0 -538
  54. package/dist/infrastructure/orchestration/conductor-adapter.js +0 -377
  55. package/dist/infrastructure/orchestration/context-handoff.js +0 -144
  56. package/dist/infrastructure/orchestration/control/codex-control-surface.js +0 -99
  57. package/dist/infrastructure/orchestration/control/control-surface.js +0 -15
  58. package/dist/infrastructure/orchestration/failover-classifier.js +0 -151
  59. package/dist/infrastructure/orchestration/failover-config.js +0 -77
  60. package/dist/infrastructure/orchestration/failover-policy.js +0 -106
  61. package/dist/infrastructure/orchestration/gemini-adapter.js +0 -583
  62. package/dist/infrastructure/orchestration/generic-cli-adapter.js +0 -558
  63. package/dist/infrastructure/orchestration/graphify-context.js +0 -254
  64. package/dist/infrastructure/orchestration/memory-injector.js +0 -380
  65. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +0 -233
  66. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +0 -73
  67. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +0 -83
  68. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +0 -37
  69. package/dist/infrastructure/orchestration/opencode-adapter.js +0 -510
  70. package/dist/infrastructure/orchestration/progress-aggregator.js +0 -689
  71. package/dist/infrastructure/orchestration/readiness.js +0 -103
  72. package/dist/infrastructure/orchestration/session-manager.js +0 -1091
  73. package/dist/infrastructure/orchestration/session-persistence.js +0 -258
  74. package/dist/infrastructure/orchestration/thread-key-store.js +0 -127
  75. package/dist/infrastructure/orchestration/write-pass-store.js +0 -46
  76. package/dist/infrastructure/protocols/cloud-sync.js +0 -207
  77. package/dist/infrastructure/protocols/mcp-helpers.js +0 -25
  78. package/dist/infrastructure/protocols/mcp-server-impl.js +0 -2853
  79. package/dist/infrastructure/protocols/probe-port.js +0 -36
  80. package/dist/infrastructure/protocols/thread-phase.js +0 -105
  81. package/dist/infrastructure/protocols/ui-page-v2.js +0 -4418
  82. package/dist/infrastructure/protocols/ui-page.js +0 -2377
  83. package/dist/infrastructure/protocols/verify-page.js +0 -983
  84. package/dist/infrastructure/security/file-collection.js +0 -118
  85. package/dist/infrastructure/security/input-validator.js +0 -152
  86. package/dist/infrastructure/security/permissions-config.js +0 -234
  87. package/dist/infrastructure/security/trust-scorer.js +0 -657
  88. package/dist/infrastructure/storage/cross-tool-agents.js +0 -268
  89. package/dist/infrastructure/storage/embeddings-path.js +0 -37
  90. package/dist/infrastructure/storage/project-thread-migration.js +0 -237
  91. package/dist/infrastructure/storage/project-thread-mirror.js +0 -524
  92. package/dist/infrastructure/storage/project-thread-reader.js +0 -172
  93. package/dist/infrastructure/storage/project-thread-title-backfill.js +0 -214
  94. package/dist/infrastructure/storage/task-repository.js +0 -206
  95. package/dist/monitoring.js +0 -212
  96. package/dist/presentation/cli/commands/audit-command.js +0 -126
  97. package/dist/presentation/cli/commands/budget-command.js +0 -79
  98. package/dist/presentation/cli/commands/config-command.js +0 -125
  99. package/dist/presentation/cli/commands/context-source-command.js +0 -198
  100. package/dist/presentation/cli/commands/hook-command.js +0 -265
  101. package/dist/presentation/cli/commands/init-command.js +0 -578
  102. package/dist/presentation/cli/commands/memory-command.js +0 -1031
  103. package/dist/presentation/cli/commands/narrator-command.js +0 -101
  104. package/dist/presentation/cli/commands/permission-command.js +0 -116
  105. package/dist/presentation/cli/commands/protocols-builder.js +0 -254
  106. package/dist/presentation/cli/commands/protocols-command.js +0 -84
  107. package/dist/presentation/cli/commands/repo-map-builder.js +0 -247
  108. package/dist/presentation/cli/commands/report-command.js +0 -387
  109. package/dist/presentation/cli/commands/scan-tool-command.js +0 -94
  110. package/dist/presentation/cli/commands/setup-command.js +0 -307
  111. package/dist/presentation/cli/commands/status-command.js +0 -661
  112. package/dist/presentation/cli/commands/thread-command.js +0 -288
  113. package/dist/presentation/cli/commands/trust-command.js +0 -306
  114. package/dist/presentation/cli/commands/verify-command.js +0 -233
  115. package/dist/presentation/cli/error-display.js +0 -336
  116. package/dist/presentation/cli/repl.js +0 -797
  117. package/dist/presentation/cli/task-deliverable.js +0 -127
  118. package/dist/testing/cassette.js +0 -125
  119. package/dist/testing/replay-adapter.js +0 -166
@@ -1,2853 +0,0 @@
1
- /**
2
- * codehere MCP Server — Working Implementation
3
- *
4
- * Starts an HTTP server that speaks the Model Context Protocol.
5
- * Exposes codehere's trust scanning tools to any MCP-compatible client:
6
- * Claude Desktop, Cursor, Zed, Continue, Cline, and any agent that supports MCP.
7
- *
8
- * Tools exposed:
9
- * - codehere_trust — score a file or code snippet (Layer 0, no API)
10
- * - codehere_scan — scan an instruction/text for injection/threats
11
- * - codehere_review — list findings for a codebase path
12
- * - codehere_audit — read recent hook audit log entries
13
- *
14
- * Protocol: MCP over HTTP+SSE (Model Context Protocol Spec 2024-11)
15
- * Authentication: Bearer token (optional, configurable)
16
- *
17
- * Usage:
18
- * codehere serve # start on port 3773
19
- * codehere serve --port 8080 # custom port
20
- */
21
- import http from 'http';
22
- import { readFileSync, existsSync, statSync, watchFile, unwatchFile } from 'fs';
23
- import { join, relative, dirname } from 'path';
24
- import { fileURLToPath } from 'url';
25
- import { collectFilesForMCP, scoreFileForMCP } from './mcp-helpers.js';
26
- import { scanPayload, calculateRiskScore, AUDIT_FILE, verifyAuditChain } from '../hooks/claude-sdk-hook.js';
27
- import { analyzeSessions, isRealHostSession, previewFromToolInput } from '../hooks/session-analyzer.js';
28
- import { CodexControlSurface } from '../orchestration/control/codex-control-surface.js';
29
- import { getVersion } from '../../utils/version.js';
30
- import { HISTORY_HTML, THREAD_HTML } from './ui-page.js';
31
- import { TASK_HTML_V2 } from './ui-page-v2.js';
32
- import { VERIFY_HTML } from './verify-page.js';
33
- import { verifyClaim } from '../../application/use-cases/verify-claim.js';
34
- import { signBundle, verifyBundle, shortFingerprint, } from '../../application/use-cases/thread-export.js';
35
- import { loadOrCreateKeypair, trustedKeySet, trustKey } from '../orchestration/thread-key-store.js';
36
- import { loadSessionById } from '../orchestration/session-persistence.js';
37
- import { validateIntent as validateWritePassIntent, validateDecision as validateWritePassDecision, applyDecision as applyWritePassDecision, applyResponse as applyWritePassResponse, statusToAuditLine as writePassStatusToAuditLine, newPassId, WritePassError, } from '../../application/use-cases/write-pass.js';
38
- import { putPending as wpPutPending, getById as wpGetById, setStatus as wpSetStatus, listForSession as wpListForSession, listPending as wpListPending, } from '../orchestration/write-pass-store.js';
39
- import { resolveWriteRequest, WritePassResolveError } from '../context/write-pass-resolver.js';
40
- import { trustDomain as trustDomainStore } from '../../application/use-cases/domain-trust.js';
41
- import { appendAuditLog, appendOverrideAudit } from '../hooks/claude-sdk-hook.js';
42
- import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from 'fs';
43
- import { resolveContextSourceUrls } from '../context/context-resolver.js';
44
- import { getDefaultRegistry, createDefaultFetchContextWithPost } from '../context/registry-bootstrap.js';
45
- import { begin as oauthBegin, complete as oauthComplete, getProvider as getOAuthProvider, listProviders as listOAuthProviders, } from '../context/oauth-flow.js';
46
- import { saveToken, clearToken, connectionStatus } from '../context/oauth-store.js';
47
- import { SessionManager, availableAgents, readinessForAllAgents, getAdapter, allAdapters, } from '../orchestration/session-manager.js';
48
- import { isAbsolute } from 'path';
49
- import { findProjectRoot } from '../orchestration/memory-injector.js';
50
- import { scaffoldProjectAt } from '../../presentation/cli/commands/init-command.js';
51
- import { readCrossToolAgents } from '../storage/cross-tool-agents.js';
52
- import { listProjects, readProjectThread } from '../storage/project-thread-reader.js';
53
- import { mirrorReplyToThread, renameProject } from '../storage/project-thread-mirror.js';
54
- import { randomUUID, timingSafeEqual } from 'crypto';
55
- import { installAgent } from '../orchestration/agent-installer.js';
56
- import { detectReadiness } from '../orchestration/readiness.js';
57
- import { getHookMode } from '../hooks/hook-mode.js';
58
- import { addPermissionEntry, loadPermissions, removePermissionEntry } from '../security/permissions-config.js';
59
- import { computeSpendToday, estimateCostUsd } from '../cost/spend-aggregator.js';
60
- import { loadBudget, isOverBudget, budgetUsedPct } from '../cost/budget-config.js';
61
- // ── Tool definitions ─────────────────────────────────────────────────────────
62
- const TOOLS = [
63
- {
64
- name: 'codehere_trust',
65
- description: 'Scan a file or code snippet for security vulnerabilities. Returns a trust score 0-100, grade, and plain-English findings. Zero API calls — runs entirely locally.',
66
- inputSchema: {
67
- type: 'object',
68
- properties: {
69
- filepath: { type: 'string', description: 'Path to file (relative to cwd)' },
70
- code: { type: 'string', description: 'Code content to scan (alternative to filepath)' },
71
- verbose: { type: 'boolean', description: 'Include all findings with line numbers' },
72
- },
73
- },
74
- },
75
- {
76
- name: 'codehere_scan',
77
- description: 'Scan an instruction, prompt, or command text for injection attacks, credential theft attempts, destructive operations, and other threats. Returns block/allow decision with findings.',
78
- inputSchema: {
79
- type: 'object',
80
- required: ['text'],
81
- properties: {
82
- text: { type: 'string', description: 'Text to scan (instruction, prompt, command)' },
83
- },
84
- },
85
- },
86
- {
87
- name: 'codehere_review',
88
- description: 'Review all files in a directory and return trust scores. Identifies the riskiest files to focus review effort.',
89
- inputSchema: {
90
- type: 'object',
91
- properties: {
92
- path: { type: 'string', description: 'Directory path (default: current directory)' },
93
- topN: { type: 'number', description: 'Return top N riskiest files (default: 10)' },
94
- },
95
- },
96
- },
97
- {
98
- name: 'codehere_audit',
99
- description: 'Read recent hook audit log entries — shows which tool calls codehere blocked, allowed, or overrode (fail-open paths) in Claude Code sessions. Pass verify:true to instead validate the SHA-256 hash chain and return any tampering detected.',
100
- inputSchema: {
101
- type: 'object',
102
- properties: {
103
- limit: { type: 'number', description: 'Number of recent entries to return (default: 20)' },
104
- blockedOnly: { type: 'boolean', description: 'Only return blocked events' },
105
- overrideOnly: {
106
- type: 'boolean',
107
- description: 'Only return trust-override events — cases where the scanner was bypassed (stdin failure, parse failure, --log-only, scan exception). Per CC frontier Q1 (auditable permission trails) — surface every bypass for review.',
108
- },
109
- verify: {
110
- type: 'boolean',
111
- description: 'Verify the SHA-256 hash chain instead of returning entries. Returns { valid, totalEntries, chainedEntries, legacyEntries, errors[] } where errors lists every detected tampering point (modification / deletion / insertion / parse failure). Per CC frontier Q1 (cryptographic commitment + tamper evidence).',
112
- },
113
- },
114
- },
115
- },
116
- ];
117
- // ── Tool handlers ────────────────────────────────────────────────────────────
118
- import { homedir } from 'os';
119
- const controlSessions = new Map();
120
- let controlSeq = 0;
121
- /**
122
- * Dev-only behavior-observer injection (v0.6.0 — elu joined the team roster
123
- * 2026-05-03 as behavior reviewer). Activated only when CODEHERE_DEV_ELU=1
124
- * is set in the founder's local env; never in shipped releases. The script's
125
- * public key is meant for client-side embedding (analytics tracking key, not
126
- * a secret) so committing the URL is safe.
127
- *
128
- * Why runtime injection instead of editing TASK_HTML/HISTORY_HTML directly:
129
- * the templates stay identical between dev and release builds. The only
130
- * delta is the env var. This keeps the release surface clean (zero-telemetry
131
- * value per PRD §1.5 #2 stays intact) while letting elu observe founder
132
- * dogfood sessions.
133
- *
134
- * Removal trigger lives in team_roster.md memory: if elu produces zero
135
- * unique findings within one month, remove this helper + roster entry.
136
- */
137
- const ELU_DEV_SCRIPT = '<script async src="https://elu.dev/v1/elu_pk_live_HrKo9yFFLx03tz9xuSKFcE2Jpv.js"></script>';
138
- function withDevElu(html) {
139
- if (process.env.CODEHERE_DEV_ELU !== '1')
140
- return html;
141
- return html.replace('</head>', `${ELU_DEV_SCRIPT}\n</head>`);
142
- }
143
- function handleTrust(params) {
144
- const { filepath, code, verbose } = params;
145
- let content;
146
- let label;
147
- if (code) {
148
- content = code;
149
- label = 'provided code';
150
- }
151
- else if (filepath) {
152
- const resolved = join(process.cwd(), filepath);
153
- if (!existsSync(resolved)) {
154
- return JSON.stringify({ error: `File not found: ${filepath}` });
155
- }
156
- content = readFileSync(resolved, 'utf-8');
157
- label = filepath;
158
- }
159
- else {
160
- return JSON.stringify({ error: 'Provide either filepath or code' });
161
- }
162
- const result = scoreFileForMCP(label, content);
163
- if (!verbose) {
164
- // Compact response
165
- return JSON.stringify({
166
- score: result.score,
167
- grade: result.grade,
168
- summary: result.summary,
169
- criticalCount: result.findings.filter((f) => f.severity === 'critical').length,
170
- highCount: result.findings.filter((f) => f.severity === 'high').length,
171
- recommendation: result.score >= 75
172
- ? 'Safe — minor issues can be addressed in follow-up'
173
- : result.score >= 60
174
- ? 'Review recommended before merge'
175
- : 'Block — resolve critical/high findings first',
176
- });
177
- }
178
- return JSON.stringify(result, null, 2);
179
- }
180
- function handleScan(params) {
181
- const text = String(params.text ?? '');
182
- const findings = scanPayload(text);
183
- const riskScore = calculateRiskScore(findings);
184
- const criticalFindings = findings.filter((f) => f.severity === 'critical');
185
- const blocked = criticalFindings.length > 0 || riskScore >= 60;
186
- return JSON.stringify({
187
- blocked,
188
- riskScore,
189
- decision: blocked ? 'block' : 'allow',
190
- reason: blocked
191
- ? criticalFindings.length > 0
192
- ? `${criticalFindings[0].category}: ${criticalFindings[0].description}`
193
- : `Risk score ${riskScore}/100 exceeds threshold`
194
- : 'No threats detected',
195
- findings: findings.slice(0, 10), // cap at 10 to keep response small
196
- });
197
- }
198
- /**
199
- * Coerce a client-supplied numeric param to a bounded integer. Daemon
200
- * endpoints take topN/limit straight from request bodies; a negative or NaN
201
- * value silently corrupts slice() math (limit=-1 → slice keeps all-but-last,
202
- * NaN → slice returns everything). Clamp to a sane range instead of trusting
203
- * the wire.
204
- */
205
- export function clampInt(v, def, min, max) {
206
- // null/undefined = absent → default. Number(null) is 0 (finite), so without
207
- // this guard a literal null would coerce to the min instead of the default.
208
- if (v === null || v === undefined)
209
- return def;
210
- const n = Math.floor(Number(v));
211
- if (!Number.isFinite(n))
212
- return def;
213
- return Math.max(min, Math.min(n, max));
214
- }
215
- function handleReview(params) {
216
- const targetPath = String(params.path ?? process.cwd());
217
- const topN = clampInt(params.topN, 10, 1, 100);
218
- try {
219
- const files = collectFilesForMCP(targetPath);
220
- if (files.length === 0) {
221
- return JSON.stringify({ message: 'No code files found', path: targetPath });
222
- }
223
- const results = files.map((f) => scoreFileForMCP(relative(targetPath, f.filepath), f.code));
224
- results.sort((a, b) => a.score - b.score); // worst first
225
- const top = results.slice(0, topN);
226
- const overallScore = Math.round(results.reduce((s, r) => s + r.score, 0) / results.length);
227
- return JSON.stringify({
228
- overallScore,
229
- grade: overallScore >= 90 ? 'A' : overallScore >= 75 ? 'B' : overallScore >= 60 ? 'C' : overallScore >= 40 ? 'D' : 'F',
230
- totalFiles: results.length,
231
- riskiestFiles: top.map((r) => ({
232
- filepath: r.filepath,
233
- score: r.score,
234
- grade: r.grade,
235
- summary: r.summary,
236
- criticalCount: r.findings.filter((f) => f.severity === 'critical').length,
237
- highCount: r.findings.filter((f) => f.severity === 'high').length,
238
- })),
239
- });
240
- }
241
- catch (e) {
242
- return JSON.stringify({ error: String(e) });
243
- }
244
- }
245
- function handleAudit(params) {
246
- const limit = clampInt(params.limit, 20, 1, 500);
247
- const blockedOnly = Boolean(params.blockedOnly);
248
- const overrideOnly = Boolean(params.overrideOnly);
249
- const verify = Boolean(params.verify);
250
- if (verify) {
251
- // Tamper-evidence verification — walks the file, recomputes every hash,
252
- // checks the chain. Returns full report including ALL break points.
253
- const result = verifyAuditChain(AUDIT_FILE);
254
- return JSON.stringify(result, null, 2);
255
- }
256
- if (!existsSync(AUDIT_FILE)) {
257
- return JSON.stringify({
258
- message: 'No audit log yet — install hooks first with: codehere hook install',
259
- entries: [],
260
- });
261
- }
262
- const lines = readFileSync(AUDIT_FILE, 'utf-8')
263
- .split('\n')
264
- .filter(Boolean)
265
- .slice(-Math.min(limit * 3, 500)); // read more than needed to account for filter
266
- const entries = lines
267
- .map((l) => {
268
- try {
269
- return JSON.parse(l);
270
- }
271
- catch {
272
- return null;
273
- }
274
- })
275
- .filter(Boolean)
276
- .filter((e) => {
277
- if (overrideOnly)
278
- return Boolean(e.overrideReason);
279
- if (blockedOnly)
280
- return Boolean(e.blocked);
281
- return true;
282
- })
283
- .slice(-limit);
284
- return JSON.stringify({ entries, total: entries.length });
285
- }
286
- // ── JSON-RPC dispatcher ──────────────────────────────────────────────────────
287
- function dispatch(req) {
288
- const id = req.id;
289
- const params = (req.params ?? {});
290
- try {
291
- switch (req.method) {
292
- case 'initialize':
293
- return {
294
- jsonrpc: '2.0',
295
- id,
296
- result: {
297
- protocolVersion: '2024-11-05',
298
- capabilities: { tools: {} },
299
- serverInfo: { name: 'codehere', version: getVersion() },
300
- },
301
- };
302
- case 'tools/list':
303
- return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
304
- case 'tools/call': {
305
- const toolName = String(params.name ?? '');
306
- const toolParams = (params.arguments ?? {});
307
- let content;
308
- switch (toolName) {
309
- case 'codehere_trust':
310
- content = handleTrust(toolParams);
311
- break;
312
- case 'codehere_scan':
313
- content = handleScan(toolParams);
314
- break;
315
- case 'codehere_review':
316
- content = handleReview(toolParams);
317
- break;
318
- case 'codehere_audit':
319
- content = handleAudit(toolParams);
320
- break;
321
- default:
322
- return { jsonrpc: '2.0', id, error: { code: -32601, message: `Unknown tool: ${toolName}` } };
323
- }
324
- return {
325
- jsonrpc: '2.0',
326
- id,
327
- result: { content: [{ type: 'text', text: content }] },
328
- };
329
- }
330
- case 'ping':
331
- return { jsonrpc: '2.0', id, result: {} };
332
- default:
333
- return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${req.method}` } };
334
- }
335
- }
336
- catch (e) {
337
- return { jsonrpc: '2.0', id, error: { code: -32603, message: String(e) } };
338
- }
339
- }
340
- const MAX_BODY_SIZE = 2_097_152; // 2 MiB — covers 1MB code-scan payloads + framing
341
- // v0.8 S2.5 · in-memory map of pending OAuth flows keyed by `state` value.
342
- // Lifecycle: begin() inserts; callback resolves and removes; entries also
343
- // expire on a 10-min wall clock (the oauth-flow.ts complete() does the
344
- // check). Daemon restart wipes this · users just click Connect again.
345
- const PENDING_OAUTH_FLOWS = new Map();
346
- function readBody(req, res) {
347
- return new Promise((resolve, reject) => {
348
- let body = '';
349
- req.on('data', (c) => {
350
- body += c;
351
- if (body.length > MAX_BODY_SIZE) {
352
- req.pause();
353
- res.writeHead(413, { 'Content-Type': 'application/json' });
354
- res.end(JSON.stringify({ error: 'Request body too large' }));
355
- reject(new Error('oversized'));
356
- }
357
- });
358
- req.on('end', () => resolve(body));
359
- req.on('error', () => reject(new Error('request aborted')));
360
- });
361
- }
362
- /** v0.9 S6 · Load context-sources.json into the shape resolveWriteRequest
363
- * expects. Same on-disk file as the READ adapters · symmetric storage. */
364
- function loadContextSourcesConfigForWritePass() {
365
- const path = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'context-sources.json');
366
- if (!fsExistsSync(path))
367
- return {};
368
- try {
369
- const raw = JSON.parse(fsReadFileSync(path, 'utf-8'));
370
- return raw;
371
- }
372
- catch {
373
- return {};
374
- }
375
- }
376
- export function startMCPServer(options = {}) {
377
- const port = options.port ?? 3773;
378
- const host = options.host ?? '127.0.0.1'; // localhost only by default
379
- const token = options.token;
380
- const server = http.createServer((req, res) => {
381
- // v0.7 Week-2.2 · CORS for read-only public endpoints.
382
- // The landing-page daemon-status pill (codehere.uk → 127.0.0.1:3773)
383
- // fetches /health · /api/version · /api/tasks across origins to render
384
- // "running locally · vX.Y.Z · N tasks." Without these headers the
385
- // browser blocks the cross-origin fetch. Safe to allow * on these
386
- // because the endpoints expose only version + task COUNTS (no PII ·
387
- // no auth-state · no payload). Mutation endpoints (POST /api/task,
388
- // /api/writepass/:id/:decision, etc.) are NOT covered by this · they
389
- // remain same-origin-only.
390
- const url = req.url ?? '';
391
- const isReadOnlyPublic = url === '/health' ||
392
- url === '/api/version' ||
393
- url === '/api/tasks' ||
394
- url === '/api/host-sessions' ||
395
- url.startsWith('/api/host-sessions/') ||
396
- url.startsWith('/api/audit/stats') ||
397
- url.startsWith('/api/readiness/agents');
398
- if (isReadOnlyPublic) {
399
- res.setHeader('Access-Control-Allow-Origin', '*');
400
- res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
401
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
402
- if (req.method === 'OPTIONS') {
403
- res.writeHead(204);
404
- res.end();
405
- return;
406
- }
407
- }
408
- // Auth check — timing-safe comparison
409
- if (token) {
410
- const authHeader = req.headers.authorization ?? '';
411
- const expected = `Bearer ${token}`;
412
- const cmpLen = Math.max(expected.length, authHeader.length);
413
- const bufExpected = Buffer.alloc(cmpLen);
414
- const bufActual = Buffer.alloc(cmpLen);
415
- bufExpected.write(expected);
416
- bufActual.write(authHeader);
417
- const ok = timingSafeEqual(bufExpected, bufActual);
418
- if (!ok) {
419
- res.writeHead(401, { 'Content-Type': 'application/json' });
420
- res.end(JSON.stringify({ error: 'Unauthorized' }));
421
- return;
422
- }
423
- }
424
- // CSRF / DNS-rebind defense for state-changing requests. The daemon binds
425
- // localhost, but the user's own browser can reach it — so a page on a
426
- // malicious site could fire a cross-origin POST (a text/plain "simple
427
- // request" sidesteps the CORS preflight) to spawn an agent or mutate state.
428
- // `codehere start` runs without a bearer token, so that gate is off by
429
- // default; this enforces the same-origin-only intent noted above. Browsers
430
- // attach Origin / Sec-Fetch-Site on these requests; native clients (the
431
- // CLI, curl, MCP stdio) do not — so we reject only requests a browser
432
- // marks as cross-origin, and leave header-less non-browser clients alone.
433
- const method = req.method ?? 'GET';
434
- if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
435
- const sfs = req.headers['sec-fetch-site'];
436
- const origin = req.headers.origin;
437
- const selfOrigins = new Set([`http://${host}:${port}`, `http://127.0.0.1:${port}`, `http://localhost:${port}`]);
438
- const browserCrossOrigin = (typeof sfs === 'string' && sfs !== 'same-origin' && sfs !== 'none') ||
439
- (typeof origin === 'string' && origin !== 'null' && !selfOrigins.has(origin));
440
- if (browserCrossOrigin) {
441
- res.writeHead(403, { 'Content-Type': 'application/json' });
442
- res.end(JSON.stringify({ error: 'cross-origin request rejected' }));
443
- return;
444
- }
445
- }
446
- // Health check
447
- if (req.method === 'GET' && req.url === '/health') {
448
- res.writeHead(200, { 'Content-Type': 'application/json' });
449
- // `mock` lets a no-spend tool (verify-all) confirm a daemon is mock BEFORE
450
- // attaching — so it can refuse to drive a LIVE daemon while claiming "no spend".
451
- res.end(JSON.stringify({ status: 'ok', server: 'codehere-mcp', version: getVersion(), tools: TOOLS.length, mock: process.env.CODEHERE_MOCK === '1' }));
452
- return;
453
- }
454
- // v2 Task-Centric PM UI (primary — served at /)
455
- if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
456
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
457
- res.end(withDevElu(TASK_HTML_V2));
458
- return;
459
- }
460
- // /v1 deleted 2026-05-23 · failover UI ported to v2 (see ui-page-v2.ts
461
- // .failover-banner). The route now redirects to / so old bookmarks land
462
- // on the real product instead of 404.
463
- if (req.method === 'GET' && (req.url === '/v1' || req.url === '/v1/')) {
464
- res.writeHead(301, { Location: '/' });
465
- res.end();
466
- return;
467
- }
468
- // verify-at-the-surface dashboard · /verify (+ /verify/replay, query params).
469
- // Serves the SAME HTML for both — the page reads ?mode=replay itself. It
470
- // embeds `/` in an iframe as the real observation surface. Not dev-elu
471
- // wrapped: it IS the verification view, the elu overlay would be noise.
472
- if (req.method === 'GET' && req.url && /^\/verify(\/replay)?(\?.*)?$/.test(req.url)) {
473
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
474
- res.end(VERIFY_HTML);
475
- return;
476
- }
477
- // History / audit feed (what we built Day 0; now secondary)
478
- if (req.method === 'GET' && (req.url === '/history' || req.url === '/history/')) {
479
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
480
- res.end(withDevElu(HISTORY_HTML));
481
- return;
482
- }
483
- // v0.6.0 M1 — project thread view; consumes /api/projects + /api/projects/:slug
484
- if (req.method === 'GET' &&
485
- (req.url === '/threads' || req.url === '/threads/' || req.url?.startsWith('/threads?'))) {
486
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
487
- res.end(withDevElu(THREAD_HTML));
488
- return;
489
- }
490
- // Backwards-compat: /ui used to be the Mission Control audit view — now at /history
491
- if (req.method === 'GET' && (req.url === '/ui' || req.url === '/ui/')) {
492
- res.writeHead(302, { Location: '/history' });
493
- res.end();
494
- return;
495
- }
496
- // Live audit event stream (SSE)
497
- if (req.method === 'GET' && req.url === '/events') {
498
- res.writeHead(200, {
499
- 'Content-Type': 'text/event-stream',
500
- 'Cache-Control': 'no-cache',
501
- Connection: 'keep-alive',
502
- });
503
- // Replay last 50 events on connect so the UI isn't empty
504
- let lastSize = 0;
505
- if (existsSync(AUDIT_FILE)) {
506
- try {
507
- const content = readFileSync(AUDIT_FILE, 'utf-8');
508
- lastSize = Buffer.byteLength(content, 'utf-8');
509
- const lines = content.split('\n').filter((l) => l.trim().length > 0);
510
- for (const line of lines.slice(-50)) {
511
- res.write(`data: ${line}\n\n`);
512
- }
513
- }
514
- catch {
515
- /* no audit yet */
516
- }
517
- }
518
- // Tail the audit file for new events
519
- const onChange = () => {
520
- try {
521
- if (!existsSync(AUDIT_FILE))
522
- return;
523
- const currentSize = statSync(AUDIT_FILE).size;
524
- if (currentSize <= lastSize)
525
- return;
526
- const fd = readFileSync(AUDIT_FILE, 'utf-8');
527
- const fresh = fd.slice(lastSize);
528
- lastSize = Buffer.byteLength(fd, 'utf-8');
529
- for (const line of fresh.split('\n').filter((l) => l.trim().length > 0)) {
530
- res.write(`data: ${line}\n\n`);
531
- }
532
- }
533
- catch {
534
- /* transient read errors are fine */
535
- }
536
- };
537
- watchFile(AUDIT_FILE, { interval: 500 }, onChange);
538
- // Keep-alive ping every 15s so proxies don't kill the stream
539
- const ping = setInterval(() => res.write(': ping\n\n'), 15000);
540
- req.on('close', () => {
541
- clearInterval(ping);
542
- unwatchFile(AUDIT_FILE, onChange);
543
- });
544
- return;
545
- }
546
- // ── P4 SSE — session lifecycle events (realtime /v2 updates) ────────────
547
- // Tails sessions.jsonl and pushes "change" events so the /v2 UI can
548
- // re-fetch task state instantly instead of waiting 8s for the next poll.
549
- // Same pattern as /events (audit log tail) but for sessions.
550
- const SESSIONS_FILE = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'sessions.jsonl');
551
- if (req.method === 'GET' && req.url === '/api/events/sessions') {
552
- res.writeHead(200, {
553
- 'Content-Type': 'text/event-stream',
554
- 'Cache-Control': 'no-cache',
555
- Connection: 'keep-alive',
556
- });
557
- // Send current session state on connect
558
- try {
559
- const manager = SessionManager.getInstance();
560
- const sessions = manager.list().map((s) => ({
561
- sessionId: s.sessionId,
562
- status: s.status,
563
- agent: s.agent,
564
- }));
565
- res.write(`data: ${JSON.stringify({ type: 'init', sessions })}\n\n`);
566
- }
567
- catch {
568
- res.write(`data: ${JSON.stringify({ type: 'init', sessions: [] })}\n\n`);
569
- }
570
- // Tail sessions.jsonl for changes
571
- let lastSize = 0;
572
- if (existsSync(SESSIONS_FILE)) {
573
- try {
574
- lastSize = statSync(SESSIONS_FILE).size;
575
- }
576
- catch {
577
- /* first time */
578
- }
579
- }
580
- const onChange = () => {
581
- try {
582
- if (!existsSync(SESSIONS_FILE))
583
- return;
584
- const currentSize = statSync(SESSIONS_FILE).size;
585
- if (currentSize <= lastSize)
586
- return;
587
- lastSize = currentSize;
588
- res.write(`data: ${JSON.stringify({ type: 'change' })}\n\n`);
589
- }
590
- catch {
591
- /* transient */
592
- }
593
- };
594
- watchFile(SESSIONS_FILE, { interval: 500 }, onChange);
595
- const ping = setInterval(() => res.write(': ping\n\n'), 15000);
596
- req.on('close', () => {
597
- clearInterval(ping);
598
- unwatchFile(SESSIONS_FILE, onChange);
599
- });
600
- return;
601
- }
602
- // ── F2 agent registry ─────────────────────────────────────────────────────
603
- if (req.method === 'GET' && req.url === '/api/agents') {
604
- res.writeHead(200, { 'Content-Type': 'application/json' });
605
- res.end(JSON.stringify({ agents: availableAgents() }));
606
- return;
607
- }
608
- // P12 slice — tamper-evidence verification of the audit log.
609
- // Calls verifyAuditChain() and returns a compact summary. /history
610
- // polls this every minute and shows a red banner if valid=false.
611
- if (req.method === 'GET' && req.url === '/api/audit/verify') {
612
- try {
613
- const r = verifyAuditChain();
614
- res.writeHead(200, { 'Content-Type': 'application/json' });
615
- res.end(JSON.stringify({
616
- valid: r.valid,
617
- totalEntries: r.totalEntries,
618
- chainedEntries: r.chainedEntries,
619
- legacyEntries: r.legacyEntries,
620
- errors: r.errors.slice(0, 10),
621
- }));
622
- }
623
- catch {
624
- res.writeHead(200, { 'Content-Type': 'application/json' });
625
- res.end(JSON.stringify({ valid: true, totalEntries: 0, chainedEntries: 0, legacyEntries: 0, errors: [] }));
626
- }
627
- return;
628
- }
629
- // P18 slice 3 — permissions read/write surface. Lets the future
630
- // dashboard panel (when a hook block fires) call "Approve in this
631
- // folder" without shelling out. The CLI command and this endpoint
632
- // both call addPermissionEntry → single source of truth.
633
- if (req.method === 'GET' && req.url?.startsWith('/api/permissions?')) {
634
- const cwd = decodeURIComponent(req.url.split('cwd=')[1] || '');
635
- if (!cwd || !isAbsolute(cwd)) {
636
- res.writeHead(400, { 'Content-Type': 'application/json' });
637
- res.end(JSON.stringify({ error: 'cwd query param required (absolute path)' }));
638
- return;
639
- }
640
- try {
641
- const cfg = loadPermissions(cwd);
642
- res.writeHead(200, { 'Content-Type': 'application/json' });
643
- res.end(JSON.stringify({ cwd, permissions: cfg }));
644
- }
645
- catch {
646
- res.writeHead(200, { 'Content-Type': 'application/json' });
647
- res.end(JSON.stringify({ cwd, permissions: { allow: [], deny: [] } }));
648
- }
649
- return;
650
- }
651
- if (req.method === 'POST' && req.url === '/api/permissions') {
652
- (async () => {
653
- try {
654
- const body = await readBody(req, res);
655
- let parsed;
656
- try {
657
- parsed = JSON.parse(body || '{}');
658
- }
659
- catch (e) {
660
- res.writeHead(400, { 'Content-Type': 'application/json' });
661
- res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
662
- return;
663
- }
664
- const cwd = parsed.cwd;
665
- const pattern = parsed.pattern;
666
- const bucket = parsed.bucket === 'deny' ? 'deny' : 'allow';
667
- if (!cwd || typeof cwd !== 'string' || !isAbsolute(cwd) || cwd.includes('\0')) {
668
- res.writeHead(400, { 'Content-Type': 'application/json' });
669
- res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
670
- return;
671
- }
672
- if (!pattern || typeof pattern !== 'string') {
673
- res.writeHead(400, { 'Content-Type': 'application/json' });
674
- res.end(JSON.stringify({ error: 'pattern (non-empty string) required' }));
675
- return;
676
- }
677
- try {
678
- const result = addPermissionEntry({ cwd, pattern, bucket, reason: parsed.reason });
679
- res.writeHead(200, { 'Content-Type': 'application/json' });
680
- res.end(JSON.stringify(result));
681
- }
682
- catch (e) {
683
- res.writeHead(500, { 'Content-Type': 'application/json' });
684
- res.end(JSON.stringify({ error: 'write_failed', detail: String(e?.message || e) }));
685
- }
686
- }
687
- catch {
688
- if (process.env.CODEHERE_DEBUG === '1')
689
- console.error('[codehere] readBody rejected (oversized or aborted)');
690
- }
691
- })();
692
- return;
693
- }
694
- if (req.method === 'DELETE' && req.url?.startsWith('/api/permissions?')) {
695
- const params = new URLSearchParams(req.url.split('?')[1] || '');
696
- const cwd = params.get('cwd') || '';
697
- const pattern = params.get('pattern') || '';
698
- if (!cwd || !isAbsolute(cwd) || !pattern) {
699
- res.writeHead(400, { 'Content-Type': 'application/json' });
700
- res.end(JSON.stringify({ error: 'cwd (absolute) + pattern query params required' }));
701
- return;
702
- }
703
- try {
704
- const result = removePermissionEntry(cwd, pattern);
705
- res.writeHead(200, { 'Content-Type': 'application/json' });
706
- res.end(JSON.stringify(result));
707
- }
708
- catch (e) {
709
- res.writeHead(500, { 'Content-Type': 'application/json' });
710
- res.end(JSON.stringify({ error: 'remove_failed', detail: String(e?.message || e) }));
711
- }
712
- return;
713
- }
714
- // Pattern 20 — hook-mode indicator. Read-only by design; mode
715
- // switching goes through `codehere hook install/uninstall` so the
716
- // safety surface re-validates. Cheap (one settings.json parse);
717
- // safe to poll from the dashboard header at 30s cadence.
718
- if (req.method === 'GET' && req.url === '/api/hook/mode') {
719
- let mode;
720
- try {
721
- mode = getHookMode();
722
- }
723
- catch {
724
- mode = 'off';
725
- }
726
- res.writeHead(200, { 'Content-Type': 'application/json' });
727
- res.end(JSON.stringify({ mode }));
728
- return;
729
- }
730
- // ── Gate B3 · Audit summary (moat visible on main page) ───────────────────
731
- // Principle #3 status-at-a-glance + #7 agent-provenance. Reads the hook audit
732
- // log's last 24h, aggregates: how many actions fired across all agents, how
733
- // many codehere blocked, mean trust (= 100 - mean riskScore).
734
- if (req.method === 'GET' && req.url === '/api/audit/summary') {
735
- const windowMs = 24 * 60 * 60 * 1000;
736
- const cutoff = Date.now() - windowMs;
737
- let actions = 0, blocked = 0, riskSum = 0, riskN = 0;
738
- const sessionIds = new Set();
739
- try {
740
- if (existsSync(AUDIT_FILE)) {
741
- const content = readFileSync(AUDIT_FILE, 'utf-8');
742
- const lines = content.split('\n');
743
- // Walk backwards; stop at first line older than cutoff (log is append-chronological)
744
- for (let i = lines.length - 1; i >= 0; i--) {
745
- const line = lines[i].trim();
746
- if (!line)
747
- continue;
748
- let entry;
749
- try {
750
- entry = JSON.parse(line);
751
- }
752
- catch {
753
- continue;
754
- }
755
- const ts = typeof entry.timestamp === 'number'
756
- ? entry.timestamp
757
- : entry.timestamp
758
- ? Date.parse(String(entry.timestamp))
759
- : 0;
760
- if (!ts || ts < cutoff)
761
- break;
762
- actions += 1;
763
- if (entry.blocked)
764
- blocked += 1;
765
- if (typeof entry.riskScore === 'number') {
766
- riskSum += entry.riskScore;
767
- riskN += 1;
768
- }
769
- if (entry.sessionId)
770
- sessionIds.add(entry.sessionId);
771
- }
772
- }
773
- }
774
- catch {
775
- /* empty audit — zeros are fine */
776
- }
777
- const avgTrust = riskN > 0 ? Math.max(0, Math.round(100 - riskSum / riskN)) : null;
778
- res.writeHead(200, { 'Content-Type': 'application/json' });
779
- res.end(JSON.stringify({
780
- windowHours: 24,
781
- actions,
782
- blocked,
783
- agents: sessionIds.size,
784
- avgTrust,
785
- }));
786
- return;
787
- }
788
- // ── Host sessions — the coding-agent CLIs codehere observes via the hook ──
789
- // These are NOT spawned-by-codehere agents (those live in /api/tasks). These
790
- // are the Claude Code / Codex / etc. sessions running in the user's own
791
- // terminal, captured by the PreToolUse hook. This is the surface that proves
792
- // the INWARD axis: every coding agent — including the one the user is talking
793
- // to right now — is a first-class workspace member, not a black box.
794
- if (req.method === 'GET' && req.url === '/api/host-sessions') {
795
- const entries = [];
796
- try {
797
- if (existsSync(AUDIT_FILE)) {
798
- const lines = readFileSync(AUDIT_FILE, 'utf-8').split('\n');
799
- for (const line of lines) {
800
- const t = line.trim();
801
- if (!t)
802
- continue;
803
- try {
804
- const e = JSON.parse(t);
805
- if (e && typeof e.sessionId === 'string')
806
- entries.push(e);
807
- }
808
- catch {
809
- /* skip malformed line */
810
- }
811
- }
812
- }
813
- }
814
- catch {
815
- /* empty audit — empty list is fine */
816
- }
817
- // Only surface genuine host coding sessions — not seed fixtures or the
818
- // synthetic WritePass/pre_prompt audit rows. isRealHostSession() requires
819
- // a real coding tool + 48h recency. Without it the strip would claim
820
- // "10 sessions running" when 9 are stale demo artifacts — the screen-lie
821
- // the no-screen-lies rule bans.
822
- const now = Date.now();
823
- const sessions = analyzeSessions(entries)
824
- .filter((s) => s.sessionId !== 'unknown')
825
- .filter((s) => isRealHostSession(s, now))
826
- .sort((a, b) => new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime())
827
- .slice(0, 10)
828
- .map((s) => ({
829
- sessionId: s.sessionId,
830
- shortId: s.sessionId.substring(0, 8),
831
- totalCalls: s.totalCalls,
832
- blockedCalls: s.blockedCalls,
833
- criticalCalls: s.criticalCalls,
834
- durationMs: s.durationMs,
835
- lastSeen: s.lastSeen,
836
- risk: s.amplificationRisk,
837
- toolMix: s.toolMix,
838
- }));
839
- res.writeHead(200, { 'Content-Type': 'application/json' });
840
- res.end(JSON.stringify({ sessions, total: sessions.length }));
841
- return;
842
- }
843
- // ── Host session as a THREAD — full tool-call timeline for one observed ──
844
- // session. This is the meta-dogfood payoff: open the coding-agent session
845
- // running in your terminal and see every call codehere scanned, in order,
846
- // hash-chained. GET /api/host-sessions/:id
847
- const hostDetailMatch = req.url?.match(/^\/api\/host-sessions\/([^/?]+)$/);
848
- if (req.method === 'GET' && hostDetailMatch) {
849
- const wantId = decodeURIComponent(hostDetailMatch[1]);
850
- // Group matching entries by their FULL session id, then resolve to ONE
851
- // session. The strip links with an 8-char short id; if two sessions share
852
- // that prefix, a naive startsWith() would merge their timelines into a
853
- // single bogus session. Grouping + resolving keeps each session distinct.
854
- const bySession = new Map();
855
- const lastTs = new Map();
856
- try {
857
- if (existsSync(AUDIT_FILE)) {
858
- const lines = readFileSync(AUDIT_FILE, 'utf-8').split('\n');
859
- for (const line of lines) {
860
- const t = line.trim();
861
- if (!t)
862
- continue;
863
- let e;
864
- try {
865
- e = JSON.parse(t);
866
- }
867
- catch {
868
- continue;
869
- }
870
- const sid = e.sessionId;
871
- if (!sid || (sid !== wantId && !sid.startsWith(wantId)))
872
- continue;
873
- // Plain-English preview: the bash command or the file path, never
874
- // the full JSON blob. Persona-facing surface, not a log dump.
875
- const preview = previewFromToolInput(e.toolInput);
876
- const call = {
877
- timestamp: e.timestamp,
878
- toolName: e.toolName,
879
- riskScore: e.riskScore ?? 0,
880
- blocked: !!e.blocked,
881
- findings: e.findings ?? 0,
882
- criticalFindings: e.criticalFindings ?? 0,
883
- seq: e.seq,
884
- chained: typeof e.entryHash === 'string' && e.entryHash.length > 0,
885
- preview,
886
- };
887
- if (!bySession.has(sid))
888
- bySession.set(sid, []);
889
- bySession.get(sid).push(call);
890
- const ts = e.timestamp ? Date.parse(e.timestamp) : 0;
891
- if (Number.isFinite(ts) && ts > (lastTs.get(sid) ?? 0))
892
- lastTs.set(sid, ts);
893
- }
894
- }
895
- }
896
- catch {
897
- /* empty / unreadable audit — empty timeline is honest */
898
- }
899
- // Resolve to one session: an exact full-id match wins; otherwise the most
900
- // recently active session carrying the prefix. We never merge two.
901
- let resolvedId;
902
- if (bySession.has(wantId)) {
903
- resolvedId = wantId;
904
- }
905
- else if (bySession.size >= 1) {
906
- resolvedId = [...bySession.keys()].sort((a, b) => (lastTs.get(b) ?? 0) - (lastTs.get(a) ?? 0))[0];
907
- }
908
- const calls = resolvedId ? bySession.get(resolvedId) : [];
909
- if (calls.length === 0) {
910
- res.writeHead(404, { 'Content-Type': 'application/json' });
911
- res.end(JSON.stringify({ error: 'No observed session matching ' + wantId }));
912
- return;
913
- }
914
- calls.sort((a, b) => new Date(String(a.timestamp)).getTime() - new Date(String(b.timestamp)).getTime());
915
- const chain = verifyAuditChain(AUDIT_FILE);
916
- res.writeHead(200, { 'Content-Type': 'application/json' });
917
- res.end(JSON.stringify({
918
- sessionId: resolvedId ?? wantId,
919
- calls,
920
- totalCalls: calls.length,
921
- blockedCalls: calls.filter((c) => c.blocked).length,
922
- criticalCalls: calls.filter((c) => c.criticalFindings > 0).length,
923
- chainValid: chain.valid,
924
- chainEntries: chain.chainedEntries,
925
- }));
926
- return;
927
- }
928
- // ── Remote control · drive a running agent from the web ───────────────
929
- // POST /api/control/open { agent, prompt } → opens a ControlSurface, starts
930
- // a thread + initial turn, returns { controlId, threadId }.
931
- // POST /api/control/:id/inject { message } → turn/steer into the running
932
- // turn (the remote-control primitive).
933
- // GET /api/control/:id/events → what the driven agent emitted.
934
- // Same-origin only (mutating). Under CODEHERE_MOCK=1 the surface drives the
935
- // mock app-server — zero API, no rate-limit.
936
- if (req.method === 'POST' && req.url === '/api/control/open') {
937
- (async () => {
938
- try {
939
- const raw = JSON.parse((await readBody(req, res)) || '{}');
940
- const agent = String(raw.agent ?? 'codex');
941
- const prompt = String(raw.prompt ?? '').trim();
942
- if (!prompt) {
943
- res.writeHead(400, { 'Content-Type': 'application/json' });
944
- res.end(JSON.stringify({ error: 'prompt required' }));
945
- return;
946
- }
947
- if (agent !== 'codex') {
948
- res.writeHead(400, { 'Content-Type': 'application/json' });
949
- res.end(JSON.stringify({ error: 'only codex control surface is wired today' }));
950
- return;
951
- }
952
- const surface = new CodexControlSurface();
953
- const controlId = 'ctrl-' + ++controlSeq;
954
- const entry = { surface, agent, threadId: '', events: [], openedAt: Date.now() };
955
- surface.onEvent((e) => entry.events.push(e));
956
- controlSessions.set(controlId, entry);
957
- const { threadId } = await surface.open(prompt);
958
- entry.threadId = threadId;
959
- res.writeHead(200, { 'Content-Type': 'application/json' });
960
- res.end(JSON.stringify({ controlId, threadId, agent }));
961
- }
962
- catch (e) {
963
- res.writeHead(500, { 'Content-Type': 'application/json' });
964
- res.end(JSON.stringify({ error: 'control open failed: ' + e.message }));
965
- }
966
- })();
967
- return;
968
- }
969
- const ctlInject = req.url?.match(/^\/api\/control\/([^/]+)\/inject$/);
970
- if (req.method === 'POST' && ctlInject) {
971
- (async () => {
972
- const entry = controlSessions.get(decodeURIComponent(ctlInject[1]));
973
- if (!entry) {
974
- res.writeHead(404, { 'Content-Type': 'application/json' });
975
- res.end(JSON.stringify({ error: 'no such control session' }));
976
- return;
977
- }
978
- try {
979
- const raw = JSON.parse((await readBody(req, res)) || '{}');
980
- const message = String(raw.message ?? '').trim();
981
- if (!message) {
982
- res.writeHead(400, { 'Content-Type': 'application/json' });
983
- res.end(JSON.stringify({ error: 'message required' }));
984
- return;
985
- }
986
- const r = await entry.surface.inject(message);
987
- res.writeHead(200, { 'Content-Type': 'application/json' });
988
- res.end(JSON.stringify({ accepted: r.accepted }));
989
- }
990
- catch (e) {
991
- res.writeHead(500, { 'Content-Type': 'application/json' });
992
- res.end(JSON.stringify({ error: 'inject failed: ' + e.message }));
993
- }
994
- })();
995
- return;
996
- }
997
- const ctlEvents = req.url?.match(/^\/api\/control\/([^/]+)\/events$/);
998
- if (req.method === 'GET' && ctlEvents) {
999
- const entry = controlSessions.get(decodeURIComponent(ctlEvents[1]));
1000
- if (!entry) {
1001
- res.writeHead(404, { 'Content-Type': 'application/json' });
1002
- res.end(JSON.stringify({ error: 'no such control session' }));
1003
- return;
1004
- }
1005
- res.writeHead(200, { 'Content-Type': 'application/json' });
1006
- res.end(JSON.stringify({ threadId: entry.threadId, agent: entry.agent, events: entry.events }));
1007
- return;
1008
- }
1009
- // ── User override · a human reviewed a block and chose to proceed ──────
1010
- // POST /api/audit/override · body { sessionId, toolName, reason }. Appends
1011
- // a 'user_override' entry with the free-text reason to the hash chain and
1012
- // returns the new entry's seq + short hash, so the UI can show "logged ·
1013
- // chain continues <hash>" with the REAL hash, not a mockup. Same-origin
1014
- // only (mutating · not in the CORS read-only list).
1015
- if (req.method === 'POST' && req.url === '/api/audit/override') {
1016
- (async () => {
1017
- try {
1018
- const text = await readBody(req, res);
1019
- let raw;
1020
- try {
1021
- raw = JSON.parse(text || '{}');
1022
- }
1023
- catch {
1024
- res.writeHead(400, { 'Content-Type': 'application/json' });
1025
- res.end(JSON.stringify({ error: 'invalid JSON' }));
1026
- return;
1027
- }
1028
- const reason = String(raw.reason ?? '').trim();
1029
- if (!reason) {
1030
- res.writeHead(400, { 'Content-Type': 'application/json' });
1031
- res.end(JSON.stringify({ error: 'a reason is required to log an override' }));
1032
- return;
1033
- }
1034
- appendOverrideAudit('user_override', {
1035
- sessionId: typeof raw.sessionId === 'string' ? raw.sessionId : 'unknown',
1036
- toolName: typeof raw.toolName === 'string' ? raw.toolName : 'unknown',
1037
- note: reason,
1038
- });
1039
- // appendOverrideAudit → appendAuditLog is fail-open: it swallows
1040
- // write errors so a logging failure never blocks a tool call. That's
1041
- // right for the hook, but here the UI shows "✓ override logged", so
1042
- // we must CONFIRM our entry actually landed before claiming success
1043
- // (no screen-lies). Scan the tail backward for our exact entry — a
1044
- // concurrent hook process may have appended after ours, so it isn't
1045
- // guaranteed to be the very last line.
1046
- const wantSid = typeof raw.sessionId === 'string' ? raw.sessionId : 'unknown';
1047
- const wantNote = reason.slice(0, 500);
1048
- let seq = null;
1049
- let entryHash = '';
1050
- let confirmed = false;
1051
- try {
1052
- const lines = readFileSync(AUDIT_FILE, 'utf-8').trim().split('\n');
1053
- for (let i = lines.length - 1; i >= 0 && i >= lines.length - 10; i--) {
1054
- let e;
1055
- try {
1056
- e = JSON.parse(lines[i]);
1057
- }
1058
- catch {
1059
- continue;
1060
- }
1061
- if (e.overrideReason === 'user_override' && e.sessionId === wantSid && e.overrideNote === wantNote) {
1062
- seq = typeof e.seq === 'number' ? e.seq : null;
1063
- entryHash = typeof e.entryHash === 'string' ? e.entryHash : '';
1064
- confirmed = true;
1065
- break;
1066
- }
1067
- }
1068
- }
1069
- catch {
1070
- /* readback failed — cannot confirm the write landed */
1071
- }
1072
- if (!confirmed) {
1073
- res.writeHead(500, { 'Content-Type': 'application/json' });
1074
- res.end(JSON.stringify({ ok: false, error: 'override could not be confirmed in the audit log' }));
1075
- return;
1076
- }
1077
- res.writeHead(200, { 'Content-Type': 'application/json' });
1078
- res.end(JSON.stringify({
1079
- ok: true,
1080
- seq,
1081
- entryHash,
1082
- shortHash: entryHash ? entryHash.slice(0, 4) + '...' + entryHash.slice(-4) : '',
1083
- }));
1084
- }
1085
- catch {
1086
- res.writeHead(500, { 'Content-Type': 'application/json' });
1087
- res.end(JSON.stringify({ error: 'override failed' }));
1088
- }
1089
- })();
1090
- return;
1091
- }
1092
- // ── F1 · Agent install (zero-terminal setup — principle #10) ──────────────
1093
- // POST /api/agents/:id/install → SSE stream of install output.
1094
- // The command comes from the adapter's checkReadiness().installHint — never
1095
- // from the user over the wire. codehere is the thing that runs the install,
1096
- // not a page that tells the user how to run it.
1097
- const installMatch = req.url?.match(/^\/api\/agents\/([a-z0-9-]+)\/install$/i);
1098
- if (req.method === 'POST' && installMatch) {
1099
- const adapterId = installMatch[1];
1100
- const adapter = getAdapter(adapterId);
1101
- if (!adapter) {
1102
- res.writeHead(404, { 'Content-Type': 'application/json' });
1103
- res.end(JSON.stringify({ error: `No such agent: ${adapterId}` }));
1104
- return;
1105
- }
1106
- const session = installAgent(adapter);
1107
- if (!session) {
1108
- res.writeHead(400, { 'Content-Type': 'application/json' });
1109
- res.end(JSON.stringify({ error: 'Agent already installed or has no install hint' }));
1110
- return;
1111
- }
1112
- res.writeHead(200, {
1113
- 'Content-Type': 'text/event-stream',
1114
- 'Cache-Control': 'no-cache',
1115
- Connection: 'keep-alive',
1116
- });
1117
- const unsubscribe = session.subscribe((evt) => {
1118
- res.write(`data: ${JSON.stringify(evt)}\n\n`);
1119
- });
1120
- const ping = setInterval(() => res.write(': ping\n\n'), 15000);
1121
- req.on('close', () => {
1122
- clearInterval(ping);
1123
- unsubscribe();
1124
- });
1125
- return;
1126
- }
1127
- // ── E2 · Per-agent readiness (onboarding surface) ─────────────────────────
1128
- // Full registry snapshot: install state + auth state + hints. Powers the
1129
- // setup-aware agent dropdown and the one-click swap flow on auth errors.
1130
- //
1131
- // Apr 29: Conductor moved to a separate `auto` field. The web UI IS the
1132
- // Conductor surface — exposing "conductor" as a peer in the agents array
1133
- // is the "Gmail asks you to pick 'email' before composing" anti-pattern.
1134
- // But Conductor's readiness state IS the Auto option's readiness, so the
1135
- // UI needs it preserved (codex Round 1 caught: fresh install with no
1136
- // specialists installed would otherwise lose the setup prompt — Auto
1137
- // gets selected by default, submission fails at /api/task because
1138
- // conductor.isInstalled() returns false). The separate `auto` field
1139
- // keeps the setup hint plumbing intact while preventing the dropdown
1140
- // from showing Conductor as a peer choice.
1141
- if (req.method === 'GET' && req.url === '/api/readiness/agents') {
1142
- try {
1143
- const all = readinessForAllAgents();
1144
- const auto = all.find((a) => a.id === 'conductor');
1145
- const agents = all.filter((a) => a.id !== 'conductor');
1146
- // v0.6.0 M8 — surface cross-tool agent definitions (.opencode/agents/,
1147
- // .claude/agents/, .codehere/agents/) alongside the built-in adapters.
1148
- // These don't have a `state` (ready/needs_install) — they're definitions
1149
- // not runtimes. UI can render them as "available specialists" tagged
1150
- // with their source format. Best-effort: failures fall back to no
1151
- // cross-tool agents listed.
1152
- let crossTool = [];
1153
- try {
1154
- const result = readCrossToolAgents(process.cwd());
1155
- crossTool = result.agents.map((a) => ({
1156
- name: a.name,
1157
- description: a.description.length > 240 ? a.description.slice(0, 237) + '…' : a.description,
1158
- sourceFormat: a.sourceFormat,
1159
- mode: a.mode,
1160
- sourcePath: a.sourcePath,
1161
- }));
1162
- }
1163
- catch {
1164
- // best-effort; ignore parse failures
1165
- }
1166
- res.writeHead(200, { 'Content-Type': 'application/json' });
1167
- res.end(JSON.stringify({ agents, auto, crossTool }));
1168
- }
1169
- catch (e) {
1170
- res.writeHead(500, { 'Content-Type': 'application/json' });
1171
- res.end(JSON.stringify({ error: 'readiness_check_failed', detail: String(e) }));
1172
- }
1173
- return;
1174
- }
1175
- // ── v0.6 S3 — spend-today aggregator + budget state ──────────────────────
1176
- // GET /api/spend/today — read-only aggregate of today's estimated USD spend
1177
- // plus budget config (slice 2: dailyUsd, pctUsed, over). Powers the
1178
- // dashboard's "spent today" pill + budget warn/block colors. Per
1179
- // claim_market_position v3 capture-strategy edge "cost-bounded execution":
1180
- // this is the read surface; the actual block happens at /api/task spawn.
1181
- if (req.method === 'GET' && req.url === '/api/spend/today') {
1182
- try {
1183
- const spend = computeSpendToday();
1184
- const budget = loadBudget();
1185
- const pctUsed = budgetUsedPct(spend.totalUsd, budget);
1186
- res.writeHead(200, { 'Content-Type': 'application/json' });
1187
- res.end(JSON.stringify({
1188
- ...spend,
1189
- budget: {
1190
- dailyUsd: budget.dailyUsd,
1191
- warnAtPct: budget.warnAtPct,
1192
- pctUsed,
1193
- over: isOverBudget(spend.totalUsd, budget),
1194
- },
1195
- }));
1196
- }
1197
- catch (e) {
1198
- res.writeHead(500, { 'Content-Type': 'application/json' });
1199
- res.end(JSON.stringify({ error: 'spend_today_failed', detail: String(e) }));
1200
- }
1201
- return;
1202
- }
1203
- // ── F1a readiness check ───────────────────────────────────────────────────
1204
- if (req.method === 'GET' && req.url === '/api/readiness') {
1205
- try {
1206
- const report = detectReadiness();
1207
- res.writeHead(200, { 'Content-Type': 'application/json' });
1208
- res.end(JSON.stringify(report));
1209
- }
1210
- catch (e) {
1211
- res.writeHead(500, { 'Content-Type': 'application/json' });
1212
- res.end(JSON.stringify({ error: 'readiness_check_failed', detail: String(e) }));
1213
- }
1214
- return;
1215
- }
1216
- // ── v1 project-memory routes (the room's init nudge) ─────────────────────
1217
- // Found by the stranger walk: web-only users never run `codehere init`, so
1218
- // the spec's "agent remembers the project" half of nothing-lost silently
1219
- // doesn't apply. Status reports the SAME readiness the injector acts on
1220
- // (shared findProjectRoot); init runs the SAME hardened scaffold the CLI
1221
- // does (shared scaffoldProjectAt — symlink defenses included).
1222
- if (req.method === 'GET' && req.url?.startsWith('/api/memory/status')) {
1223
- try {
1224
- const u = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`);
1225
- const cwd = u.searchParams.get('cwd') || '';
1226
- if (!cwd || !isAbsolute(cwd) || cwd.includes('\0')) {
1227
- res.writeHead(400, { 'Content-Type': 'application/json' });
1228
- res.end(JSON.stringify({ error: 'cwd (absolute path) required' }));
1229
- return;
1230
- }
1231
- const root = findProjectRoot(cwd);
1232
- const ready = !!root && existsSync(join(root, '.codehere', 'memory', 'working'));
1233
- res.writeHead(200, { 'Content-Type': 'application/json' });
1234
- res.end(JSON.stringify({ ready, projectRoot: root }));
1235
- }
1236
- catch (e) {
1237
- res.writeHead(500, { 'Content-Type': 'application/json' });
1238
- res.end(JSON.stringify({ error: 'memory_status_failed', detail: String(e) }));
1239
- }
1240
- return;
1241
- }
1242
- if (req.method === 'POST' && req.url === '/api/memory/init') {
1243
- (async () => {
1244
- try {
1245
- const body = await readBody(req, res);
1246
- let cwd = '';
1247
- try {
1248
- cwd = String(JSON.parse(body || '{}').cwd || '');
1249
- }
1250
- catch {
1251
- /* falls through to the validation below */
1252
- }
1253
- if (!cwd || !isAbsolute(cwd) || cwd.includes('\0') || !existsSync(cwd) || !statSync(cwd).isDirectory()) {
1254
- res.writeHead(400, { 'Content-Type': 'application/json' });
1255
- res.end(JSON.stringify({ error: 'cwd (absolute path to an existing directory) required' }));
1256
- return;
1257
- }
1258
- const r = scaffoldProjectAt(cwd);
1259
- if (!r.ok) {
1260
- res.writeHead(409, { 'Content-Type': 'application/json' });
1261
- res.end(JSON.stringify({ ok: false, reason: r.reason }));
1262
- return;
1263
- }
1264
- res.writeHead(200, { 'Content-Type': 'application/json' });
1265
- res.end(JSON.stringify({ ok: true, created: r.created.length, skipped: r.skipped.length, refused: r.refused.length + r.refusedDirs.length }));
1266
- }
1267
- catch (e) {
1268
- res.writeHead(500, { 'Content-Type': 'application/json' });
1269
- res.end(JSON.stringify({ error: 'memory_init_failed', detail: String(e) }));
1270
- }
1271
- })().catch(() => {
1272
- /* handled above — keep the route from rejecting unhandled */
1273
- });
1274
- return;
1275
- }
1276
- // ── v0.6.0 M1 project thread routes ──────────────────────────────────────
1277
- // GET /api/projects — list all known projects (slug, cwd, title, taskCount, lastActivityAt)
1278
- // GET /api/projects/:slug — single project thread (meta + tasks merged by sessionId)
1279
- // Reader source of truth: ~/.codehere/projects/<slug>/thread.jsonl (per-project append-only log
1280
- // populated by migration + forward-mirror). Drives the new thread-view UI.
1281
- if (req.method === 'GET' && req.url === '/api/projects') {
1282
- try {
1283
- const projects = listProjects();
1284
- res.writeHead(200, { 'Content-Type': 'application/json' });
1285
- res.end(JSON.stringify({ projects }));
1286
- }
1287
- catch (e) {
1288
- res.writeHead(500, { 'Content-Type': 'application/json' });
1289
- res.end(JSON.stringify({ error: 'list_projects_failed', detail: String(e) }));
1290
- }
1291
- return;
1292
- }
1293
- if (req.method === 'GET' && req.url?.startsWith('/api/projects/')) {
1294
- try {
1295
- // Slug is the segment after /api/projects/. Strip optional query string.
1296
- const slug = req.url.replace('/api/projects/', '').split('?')[0];
1297
- // Slug shape is sha1[:12] (12 hex chars) OR the literal 'unknown' bucket.
1298
- // Reject anything else to keep the surface tight (and stop ../ traversal).
1299
- if (!/^([a-f0-9]{12}|unknown)$/.test(slug)) {
1300
- res.writeHead(400, { 'Content-Type': 'application/json' });
1301
- res.end(JSON.stringify({ error: 'invalid_slug' }));
1302
- return;
1303
- }
1304
- const thread = readProjectThread(slug);
1305
- if (!thread) {
1306
- res.writeHead(404, { 'Content-Type': 'application/json' });
1307
- res.end(JSON.stringify({ error: 'project_not_found', slug }));
1308
- return;
1309
- }
1310
- res.writeHead(200, { 'Content-Type': 'application/json' });
1311
- res.end(JSON.stringify({ thread }));
1312
- }
1313
- catch (e) {
1314
- res.writeHead(500, { 'Content-Type': 'application/json' });
1315
- res.end(JSON.stringify({ error: 'read_project_failed', detail: String(e) }));
1316
- }
1317
- return;
1318
- }
1319
- // POST /api/projects/:slug/rename — body { title }
1320
- // User-initiated rename. Title agent generates a title from the first
1321
- // prompt; project intent drifts. Without rename, /agent dir gets stuck
1322
- // titled "Scan Auth File" forever even after 100+ unrelated tasks.
1323
- if (req.method === 'POST' && req.url?.match(/^\/api\/projects\/[^/]+\/rename$/)) {
1324
- const slug = req.url.split('/')[3];
1325
- (async () => {
1326
- try {
1327
- const body = await readBody(req, res);
1328
- let parsed;
1329
- try {
1330
- const raw = JSON.parse(body);
1331
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
1332
- res.writeHead(400, { 'Content-Type': 'application/json' });
1333
- res.end(JSON.stringify({ error: 'invalid_body' }));
1334
- return;
1335
- }
1336
- parsed = raw;
1337
- }
1338
- catch {
1339
- res.writeHead(400, { 'Content-Type': 'application/json' });
1340
- res.end(JSON.stringify({ error: 'invalid_json' }));
1341
- return;
1342
- }
1343
- if (typeof parsed.title !== 'string') {
1344
- res.writeHead(400, { 'Content-Type': 'application/json' });
1345
- res.end(JSON.stringify({ error: 'title_required' }));
1346
- return;
1347
- }
1348
- const result = renameProject(slug, parsed.title);
1349
- if (!result.ok) {
1350
- let status = 400;
1351
- if (result.error === 'Project not found')
1352
- status = 404;
1353
- else if (result.error === 'Write failed')
1354
- status = 500;
1355
- res.writeHead(status, { 'Content-Type': 'application/json' });
1356
- res.end(JSON.stringify({ error: result.error }));
1357
- return;
1358
- }
1359
- res.writeHead(200, { 'Content-Type': 'application/json' });
1360
- res.end(JSON.stringify({ ok: true, slug, title: parsed.title.trim() }));
1361
- }
1362
- catch {
1363
- if (process.env.CODEHERE_DEBUG === '1')
1364
- console.error('[codehere] readBody rejected (oversized or aborted)');
1365
- }
1366
- })();
1367
- return;
1368
- }
1369
- // ── F1 task orchestration routes ─────────────────────────────────────────
1370
- // POST /api/task — { prompt, cwd? } → { sessionId }
1371
- // GET /api/tasks — list all sessions
1372
- // GET /api/task/:id/events — SSE stream
1373
- // POST /api/task/:id/kill — terminate
1374
- if (req.url && req.url.startsWith('/api/task')) {
1375
- const manager = SessionManager.getInstance();
1376
- // v0.6 M5 slice 1 — POST /api/task/batch.
1377
- // Spawns N tasks at once (independent files only, per W2 — same-file
1378
- // conflicts ship in v0.7). Each spawn flows through SessionManager
1379
- // so pre-prompt scan + audit still run per task. Cap of 5 concurrent
1380
- // to prevent runaway. Returns array of { sessionId, agent } in input
1381
- // order; per-task failures are surfaced as { error, agent } entries
1382
- // so a partial-failure batch still returns useful info.
1383
- if (req.method === 'POST' && req.url === '/api/task/batch') {
1384
- (async () => {
1385
- try {
1386
- const body = await readBody(req, res);
1387
- let parsed;
1388
- try {
1389
- parsed = JSON.parse(body || '{}');
1390
- }
1391
- catch (e) {
1392
- res.writeHead(400, { 'Content-Type': 'application/json' });
1393
- res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
1394
- return;
1395
- }
1396
- const tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
1397
- if (tasks.length === 0) {
1398
- res.writeHead(400, { 'Content-Type': 'application/json' });
1399
- res.end(JSON.stringify({ error: 'tasks (non-empty array) required' }));
1400
- return;
1401
- }
1402
- if (tasks.length > 5) {
1403
- res.writeHead(400, { 'Content-Type': 'application/json' });
1404
- res.end(JSON.stringify({ error: 'batch size capped at 5', requested: tasks.length }));
1405
- return;
1406
- }
1407
- const validAgentIds = new Set(allAdapters().map((a) => a.id));
1408
- // v0.6 S3 slice 2 — pre-spawn budget gate, applied per-task. Fan-out
1409
- // is the most likely way to overshoot a daily budget (5 paid spawns
1410
- // at once); checking once at batch entry then re-checking per task
1411
- // is overkill, so we snapshot the budget + spend ONCE and let each
1412
- // paid spawn that would push us over fail with the same error shape
1413
- // /api/task uses. Free-tier spawns always pass.
1414
- const batchBudget = loadBudget();
1415
- const batchSpendNow = batchBudget.dailyUsd !== null ? computeSpendToday().totalUsd : 0;
1416
- const results = [];
1417
- for (const t of tasks) {
1418
- const prompt = t.prompt;
1419
- if (!prompt || typeof prompt !== 'string') {
1420
- results.push({ error: 'prompt (string) required', agent: t.agent ?? null });
1421
- continue;
1422
- }
1423
- if (prompt.trim().length === 0) {
1424
- results.push({ error: 'prompt is empty — describe what you want done', agent: t.agent ?? null });
1425
- continue;
1426
- }
1427
- const agentId = typeof t.agent === 'string' && t.agent.length > 0 ? t.agent : 'conductor';
1428
- if (!validAgentIds.has(agentId)) {
1429
- results.push({ error: `Unknown agent: ${agentId}`, agent: agentId });
1430
- continue;
1431
- }
1432
- if (t.cwd !== undefined) {
1433
- if (typeof t.cwd !== 'string' || t.cwd.includes('\0') || !isAbsolute(t.cwd)) {
1434
- results.push({ error: 'cwd must be absolute, no NUL bytes', agent: agentId });
1435
- continue;
1436
- }
1437
- }
1438
- // Per-task budget check (paid agents only).
1439
- if (batchBudget.dailyUsd !== null) {
1440
- const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
1441
- if (probe > 0 && isOverBudget(batchSpendNow, batchBudget)) {
1442
- results.push({
1443
- error: 'daily_budget_exhausted',
1444
- agent: agentId,
1445
- spentTodayUsd: batchSpendNow,
1446
- dailyBudgetUsd: batchBudget.dailyUsd,
1447
- });
1448
- continue;
1449
- }
1450
- }
1451
- try {
1452
- const session = manager.spawn(prompt, agentId, { cwd: t.cwd });
1453
- results.push({
1454
- sessionId: session.sessionId,
1455
- agent: session.agent,
1456
- status: session.status,
1457
- startedAt: session.startedAt,
1458
- });
1459
- }
1460
- catch (e) {
1461
- const msg = String(e?.message || e);
1462
- const code = msg.includes('pre-prompt scan blocked') ? 'pre_prompt_blocked' : 'spawn_failed';
1463
- results.push({ error: code, reason: msg, agent: agentId });
1464
- }
1465
- }
1466
- res.writeHead(200, { 'Content-Type': 'application/json' });
1467
- res.end(JSON.stringify({ tasks: results, count: results.length }));
1468
- }
1469
- catch {
1470
- if (process.env.CODEHERE_DEBUG === '1')
1471
- console.error('[codehere] readBody rejected (oversized or aborted)');
1472
- }
1473
- })();
1474
- return;
1475
- }
1476
- if (req.method === 'POST' && req.url === '/api/task') {
1477
- (async () => {
1478
- try {
1479
- const body = await readBody(req, res);
1480
- // Two-phase try: JSON parse failures get one error shape, spawn
1481
- // failures get a different one. Pre-fix the catch wrapped every
1482
- // failure as "Invalid JSON body" — including the pre-prompt scan
1483
- // throw — which read as a parse bug to the client.
1484
- let parsed;
1485
- try {
1486
- parsed = JSON.parse(body || '{}');
1487
- }
1488
- catch (e) {
1489
- res.writeHead(400, { 'Content-Type': 'application/json' });
1490
- res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
1491
- return;
1492
- }
1493
- try {
1494
- const { prompt, cwd, agent } = parsed;
1495
- if (!prompt || typeof prompt !== 'string') {
1496
- res.writeHead(400, { 'Content-Type': 'application/json' });
1497
- res.end(JSON.stringify({ error: 'prompt (string) required' }));
1498
- return;
1499
- }
1500
- // Whitespace-only prompts are a real spawn with nothing to do —
1501
- // they burn an agent run for no work (dogfood 2026-05-27 Phase D).
1502
- // Reject before spawn; require something to act on.
1503
- if (prompt.trim().length === 0) {
1504
- res.writeHead(400, { 'Content-Type': 'application/json' });
1505
- res.end(JSON.stringify({ error: 'prompt is empty — describe what you want done' }));
1506
- return;
1507
- }
1508
- // CRIT-5 fix · whitelist agent ID against the registry BEFORE any
1509
- // log/spawn. Stops log injection (`agent: "claude-code\n[FAKE]"`),
1510
- // future-proofs against partial-match exploits, and fails fast
1511
- // with a clear 400 instead of letting SessionManager.spawn throw.
1512
- //
1513
- // Apr 29: default flipped from 'claude-code' to 'conductor'. The web
1514
- // UI is the Conductor surface; tasks submitted without an explicit
1515
- // agent route through the supervisor (rule-based routing today,
1516
- // LLM classifier in v0.6). Empty string is treated like undefined
1517
- // so the form submitting `agent: ""` (Auto option) hits the same
1518
- // path. Specialists remain explicit overrides via the dropdown.
1519
- // Type-check is preserved: non-string non-undefined values still 400.
1520
- if (agent !== undefined && agent !== null && typeof agent !== 'string') {
1521
- res.writeHead(400, { 'Content-Type': 'application/json' });
1522
- res.end(JSON.stringify({ error: 'agent must be a string or omitted' }));
1523
- return;
1524
- }
1525
- const validAgentIds = new Set(allAdapters().map((a) => a.id));
1526
- const agentId = typeof agent === 'string' && agent.length > 0 ? agent : 'conductor';
1527
- if (!validAgentIds.has(agentId)) {
1528
- res.writeHead(400, { 'Content-Type': 'application/json' });
1529
- res.end(JSON.stringify({
1530
- error: `Unknown agent: ${typeof agentId === 'string' ? agentId.slice(0, 64) : '<non-string>'}`,
1531
- valid: [...validAgentIds],
1532
- }));
1533
- return;
1534
- }
1535
- // Path-traversal + null-byte defense · cwd must be absolute and
1536
- // free of NUL bytes (some filesystems treat \0 as terminator).
1537
- if (cwd !== undefined) {
1538
- if (typeof cwd !== 'string' || cwd.includes('\0') || !isAbsolute(cwd)) {
1539
- res.writeHead(400, { 'Content-Type': 'application/json' });
1540
- res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
1541
- return;
1542
- }
1543
- }
1544
- const budgetForSpawn = loadBudget();
1545
- if (budgetForSpawn.dailyUsd !== null) {
1546
- const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
1547
- const isPaidSpawn = probe > 0;
1548
- if (isPaidSpawn) {
1549
- const todaySpend = computeSpendToday();
1550
- if (isOverBudget(todaySpend.totalUsd, budgetForSpawn)) {
1551
- res.writeHead(429, { 'Content-Type': 'application/json' });
1552
- res.end(JSON.stringify({
1553
- error: 'daily_budget_exhausted',
1554
- message: `Today's estimated spend ($${todaySpend.totalUsd.toFixed(2)}) has reached the daily budget ($${budgetForSpawn.dailyUsd.toFixed(2)}). Switch to a free agent (gemini, opencode) or raise the budget with \`codehere budget set <amount>\`.`,
1555
- spentTodayUsd: todaySpend.totalUsd,
1556
- dailyBudgetUsd: budgetForSpawn.dailyUsd,
1557
- }));
1558
- return;
1559
- }
1560
- }
1561
- }
1562
- // v0.8 S2 · resolve any context-source URLs in the prompt
1563
- // (linear://INFRA-247, figma://file/<key>/...) before spawn.
1564
- // Best-effort · resolver failures get logged inline in the
1565
- // prompt + emit audit lines, never block the spawn.
1566
- const reg = getDefaultRegistry();
1567
- const fetchCtx = createDefaultFetchContextWithPost({
1568
- emitAuditLine: (line) => {
1569
- // Future · feed into the hook audit chain (F-05/F-06).
1570
- // For now console-debug only · the spawn audit line below
1571
- // captures the user-facing outcome.
1572
- if (process.env.CODEHERE_DEBUG === '1') {
1573
- console.error('[context-source]', line.status, line.source, line.url, line.detail ?? '');
1574
- }
1575
- },
1576
- });
1577
- let promptForSpawn = prompt;
1578
- try {
1579
- const r = await resolveContextSourceUrls(prompt, reg, fetchCtx);
1580
- promptForSpawn = r.enrichedPrompt;
1581
- if (r.outcomes.length && process.env.CODEHERE_DEBUG === '1') {
1582
- console.error('[context-source] resolved', r.outcomes.length, 'URL(s) ·', r.totalEstimatedTokens, 'tokens');
1583
- }
1584
- }
1585
- catch (e) {
1586
- // Resolver-level failure (registry corrupt, etc) · log and
1587
- // fall back to the raw prompt. The agent runs without
1588
- // enrichment instead of failing outright.
1589
- console.error('[context-source] resolver failed:', e instanceof Error ? e.message : String(e));
1590
- }
1591
- const session = manager.spawn(promptForSpawn, agentId, { cwd });
1592
- res.writeHead(200, { 'Content-Type': 'application/json' });
1593
- res.end(JSON.stringify({
1594
- sessionId: session.sessionId,
1595
- startedAt: session.startedAt,
1596
- status: session.status,
1597
- agent: session.agent,
1598
- canAppendMessage: session.canAppendMessage === true,
1599
- }));
1600
- }
1601
- catch (e) {
1602
- const msg = String(e?.message || e);
1603
- if (msg.includes('pre-prompt scan blocked')) {
1604
- res.writeHead(422, { 'Content-Type': 'application/json' });
1605
- res.end(JSON.stringify({ error: 'pre_prompt_blocked', reason: msg }));
1606
- return;
1607
- }
1608
- res.writeHead(500, { 'Content-Type': 'application/json' });
1609
- res.end(JSON.stringify({ error: 'spawn_failed', detail: msg }));
1610
- }
1611
- }
1612
- catch {
1613
- if (process.env.CODEHERE_DEBUG === '1')
1614
- console.error('[codehere] readBody rejected (oversized or aborted)');
1615
- }
1616
- })();
1617
- return;
1618
- }
1619
- if (req.method === 'GET' && req.url === '/api/tasks') {
1620
- const spawned = manager.list().map((s) => {
1621
- const completedAt = manager.getCompletedAt(s.sessionId);
1622
- const finalReport = manager.getFinalReport(s.sessionId);
1623
- const cwd = manager.getCwd(s.sessionId);
1624
- const continuedFrom = manager.getContinuedFrom(s.sessionId);
1625
- const continuedAs = manager.getContinuedAs(s.sessionId);
1626
- const failedReport = manager.hasFailedReport(s.sessionId);
1627
- const stalledReason = manager.getStallReason(s.sessionId);
1628
- // F-07 · verifier-agent block · run claim vs reality on every task.
1629
- // Pure function, no I/O, runs sync · cheap to do per request.
1630
- // The UI renders the verdict + findings inline below the Summary.
1631
- const verifyResult = verifyClaim({
1632
- summary: finalReport?.summary,
1633
- artifacts: s.artifacts,
1634
- steps: s.steps,
1635
- });
1636
- return {
1637
- sessionId: s.sessionId,
1638
- prompt: s.prompt,
1639
- startedAt: s.startedAt,
1640
- status: s.status,
1641
- agent: s.agent,
1642
- steps: s.steps ?? [],
1643
- artifacts: s.artifacts,
1644
- ...(completedAt !== undefined ? { completedAt, durationMs: completedAt - s.startedAt } : {}),
1645
- ...(finalReport !== undefined ? { finalReport } : {}),
1646
- ...(failedReport ? { failedReport: true } : {}),
1647
- ...(stalledReason ? { stalledReason } : {}),
1648
- verifyResult,
1649
- canAppendMessage: s.canAppendMessage === true,
1650
- ...(cwd !== undefined ? { cwd } : {}),
1651
- ...(continuedFrom !== undefined ? { continuedFrom } : {}),
1652
- ...(continuedAs !== undefined ? { continuedAs } : {}),
1653
- provenance: 'spawned',
1654
- };
1655
- });
1656
- const activeAgents = manager.listActiveAgents();
1657
- // F-07 · attach verifyResult to persisted records too · same logic.
1658
- const persisted = manager.listPersisted().map((p) => ({
1659
- ...p,
1660
- verifyResult: verifyClaim({
1661
- summary: p.finalReport?.summary,
1662
- artifacts: p.artifacts,
1663
- steps: p.steps,
1664
- }),
1665
- }));
1666
- // Layer 7: timestamp of most recent hook-audit line (= "last edit on this machine")
1667
- let lastEditAt = null;
1668
- try {
1669
- if (existsSync(AUDIT_FILE))
1670
- lastEditAt = statSync(AUDIT_FILE).mtimeMs;
1671
- }
1672
- catch {
1673
- /* no audit yet — null */
1674
- }
1675
- res.writeHead(200, { 'Content-Type': 'application/json' });
1676
- res.end(JSON.stringify({
1677
- spawned,
1678
- persisted,
1679
- activeAgents,
1680
- lastEditAt,
1681
- sessions: spawned, // back-compat for earlier UI code
1682
- observed: manager.listObserved(), // legacy, kept for /history or future use
1683
- }));
1684
- return;
1685
- }
1686
- // P18 slice 4 — GET /api/task/:id/blocks → audit entries with
1687
- // blocked:true for this session. Lets the session-card UI panel
1688
- // surface "codehere noticed this pattern" prompts when the per-
1689
- // tool hook fires mid-session. Reads from the tail of the audit
1690
- // log; cheap because hook entries are short.
1691
- const blocksMatch = req.url.match(/^\/api\/task\/([^/]+)\/blocks$/);
1692
- if (blocksMatch && req.method === 'GET') {
1693
- const sessId = blocksMatch[1];
1694
- const blocks = [];
1695
- try {
1696
- if (existsSync(AUDIT_FILE)) {
1697
- const content = readFileSync(AUDIT_FILE, 'utf-8');
1698
- const lines = content.split('\n');
1699
- // Walk backward — most recent first — collect up to 20.
1700
- for (let i = lines.length - 1; i >= 0 && blocks.length < 20; i--) {
1701
- const ln = lines[i].trim();
1702
- if (!ln)
1703
- continue;
1704
- try {
1705
- const e = JSON.parse(ln);
1706
- if (e.sessionId === sessId && e.blocked === true) {
1707
- blocks.unshift(e);
1708
- }
1709
- }
1710
- catch {
1711
- /* skip malformed */
1712
- }
1713
- }
1714
- }
1715
- }
1716
- catch {
1717
- /* fail open — empty list */
1718
- }
1719
- res.writeHead(200, { 'Content-Type': 'application/json' });
1720
- res.end(JSON.stringify({ blocks }));
1721
- return;
1722
- }
1723
- // v0.6 M3 — GET /api/task/:id/narration → latest mid-task summary.
1724
- // Read-only; SessionManager generates the narration in a background
1725
- // timer and caches the latest. Frontend polls (~30s) while the
1726
- // session is running.
1727
- const narrMatch = req.url.match(/^\/api\/task\/([^/]+)\/narration$/);
1728
- if (narrMatch && req.method === 'GET') {
1729
- const sessId = narrMatch[1];
1730
- const narration = manager.getNarration(sessId);
1731
- res.writeHead(200, { 'Content-Type': 'application/json' });
1732
- res.end(JSON.stringify({ narration: narration ?? null }));
1733
- return;
1734
- }
1735
- // /api/task/:id/events or /api/task/:id/kill
1736
- const m = req.url.match(/^\/api\/task\/([^/]+)\/(events|kill|message)$/);
1737
- if (m) {
1738
- const [, sessionId, verb] = m;
1739
- const session = manager.get(sessionId);
1740
- if (!session) {
1741
- res.writeHead(404, { 'Content-Type': 'application/json' });
1742
- res.end(JSON.stringify({ error: `No session: ${sessionId}` }));
1743
- return;
1744
- }
1745
- if (verb === 'events' && req.method === 'GET') {
1746
- res.writeHead(200, {
1747
- 'Content-Type': 'text/event-stream',
1748
- 'Cache-Control': 'no-cache',
1749
- Connection: 'keep-alive',
1750
- });
1751
- const unsubscribe = session.subscribe((event) => {
1752
- res.write(`data: ${JSON.stringify(event)}\n\n`);
1753
- });
1754
- const ping = setInterval(() => res.write(': ping\n\n'), 15000);
1755
- req.on('close', () => {
1756
- clearInterval(ping);
1757
- unsubscribe();
1758
- });
1759
- return;
1760
- }
1761
- if (verb === 'kill' && req.method === 'POST') {
1762
- session.kill();
1763
- res.writeHead(200, { 'Content-Type': 'application/json' });
1764
- res.end(JSON.stringify({ sessionId, status: session.status }));
1765
- return;
1766
- }
1767
- // Iter 12 — POST /api/task/:id/message — follow-up message to a
1768
- // completed session. The reply-to-thread surface in the web UI calls
1769
- // this. Body: { text }. Response: { result, durationMs }.
1770
- if (verb === 'message' && req.method === 'POST') {
1771
- if (!session.canAppendMessage || !session.appendMessage) {
1772
- res.writeHead(400, { 'Content-Type': 'application/json' });
1773
- res.end(JSON.stringify({ error: `Agent ${session.agent} does not support follow-up messages yet.` }));
1774
- return;
1775
- }
1776
- (async () => {
1777
- try {
1778
- const body = await readBody(req, res);
1779
- try {
1780
- const parsed = JSON.parse(body || '{}');
1781
- const text = (parsed.text ?? '').trim();
1782
- if (!text) {
1783
- res.writeHead(400, { 'Content-Type': 'application/json' });
1784
- res.end(JSON.stringify({ error: 'text is required' }));
1785
- return;
1786
- }
1787
- const replyStartedAt = Date.now();
1788
- const out = await session.appendMessage(text);
1789
- const replyCompletedAt = Date.now();
1790
- try {
1791
- const replyCwd = manager.getCwd(sessionId);
1792
- if (replyCwd) {
1793
- mirrorReplyToThread({
1794
- parentSessionId: sessionId,
1795
- replySessionId: randomUUID(),
1796
- agent: session.agent,
1797
- prompt: text,
1798
- cwd: replyCwd,
1799
- startedAt: replyStartedAt,
1800
- completedAt: replyCompletedAt,
1801
- durationMs: out.durationMs,
1802
- result: out.result,
1803
- });
1804
- }
1805
- }
1806
- catch {
1807
- /* mirror is best-effort */
1808
- }
1809
- res.writeHead(200, { 'Content-Type': 'application/json' });
1810
- res.end(JSON.stringify({ sessionId, ...out }));
1811
- }
1812
- catch (err) {
1813
- const msg = err instanceof Error ? err.message : String(err);
1814
- res.writeHead(500, { 'Content-Type': 'application/json' });
1815
- res.end(JSON.stringify({ error: msg }));
1816
- }
1817
- }
1818
- catch {
1819
- if (process.env.CODEHERE_DEBUG === '1')
1820
- console.error('[codehere] readBody rejected (oversized or aborted)');
1821
- }
1822
- })();
1823
- return;
1824
- }
1825
- }
1826
- res.writeHead(405, { 'Content-Type': 'application/json' });
1827
- res.end(JSON.stringify({ error: 'Method not allowed for this path' }));
1828
- return;
1829
- }
1830
- // v0.6.x P0 — GET /api/failover-offers
1831
- // Returns active failover offers so the dashboard can surface a
1832
- // "Claude rate-limited — switch to Codex?" banner. Offers expire
1833
- // after 60 s (handled by SessionManager).
1834
- if (req.method === 'GET' && req.url === '/api/failover-offers') {
1835
- const offers = SessionManager.getInstance().getFailoverOffers();
1836
- res.writeHead(200, { 'Content-Type': 'application/json' });
1837
- res.end(JSON.stringify({ offers }));
1838
- return;
1839
- }
1840
- // v0.6.x P0 — POST /api/failover-accept
1841
- // User clicked "Switch" on the failoffer banner. Spawns a
1842
- // replacement session on the target agent, preserving the
1843
- // original prompt + cwd.
1844
- if (req.method === 'POST' && req.url === '/api/failover-accept') {
1845
- (async () => {
1846
- try {
1847
- const body = await readBody(req, res);
1848
- let parsed;
1849
- try {
1850
- const raw = JSON.parse(body);
1851
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
1852
- res.writeHead(400, { 'Content-Type': 'application/json' });
1853
- res.end(JSON.stringify({ error: 'invalid_body' }));
1854
- return;
1855
- }
1856
- parsed = raw;
1857
- }
1858
- catch {
1859
- res.writeHead(400, { 'Content-Type': 'application/json' });
1860
- res.end(JSON.stringify({ error: 'invalid_body' }));
1861
- return;
1862
- }
1863
- if (typeof parsed.sessionId !== 'string' || typeof parsed.targetAgent !== 'string') {
1864
- res.writeHead(400, { 'Content-Type': 'application/json' });
1865
- res.end(JSON.stringify({ error: 'missing_sessionId_or_targetAgent' }));
1866
- return;
1867
- }
1868
- try {
1869
- const result = SessionManager.getInstance().acceptFailover(parsed.sessionId, parsed.targetAgent);
1870
- if (result) {
1871
- res.writeHead(200, { 'Content-Type': 'application/json' });
1872
- res.end(JSON.stringify({ ok: true, newSessionId: result.sessionId }));
1873
- }
1874
- else {
1875
- res.writeHead(200, { 'Content-Type': 'application/json' });
1876
- res.end(JSON.stringify({
1877
- ok: false,
1878
- error: 'target_unavailable',
1879
- detail: `Could not spawn ${parsed.targetAgent}. Check if it's installed and authenticated.`,
1880
- }));
1881
- }
1882
- }
1883
- catch (e) {
1884
- res.writeHead(500, { 'Content-Type': 'application/json' });
1885
- res.end(JSON.stringify({ error: 'failover_failed', detail: String(e) }));
1886
- }
1887
- }
1888
- catch {
1889
- if (process.env.CODEHERE_DEBUG === '1')
1890
- console.error('[codehere] readBody rejected (oversized or aborted)');
1891
- }
1892
- })();
1893
- return;
1894
- }
1895
- // MCP endpoint — POST jsonrpc; reject other verbs with 405 + Allow header
1896
- // (rc.17: was 404, which is wrong HTTP semantic — UX_OBSERVATIONS P3).
1897
- if (req.url === '/mcp') {
1898
- if (req.method !== 'POST') {
1899
- res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'POST' });
1900
- res.end(JSON.stringify({ error: 'MCP endpoint accepts POST only', method: req.method }));
1901
- return;
1902
- }
1903
- (async () => {
1904
- try {
1905
- const body = await readBody(req, res);
1906
- try {
1907
- const mcpReq = JSON.parse(body);
1908
- const mcpRes = dispatch(mcpReq);
1909
- res.writeHead(200, {
1910
- 'Content-Type': 'application/json',
1911
- 'Access-Control-Allow-Origin': 'http://localhost',
1912
- });
1913
- res.end(JSON.stringify(mcpRes));
1914
- }
1915
- catch {
1916
- res.writeHead(400, { 'Content-Type': 'application/json' });
1917
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
1918
- }
1919
- }
1920
- catch {
1921
- if (process.env.CODEHERE_DEBUG === '1')
1922
- console.error('[codehere] readBody rejected (oversized or aborted)');
1923
- }
1924
- })();
1925
- return;
1926
- }
1927
- // F-05 / F-06 · public endpoint for the audit chain verifier · powers
1928
- // the web UI's "Verify chain" button + can be hit directly by CI.
1929
- // v0.8 S2.5 · context-source OAuth routes
1930
- // GET /api/context-sources · list all providers + status
1931
- // POST /api/context-sources/:id/connect · returns authorize URL + opens browser
1932
- // POST /api/context-sources/:id/disconnect · clears stored token
1933
- // GET /oauth/callback?code=...&state=... · provider redirect target
1934
- if (req.url === '/api/context-sources' && req.method === 'GET') {
1935
- try {
1936
- const oauthItems = listOAuthProviders().map((p) => {
1937
- const status = connectionStatus(p.id);
1938
- return {
1939
- id: p.id,
1940
- label: p.label,
1941
- scheme: p.id,
1942
- authType: 'oauth',
1943
- status: status.status,
1944
- userLabel: status.userLabel ?? null,
1945
- connectedAt: status.connectedAt ?? null,
1946
- expiresAt: status.expiresAt ?? null,
1947
- clientIdConfigured: !!process.env[p.clientIdEnv],
1948
- };
1949
- });
1950
- // v0.8 S5 · public-by-URL adapters appear with status='available'
1951
- // (no Connect button needed · they work out of the box).
1952
- const oauthIds = new Set(oauthItems.map((i) => i.id));
1953
- const reg = getDefaultRegistry();
1954
- const noAuthItems = [...reg.bySchemeOnly.values()]
1955
- .filter((s) => !oauthIds.has(s.id))
1956
- .map((s) => ({
1957
- id: s.id,
1958
- label: s.label,
1959
- scheme: s.scheme,
1960
- authType: 'public',
1961
- status: 'available',
1962
- userLabel: null,
1963
- connectedAt: null,
1964
- expiresAt: null,
1965
- clientIdConfigured: true,
1966
- description: s.description,
1967
- }));
1968
- res.writeHead(200, { 'Content-Type': 'application/json' });
1969
- res.end(JSON.stringify({ providers: [...oauthItems, ...noAuthItems] }));
1970
- }
1971
- catch (e) {
1972
- console.error('[/api/context-sources] failed:', e);
1973
- res.writeHead(500, { 'Content-Type': 'application/json' });
1974
- res.end(JSON.stringify({ error: 'Failed to list context sources' }));
1975
- }
1976
- return;
1977
- }
1978
- const connectMatch = req.url?.match(/^\/api\/context-sources\/([^/]+)\/connect$/);
1979
- if (connectMatch && req.method === 'POST') {
1980
- const providerId = connectMatch[1];
1981
- const provider = getOAuthProvider(providerId);
1982
- if (!provider) {
1983
- res.writeHead(404, { 'Content-Type': 'application/json' });
1984
- res.end(JSON.stringify({ error: `unknown provider: ${providerId}` }));
1985
- return;
1986
- }
1987
- const clientId = process.env[provider.clientIdEnv] || null;
1988
- const r = oauthBegin(provider, { clientId });
1989
- if ('kind' in r && r.kind === 'not_registered') {
1990
- res.writeHead(409, { 'Content-Type': 'application/json' });
1991
- res.end(JSON.stringify({
1992
- error: 'provider_not_registered',
1993
- provider: providerId,
1994
- envVar: provider.clientIdEnv,
1995
- message: `Set ${provider.clientIdEnv} to the OAuth client id from ${provider.label}. Until then, paste a manual key into ~/.codehere/context-sources.json under '${providerId}.apiKey'.`,
1996
- }));
1997
- return;
1998
- }
1999
- const result = r;
2000
- PENDING_OAUTH_FLOWS.set(result.pending.state, result.pending);
2001
- res.writeHead(200, { 'Content-Type': 'application/json' });
2002
- res.end(JSON.stringify({ authorizeUrl: result.authorizeUrl, state: result.pending.state }));
2003
- return;
2004
- }
2005
- // S2.6 · /test endpoint · proves the stored token actually works against
2006
- // the provider with a minimal read. Returns ok or detail.
2007
- const testMatch = req.url?.match(/^\/api\/context-sources\/([^/]+)\/test$/);
2008
- if (testMatch && req.method === 'POST') {
2009
- const providerId = testMatch[1];
2010
- const provider = getOAuthProvider(providerId);
2011
- if (!provider) {
2012
- res.writeHead(404, { 'Content-Type': 'application/json' });
2013
- res.end(JSON.stringify({ ok: false, error: 'unknown_provider' }));
2014
- return;
2015
- }
2016
- (async () => {
2017
- try {
2018
- const status = connectionStatus(providerId);
2019
- if (status.status === 'disconnected') {
2020
- res.writeHead(400, { 'Content-Type': 'application/json' });
2021
- res.end(JSON.stringify({ ok: false, error: 'not_connected' }));
2022
- return;
2023
- }
2024
- // Minimal probe per provider · the user-info endpoint is the
2025
- // cheapest "does this token read at all" call.
2026
- const userInfoUrl = provider.userInfoUrl;
2027
- if (!userInfoUrl) {
2028
- res.writeHead(501, { 'Content-Type': 'application/json' });
2029
- res.end(JSON.stringify({ ok: false, error: 'no_test_endpoint' }));
2030
- return;
2031
- }
2032
- const cfg = JSON.parse(readFileSync(join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'context-sources.json'), 'utf-8'));
2033
- const entry = cfg[providerId] ?? {};
2034
- let probeRes;
2035
- if (providerId === 'linear') {
2036
- const auth = entry.oauth?.accessToken ? `Bearer ${entry.oauth.accessToken}` : (entry.apiKey ?? '');
2037
- probeRes = await fetch(userInfoUrl, {
2038
- method: 'POST',
2039
- headers: { Authorization: auth, 'Content-Type': 'application/json' },
2040
- body: JSON.stringify({ query: '{ viewer { name email } }' }),
2041
- });
2042
- }
2043
- else {
2044
- // figma · GET with X-Figma-Token OR Bearer
2045
- const headers = entry.oauth?.accessToken
2046
- ? { Authorization: `Bearer ${entry.oauth.accessToken}` }
2047
- : { 'X-Figma-Token': entry.token ?? '' };
2048
- probeRes = await fetch(userInfoUrl, { headers });
2049
- }
2050
- if (!probeRes.ok) {
2051
- res.writeHead(200, { 'Content-Type': 'application/json' });
2052
- res.end(JSON.stringify({ ok: false, error: 'provider_returned_' + probeRes.status }));
2053
- return;
2054
- }
2055
- const body = (await probeRes.json());
2056
- const who = body.data?.viewer?.email || body.data?.viewer?.name || body.email || body.handle || null;
2057
- res.writeHead(200, { 'Content-Type': 'application/json' });
2058
- res.end(JSON.stringify({ ok: true, who }));
2059
- }
2060
- catch (e) {
2061
- res.writeHead(500, { 'Content-Type': 'application/json' });
2062
- res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }));
2063
- }
2064
- })();
2065
- return;
2066
- }
2067
- const disconnectMatch = req.url?.match(/^\/api\/context-sources\/([^/]+)\/disconnect$/);
2068
- if (disconnectMatch && req.method === 'POST') {
2069
- const providerId = disconnectMatch[1];
2070
- const ok = clearToken(providerId);
2071
- res.writeHead(200, { 'Content-Type': 'application/json' });
2072
- res.end(JSON.stringify({ ok, provider: providerId }));
2073
- return;
2074
- }
2075
- if (req.url?.startsWith('/oauth/callback') && req.method === 'GET') {
2076
- (async () => {
2077
- try {
2078
- const u = new URL(req.url, 'http://127.0.0.1');
2079
- const code = u.searchParams.get('code');
2080
- const state = u.searchParams.get('state');
2081
- const error = u.searchParams.get('error');
2082
- if (error || !code || !state) {
2083
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
2084
- res.end(callbackHtml('error', error || 'missing code/state', null));
2085
- return;
2086
- }
2087
- const pending = PENDING_OAUTH_FLOWS.get(state);
2088
- if (!pending) {
2089
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
2090
- res.end(callbackHtml('error', 'unknown state · this flow was not started by this daemon or has expired', null));
2091
- return;
2092
- }
2093
- const provider = getOAuthProvider(pending.provider);
2094
- if (!provider) {
2095
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
2096
- res.end(callbackHtml('error', `unknown provider: ${pending.provider}`, null));
2097
- return;
2098
- }
2099
- const clientId = process.env[provider.clientIdEnv] || null;
2100
- const out = await oauthComplete(code, state, {
2101
- clientId,
2102
- pending: PENDING_OAUTH_FLOWS,
2103
- async httpPost(target, body, headers) {
2104
- const r = await fetch(target, { method: 'POST', headers: headers ?? {}, body });
2105
- const text = await r.text();
2106
- const h = {};
2107
- r.headers.forEach((v, k) => {
2108
- h[k.toLowerCase()] = v;
2109
- });
2110
- return { status: r.status, body: text, headers: h };
2111
- },
2112
- async fetchUserInfo(token, userInfoUrl) {
2113
- try {
2114
- if (provider.id === 'linear') {
2115
- const r = await fetch(userInfoUrl, {
2116
- method: 'POST',
2117
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
2118
- body: JSON.stringify({ query: '{ viewer { name email } }' }),
2119
- });
2120
- if (!r.ok)
2121
- return null;
2122
- const j = (await r.json());
2123
- return j.data?.viewer ?? null;
2124
- }
2125
- if (provider.id === 'figma') {
2126
- const r = await fetch(userInfoUrl, {
2127
- headers: { Authorization: `Bearer ${token}` },
2128
- });
2129
- if (!r.ok)
2130
- return null;
2131
- const j = (await r.json());
2132
- return { name: j.handle, email: j.email };
2133
- }
2134
- return null;
2135
- }
2136
- catch {
2137
- return null;
2138
- }
2139
- },
2140
- });
2141
- if ('kind' in out) {
2142
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
2143
- res.end(callbackHtml('error', `${out.kind}: ${out.detail}`, provider.label));
2144
- return;
2145
- }
2146
- saveToken(out);
2147
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2148
- res.end(callbackHtml('ok', `Connected as ${out.userLabel ?? '(unknown user)'}`, provider.label));
2149
- }
2150
- catch (e) {
2151
- console.error('[/oauth/callback] failed:', e);
2152
- res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
2153
- res.end(callbackHtml('error', e instanceof Error ? e.message : String(e), null));
2154
- }
2155
- })();
2156
- return;
2157
- }
2158
- if (req.url === '/api/audit/verify' && req.method === 'GET') {
2159
- try {
2160
- const r = verifyAuditChain(AUDIT_FILE);
2161
- res.writeHead(200, { 'Content-Type': 'application/json' });
2162
- res.end(JSON.stringify({
2163
- path: AUDIT_FILE,
2164
- valid: r.valid,
2165
- totalEntries: r.totalEntries,
2166
- chainedEntries: r.chainedEntries,
2167
- legacyEntries: r.legacyEntries,
2168
- errors: r.errors,
2169
- }));
2170
- }
2171
- catch (e) {
2172
- console.error('[/api/audit/verify] failed:', e);
2173
- res.writeHead(500, { 'Content-Type': 'application/json' });
2174
- res.end(JSON.stringify({ error: 'Failed to verify audit chain' }));
2175
- }
2176
- return;
2177
- }
2178
- // v0.8 S4 · share-thread · POST /api/thread/:id/export returns a signed
2179
- // bundle of one session that the recipient can replay locally. No relay
2180
- // through codehere's infrastructure · the response stays on-machine.
2181
- if (req.method === 'POST' && req.url && /^\/api\/thread\/[^/]+\/export$/.test(req.url)) {
2182
- try {
2183
- const m = req.url.match(/^\/api\/thread\/([^/]+)\/export$/);
2184
- const sessionId = m ? decodeURIComponent(m[1]) : '';
2185
- const session = sessionId ? loadSessionById(sessionId) : undefined;
2186
- if (!session) {
2187
- res.writeHead(404, { 'Content-Type': 'application/json' });
2188
- res.end(JSON.stringify({ error: 'session not found', sessionId }));
2189
- return;
2190
- }
2191
- const keys = loadOrCreateKeypair();
2192
- const body = {
2193
- schema: 1,
2194
- kind: 'thread',
2195
- sessionId: session.sessionId,
2196
- session,
2197
- exporter: { codehereVersion: '0.7.0' },
2198
- exportedAt: new Date().toISOString(),
2199
- };
2200
- const bundle = signBundle(body, keys);
2201
- const fp = shortFingerprint(bundle.publicKey);
2202
- const link = `codehere://thread/${session.sessionId}?signed=ed25519:${bundle.signature.slice(0, 24)}`;
2203
- res.writeHead(200, { 'Content-Type': 'application/json' });
2204
- res.end(JSON.stringify({ bundle, link, fingerprint: fp }));
2205
- }
2206
- catch (e) {
2207
- console.error('[/api/thread/.../export] failed:', e);
2208
- res.writeHead(500, { 'Content-Type': 'application/json' });
2209
- res.end(JSON.stringify({ error: 'export failed' }));
2210
- }
2211
- return;
2212
- }
2213
- // v0.8 S4 · share-thread · POST /api/thread/import · accepts a bundle in
2214
- // the body, verifies it, optionally trusts the signer if the caller sets
2215
- // { trust: true }, persists the session record on success.
2216
- if (req.method === 'POST' && req.url === '/api/thread/import') {
2217
- (async () => {
2218
- try {
2219
- const text = await readBody(req, res);
2220
- let payload = {};
2221
- try {
2222
- payload = JSON.parse(text || '{}');
2223
- }
2224
- catch {
2225
- res.writeHead(400, { 'Content-Type': 'application/json' });
2226
- res.end(JSON.stringify({ error: 'invalid JSON' }));
2227
- return;
2228
- }
2229
- const verdict = verifyBundle(payload.bundle, trustedKeySet());
2230
- if (verdict.status === 'malformed' || verdict.status === 'tampered') {
2231
- res.writeHead(verdict.status === 'malformed' ? 400 : 409, { 'Content-Type': 'application/json' });
2232
- res.end(JSON.stringify({ status: verdict.status, reason: verdict.reason }));
2233
- return;
2234
- }
2235
- if (verdict.status === 'unknown_key') {
2236
- if (!payload.trust) {
2237
- res.writeHead(202, { 'Content-Type': 'application/json' });
2238
- res.end(JSON.stringify({
2239
- status: 'unknown_key',
2240
- publicKey: verdict.publicKey,
2241
- fingerprint: shortFingerprint(verdict.publicKey),
2242
- hint: 'Re-POST with { "trust": true } to trust this key and import.',
2243
- }));
2244
- return;
2245
- }
2246
- trustKey(verdict.publicKey);
2247
- }
2248
- const finalBundle = verdict.bundle;
2249
- const ses = finalBundle.body.session;
2250
- const home = process.env.CODEHERE_HOME || homedir();
2251
- const sessionsPath = join(home, '.codehere', 'sessions.jsonl');
2252
- const fs = await import('fs');
2253
- fs.mkdirSync(dirname(sessionsPath), { recursive: true });
2254
- const lines = [];
2255
- lines.push(JSON.stringify({
2256
- t: 'spawned',
2257
- id: ses.sessionId,
2258
- agent: ses.agent,
2259
- prompt: ses.prompt,
2260
- cwd: ses.cwd,
2261
- startedAt: ses.startedAt,
2262
- }));
2263
- lines.push(JSON.stringify({
2264
- t: 'completed',
2265
- id: ses.sessionId,
2266
- status: ses.status,
2267
- durationMs: ses.durationMs,
2268
- completedAt: ses.completedAt,
2269
- steps: ses.steps,
2270
- artifacts: ses.artifacts,
2271
- }));
2272
- if (ses.finalReport) {
2273
- lines.push(JSON.stringify({
2274
- t: 'finalReport',
2275
- id: ses.sessionId,
2276
- summary: ses.finalReport.summary,
2277
- generatedAt: ses.finalReport.generatedAt,
2278
- durationMs: ses.finalReport.durationMs,
2279
- }));
2280
- }
2281
- fs.appendFileSync(sessionsPath, lines.join('\n') + '\n', 'utf-8');
2282
- res.writeHead(200, { 'Content-Type': 'application/json' });
2283
- res.end(JSON.stringify({
2284
- status: 'imported',
2285
- sessionId: ses.sessionId,
2286
- fingerprint: shortFingerprint(finalBundle.publicKey),
2287
- }));
2288
- }
2289
- catch (e) {
2290
- console.error('[/api/thread/import] failed:', e);
2291
- res.writeHead(500, { 'Content-Type': 'application/json' });
2292
- res.end(JSON.stringify({ error: 'import failed' }));
2293
- }
2294
- })();
2295
- return;
2296
- }
2297
- /* v0.9 S7 · overlay-renderer · preview a 3rd-party URL via context-source */
2298
- // GET /api/context-source/preview?url=<urlencoded scheme://...>
2299
- if (req.method === 'GET' && req.url && req.url.startsWith('/api/context-source/preview')) {
2300
- (async () => {
2301
- try {
2302
- const u = new URL(req.url, 'http://localhost');
2303
- const target = u.searchParams.get('url');
2304
- if (!target) {
2305
- res.writeHead(400, { 'Content-Type': 'application/json' });
2306
- res.end(JSON.stringify({ error: 'url required' }));
2307
- return;
2308
- }
2309
- const reg = getDefaultRegistry();
2310
- const fetchCtx = createDefaultFetchContextWithPost({
2311
- emitAuditLine: (line) => {
2312
- if (process.env.CODEHERE_DEBUG === '1') {
2313
- console.error('[overlay-renderer]', line.status, line.source, line.url, line.detail ?? '');
2314
- }
2315
- },
2316
- });
2317
- const { fetchViaRegistry } = await import('../context/context-source.js');
2318
- try {
2319
- const payload = await fetchViaRegistry(reg, target, fetchCtx);
2320
- if (!payload) {
2321
- // No adapter registered for that scheme · honest 400 instead of
2322
- // silent ok:true with payload:null.
2323
- res.writeHead(400, { 'Content-Type': 'application/json' });
2324
- res.end(JSON.stringify({
2325
- ok: false,
2326
- kind: 'invalid_url',
2327
- error: `No adapter registered for "${target}" · supported: linear://, github://, figma://, drawio://`,
2328
- }));
2329
- return;
2330
- }
2331
- res.writeHead(200, { 'Content-Type': 'application/json' });
2332
- res.end(JSON.stringify({ ok: true, payload }));
2333
- }
2334
- catch (err) {
2335
- const code = err.kind ?? 'unknown';
2336
- const message = err instanceof Error ? err.message : String(err);
2337
- // Map adapter errors to honest HTTP codes so the UI can render
2338
- // a useful empty/error state instead of swallowing everything.
2339
- const httpStatus = code === 'not_found'
2340
- ? 404
2341
- : code === 'auth'
2342
- ? 401
2343
- : code === 'rate_limit'
2344
- ? 429
2345
- : code === 'invalid_url'
2346
- ? 400
2347
- : 502;
2348
- res.writeHead(httpStatus, { 'Content-Type': 'application/json' });
2349
- res.end(JSON.stringify({ ok: false, kind: code, error: message }));
2350
- }
2351
- }
2352
- catch (e) {
2353
- console.error('[/api/context-source/preview] failed:', e);
2354
- res.writeHead(500, { 'Content-Type': 'application/json' });
2355
- res.end(JSON.stringify({ ok: false, error: 'preview failed' }));
2356
- }
2357
- })();
2358
- return;
2359
- }
2360
- /* v0.9 S6 part B · W-PASS write-pass routes ────────────────────────── */
2361
- // POST /api/writepass/intent · agent emits an intent.
2362
- // Body: { sessionId, target, method, payload, reason, emittedBy? }
2363
- // Response: 201 { status: 'pending', intent: WritePassIntent }
2364
- if (req.method === 'POST' && req.url === '/api/writepass/intent') {
2365
- (async () => {
2366
- try {
2367
- const text = await readBody(req, res);
2368
- let raw;
2369
- try {
2370
- raw = JSON.parse(text || '{}');
2371
- }
2372
- catch {
2373
- res.writeHead(400, { 'Content-Type': 'application/json' });
2374
- res.end(JSON.stringify({ error: 'invalid JSON' }));
2375
- return;
2376
- }
2377
- // Server assigns the id · agents shouldn't pick collisions.
2378
- raw.id = typeof raw.id === 'string' && raw.id ? raw.id : newPassId();
2379
- raw.emittedAt =
2380
- typeof raw.emittedAt === 'number' && Number.isFinite(raw.emittedAt) ? raw.emittedAt : Date.now();
2381
- let intent;
2382
- try {
2383
- intent = validateWritePassIntent(raw);
2384
- }
2385
- catch (err) {
2386
- if (err instanceof WritePassError) {
2387
- res.writeHead(400, { 'Content-Type': 'application/json' });
2388
- res.end(JSON.stringify({ error: err.code, detail: err.message }));
2389
- return;
2390
- }
2391
- throw err;
2392
- }
2393
- const status = wpPutPending(intent);
2394
- // Audit the intent immediately · the chain captures the agent's
2395
- // request even if the user never decides (i.e. session ends).
2396
- try {
2397
- const line = writePassStatusToAuditLine(status);
2398
- appendAuditLog({
2399
- timestamp: line.timestamp,
2400
- sessionId: line.sessionId,
2401
- toolName: line.toolName,
2402
- toolInput: line.toolInput,
2403
- riskScore: line.riskScore,
2404
- blocked: line.blocked,
2405
- findings: line.findings,
2406
- criticalFindings: line.criticalFindings,
2407
- });
2408
- }
2409
- catch (e) {
2410
- // Audit failure must not block the API (matches claude-sdk-hook discipline).
2411
- console.error('[w-pass] audit append failed for intent:', e);
2412
- }
2413
- res.writeHead(201, { 'Content-Type': 'application/json' });
2414
- res.end(JSON.stringify(status));
2415
- }
2416
- catch (e) {
2417
- console.error('[/api/writepass/intent] failed:', e);
2418
- res.writeHead(500, { 'Content-Type': 'application/json' });
2419
- res.end(JSON.stringify({ error: 'intent failed' }));
2420
- }
2421
- })();
2422
- return;
2423
- }
2424
- // GET /api/writepass/pending · list all pending intents (UI poll target).
2425
- if (req.method === 'GET' && req.url === '/api/writepass/pending') {
2426
- try {
2427
- const pending = wpListPending();
2428
- res.writeHead(200, { 'Content-Type': 'application/json' });
2429
- res.end(JSON.stringify({ passes: pending }));
2430
- }
2431
- catch (e) {
2432
- console.error('[/api/writepass/pending] failed:', e);
2433
- res.writeHead(500, { 'Content-Type': 'application/json' });
2434
- res.end(JSON.stringify({ error: 'list failed' }));
2435
- }
2436
- return;
2437
- }
2438
- // GET /api/writepass/session/:id · list all passes for a session.
2439
- if (req.method === 'GET' && req.url && /^\/api\/writepass\/session\/[^/]+$/.test(req.url)) {
2440
- try {
2441
- const m = req.url.match(/^\/api\/writepass\/session\/([^/]+)$/);
2442
- const sessionId = m ? decodeURIComponent(m[1]) : '';
2443
- const passes = wpListForSession(sessionId);
2444
- res.writeHead(200, { 'Content-Type': 'application/json' });
2445
- res.end(JSON.stringify({ sessionId, passes }));
2446
- }
2447
- catch (e) {
2448
- console.error('[/api/writepass/session] failed:', e);
2449
- res.writeHead(500, { 'Content-Type': 'application/json' });
2450
- res.end(JSON.stringify({ error: 'list failed' }));
2451
- }
2452
- return;
2453
- }
2454
- // GET /api/writepass/:id/trust · returns the domain trust grade the
2455
- // pending pass would resolve to. UI uses this to render the trust chip
2456
- // BEFORE the user clicks approve.
2457
- if (req.method === 'GET' && req.url && /^\/api\/writepass\/[^/]+\/trust$/.test(req.url)) {
2458
- try {
2459
- const m = req.url.match(/^\/api\/writepass\/([^/]+)\/trust$/);
2460
- const passId = m ? decodeURIComponent(m[1]) : '';
2461
- const current = wpGetById(passId);
2462
- if (!current) {
2463
- res.writeHead(404, { 'Content-Type': 'application/json' });
2464
- res.end(JSON.stringify({ error: 'pass not found' }));
2465
- return;
2466
- }
2467
- const payload = current.kind === 'edited' ? current.effectivePayload : current.intent.payload;
2468
- try {
2469
- const resolved = resolveWriteRequest(current.intent, payload, loadContextSourcesConfigForWritePass());
2470
- res.writeHead(200, { 'Content-Type': 'application/json' });
2471
- res.end(JSON.stringify(resolved.trust));
2472
- }
2473
- catch (err) {
2474
- // Resolver failed (auth missing, unsupported scheme) · return null
2475
- // trust so the UI knows there's no domain to grade yet.
2476
- res.writeHead(200, { 'Content-Type': 'application/json' });
2477
- res.end(JSON.stringify({
2478
- host: '',
2479
- grade: 'F',
2480
- reason: err instanceof Error ? err.message : 'resolve failed',
2481
- userTrusted: false,
2482
- requiresFirstWriteConfirmation: false,
2483
- }));
2484
- }
2485
- }
2486
- catch (e) {
2487
- console.error('[/api/writepass/.../trust] failed:', e);
2488
- res.writeHead(500, { 'Content-Type': 'application/json' });
2489
- res.end(JSON.stringify({ error: 'trust lookup failed' }));
2490
- }
2491
- return;
2492
- }
2493
- // POST /api/writepass/:id/decide · user approves / edits / cancels.
2494
- // Body: { kind: 'approve'|'edit'|'cancel', editedPayload?, note? }
2495
- // On approve/edit · the daemon resolves + sends, audits the outcome.
2496
- if (req.method === 'POST' && req.url && /^\/api\/writepass\/[^/]+\/decide$/.test(req.url)) {
2497
- (async () => {
2498
- try {
2499
- const m = req.url.match(/^\/api\/writepass\/([^/]+)\/decide$/);
2500
- const passId = m ? decodeURIComponent(m[1]) : '';
2501
- const current = wpGetById(passId);
2502
- if (!current) {
2503
- res.writeHead(404, { 'Content-Type': 'application/json' });
2504
- res.end(JSON.stringify({ error: 'pass not found', passId }));
2505
- return;
2506
- }
2507
- if (current.kind !== 'pending') {
2508
- res.writeHead(409, { 'Content-Type': 'application/json' });
2509
- res.end(JSON.stringify({ error: 'already decided', current }));
2510
- return;
2511
- }
2512
- const bodyText = await readBody(req, res);
2513
- let raw;
2514
- try {
2515
- raw = JSON.parse(bodyText || '{}');
2516
- }
2517
- catch {
2518
- res.writeHead(400, { 'Content-Type': 'application/json' });
2519
- res.end(JSON.stringify({ error: 'invalid JSON' }));
2520
- return;
2521
- }
2522
- // Default decidedAt to now if the client didn't set one.
2523
- raw.passId = passId;
2524
- raw.decidedAt =
2525
- typeof raw.decidedAt === 'number' && Number.isFinite(raw.decidedAt) ? raw.decidedAt : Date.now();
2526
- let decision;
2527
- try {
2528
- decision = validateWritePassDecision(raw, current.intent);
2529
- }
2530
- catch (err) {
2531
- if (err instanceof WritePassError) {
2532
- res.writeHead(400, { 'Content-Type': 'application/json' });
2533
- res.end(JSON.stringify({ error: err.code, detail: err.message }));
2534
- return;
2535
- }
2536
- throw err;
2537
- }
2538
- const next = applyWritePassDecision(current.intent, decision);
2539
- wpSetStatus(next);
2540
- // Audit the decision.
2541
- try {
2542
- const line = writePassStatusToAuditLine(next);
2543
- appendAuditLog({
2544
- timestamp: line.timestamp,
2545
- sessionId: line.sessionId,
2546
- toolName: line.toolName,
2547
- toolInput: line.toolInput,
2548
- riskScore: line.riskScore,
2549
- blocked: line.blocked,
2550
- findings: line.findings,
2551
- criticalFindings: line.criticalFindings,
2552
- });
2553
- }
2554
- catch (e) {
2555
- console.error('[w-pass] audit append failed for decision:', e);
2556
- }
2557
- // Cancelled · we stop here.
2558
- if (next.kind === 'cancelled') {
2559
- res.writeHead(200, { 'Content-Type': 'application/json' });
2560
- res.end(JSON.stringify(next));
2561
- return;
2562
- }
2563
- // Approved or edited · do the mediated send.
2564
- if (next.kind !== 'approved' && next.kind !== 'edited') {
2565
- res.writeHead(500, { 'Content-Type': 'application/json' });
2566
- res.end(JSON.stringify({ error: 'unexpected status after decision', next }));
2567
- return;
2568
- }
2569
- const effectivePayload = next.kind === 'edited' ? next.effectivePayload : current.intent.payload;
2570
- // Load auth config from the same context-sources.json the read
2571
- // adapters use · symmetric storage.
2572
- const writePassConfig = loadContextSourcesConfigForWritePass();
2573
- let resolved;
2574
- try {
2575
- resolved = resolveWriteRequest(current.intent, effectivePayload, writePassConfig);
2576
- }
2577
- catch (err) {
2578
- if (err instanceof WritePassResolveError) {
2579
- const failResponse = {
2580
- passId,
2581
- status: 0,
2582
- bodySnippet: '',
2583
- receivedAt: Date.now(),
2584
- error: err.message,
2585
- };
2586
- const failed = applyWritePassResponse(next, failResponse);
2587
- wpSetStatus(failed);
2588
- try {
2589
- const line = writePassStatusToAuditLine(failed);
2590
- appendAuditLog({
2591
- timestamp: line.timestamp,
2592
- sessionId: line.sessionId,
2593
- toolName: line.toolName,
2594
- toolInput: line.toolInput,
2595
- riskScore: line.riskScore,
2596
- blocked: line.blocked,
2597
- findings: line.findings,
2598
- criticalFindings: line.criticalFindings,
2599
- });
2600
- }
2601
- catch { }
2602
- res.writeHead(424, { 'Content-Type': 'application/json' });
2603
- res.end(JSON.stringify({ error: err.code, detail: err.message, status: failed }));
2604
- return;
2605
- }
2606
- throw err;
2607
- }
2608
- // v0.9 · per-domain mediated-write trust gate. If the resolved
2609
- // target's host is graded F (unknown), require the decision to
2610
- // explicitly include trustThisDomain:true before sending. Once
2611
- // set, persist the host to trusted-domains.json so subsequent
2612
- // writes auto-allow at grade B.
2613
- if (resolved.trust.requiresFirstWriteConfirmation) {
2614
- const decisionRaw = raw;
2615
- if (!decisionRaw.trustThisDomain) {
2616
- res.writeHead(202, { 'Content-Type': 'application/json' });
2617
- res.end(JSON.stringify({
2618
- status: 'requires_domain_trust',
2619
- host: resolved.trust.host,
2620
- grade: resolved.trust.grade,
2621
- reason: resolved.trust.reason,
2622
- hint: 'Re-POST with { "kind": "approve", "trustThisDomain": true } to trust this host once + send.',
2623
- }));
2624
- return;
2625
- }
2626
- try {
2627
- trustDomainStore(resolved.trust.host, 'trusted during first W-PASS approval');
2628
- }
2629
- catch (e) {
2630
- console.error('[w-pass] trustDomain store failed:', e);
2631
- }
2632
- }
2633
- // Mediated send.
2634
- let response;
2635
- try {
2636
- const f = await fetch(resolved.url, {
2637
- method: resolved.method,
2638
- headers: resolved.headers,
2639
- body: resolved.body,
2640
- });
2641
- const text = await f.text();
2642
- response = {
2643
- passId,
2644
- status: f.status,
2645
- bodySnippet: text.slice(0, 2048),
2646
- receivedAt: Date.now(),
2647
- };
2648
- }
2649
- catch (err) {
2650
- response = {
2651
- passId,
2652
- status: 0,
2653
- bodySnippet: '',
2654
- receivedAt: Date.now(),
2655
- error: err instanceof Error ? err.message : String(err),
2656
- };
2657
- }
2658
- const final = applyWritePassResponse(next, response);
2659
- wpSetStatus(final);
2660
- try {
2661
- const line = writePassStatusToAuditLine(final);
2662
- appendAuditLog({
2663
- timestamp: line.timestamp,
2664
- sessionId: line.sessionId,
2665
- toolName: line.toolName,
2666
- toolInput: line.toolInput,
2667
- riskScore: line.riskScore,
2668
- blocked: line.blocked,
2669
- findings: line.findings,
2670
- criticalFindings: line.criticalFindings,
2671
- });
2672
- }
2673
- catch (e) {
2674
- console.error('[w-pass] audit append failed for response:', e);
2675
- }
2676
- res.writeHead(final.kind === 'sent' ? 200 : 502, { 'Content-Type': 'application/json' });
2677
- res.end(JSON.stringify(final));
2678
- }
2679
- catch (e) {
2680
- console.error('[/api/writepass/decide] failed:', e);
2681
- res.writeHead(500, { 'Content-Type': 'application/json' });
2682
- res.end(JSON.stringify({ error: 'decide failed' }));
2683
- }
2684
- })();
2685
- return;
2686
- }
2687
- // /api/version — small completeness item for ops monitoring (rc.17).
2688
- // ESM-friendly: resolve package.json relative to the compiled module's
2689
- // own URL via fileURLToPath. Works in both src/ (during dev) and dist/
2690
- // because the relative path up to package.json is the same in both.
2691
- if (req.url === '/api/version' && req.method === 'GET') {
2692
- try {
2693
- const moduleDir = dirname(fileURLToPath(import.meta.url));
2694
- const pkgPath = join(moduleDir, '..', '..', '..', 'package.json');
2695
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
2696
- res.writeHead(200, { 'Content-Type': 'application/json' });
2697
- res.end(JSON.stringify({ version: pkg.version, name: pkg.name }));
2698
- }
2699
- catch (e) {
2700
- // rc.19: don't leak fs paths to the public HTTP surface.
2701
- // rc.20: but DO log the actual error context for the operator (Codex
2702
- // rc.19 review caught: previous version dropped `e` and logged only a
2703
- // static string, costing diagnosability).
2704
- console.error('[/api/version] failed to read package metadata:', e);
2705
- res.writeHead(500, { 'Content-Type': 'application/json' });
2706
- res.end(JSON.stringify({ error: 'Failed to read package metadata' }));
2707
- }
2708
- return;
2709
- }
2710
- // 404 — friendly HTML page with route list (Bug A6 fix May 6 2026).
2711
- // Pre-fix: empty body, blank-page failure mode the dogfood walk caught.
2712
- // Per claim_user_persona HIGH non-technical-first: a non-technical user
2713
- // who typos a URL gets a useful page back, not a void.
2714
- const requestedPath = (req.url ?? '/').split('?')[0];
2715
- res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
2716
- res.end(`<!doctype html>
2717
- <html lang="en">
2718
- <head>
2719
- <meta charset="utf-8"/>
2720
- <meta name="viewport" content="width=device-width,initial-scale=1"/>
2721
- <title>codehere · page not found</title>
2722
- <style>
2723
- :root {
2724
- --bg: #0f0f12;
2725
- --fg: #f5f5f7;
2726
- --fg-50: #8b8b94;
2727
- --fg-30: #5a5a62;
2728
- --border: #2a2a32;
2729
- --accent: #ff9a76;
2730
- --mono: ui-monospace, 'SF Mono', Menlo, monospace;
2731
- }
2732
- * { box-sizing: border-box; }
2733
- body {
2734
- margin: 0; min-height: 100vh;
2735
- background: var(--bg); color: var(--fg);
2736
- font-family: Inter, system-ui, -apple-system, 'Segoe UI', sans-serif;
2737
- display: flex; flex-direction: column; align-items: center; justify-content: center;
2738
- padding: 48px 24px;
2739
- }
2740
- main { max-width: 480px; width: 100%; }
2741
- .label {
2742
- font-family: var(--mono); font-size: 11px; letter-spacing: 1.5px;
2743
- color: var(--fg-30); margin-bottom: 12px;
2744
- }
2745
- h1 { font-size: 22px; font-weight: 600; margin: 0 0 12px 0; }
2746
- .path {
2747
- font-family: var(--mono); font-size: 13px;
2748
- color: var(--fg-50); background: rgba(255,255,255,0.04);
2749
- border: 1px solid var(--border); border-radius: 4px;
2750
- padding: 6px 10px; display: inline-block; margin-bottom: 28px;
2751
- word-break: break-all;
2752
- }
2753
- .routes {
2754
- border-top: 1px solid var(--border);
2755
- padding-top: 24px; margin-top: 8px;
2756
- }
2757
- .routes-label {
2758
- font-family: var(--mono); font-size: 11px; letter-spacing: 1.5px;
2759
- color: var(--fg-30); margin-bottom: 12px;
2760
- }
2761
- .route {
2762
- display: flex; align-items: baseline; gap: 14px;
2763
- padding: 8px 0; border-bottom: 1px solid var(--border);
2764
- text-decoration: none; color: var(--fg);
2765
- }
2766
- .route:last-child { border-bottom: none; }
2767
- .route:hover { background: rgba(255,255,255,0.02); }
2768
- .route-name { font-weight: 500; }
2769
- .route-path { font-family: var(--mono); font-size: 12px; color: var(--fg-50); margin-left: auto; }
2770
- </style>
2771
- </head>
2772
- <body>
2773
- <main>
2774
- <div class="label">404 · NOT FOUND</div>
2775
- <h1>That page doesn't exist</h1>
2776
- <div class="path">${escapeHtmlText(requestedPath)}</div>
2777
- <div class="routes">
2778
- <div class="routes-label">VALID ROUTES</div>
2779
- <a class="route" href="/"><span class="route-name">Dashboard</span><span class="route-path">/</span></a>
2780
- <a class="route" href="/threads"><span class="route-name">Project threads</span><span class="route-path">/threads</span></a>
2781
- <a class="route" href="/history"><span class="route-name">Audit history</span><span class="route-path">/history</span></a>
2782
- </div>
2783
- </main>
2784
- </body>
2785
- </html>`);
2786
- });
2787
- function escapeHtmlText(s) {
2788
- return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
2789
- }
2790
- server.listen(port, host, () => {
2791
- // Daemon-start data hygiene — runs once after listen so any failure
2792
- // doesn't block the server coming up. Both are best-effort.
2793
- void (async () => {
2794
- try {
2795
- const { backfillProjectTitles } = await import('../storage/project-thread-mirror.js');
2796
- const r = await backfillProjectTitles();
2797
- if (process.env.CODEHERE_DEBUG === '1' && r.rewritten > 0) {
2798
- console.error(`[daemon-start] backfilled ${r.rewritten}/${r.scanned} project titles`);
2799
- }
2800
- }
2801
- catch {
2802
- /* best-effort */
2803
- }
2804
- // Stale-session reconciliation. Walks per-project thread.jsonl files
2805
- // and marks any sessions stuck in 'running' for >60 min as crashed.
2806
- // Closes the dogfood-found gap where 3-day-old tasks displayed as
2807
- // RUNNING in the threads view because a daemon crash never wrote
2808
- // their completion event.
2809
- try {
2810
- const { reconcileStaleProjectTasks } = await import('../storage/project-thread-mirror.js');
2811
- const r = await reconcileStaleProjectTasks();
2812
- if (process.env.CODEHERE_DEBUG === '1' && r.reconciled > 0) {
2813
- console.error(`[daemon-start] reconciled ${r.reconciled} stale running tasks`);
2814
- }
2815
- }
2816
- catch {
2817
- /* best-effort */
2818
- }
2819
- })();
2820
- });
2821
- return server;
2822
- }
2823
- /**
2824
- * v0.8 S2.5 · response page rendered to the user's browser after the OAuth
2825
- * provider redirects to /oauth/callback. Tiny styled card with status +
2826
- * "you can close this tab now" affordance. Auto-closes after 3s if window
2827
- * was opened via window.open() from our UI.
2828
- */
2829
- function callbackHtml(status, message, providerLabel) {
2830
- const safe = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2831
- const tone = status === 'ok' ? '#28c840' : '#ff5f57';
2832
- const icon = status === 'ok' ? '✓' : '✗';
2833
- const title = status === 'ok' ? `${providerLabel ?? 'Provider'} connected` : 'Connection failed';
2834
- return `<!doctype html><html lang="en"><head>
2835
- <meta charset="utf-8"><title>${safe(title)}</title>
2836
- <style>
2837
- body { margin:0; background:#0a0a0a; color:#fff; font-family:ui-sans-serif,system-ui,-apple-system,sans-serif; display:flex; align-items:center; justify-content:center; min-height:100vh; }
2838
- .card { max-width:480px; padding:32px 36px; border:1px solid rgba(255,255,255,0.08); border-radius:12px; background:#111; }
2839
- .icon { width:48px; height:48px; border-radius:50%; background:${tone}; display:flex; align-items:center; justify-content:center; font-size:28px; font-weight:600; color:#000; margin-bottom:16px; }
2840
- h1 { font-size:18px; font-weight:600; margin:0 0 8px; }
2841
- p { color:rgba(255,255,255,0.65); font-size:14px; line-height:1.5; margin:0 0 16px; }
2842
- .hint { color:rgba(255,255,255,0.4); font-size:12px; font-family:ui-monospace,Menlo,monospace; }
2843
- </style></head><body>
2844
- <div class="card">
2845
- <div class="icon">${icon}</div>
2846
- <h1>${safe(title)}</h1>
2847
- <p>${safe(message)}</p>
2848
- <p class="hint">You can close this tab. Codehere has stored the connection locally.</p>
2849
- </div>
2850
- <script>setTimeout(function(){ try{ window.close(); }catch(_){} }, 3000);</script>
2851
- </body></html>`;
2852
- }
2853
- //# sourceMappingURL=mcp-server-impl.js.map