codehere 0.6.2 → 0.7.1
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.
- package/README.md +5 -3
- package/dist/application/use-cases/domain-trust.js +110 -0
- package/dist/application/use-cases/generate-final-report.js +87 -168
- package/dist/application/use-cases/generate-narration.js +11 -0
- package/dist/application/use-cases/generate-thread-title.js +4 -0
- package/dist/application/use-cases/thread-export.js +155 -0
- package/dist/application/use-cases/verify-claim.js +274 -0
- package/dist/application/use-cases/write-pass.js +277 -0
- package/dist/index.js +211 -16
- package/dist/infrastructure/completion/bash-completion.js +2 -2
- package/dist/infrastructure/config/config-manager.js +1 -1
- package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
- package/dist/infrastructure/context/adapters/figma-source.js +163 -0
- package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
- package/dist/infrastructure/context/adapters/linear-source.js +190 -0
- package/dist/infrastructure/context/context-resolver.js +144 -0
- package/dist/infrastructure/context/context-source.js +114 -0
- package/dist/infrastructure/context/oauth-flow.js +207 -0
- package/dist/infrastructure/context/oauth-store.js +119 -0
- package/dist/infrastructure/context/registry-bootstrap.js +99 -0
- package/dist/infrastructure/context/write-pass-resolver.js +106 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +73 -57
- package/dist/infrastructure/hooks/session-analyzer.js +65 -8
- package/dist/infrastructure/observability/execution-tracer.js +0 -5
- package/dist/infrastructure/orchestration/aider-adapter.js +49 -18
- package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
- package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
- package/dist/infrastructure/orchestration/claude-code-adapter.js +38 -5
- package/dist/infrastructure/orchestration/codex-adapter.js +150 -22
- package/dist/infrastructure/orchestration/conductor-adapter.js +9 -3
- package/dist/infrastructure/orchestration/context-handoff.js +3 -2
- package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
- package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +12 -2
- package/dist/infrastructure/orchestration/graphify-context.js +18 -13
- package/dist/infrastructure/orchestration/memory-injector.js +46 -1
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +230 -0
- package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
- package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
- package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +22 -2
- package/dist/infrastructure/orchestration/progress-aggregator.js +177 -3
- package/dist/infrastructure/orchestration/session-manager.js +112 -6
- package/dist/infrastructure/orchestration/session-persistence.js +140 -31
- package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
- package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
- package/dist/infrastructure/protocols/cloud-sync.js +1 -6
- package/dist/infrastructure/protocols/mcp-server-impl.js +1283 -13
- package/dist/infrastructure/protocols/thread-phase.js +105 -0
- package/dist/infrastructure/protocols/ui-page-v2.js +3913 -88
- package/dist/infrastructure/protocols/ui-page.js +12 -3299
- package/dist/infrastructure/protocols/verify-page.js +941 -0
- package/dist/infrastructure/security/trust-scorer.js +76 -1
- package/dist/infrastructure/storage/cross-tool-agents.js +1 -1
- package/dist/infrastructure/storage/project-thread-migration.js +2 -2
- package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
- package/dist/monitoring.js +1 -1
- package/dist/presentation/cli/commands/audit-command.js +126 -0
- package/dist/presentation/cli/commands/config-command.js +2 -2
- package/dist/presentation/cli/commands/context-source-command.js +198 -0
- package/dist/presentation/cli/commands/help-command.js +51 -1
- package/dist/presentation/cli/commands/report-command.js +6 -4
- package/dist/presentation/cli/commands/setup-command.js +71 -18
- package/dist/presentation/cli/commands/status-command.js +0 -4
- package/dist/presentation/cli/commands/thread-command.js +288 -0
- package/dist/presentation/cli/commands/verify-command.js +233 -0
- package/dist/presentation/cli/error-display.js +1 -1
- package/dist/presentation/cli/repl.js +32 -1
- package/dist/presentation/cli/task-deliverable.js +127 -0
- package/dist/testing/cassette.js +125 -0
- package/dist/testing/replay-adapter.js +166 -0
- package/dist/ui.js +18 -1
- package/package.json +5 -2
|
@@ -23,10 +23,27 @@ import { readFileSync, existsSync, statSync, watchFile, unwatchFile } from 'fs';
|
|
|
23
23
|
import { join, relative, dirname } from 'path';
|
|
24
24
|
import { fileURLToPath } from 'url';
|
|
25
25
|
import { collectFilesForMCP, scoreFileForMCP } from './mcp-helpers.js';
|
|
26
|
-
import { scanPayload, calculateRiskScore, AUDIT_FILE, verifyAuditChain
|
|
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';
|
|
27
29
|
import { getVersion } from '../../utils/version.js';
|
|
28
|
-
import {
|
|
30
|
+
import { HISTORY_HTML, THREAD_HTML } from './ui-page.js';
|
|
29
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';
|
|
30
47
|
import { SessionManager, availableAgents, readinessForAllAgents, getAdapter, allAdapters, } from '../orchestration/session-manager.js';
|
|
31
48
|
import { isAbsolute } from 'path';
|
|
32
49
|
import { readCrossToolAgents } from '../storage/cross-tool-agents.js';
|
|
@@ -97,7 +114,8 @@ const TOOLS = [
|
|
|
97
114
|
];
|
|
98
115
|
// ── Tool handlers ────────────────────────────────────────────────────────────
|
|
99
116
|
import { homedir } from 'os';
|
|
100
|
-
const
|
|
117
|
+
const controlSessions = new Map();
|
|
118
|
+
let controlSeq = 0;
|
|
101
119
|
/**
|
|
102
120
|
* Dev-only behavior-observer injection (v0.6.0 — elu joined the team roster
|
|
103
121
|
* 2026-05-03 as behavior reviewer). Activated only when CODEHERE_DEV_ELU=1
|
|
@@ -175,9 +193,26 @@ function handleScan(params) {
|
|
|
175
193
|
findings: findings.slice(0, 10), // cap at 10 to keep response small
|
|
176
194
|
});
|
|
177
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Coerce a client-supplied numeric param to a bounded integer. Daemon
|
|
198
|
+
* endpoints take topN/limit straight from request bodies; a negative or NaN
|
|
199
|
+
* value silently corrupts slice() math (limit=-1 → slice keeps all-but-last,
|
|
200
|
+
* NaN → slice returns everything). Clamp to a sane range instead of trusting
|
|
201
|
+
* the wire.
|
|
202
|
+
*/
|
|
203
|
+
export function clampInt(v, def, min, max) {
|
|
204
|
+
// null/undefined = absent → default. Number(null) is 0 (finite), so without
|
|
205
|
+
// this guard a literal null would coerce to the min instead of the default.
|
|
206
|
+
if (v === null || v === undefined)
|
|
207
|
+
return def;
|
|
208
|
+
const n = Math.floor(Number(v));
|
|
209
|
+
if (!Number.isFinite(n))
|
|
210
|
+
return def;
|
|
211
|
+
return Math.max(min, Math.min(n, max));
|
|
212
|
+
}
|
|
178
213
|
function handleReview(params) {
|
|
179
214
|
const targetPath = String(params.path ?? process.cwd());
|
|
180
|
-
const topN =
|
|
215
|
+
const topN = clampInt(params.topN, 10, 1, 100);
|
|
181
216
|
try {
|
|
182
217
|
const files = collectFilesForMCP(targetPath);
|
|
183
218
|
if (files.length === 0) {
|
|
@@ -206,23 +241,23 @@ function handleReview(params) {
|
|
|
206
241
|
}
|
|
207
242
|
}
|
|
208
243
|
function handleAudit(params) {
|
|
209
|
-
const limit =
|
|
244
|
+
const limit = clampInt(params.limit, 20, 1, 500);
|
|
210
245
|
const blockedOnly = Boolean(params.blockedOnly);
|
|
211
246
|
const overrideOnly = Boolean(params.overrideOnly);
|
|
212
247
|
const verify = Boolean(params.verify);
|
|
213
248
|
if (verify) {
|
|
214
249
|
// Tamper-evidence verification — walks the file, recomputes every hash,
|
|
215
250
|
// checks the chain. Returns full report including ALL break points.
|
|
216
|
-
const result = verifyAuditChain(
|
|
251
|
+
const result = verifyAuditChain(AUDIT_FILE);
|
|
217
252
|
return JSON.stringify(result, null, 2);
|
|
218
253
|
}
|
|
219
|
-
if (!existsSync(
|
|
254
|
+
if (!existsSync(AUDIT_FILE)) {
|
|
220
255
|
return JSON.stringify({
|
|
221
256
|
message: 'No audit log yet — install hooks first with: codehere hook install',
|
|
222
257
|
entries: [],
|
|
223
258
|
});
|
|
224
259
|
}
|
|
225
|
-
const lines = readFileSync(
|
|
260
|
+
const lines = readFileSync(AUDIT_FILE, 'utf-8')
|
|
226
261
|
.split('\n')
|
|
227
262
|
.filter(Boolean)
|
|
228
263
|
.slice(-Math.min(limit * 3, 500)); // read more than needed to account for filter
|
|
@@ -301,6 +336,11 @@ function dispatch(req) {
|
|
|
301
336
|
}
|
|
302
337
|
}
|
|
303
338
|
const MAX_BODY_SIZE = 2_097_152; // 2 MiB — covers 1MB code-scan payloads + framing
|
|
339
|
+
// v0.8 S2.5 · in-memory map of pending OAuth flows keyed by `state` value.
|
|
340
|
+
// Lifecycle: begin() inserts; callback resolves and removes; entries also
|
|
341
|
+
// expire on a 10-min wall clock (the oauth-flow.ts complete() does the
|
|
342
|
+
// check). Daemon restart wipes this · users just click Connect again.
|
|
343
|
+
const PENDING_OAUTH_FLOWS = new Map();
|
|
304
344
|
function readBody(req, res) {
|
|
305
345
|
return new Promise((resolve, reject) => {
|
|
306
346
|
let body = '';
|
|
@@ -317,11 +357,52 @@ function readBody(req, res) {
|
|
|
317
357
|
req.on('error', () => reject(new Error('request aborted')));
|
|
318
358
|
});
|
|
319
359
|
}
|
|
360
|
+
/** v0.9 S6 · Load context-sources.json into the shape resolveWriteRequest
|
|
361
|
+
* expects. Same on-disk file as the READ adapters · symmetric storage. */
|
|
362
|
+
function loadContextSourcesConfigForWritePass() {
|
|
363
|
+
const path = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'context-sources.json');
|
|
364
|
+
if (!fsExistsSync(path))
|
|
365
|
+
return {};
|
|
366
|
+
try {
|
|
367
|
+
const raw = JSON.parse(fsReadFileSync(path, 'utf-8'));
|
|
368
|
+
return raw;
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return {};
|
|
372
|
+
}
|
|
373
|
+
}
|
|
320
374
|
export function startMCPServer(options = {}) {
|
|
321
375
|
const port = options.port ?? 3773;
|
|
322
376
|
const host = options.host ?? '127.0.0.1'; // localhost only by default
|
|
323
377
|
const token = options.token;
|
|
324
378
|
const server = http.createServer((req, res) => {
|
|
379
|
+
// v0.7 Week-2.2 · CORS for read-only public endpoints.
|
|
380
|
+
// The landing-page daemon-status pill (codehere.uk → 127.0.0.1:3773)
|
|
381
|
+
// fetches /health · /api/version · /api/tasks across origins to render
|
|
382
|
+
// "running locally · vX.Y.Z · N tasks." Without these headers the
|
|
383
|
+
// browser blocks the cross-origin fetch. Safe to allow * on these
|
|
384
|
+
// because the endpoints expose only version + task COUNTS (no PII ·
|
|
385
|
+
// no auth-state · no payload). Mutation endpoints (POST /api/task,
|
|
386
|
+
// /api/writepass/:id/:decision, etc.) are NOT covered by this · they
|
|
387
|
+
// remain same-origin-only.
|
|
388
|
+
const url = req.url ?? '';
|
|
389
|
+
const isReadOnlyPublic = url === '/health' ||
|
|
390
|
+
url === '/api/version' ||
|
|
391
|
+
url === '/api/tasks' ||
|
|
392
|
+
url === '/api/host-sessions' ||
|
|
393
|
+
url.startsWith('/api/host-sessions/') ||
|
|
394
|
+
url.startsWith('/api/audit/stats') ||
|
|
395
|
+
url.startsWith('/api/readiness/agents');
|
|
396
|
+
if (isReadOnlyPublic) {
|
|
397
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
398
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
399
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
400
|
+
if (req.method === 'OPTIONS') {
|
|
401
|
+
res.writeHead(204);
|
|
402
|
+
res.end();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
325
406
|
// Auth check — timing-safe comparison
|
|
326
407
|
if (token) {
|
|
327
408
|
const authHeader = req.headers.authorization ?? '';
|
|
@@ -338,10 +419,34 @@ export function startMCPServer(options = {}) {
|
|
|
338
419
|
return;
|
|
339
420
|
}
|
|
340
421
|
}
|
|
422
|
+
// CSRF / DNS-rebind defense for state-changing requests. The daemon binds
|
|
423
|
+
// localhost, but the user's own browser can reach it — so a page on a
|
|
424
|
+
// malicious site could fire a cross-origin POST (a text/plain "simple
|
|
425
|
+
// request" sidesteps the CORS preflight) to spawn an agent or mutate state.
|
|
426
|
+
// `codehere start` runs without a bearer token, so that gate is off by
|
|
427
|
+
// default; this enforces the same-origin-only intent noted above. Browsers
|
|
428
|
+
// attach Origin / Sec-Fetch-Site on these requests; native clients (the
|
|
429
|
+
// CLI, curl, MCP stdio) do not — so we reject only requests a browser
|
|
430
|
+
// marks as cross-origin, and leave header-less non-browser clients alone.
|
|
431
|
+
const method = req.method ?? 'GET';
|
|
432
|
+
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
|
|
433
|
+
const sfs = req.headers['sec-fetch-site'];
|
|
434
|
+
const origin = req.headers.origin;
|
|
435
|
+
const selfOrigins = new Set([`http://${host}:${port}`, `http://127.0.0.1:${port}`, `http://localhost:${port}`]);
|
|
436
|
+
const browserCrossOrigin = (typeof sfs === 'string' && sfs !== 'same-origin' && sfs !== 'none') ||
|
|
437
|
+
(typeof origin === 'string' && origin !== 'null' && !selfOrigins.has(origin));
|
|
438
|
+
if (browserCrossOrigin) {
|
|
439
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
440
|
+
res.end(JSON.stringify({ error: 'cross-origin request rejected' }));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
341
444
|
// Health check
|
|
342
445
|
if (req.method === 'GET' && req.url === '/health') {
|
|
343
446
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
344
|
-
|
|
447
|
+
// `mock` lets a no-spend tool (verify-all) confirm a daemon is mock BEFORE
|
|
448
|
+
// attaching — so it can refuse to drive a LIVE daemon while claiming "no spend".
|
|
449
|
+
res.end(JSON.stringify({ status: 'ok', server: 'codehere-mcp', version: getVersion(), tools: TOOLS.length, mock: process.env.CODEHERE_MOCK === '1' }));
|
|
345
450
|
return;
|
|
346
451
|
}
|
|
347
452
|
// v2 Task-Centric PM UI (primary — served at /)
|
|
@@ -350,10 +455,21 @@ export function startMCPServer(options = {}) {
|
|
|
350
455
|
res.end(withDevElu(TASK_HTML_V2));
|
|
351
456
|
return;
|
|
352
457
|
}
|
|
353
|
-
// v1
|
|
458
|
+
// /v1 deleted 2026-05-23 · failover UI ported to v2 (see ui-page-v2.ts
|
|
459
|
+
// .failover-banner). The route now redirects to / so old bookmarks land
|
|
460
|
+
// on the real product instead of 404.
|
|
354
461
|
if (req.method === 'GET' && (req.url === '/v1' || req.url === '/v1/')) {
|
|
462
|
+
res.writeHead(301, { Location: '/' });
|
|
463
|
+
res.end();
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
// verify-at-the-surface dashboard · /verify (+ /verify/replay, query params).
|
|
467
|
+
// Serves the SAME HTML for both — the page reads ?mode=replay itself. It
|
|
468
|
+
// embeds `/` in an iframe as the real observation surface. Not dev-elu
|
|
469
|
+
// wrapped: it IS the verification view, the elu overlay would be noise.
|
|
470
|
+
if (req.method === 'GET' && req.url && /^\/verify(\/replay)?(\?.*)?$/.test(req.url)) {
|
|
355
471
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
356
|
-
res.end(
|
|
472
|
+
res.end(VERIFY_HTML);
|
|
357
473
|
return;
|
|
358
474
|
}
|
|
359
475
|
// History / audit feed (what we built Day 0; now secondary)
|
|
@@ -667,6 +783,310 @@ export function startMCPServer(options = {}) {
|
|
|
667
783
|
}));
|
|
668
784
|
return;
|
|
669
785
|
}
|
|
786
|
+
// ── Host sessions — the coding-agent CLIs codehere observes via the hook ──
|
|
787
|
+
// These are NOT spawned-by-codehere agents (those live in /api/tasks). These
|
|
788
|
+
// are the Claude Code / Codex / etc. sessions running in the user's own
|
|
789
|
+
// terminal, captured by the PreToolUse hook. This is the surface that proves
|
|
790
|
+
// the INWARD axis: every coding agent — including the one the user is talking
|
|
791
|
+
// to right now — is a first-class workspace member, not a black box.
|
|
792
|
+
if (req.method === 'GET' && req.url === '/api/host-sessions') {
|
|
793
|
+
const entries = [];
|
|
794
|
+
try {
|
|
795
|
+
if (existsSync(AUDIT_FILE)) {
|
|
796
|
+
const lines = readFileSync(AUDIT_FILE, 'utf-8').split('\n');
|
|
797
|
+
for (const line of lines) {
|
|
798
|
+
const t = line.trim();
|
|
799
|
+
if (!t)
|
|
800
|
+
continue;
|
|
801
|
+
try {
|
|
802
|
+
const e = JSON.parse(t);
|
|
803
|
+
if (e && typeof e.sessionId === 'string')
|
|
804
|
+
entries.push(e);
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
/* skip malformed line */
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
/* empty audit — empty list is fine */
|
|
814
|
+
}
|
|
815
|
+
// Only surface genuine host coding sessions — not seed fixtures or the
|
|
816
|
+
// synthetic WritePass/pre_prompt audit rows. isRealHostSession() requires
|
|
817
|
+
// a real coding tool + 48h recency. Without it the strip would claim
|
|
818
|
+
// "10 sessions running" when 9 are stale demo artifacts — the screen-lie
|
|
819
|
+
// the no-screen-lies rule bans.
|
|
820
|
+
const now = Date.now();
|
|
821
|
+
const sessions = analyzeSessions(entries)
|
|
822
|
+
.filter((s) => s.sessionId !== 'unknown')
|
|
823
|
+
.filter((s) => isRealHostSession(s, now))
|
|
824
|
+
.sort((a, b) => new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime())
|
|
825
|
+
.slice(0, 10)
|
|
826
|
+
.map((s) => ({
|
|
827
|
+
sessionId: s.sessionId,
|
|
828
|
+
shortId: s.sessionId.substring(0, 8),
|
|
829
|
+
totalCalls: s.totalCalls,
|
|
830
|
+
blockedCalls: s.blockedCalls,
|
|
831
|
+
criticalCalls: s.criticalCalls,
|
|
832
|
+
durationMs: s.durationMs,
|
|
833
|
+
lastSeen: s.lastSeen,
|
|
834
|
+
risk: s.amplificationRisk,
|
|
835
|
+
toolMix: s.toolMix,
|
|
836
|
+
}));
|
|
837
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
838
|
+
res.end(JSON.stringify({ sessions, total: sessions.length }));
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
// ── Host session as a THREAD — full tool-call timeline for one observed ──
|
|
842
|
+
// session. This is the meta-dogfood payoff: open the coding-agent session
|
|
843
|
+
// running in your terminal and see every call codehere scanned, in order,
|
|
844
|
+
// hash-chained. GET /api/host-sessions/:id
|
|
845
|
+
const hostDetailMatch = req.url?.match(/^\/api\/host-sessions\/([^/?]+)$/);
|
|
846
|
+
if (req.method === 'GET' && hostDetailMatch) {
|
|
847
|
+
const wantId = decodeURIComponent(hostDetailMatch[1]);
|
|
848
|
+
// Group matching entries by their FULL session id, then resolve to ONE
|
|
849
|
+
// session. The strip links with an 8-char short id; if two sessions share
|
|
850
|
+
// that prefix, a naive startsWith() would merge their timelines into a
|
|
851
|
+
// single bogus session. Grouping + resolving keeps each session distinct.
|
|
852
|
+
const bySession = new Map();
|
|
853
|
+
const lastTs = new Map();
|
|
854
|
+
try {
|
|
855
|
+
if (existsSync(AUDIT_FILE)) {
|
|
856
|
+
const lines = readFileSync(AUDIT_FILE, 'utf-8').split('\n');
|
|
857
|
+
for (const line of lines) {
|
|
858
|
+
const t = line.trim();
|
|
859
|
+
if (!t)
|
|
860
|
+
continue;
|
|
861
|
+
let e;
|
|
862
|
+
try {
|
|
863
|
+
e = JSON.parse(t);
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
const sid = e.sessionId;
|
|
869
|
+
if (!sid || (sid !== wantId && !sid.startsWith(wantId)))
|
|
870
|
+
continue;
|
|
871
|
+
// Plain-English preview: the bash command or the file path, never
|
|
872
|
+
// the full JSON blob. Persona-facing surface, not a log dump.
|
|
873
|
+
const preview = previewFromToolInput(e.toolInput);
|
|
874
|
+
const call = {
|
|
875
|
+
timestamp: e.timestamp,
|
|
876
|
+
toolName: e.toolName,
|
|
877
|
+
riskScore: e.riskScore ?? 0,
|
|
878
|
+
blocked: !!e.blocked,
|
|
879
|
+
findings: e.findings ?? 0,
|
|
880
|
+
criticalFindings: e.criticalFindings ?? 0,
|
|
881
|
+
seq: e.seq,
|
|
882
|
+
chained: typeof e.entryHash === 'string' && e.entryHash.length > 0,
|
|
883
|
+
preview,
|
|
884
|
+
};
|
|
885
|
+
if (!bySession.has(sid))
|
|
886
|
+
bySession.set(sid, []);
|
|
887
|
+
bySession.get(sid).push(call);
|
|
888
|
+
const ts = e.timestamp ? Date.parse(e.timestamp) : 0;
|
|
889
|
+
if (Number.isFinite(ts) && ts > (lastTs.get(sid) ?? 0))
|
|
890
|
+
lastTs.set(sid, ts);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
/* empty / unreadable audit — empty timeline is honest */
|
|
896
|
+
}
|
|
897
|
+
// Resolve to one session: an exact full-id match wins; otherwise the most
|
|
898
|
+
// recently active session carrying the prefix. We never merge two.
|
|
899
|
+
let resolvedId;
|
|
900
|
+
if (bySession.has(wantId)) {
|
|
901
|
+
resolvedId = wantId;
|
|
902
|
+
}
|
|
903
|
+
else if (bySession.size >= 1) {
|
|
904
|
+
resolvedId = [...bySession.keys()].sort((a, b) => (lastTs.get(b) ?? 0) - (lastTs.get(a) ?? 0))[0];
|
|
905
|
+
}
|
|
906
|
+
const calls = resolvedId ? bySession.get(resolvedId) : [];
|
|
907
|
+
if (calls.length === 0) {
|
|
908
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
909
|
+
res.end(JSON.stringify({ error: 'No observed session matching ' + wantId }));
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
calls.sort((a, b) => new Date(String(a.timestamp)).getTime() - new Date(String(b.timestamp)).getTime());
|
|
913
|
+
const chain = verifyAuditChain(AUDIT_FILE);
|
|
914
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
915
|
+
res.end(JSON.stringify({
|
|
916
|
+
sessionId: resolvedId ?? wantId,
|
|
917
|
+
calls,
|
|
918
|
+
totalCalls: calls.length,
|
|
919
|
+
blockedCalls: calls.filter((c) => c.blocked).length,
|
|
920
|
+
criticalCalls: calls.filter((c) => c.criticalFindings > 0).length,
|
|
921
|
+
chainValid: chain.valid,
|
|
922
|
+
chainEntries: chain.chainedEntries,
|
|
923
|
+
}));
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
// ── Remote control · drive a running agent from the web ───────────────
|
|
927
|
+
// POST /api/control/open { agent, prompt } → opens a ControlSurface, starts
|
|
928
|
+
// a thread + initial turn, returns { controlId, threadId }.
|
|
929
|
+
// POST /api/control/:id/inject { message } → turn/steer into the running
|
|
930
|
+
// turn (the remote-control primitive).
|
|
931
|
+
// GET /api/control/:id/events → what the driven agent emitted.
|
|
932
|
+
// Same-origin only (mutating). Under CODEHERE_MOCK=1 the surface drives the
|
|
933
|
+
// mock app-server — zero API, no rate-limit.
|
|
934
|
+
if (req.method === 'POST' && req.url === '/api/control/open') {
|
|
935
|
+
(async () => {
|
|
936
|
+
try {
|
|
937
|
+
const raw = JSON.parse((await readBody(req, res)) || '{}');
|
|
938
|
+
const agent = String(raw.agent ?? 'codex');
|
|
939
|
+
const prompt = String(raw.prompt ?? '').trim();
|
|
940
|
+
if (!prompt) {
|
|
941
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
942
|
+
res.end(JSON.stringify({ error: 'prompt required' }));
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (agent !== 'codex') {
|
|
946
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
947
|
+
res.end(JSON.stringify({ error: 'only codex control surface is wired today' }));
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
const surface = new CodexControlSurface();
|
|
951
|
+
const controlId = 'ctrl-' + ++controlSeq;
|
|
952
|
+
const entry = { surface, agent, threadId: '', events: [], openedAt: Date.now() };
|
|
953
|
+
surface.onEvent((e) => entry.events.push(e));
|
|
954
|
+
controlSessions.set(controlId, entry);
|
|
955
|
+
const { threadId } = await surface.open(prompt);
|
|
956
|
+
entry.threadId = threadId;
|
|
957
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
958
|
+
res.end(JSON.stringify({ controlId, threadId, agent }));
|
|
959
|
+
}
|
|
960
|
+
catch (e) {
|
|
961
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
962
|
+
res.end(JSON.stringify({ error: 'control open failed: ' + e.message }));
|
|
963
|
+
}
|
|
964
|
+
})();
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const ctlInject = req.url?.match(/^\/api\/control\/([^/]+)\/inject$/);
|
|
968
|
+
if (req.method === 'POST' && ctlInject) {
|
|
969
|
+
(async () => {
|
|
970
|
+
const entry = controlSessions.get(decodeURIComponent(ctlInject[1]));
|
|
971
|
+
if (!entry) {
|
|
972
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
973
|
+
res.end(JSON.stringify({ error: 'no such control session' }));
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
try {
|
|
977
|
+
const raw = JSON.parse((await readBody(req, res)) || '{}');
|
|
978
|
+
const message = String(raw.message ?? '').trim();
|
|
979
|
+
if (!message) {
|
|
980
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
981
|
+
res.end(JSON.stringify({ error: 'message required' }));
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
const r = await entry.surface.inject(message);
|
|
985
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
986
|
+
res.end(JSON.stringify({ accepted: r.accepted }));
|
|
987
|
+
}
|
|
988
|
+
catch (e) {
|
|
989
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
990
|
+
res.end(JSON.stringify({ error: 'inject failed: ' + e.message }));
|
|
991
|
+
}
|
|
992
|
+
})();
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const ctlEvents = req.url?.match(/^\/api\/control\/([^/]+)\/events$/);
|
|
996
|
+
if (req.method === 'GET' && ctlEvents) {
|
|
997
|
+
const entry = controlSessions.get(decodeURIComponent(ctlEvents[1]));
|
|
998
|
+
if (!entry) {
|
|
999
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1000
|
+
res.end(JSON.stringify({ error: 'no such control session' }));
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1004
|
+
res.end(JSON.stringify({ threadId: entry.threadId, agent: entry.agent, events: entry.events }));
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
// ── User override · a human reviewed a block and chose to proceed ──────
|
|
1008
|
+
// POST /api/audit/override · body { sessionId, toolName, reason }. Appends
|
|
1009
|
+
// a 'user_override' entry with the free-text reason to the hash chain and
|
|
1010
|
+
// returns the new entry's seq + short hash, so the UI can show "logged ·
|
|
1011
|
+
// chain continues <hash>" with the REAL hash, not a mockup. Same-origin
|
|
1012
|
+
// only (mutating · not in the CORS read-only list).
|
|
1013
|
+
if (req.method === 'POST' && req.url === '/api/audit/override') {
|
|
1014
|
+
(async () => {
|
|
1015
|
+
try {
|
|
1016
|
+
const text = await readBody(req, res);
|
|
1017
|
+
let raw;
|
|
1018
|
+
try {
|
|
1019
|
+
raw = JSON.parse(text || '{}');
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1023
|
+
res.end(JSON.stringify({ error: 'invalid JSON' }));
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const reason = String(raw.reason ?? '').trim();
|
|
1027
|
+
if (!reason) {
|
|
1028
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1029
|
+
res.end(JSON.stringify({ error: 'a reason is required to log an override' }));
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
appendOverrideAudit('user_override', {
|
|
1033
|
+
sessionId: typeof raw.sessionId === 'string' ? raw.sessionId : 'unknown',
|
|
1034
|
+
toolName: typeof raw.toolName === 'string' ? raw.toolName : 'unknown',
|
|
1035
|
+
note: reason,
|
|
1036
|
+
});
|
|
1037
|
+
// appendOverrideAudit → appendAuditLog is fail-open: it swallows
|
|
1038
|
+
// write errors so a logging failure never blocks a tool call. That's
|
|
1039
|
+
// right for the hook, but here the UI shows "✓ override logged", so
|
|
1040
|
+
// we must CONFIRM our entry actually landed before claiming success
|
|
1041
|
+
// (no screen-lies). Scan the tail backward for our exact entry — a
|
|
1042
|
+
// concurrent hook process may have appended after ours, so it isn't
|
|
1043
|
+
// guaranteed to be the very last line.
|
|
1044
|
+
const wantSid = typeof raw.sessionId === 'string' ? raw.sessionId : 'unknown';
|
|
1045
|
+
const wantNote = reason.slice(0, 500);
|
|
1046
|
+
let seq = null;
|
|
1047
|
+
let entryHash = '';
|
|
1048
|
+
let confirmed = false;
|
|
1049
|
+
try {
|
|
1050
|
+
const lines = readFileSync(AUDIT_FILE, 'utf-8').trim().split('\n');
|
|
1051
|
+
for (let i = lines.length - 1; i >= 0 && i >= lines.length - 10; i--) {
|
|
1052
|
+
let e;
|
|
1053
|
+
try {
|
|
1054
|
+
e = JSON.parse(lines[i]);
|
|
1055
|
+
}
|
|
1056
|
+
catch {
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
if (e.overrideReason === 'user_override' && e.sessionId === wantSid && e.overrideNote === wantNote) {
|
|
1060
|
+
seq = typeof e.seq === 'number' ? e.seq : null;
|
|
1061
|
+
entryHash = typeof e.entryHash === 'string' ? e.entryHash : '';
|
|
1062
|
+
confirmed = true;
|
|
1063
|
+
break;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
catch {
|
|
1068
|
+
/* readback failed — cannot confirm the write landed */
|
|
1069
|
+
}
|
|
1070
|
+
if (!confirmed) {
|
|
1071
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1072
|
+
res.end(JSON.stringify({ ok: false, error: 'override could not be confirmed in the audit log' }));
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1076
|
+
res.end(JSON.stringify({
|
|
1077
|
+
ok: true,
|
|
1078
|
+
seq,
|
|
1079
|
+
entryHash,
|
|
1080
|
+
shortHash: entryHash ? entryHash.slice(0, 4) + '...' + entryHash.slice(-4) : '',
|
|
1081
|
+
}));
|
|
1082
|
+
}
|
|
1083
|
+
catch {
|
|
1084
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1085
|
+
res.end(JSON.stringify({ error: 'override failed' }));
|
|
1086
|
+
}
|
|
1087
|
+
})();
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
670
1090
|
// ── F1 · Agent install (zero-terminal setup — principle #10) ──────────────
|
|
671
1091
|
// POST /api/agents/:id/install → SSE stream of install output.
|
|
672
1092
|
// The command comes from the adapter's checkReadiness().installHint — never
|
|
@@ -938,6 +1358,10 @@ export function startMCPServer(options = {}) {
|
|
|
938
1358
|
results.push({ error: 'prompt (string) required', agent: t.agent ?? null });
|
|
939
1359
|
continue;
|
|
940
1360
|
}
|
|
1361
|
+
if (prompt.trim().length === 0) {
|
|
1362
|
+
results.push({ error: 'prompt is empty — describe what you want done', agent: t.agent ?? null });
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
941
1365
|
const agentId = typeof t.agent === 'string' && t.agent.length > 0 ? t.agent : 'conductor';
|
|
942
1366
|
if (!validAgentIds.has(agentId)) {
|
|
943
1367
|
results.push({ error: `Unknown agent: ${agentId}`, agent: agentId });
|
|
@@ -1011,6 +1435,14 @@ export function startMCPServer(options = {}) {
|
|
|
1011
1435
|
res.end(JSON.stringify({ error: 'prompt (string) required' }));
|
|
1012
1436
|
return;
|
|
1013
1437
|
}
|
|
1438
|
+
// Whitespace-only prompts are a real spawn with nothing to do —
|
|
1439
|
+
// they burn an agent run for no work (dogfood 2026-05-27 Phase D).
|
|
1440
|
+
// Reject before spawn; require something to act on.
|
|
1441
|
+
if (prompt.trim().length === 0) {
|
|
1442
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1443
|
+
res.end(JSON.stringify({ error: 'prompt is empty — describe what you want done' }));
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1014
1446
|
// CRIT-5 fix · whitelist agent ID against the registry BEFORE any
|
|
1015
1447
|
// log/spawn. Stops log injection (`agent: "claude-code\n[FAKE]"`),
|
|
1016
1448
|
// future-proofs against partial-match exploits, and fails fast
|
|
@@ -1065,7 +1497,36 @@ export function startMCPServer(options = {}) {
|
|
|
1065
1497
|
}
|
|
1066
1498
|
}
|
|
1067
1499
|
}
|
|
1068
|
-
|
|
1500
|
+
// v0.8 S2 · resolve any context-source URLs in the prompt
|
|
1501
|
+
// (linear://INFRA-247, figma://file/<key>/...) before spawn.
|
|
1502
|
+
// Best-effort · resolver failures get logged inline in the
|
|
1503
|
+
// prompt + emit audit lines, never block the spawn.
|
|
1504
|
+
const reg = getDefaultRegistry();
|
|
1505
|
+
const fetchCtx = createDefaultFetchContextWithPost({
|
|
1506
|
+
emitAuditLine: (line) => {
|
|
1507
|
+
// Future · feed into the hook audit chain (F-05/F-06).
|
|
1508
|
+
// For now console-debug only · the spawn audit line below
|
|
1509
|
+
// captures the user-facing outcome.
|
|
1510
|
+
if (process.env.CODEHERE_DEBUG === '1') {
|
|
1511
|
+
console.error('[context-source]', line.status, line.source, line.url, line.detail ?? '');
|
|
1512
|
+
}
|
|
1513
|
+
},
|
|
1514
|
+
});
|
|
1515
|
+
let promptForSpawn = prompt;
|
|
1516
|
+
try {
|
|
1517
|
+
const r = await resolveContextSourceUrls(prompt, reg, fetchCtx);
|
|
1518
|
+
promptForSpawn = r.enrichedPrompt;
|
|
1519
|
+
if (r.outcomes.length && process.env.CODEHERE_DEBUG === '1') {
|
|
1520
|
+
console.error('[context-source] resolved', r.outcomes.length, 'URL(s) ·', r.totalEstimatedTokens, 'tokens');
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
catch (e) {
|
|
1524
|
+
// Resolver-level failure (registry corrupt, etc) · log and
|
|
1525
|
+
// fall back to the raw prompt. The agent runs without
|
|
1526
|
+
// enrichment instead of failing outright.
|
|
1527
|
+
console.error('[context-source] resolver failed:', e instanceof Error ? e.message : String(e));
|
|
1528
|
+
}
|
|
1529
|
+
const session = manager.spawn(promptForSpawn, agentId, { cwd });
|
|
1069
1530
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1070
1531
|
res.end(JSON.stringify({
|
|
1071
1532
|
sessionId: session.sessionId,
|
|
@@ -1101,6 +1562,15 @@ export function startMCPServer(options = {}) {
|
|
|
1101
1562
|
const continuedFrom = manager.getContinuedFrom(s.sessionId);
|
|
1102
1563
|
const continuedAs = manager.getContinuedAs(s.sessionId);
|
|
1103
1564
|
const failedReport = manager.hasFailedReport(s.sessionId);
|
|
1565
|
+
const stalledReason = manager.getStallReason(s.sessionId);
|
|
1566
|
+
// F-07 · verifier-agent block · run claim vs reality on every task.
|
|
1567
|
+
// Pure function, no I/O, runs sync · cheap to do per request.
|
|
1568
|
+
// The UI renders the verdict + findings inline below the Summary.
|
|
1569
|
+
const verifyResult = verifyClaim({
|
|
1570
|
+
summary: finalReport?.summary,
|
|
1571
|
+
artifacts: s.artifacts,
|
|
1572
|
+
steps: s.steps,
|
|
1573
|
+
});
|
|
1104
1574
|
return {
|
|
1105
1575
|
sessionId: s.sessionId,
|
|
1106
1576
|
prompt: s.prompt,
|
|
@@ -1112,6 +1582,8 @@ export function startMCPServer(options = {}) {
|
|
|
1112
1582
|
...(completedAt !== undefined ? { completedAt, durationMs: completedAt - s.startedAt } : {}),
|
|
1113
1583
|
...(finalReport !== undefined ? { finalReport } : {}),
|
|
1114
1584
|
...(failedReport ? { failedReport: true } : {}),
|
|
1585
|
+
...(stalledReason ? { stalledReason } : {}),
|
|
1586
|
+
verifyResult,
|
|
1115
1587
|
canAppendMessage: s.canAppendMessage === true,
|
|
1116
1588
|
...(cwd !== undefined ? { cwd } : {}),
|
|
1117
1589
|
...(continuedFrom !== undefined ? { continuedFrom } : {}),
|
|
@@ -1120,7 +1592,15 @@ export function startMCPServer(options = {}) {
|
|
|
1120
1592
|
};
|
|
1121
1593
|
});
|
|
1122
1594
|
const activeAgents = manager.listActiveAgents();
|
|
1123
|
-
|
|
1595
|
+
// F-07 · attach verifyResult to persisted records too · same logic.
|
|
1596
|
+
const persisted = manager.listPersisted().map((p) => ({
|
|
1597
|
+
...p,
|
|
1598
|
+
verifyResult: verifyClaim({
|
|
1599
|
+
summary: p.finalReport?.summary,
|
|
1600
|
+
artifacts: p.artifacts,
|
|
1601
|
+
steps: p.steps,
|
|
1602
|
+
}),
|
|
1603
|
+
}));
|
|
1124
1604
|
// Layer 7: timestamp of most recent hook-audit line (= "last edit on this machine")
|
|
1125
1605
|
let lastEditAt = null;
|
|
1126
1606
|
try {
|
|
@@ -1382,6 +1862,766 @@ export function startMCPServer(options = {}) {
|
|
|
1382
1862
|
})();
|
|
1383
1863
|
return;
|
|
1384
1864
|
}
|
|
1865
|
+
// F-05 / F-06 · public endpoint for the audit chain verifier · powers
|
|
1866
|
+
// the web UI's "Verify chain" button + can be hit directly by CI.
|
|
1867
|
+
// v0.8 S2.5 · context-source OAuth routes
|
|
1868
|
+
// GET /api/context-sources · list all providers + status
|
|
1869
|
+
// POST /api/context-sources/:id/connect · returns authorize URL + opens browser
|
|
1870
|
+
// POST /api/context-sources/:id/disconnect · clears stored token
|
|
1871
|
+
// GET /oauth/callback?code=...&state=... · provider redirect target
|
|
1872
|
+
if (req.url === '/api/context-sources' && req.method === 'GET') {
|
|
1873
|
+
try {
|
|
1874
|
+
const oauthItems = listOAuthProviders().map((p) => {
|
|
1875
|
+
const status = connectionStatus(p.id);
|
|
1876
|
+
return {
|
|
1877
|
+
id: p.id,
|
|
1878
|
+
label: p.label,
|
|
1879
|
+
scheme: p.id,
|
|
1880
|
+
authType: 'oauth',
|
|
1881
|
+
status: status.status,
|
|
1882
|
+
userLabel: status.userLabel ?? null,
|
|
1883
|
+
connectedAt: status.connectedAt ?? null,
|
|
1884
|
+
expiresAt: status.expiresAt ?? null,
|
|
1885
|
+
clientIdConfigured: !!process.env[p.clientIdEnv],
|
|
1886
|
+
};
|
|
1887
|
+
});
|
|
1888
|
+
// v0.8 S5 · public-by-URL adapters appear with status='available'
|
|
1889
|
+
// (no Connect button needed · they work out of the box).
|
|
1890
|
+
const oauthIds = new Set(oauthItems.map((i) => i.id));
|
|
1891
|
+
const reg = getDefaultRegistry();
|
|
1892
|
+
const noAuthItems = [...reg.bySchemeOnly.values()]
|
|
1893
|
+
.filter((s) => !oauthIds.has(s.id))
|
|
1894
|
+
.map((s) => ({
|
|
1895
|
+
id: s.id,
|
|
1896
|
+
label: s.label,
|
|
1897
|
+
scheme: s.scheme,
|
|
1898
|
+
authType: 'public',
|
|
1899
|
+
status: 'available',
|
|
1900
|
+
userLabel: null,
|
|
1901
|
+
connectedAt: null,
|
|
1902
|
+
expiresAt: null,
|
|
1903
|
+
clientIdConfigured: true,
|
|
1904
|
+
description: s.description,
|
|
1905
|
+
}));
|
|
1906
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1907
|
+
res.end(JSON.stringify({ providers: [...oauthItems, ...noAuthItems] }));
|
|
1908
|
+
}
|
|
1909
|
+
catch (e) {
|
|
1910
|
+
console.error('[/api/context-sources] failed:', e);
|
|
1911
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1912
|
+
res.end(JSON.stringify({ error: 'Failed to list context sources' }));
|
|
1913
|
+
}
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
const connectMatch = req.url?.match(/^\/api\/context-sources\/([^/]+)\/connect$/);
|
|
1917
|
+
if (connectMatch && req.method === 'POST') {
|
|
1918
|
+
const providerId = connectMatch[1];
|
|
1919
|
+
const provider = getOAuthProvider(providerId);
|
|
1920
|
+
if (!provider) {
|
|
1921
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1922
|
+
res.end(JSON.stringify({ error: `unknown provider: ${providerId}` }));
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
const clientId = process.env[provider.clientIdEnv] || null;
|
|
1926
|
+
const r = oauthBegin(provider, { clientId });
|
|
1927
|
+
if ('kind' in r && r.kind === 'not_registered') {
|
|
1928
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1929
|
+
res.end(JSON.stringify({
|
|
1930
|
+
error: 'provider_not_registered',
|
|
1931
|
+
provider: providerId,
|
|
1932
|
+
envVar: provider.clientIdEnv,
|
|
1933
|
+
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'.`,
|
|
1934
|
+
}));
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
const result = r;
|
|
1938
|
+
PENDING_OAUTH_FLOWS.set(result.pending.state, result.pending);
|
|
1939
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1940
|
+
res.end(JSON.stringify({ authorizeUrl: result.authorizeUrl, state: result.pending.state }));
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
// S2.6 · /test endpoint · proves the stored token actually works against
|
|
1944
|
+
// the provider with a minimal read. Returns ok or detail.
|
|
1945
|
+
const testMatch = req.url?.match(/^\/api\/context-sources\/([^/]+)\/test$/);
|
|
1946
|
+
if (testMatch && req.method === 'POST') {
|
|
1947
|
+
const providerId = testMatch[1];
|
|
1948
|
+
const provider = getOAuthProvider(providerId);
|
|
1949
|
+
if (!provider) {
|
|
1950
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1951
|
+
res.end(JSON.stringify({ ok: false, error: 'unknown_provider' }));
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
(async () => {
|
|
1955
|
+
try {
|
|
1956
|
+
const status = connectionStatus(providerId);
|
|
1957
|
+
if (status.status === 'disconnected') {
|
|
1958
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1959
|
+
res.end(JSON.stringify({ ok: false, error: 'not_connected' }));
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
// Minimal probe per provider · the user-info endpoint is the
|
|
1963
|
+
// cheapest "does this token read at all" call.
|
|
1964
|
+
const userInfoUrl = provider.userInfoUrl;
|
|
1965
|
+
if (!userInfoUrl) {
|
|
1966
|
+
res.writeHead(501, { 'Content-Type': 'application/json' });
|
|
1967
|
+
res.end(JSON.stringify({ ok: false, error: 'no_test_endpoint' }));
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
const cfg = JSON.parse(readFileSync(join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'context-sources.json'), 'utf-8'));
|
|
1971
|
+
const entry = cfg[providerId] ?? {};
|
|
1972
|
+
let probeRes;
|
|
1973
|
+
if (providerId === 'linear') {
|
|
1974
|
+
const auth = entry.oauth?.accessToken ? `Bearer ${entry.oauth.accessToken}` : (entry.apiKey ?? '');
|
|
1975
|
+
probeRes = await fetch(userInfoUrl, {
|
|
1976
|
+
method: 'POST',
|
|
1977
|
+
headers: { Authorization: auth, 'Content-Type': 'application/json' },
|
|
1978
|
+
body: JSON.stringify({ query: '{ viewer { name email } }' }),
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
else {
|
|
1982
|
+
// figma · GET with X-Figma-Token OR Bearer
|
|
1983
|
+
const headers = entry.oauth?.accessToken
|
|
1984
|
+
? { Authorization: `Bearer ${entry.oauth.accessToken}` }
|
|
1985
|
+
: { 'X-Figma-Token': entry.token ?? '' };
|
|
1986
|
+
probeRes = await fetch(userInfoUrl, { headers });
|
|
1987
|
+
}
|
|
1988
|
+
if (!probeRes.ok) {
|
|
1989
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1990
|
+
res.end(JSON.stringify({ ok: false, error: 'provider_returned_' + probeRes.status }));
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
const body = (await probeRes.json());
|
|
1994
|
+
const who = body.data?.viewer?.email || body.data?.viewer?.name || body.email || body.handle || null;
|
|
1995
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1996
|
+
res.end(JSON.stringify({ ok: true, who }));
|
|
1997
|
+
}
|
|
1998
|
+
catch (e) {
|
|
1999
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2000
|
+
res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }));
|
|
2001
|
+
}
|
|
2002
|
+
})();
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
const disconnectMatch = req.url?.match(/^\/api\/context-sources\/([^/]+)\/disconnect$/);
|
|
2006
|
+
if (disconnectMatch && req.method === 'POST') {
|
|
2007
|
+
const providerId = disconnectMatch[1];
|
|
2008
|
+
const ok = clearToken(providerId);
|
|
2009
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2010
|
+
res.end(JSON.stringify({ ok, provider: providerId }));
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
if (req.url?.startsWith('/oauth/callback') && req.method === 'GET') {
|
|
2014
|
+
(async () => {
|
|
2015
|
+
try {
|
|
2016
|
+
const u = new URL(req.url, 'http://127.0.0.1');
|
|
2017
|
+
const code = u.searchParams.get('code');
|
|
2018
|
+
const state = u.searchParams.get('state');
|
|
2019
|
+
const error = u.searchParams.get('error');
|
|
2020
|
+
if (error || !code || !state) {
|
|
2021
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2022
|
+
res.end(callbackHtml('error', error || 'missing code/state', null));
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
const pending = PENDING_OAUTH_FLOWS.get(state);
|
|
2026
|
+
if (!pending) {
|
|
2027
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2028
|
+
res.end(callbackHtml('error', 'unknown state · this flow was not started by this daemon or has expired', null));
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
const provider = getOAuthProvider(pending.provider);
|
|
2032
|
+
if (!provider) {
|
|
2033
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2034
|
+
res.end(callbackHtml('error', `unknown provider: ${pending.provider}`, null));
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
const clientId = process.env[provider.clientIdEnv] || null;
|
|
2038
|
+
const out = await oauthComplete(code, state, {
|
|
2039
|
+
clientId,
|
|
2040
|
+
pending: PENDING_OAUTH_FLOWS,
|
|
2041
|
+
async httpPost(target, body, headers) {
|
|
2042
|
+
const r = await fetch(target, { method: 'POST', headers: headers ?? {}, body });
|
|
2043
|
+
const text = await r.text();
|
|
2044
|
+
const h = {};
|
|
2045
|
+
r.headers.forEach((v, k) => {
|
|
2046
|
+
h[k.toLowerCase()] = v;
|
|
2047
|
+
});
|
|
2048
|
+
return { status: r.status, body: text, headers: h };
|
|
2049
|
+
},
|
|
2050
|
+
async fetchUserInfo(token, userInfoUrl) {
|
|
2051
|
+
try {
|
|
2052
|
+
if (provider.id === 'linear') {
|
|
2053
|
+
const r = await fetch(userInfoUrl, {
|
|
2054
|
+
method: 'POST',
|
|
2055
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
2056
|
+
body: JSON.stringify({ query: '{ viewer { name email } }' }),
|
|
2057
|
+
});
|
|
2058
|
+
if (!r.ok)
|
|
2059
|
+
return null;
|
|
2060
|
+
const j = (await r.json());
|
|
2061
|
+
return j.data?.viewer ?? null;
|
|
2062
|
+
}
|
|
2063
|
+
if (provider.id === 'figma') {
|
|
2064
|
+
const r = await fetch(userInfoUrl, {
|
|
2065
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
2066
|
+
});
|
|
2067
|
+
if (!r.ok)
|
|
2068
|
+
return null;
|
|
2069
|
+
const j = (await r.json());
|
|
2070
|
+
return { name: j.handle, email: j.email };
|
|
2071
|
+
}
|
|
2072
|
+
return null;
|
|
2073
|
+
}
|
|
2074
|
+
catch {
|
|
2075
|
+
return null;
|
|
2076
|
+
}
|
|
2077
|
+
},
|
|
2078
|
+
});
|
|
2079
|
+
if ('kind' in out) {
|
|
2080
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2081
|
+
res.end(callbackHtml('error', `${out.kind}: ${out.detail}`, provider.label));
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
saveToken(out);
|
|
2085
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2086
|
+
res.end(callbackHtml('ok', `Connected as ${out.userLabel ?? '(unknown user)'}`, provider.label));
|
|
2087
|
+
}
|
|
2088
|
+
catch (e) {
|
|
2089
|
+
console.error('[/oauth/callback] failed:', e);
|
|
2090
|
+
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2091
|
+
res.end(callbackHtml('error', e instanceof Error ? e.message : String(e), null));
|
|
2092
|
+
}
|
|
2093
|
+
})();
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
if (req.url === '/api/audit/verify' && req.method === 'GET') {
|
|
2097
|
+
try {
|
|
2098
|
+
const r = verifyAuditChain(AUDIT_FILE);
|
|
2099
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2100
|
+
res.end(JSON.stringify({
|
|
2101
|
+
path: AUDIT_FILE,
|
|
2102
|
+
valid: r.valid,
|
|
2103
|
+
totalEntries: r.totalEntries,
|
|
2104
|
+
chainedEntries: r.chainedEntries,
|
|
2105
|
+
legacyEntries: r.legacyEntries,
|
|
2106
|
+
errors: r.errors,
|
|
2107
|
+
}));
|
|
2108
|
+
}
|
|
2109
|
+
catch (e) {
|
|
2110
|
+
console.error('[/api/audit/verify] failed:', e);
|
|
2111
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2112
|
+
res.end(JSON.stringify({ error: 'Failed to verify audit chain' }));
|
|
2113
|
+
}
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
// v0.8 S4 · share-thread · POST /api/thread/:id/export returns a signed
|
|
2117
|
+
// bundle of one session that the recipient can replay locally. No relay
|
|
2118
|
+
// through codehere's infrastructure · the response stays on-machine.
|
|
2119
|
+
if (req.method === 'POST' && req.url && /^\/api\/thread\/[^/]+\/export$/.test(req.url)) {
|
|
2120
|
+
try {
|
|
2121
|
+
const m = req.url.match(/^\/api\/thread\/([^/]+)\/export$/);
|
|
2122
|
+
const sessionId = m ? decodeURIComponent(m[1]) : '';
|
|
2123
|
+
const session = sessionId ? loadSessionById(sessionId) : undefined;
|
|
2124
|
+
if (!session) {
|
|
2125
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2126
|
+
res.end(JSON.stringify({ error: 'session not found', sessionId }));
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
const keys = loadOrCreateKeypair();
|
|
2130
|
+
const body = {
|
|
2131
|
+
schema: 1,
|
|
2132
|
+
kind: 'thread',
|
|
2133
|
+
sessionId: session.sessionId,
|
|
2134
|
+
session,
|
|
2135
|
+
exporter: { codehereVersion: '0.7.0' },
|
|
2136
|
+
exportedAt: new Date().toISOString(),
|
|
2137
|
+
};
|
|
2138
|
+
const bundle = signBundle(body, keys);
|
|
2139
|
+
const fp = shortFingerprint(bundle.publicKey);
|
|
2140
|
+
const link = `codehere://thread/${session.sessionId}?signed=ed25519:${bundle.signature.slice(0, 24)}`;
|
|
2141
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2142
|
+
res.end(JSON.stringify({ bundle, link, fingerprint: fp }));
|
|
2143
|
+
}
|
|
2144
|
+
catch (e) {
|
|
2145
|
+
console.error('[/api/thread/.../export] failed:', e);
|
|
2146
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2147
|
+
res.end(JSON.stringify({ error: 'export failed' }));
|
|
2148
|
+
}
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
// v0.8 S4 · share-thread · POST /api/thread/import · accepts a bundle in
|
|
2152
|
+
// the body, verifies it, optionally trusts the signer if the caller sets
|
|
2153
|
+
// { trust: true }, persists the session record on success.
|
|
2154
|
+
if (req.method === 'POST' && req.url === '/api/thread/import') {
|
|
2155
|
+
(async () => {
|
|
2156
|
+
try {
|
|
2157
|
+
const text = await readBody(req, res);
|
|
2158
|
+
let payload = {};
|
|
2159
|
+
try {
|
|
2160
|
+
payload = JSON.parse(text || '{}');
|
|
2161
|
+
}
|
|
2162
|
+
catch {
|
|
2163
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2164
|
+
res.end(JSON.stringify({ error: 'invalid JSON' }));
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
const verdict = verifyBundle(payload.bundle, trustedKeySet());
|
|
2168
|
+
if (verdict.status === 'malformed' || verdict.status === 'tampered') {
|
|
2169
|
+
res.writeHead(verdict.status === 'malformed' ? 400 : 409, { 'Content-Type': 'application/json' });
|
|
2170
|
+
res.end(JSON.stringify({ status: verdict.status, reason: verdict.reason }));
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
if (verdict.status === 'unknown_key') {
|
|
2174
|
+
if (!payload.trust) {
|
|
2175
|
+
res.writeHead(202, { 'Content-Type': 'application/json' });
|
|
2176
|
+
res.end(JSON.stringify({
|
|
2177
|
+
status: 'unknown_key',
|
|
2178
|
+
publicKey: verdict.publicKey,
|
|
2179
|
+
fingerprint: shortFingerprint(verdict.publicKey),
|
|
2180
|
+
hint: 'Re-POST with { "trust": true } to trust this key and import.',
|
|
2181
|
+
}));
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
trustKey(verdict.publicKey);
|
|
2185
|
+
}
|
|
2186
|
+
const finalBundle = verdict.bundle;
|
|
2187
|
+
const ses = finalBundle.body.session;
|
|
2188
|
+
const home = process.env.CODEHERE_HOME || homedir();
|
|
2189
|
+
const sessionsPath = join(home, '.codehere', 'sessions.jsonl');
|
|
2190
|
+
const fs = await import('fs');
|
|
2191
|
+
fs.mkdirSync(dirname(sessionsPath), { recursive: true });
|
|
2192
|
+
const lines = [];
|
|
2193
|
+
lines.push(JSON.stringify({
|
|
2194
|
+
t: 'spawned',
|
|
2195
|
+
id: ses.sessionId,
|
|
2196
|
+
agent: ses.agent,
|
|
2197
|
+
prompt: ses.prompt,
|
|
2198
|
+
cwd: ses.cwd,
|
|
2199
|
+
startedAt: ses.startedAt,
|
|
2200
|
+
}));
|
|
2201
|
+
lines.push(JSON.stringify({
|
|
2202
|
+
t: 'completed',
|
|
2203
|
+
id: ses.sessionId,
|
|
2204
|
+
status: ses.status,
|
|
2205
|
+
durationMs: ses.durationMs,
|
|
2206
|
+
completedAt: ses.completedAt,
|
|
2207
|
+
steps: ses.steps,
|
|
2208
|
+
artifacts: ses.artifacts,
|
|
2209
|
+
}));
|
|
2210
|
+
if (ses.finalReport) {
|
|
2211
|
+
lines.push(JSON.stringify({
|
|
2212
|
+
t: 'finalReport',
|
|
2213
|
+
id: ses.sessionId,
|
|
2214
|
+
summary: ses.finalReport.summary,
|
|
2215
|
+
generatedAt: ses.finalReport.generatedAt,
|
|
2216
|
+
durationMs: ses.finalReport.durationMs,
|
|
2217
|
+
}));
|
|
2218
|
+
}
|
|
2219
|
+
fs.appendFileSync(sessionsPath, lines.join('\n') + '\n', 'utf-8');
|
|
2220
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2221
|
+
res.end(JSON.stringify({
|
|
2222
|
+
status: 'imported',
|
|
2223
|
+
sessionId: ses.sessionId,
|
|
2224
|
+
fingerprint: shortFingerprint(finalBundle.publicKey),
|
|
2225
|
+
}));
|
|
2226
|
+
}
|
|
2227
|
+
catch (e) {
|
|
2228
|
+
console.error('[/api/thread/import] failed:', e);
|
|
2229
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2230
|
+
res.end(JSON.stringify({ error: 'import failed' }));
|
|
2231
|
+
}
|
|
2232
|
+
})();
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2235
|
+
/* v0.9 S7 · overlay-renderer · preview a 3rd-party URL via context-source */
|
|
2236
|
+
// GET /api/context-source/preview?url=<urlencoded scheme://...>
|
|
2237
|
+
if (req.method === 'GET' && req.url && req.url.startsWith('/api/context-source/preview')) {
|
|
2238
|
+
(async () => {
|
|
2239
|
+
try {
|
|
2240
|
+
const u = new URL(req.url, 'http://localhost');
|
|
2241
|
+
const target = u.searchParams.get('url');
|
|
2242
|
+
if (!target) {
|
|
2243
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2244
|
+
res.end(JSON.stringify({ error: 'url required' }));
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
const reg = getDefaultRegistry();
|
|
2248
|
+
const fetchCtx = createDefaultFetchContextWithPost({
|
|
2249
|
+
emitAuditLine: (line) => {
|
|
2250
|
+
if (process.env.CODEHERE_DEBUG === '1') {
|
|
2251
|
+
console.error('[overlay-renderer]', line.status, line.source, line.url, line.detail ?? '');
|
|
2252
|
+
}
|
|
2253
|
+
},
|
|
2254
|
+
});
|
|
2255
|
+
const { fetchViaRegistry } = await import('../context/context-source.js');
|
|
2256
|
+
try {
|
|
2257
|
+
const payload = await fetchViaRegistry(reg, target, fetchCtx);
|
|
2258
|
+
if (!payload) {
|
|
2259
|
+
// No adapter registered for that scheme · honest 400 instead of
|
|
2260
|
+
// silent ok:true with payload:null.
|
|
2261
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2262
|
+
res.end(JSON.stringify({
|
|
2263
|
+
ok: false,
|
|
2264
|
+
kind: 'invalid_url',
|
|
2265
|
+
error: `No adapter registered for "${target}" · supported: linear://, github://, figma://, drawio://`,
|
|
2266
|
+
}));
|
|
2267
|
+
return;
|
|
2268
|
+
}
|
|
2269
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2270
|
+
res.end(JSON.stringify({ ok: true, payload }));
|
|
2271
|
+
}
|
|
2272
|
+
catch (err) {
|
|
2273
|
+
const code = err.kind ?? 'unknown';
|
|
2274
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2275
|
+
// Map adapter errors to honest HTTP codes so the UI can render
|
|
2276
|
+
// a useful empty/error state instead of swallowing everything.
|
|
2277
|
+
const httpStatus = code === 'not_found'
|
|
2278
|
+
? 404
|
|
2279
|
+
: code === 'auth'
|
|
2280
|
+
? 401
|
|
2281
|
+
: code === 'rate_limit'
|
|
2282
|
+
? 429
|
|
2283
|
+
: code === 'invalid_url'
|
|
2284
|
+
? 400
|
|
2285
|
+
: 502;
|
|
2286
|
+
res.writeHead(httpStatus, { 'Content-Type': 'application/json' });
|
|
2287
|
+
res.end(JSON.stringify({ ok: false, kind: code, error: message }));
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
catch (e) {
|
|
2291
|
+
console.error('[/api/context-source/preview] failed:', e);
|
|
2292
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2293
|
+
res.end(JSON.stringify({ ok: false, error: 'preview failed' }));
|
|
2294
|
+
}
|
|
2295
|
+
})();
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
/* v0.9 S6 part B · W-PASS write-pass routes ────────────────────────── */
|
|
2299
|
+
// POST /api/writepass/intent · agent emits an intent.
|
|
2300
|
+
// Body: { sessionId, target, method, payload, reason, emittedBy? }
|
|
2301
|
+
// Response: 201 { status: 'pending', intent: WritePassIntent }
|
|
2302
|
+
if (req.method === 'POST' && req.url === '/api/writepass/intent') {
|
|
2303
|
+
(async () => {
|
|
2304
|
+
try {
|
|
2305
|
+
const text = await readBody(req, res);
|
|
2306
|
+
let raw;
|
|
2307
|
+
try {
|
|
2308
|
+
raw = JSON.parse(text || '{}');
|
|
2309
|
+
}
|
|
2310
|
+
catch {
|
|
2311
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2312
|
+
res.end(JSON.stringify({ error: 'invalid JSON' }));
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
// Server assigns the id · agents shouldn't pick collisions.
|
|
2316
|
+
raw.id = typeof raw.id === 'string' && raw.id ? raw.id : newPassId();
|
|
2317
|
+
raw.emittedAt =
|
|
2318
|
+
typeof raw.emittedAt === 'number' && Number.isFinite(raw.emittedAt) ? raw.emittedAt : Date.now();
|
|
2319
|
+
let intent;
|
|
2320
|
+
try {
|
|
2321
|
+
intent = validateWritePassIntent(raw);
|
|
2322
|
+
}
|
|
2323
|
+
catch (err) {
|
|
2324
|
+
if (err instanceof WritePassError) {
|
|
2325
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2326
|
+
res.end(JSON.stringify({ error: err.code, detail: err.message }));
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
throw err;
|
|
2330
|
+
}
|
|
2331
|
+
const status = wpPutPending(intent);
|
|
2332
|
+
// Audit the intent immediately · the chain captures the agent's
|
|
2333
|
+
// request even if the user never decides (i.e. session ends).
|
|
2334
|
+
try {
|
|
2335
|
+
const line = writePassStatusToAuditLine(status);
|
|
2336
|
+
appendAuditLog({
|
|
2337
|
+
timestamp: line.timestamp,
|
|
2338
|
+
sessionId: line.sessionId,
|
|
2339
|
+
toolName: line.toolName,
|
|
2340
|
+
toolInput: line.toolInput,
|
|
2341
|
+
riskScore: line.riskScore,
|
|
2342
|
+
blocked: line.blocked,
|
|
2343
|
+
findings: line.findings,
|
|
2344
|
+
criticalFindings: line.criticalFindings,
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
catch (e) {
|
|
2348
|
+
// Audit failure must not block the API (matches claude-sdk-hook discipline).
|
|
2349
|
+
console.error('[w-pass] audit append failed for intent:', e);
|
|
2350
|
+
}
|
|
2351
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
2352
|
+
res.end(JSON.stringify(status));
|
|
2353
|
+
}
|
|
2354
|
+
catch (e) {
|
|
2355
|
+
console.error('[/api/writepass/intent] failed:', e);
|
|
2356
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2357
|
+
res.end(JSON.stringify({ error: 'intent failed' }));
|
|
2358
|
+
}
|
|
2359
|
+
})();
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
// GET /api/writepass/pending · list all pending intents (UI poll target).
|
|
2363
|
+
if (req.method === 'GET' && req.url === '/api/writepass/pending') {
|
|
2364
|
+
try {
|
|
2365
|
+
const pending = wpListPending();
|
|
2366
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2367
|
+
res.end(JSON.stringify({ passes: pending }));
|
|
2368
|
+
}
|
|
2369
|
+
catch (e) {
|
|
2370
|
+
console.error('[/api/writepass/pending] failed:', e);
|
|
2371
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2372
|
+
res.end(JSON.stringify({ error: 'list failed' }));
|
|
2373
|
+
}
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2376
|
+
// GET /api/writepass/session/:id · list all passes for a session.
|
|
2377
|
+
if (req.method === 'GET' && req.url && /^\/api\/writepass\/session\/[^/]+$/.test(req.url)) {
|
|
2378
|
+
try {
|
|
2379
|
+
const m = req.url.match(/^\/api\/writepass\/session\/([^/]+)$/);
|
|
2380
|
+
const sessionId = m ? decodeURIComponent(m[1]) : '';
|
|
2381
|
+
const passes = wpListForSession(sessionId);
|
|
2382
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2383
|
+
res.end(JSON.stringify({ sessionId, passes }));
|
|
2384
|
+
}
|
|
2385
|
+
catch (e) {
|
|
2386
|
+
console.error('[/api/writepass/session] failed:', e);
|
|
2387
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2388
|
+
res.end(JSON.stringify({ error: 'list failed' }));
|
|
2389
|
+
}
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
// GET /api/writepass/:id/trust · returns the domain trust grade the
|
|
2393
|
+
// pending pass would resolve to. UI uses this to render the trust chip
|
|
2394
|
+
// BEFORE the user clicks approve.
|
|
2395
|
+
if (req.method === 'GET' && req.url && /^\/api\/writepass\/[^/]+\/trust$/.test(req.url)) {
|
|
2396
|
+
try {
|
|
2397
|
+
const m = req.url.match(/^\/api\/writepass\/([^/]+)\/trust$/);
|
|
2398
|
+
const passId = m ? decodeURIComponent(m[1]) : '';
|
|
2399
|
+
const current = wpGetById(passId);
|
|
2400
|
+
if (!current) {
|
|
2401
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2402
|
+
res.end(JSON.stringify({ error: 'pass not found' }));
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
const payload = current.kind === 'edited' ? current.effectivePayload : current.intent.payload;
|
|
2406
|
+
try {
|
|
2407
|
+
const resolved = resolveWriteRequest(current.intent, payload, loadContextSourcesConfigForWritePass());
|
|
2408
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2409
|
+
res.end(JSON.stringify(resolved.trust));
|
|
2410
|
+
}
|
|
2411
|
+
catch (err) {
|
|
2412
|
+
// Resolver failed (auth missing, unsupported scheme) · return null
|
|
2413
|
+
// trust so the UI knows there's no domain to grade yet.
|
|
2414
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2415
|
+
res.end(JSON.stringify({
|
|
2416
|
+
host: '',
|
|
2417
|
+
grade: 'F',
|
|
2418
|
+
reason: err instanceof Error ? err.message : 'resolve failed',
|
|
2419
|
+
userTrusted: false,
|
|
2420
|
+
requiresFirstWriteConfirmation: false,
|
|
2421
|
+
}));
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
catch (e) {
|
|
2425
|
+
console.error('[/api/writepass/.../trust] failed:', e);
|
|
2426
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2427
|
+
res.end(JSON.stringify({ error: 'trust lookup failed' }));
|
|
2428
|
+
}
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
// POST /api/writepass/:id/decide · user approves / edits / cancels.
|
|
2432
|
+
// Body: { kind: 'approve'|'edit'|'cancel', editedPayload?, note? }
|
|
2433
|
+
// On approve/edit · the daemon resolves + sends, audits the outcome.
|
|
2434
|
+
if (req.method === 'POST' && req.url && /^\/api\/writepass\/[^/]+\/decide$/.test(req.url)) {
|
|
2435
|
+
(async () => {
|
|
2436
|
+
try {
|
|
2437
|
+
const m = req.url.match(/^\/api\/writepass\/([^/]+)\/decide$/);
|
|
2438
|
+
const passId = m ? decodeURIComponent(m[1]) : '';
|
|
2439
|
+
const current = wpGetById(passId);
|
|
2440
|
+
if (!current) {
|
|
2441
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2442
|
+
res.end(JSON.stringify({ error: 'pass not found', passId }));
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
if (current.kind !== 'pending') {
|
|
2446
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
2447
|
+
res.end(JSON.stringify({ error: 'already decided', current }));
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
const bodyText = await readBody(req, res);
|
|
2451
|
+
let raw;
|
|
2452
|
+
try {
|
|
2453
|
+
raw = JSON.parse(bodyText || '{}');
|
|
2454
|
+
}
|
|
2455
|
+
catch {
|
|
2456
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2457
|
+
res.end(JSON.stringify({ error: 'invalid JSON' }));
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
// Default decidedAt to now if the client didn't set one.
|
|
2461
|
+
raw.passId = passId;
|
|
2462
|
+
raw.decidedAt =
|
|
2463
|
+
typeof raw.decidedAt === 'number' && Number.isFinite(raw.decidedAt) ? raw.decidedAt : Date.now();
|
|
2464
|
+
let decision;
|
|
2465
|
+
try {
|
|
2466
|
+
decision = validateWritePassDecision(raw, current.intent);
|
|
2467
|
+
}
|
|
2468
|
+
catch (err) {
|
|
2469
|
+
if (err instanceof WritePassError) {
|
|
2470
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2471
|
+
res.end(JSON.stringify({ error: err.code, detail: err.message }));
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
throw err;
|
|
2475
|
+
}
|
|
2476
|
+
const next = applyWritePassDecision(current.intent, decision);
|
|
2477
|
+
wpSetStatus(next);
|
|
2478
|
+
// Audit the decision.
|
|
2479
|
+
try {
|
|
2480
|
+
const line = writePassStatusToAuditLine(next);
|
|
2481
|
+
appendAuditLog({
|
|
2482
|
+
timestamp: line.timestamp,
|
|
2483
|
+
sessionId: line.sessionId,
|
|
2484
|
+
toolName: line.toolName,
|
|
2485
|
+
toolInput: line.toolInput,
|
|
2486
|
+
riskScore: line.riskScore,
|
|
2487
|
+
blocked: line.blocked,
|
|
2488
|
+
findings: line.findings,
|
|
2489
|
+
criticalFindings: line.criticalFindings,
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
catch (e) {
|
|
2493
|
+
console.error('[w-pass] audit append failed for decision:', e);
|
|
2494
|
+
}
|
|
2495
|
+
// Cancelled · we stop here.
|
|
2496
|
+
if (next.kind === 'cancelled') {
|
|
2497
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2498
|
+
res.end(JSON.stringify(next));
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
// Approved or edited · do the mediated send.
|
|
2502
|
+
if (next.kind !== 'approved' && next.kind !== 'edited') {
|
|
2503
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2504
|
+
res.end(JSON.stringify({ error: 'unexpected status after decision', next }));
|
|
2505
|
+
return;
|
|
2506
|
+
}
|
|
2507
|
+
const effectivePayload = next.kind === 'edited' ? next.effectivePayload : current.intent.payload;
|
|
2508
|
+
// Load auth config from the same context-sources.json the read
|
|
2509
|
+
// adapters use · symmetric storage.
|
|
2510
|
+
const writePassConfig = loadContextSourcesConfigForWritePass();
|
|
2511
|
+
let resolved;
|
|
2512
|
+
try {
|
|
2513
|
+
resolved = resolveWriteRequest(current.intent, effectivePayload, writePassConfig);
|
|
2514
|
+
}
|
|
2515
|
+
catch (err) {
|
|
2516
|
+
if (err instanceof WritePassResolveError) {
|
|
2517
|
+
const failResponse = {
|
|
2518
|
+
passId,
|
|
2519
|
+
status: 0,
|
|
2520
|
+
bodySnippet: '',
|
|
2521
|
+
receivedAt: Date.now(),
|
|
2522
|
+
error: err.message,
|
|
2523
|
+
};
|
|
2524
|
+
const failed = applyWritePassResponse(next, failResponse);
|
|
2525
|
+
wpSetStatus(failed);
|
|
2526
|
+
try {
|
|
2527
|
+
const line = writePassStatusToAuditLine(failed);
|
|
2528
|
+
appendAuditLog({
|
|
2529
|
+
timestamp: line.timestamp,
|
|
2530
|
+
sessionId: line.sessionId,
|
|
2531
|
+
toolName: line.toolName,
|
|
2532
|
+
toolInput: line.toolInput,
|
|
2533
|
+
riskScore: line.riskScore,
|
|
2534
|
+
blocked: line.blocked,
|
|
2535
|
+
findings: line.findings,
|
|
2536
|
+
criticalFindings: line.criticalFindings,
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
catch { }
|
|
2540
|
+
res.writeHead(424, { 'Content-Type': 'application/json' });
|
|
2541
|
+
res.end(JSON.stringify({ error: err.code, detail: err.message, status: failed }));
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
throw err;
|
|
2545
|
+
}
|
|
2546
|
+
// v0.9 · per-domain mediated-write trust gate. If the resolved
|
|
2547
|
+
// target's host is graded F (unknown), require the decision to
|
|
2548
|
+
// explicitly include trustThisDomain:true before sending. Once
|
|
2549
|
+
// set, persist the host to trusted-domains.json so subsequent
|
|
2550
|
+
// writes auto-allow at grade B.
|
|
2551
|
+
if (resolved.trust.requiresFirstWriteConfirmation) {
|
|
2552
|
+
const decisionRaw = raw;
|
|
2553
|
+
if (!decisionRaw.trustThisDomain) {
|
|
2554
|
+
res.writeHead(202, { 'Content-Type': 'application/json' });
|
|
2555
|
+
res.end(JSON.stringify({
|
|
2556
|
+
status: 'requires_domain_trust',
|
|
2557
|
+
host: resolved.trust.host,
|
|
2558
|
+
grade: resolved.trust.grade,
|
|
2559
|
+
reason: resolved.trust.reason,
|
|
2560
|
+
hint: 'Re-POST with { "kind": "approve", "trustThisDomain": true } to trust this host once + send.',
|
|
2561
|
+
}));
|
|
2562
|
+
return;
|
|
2563
|
+
}
|
|
2564
|
+
try {
|
|
2565
|
+
trustDomainStore(resolved.trust.host, 'trusted during first W-PASS approval');
|
|
2566
|
+
}
|
|
2567
|
+
catch (e) {
|
|
2568
|
+
console.error('[w-pass] trustDomain store failed:', e);
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
// Mediated send.
|
|
2572
|
+
let response;
|
|
2573
|
+
try {
|
|
2574
|
+
const f = await fetch(resolved.url, {
|
|
2575
|
+
method: resolved.method,
|
|
2576
|
+
headers: resolved.headers,
|
|
2577
|
+
body: resolved.body,
|
|
2578
|
+
});
|
|
2579
|
+
const text = await f.text();
|
|
2580
|
+
response = {
|
|
2581
|
+
passId,
|
|
2582
|
+
status: f.status,
|
|
2583
|
+
bodySnippet: text.slice(0, 2048),
|
|
2584
|
+
receivedAt: Date.now(),
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
catch (err) {
|
|
2588
|
+
response = {
|
|
2589
|
+
passId,
|
|
2590
|
+
status: 0,
|
|
2591
|
+
bodySnippet: '',
|
|
2592
|
+
receivedAt: Date.now(),
|
|
2593
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2594
|
+
};
|
|
2595
|
+
}
|
|
2596
|
+
const final = applyWritePassResponse(next, response);
|
|
2597
|
+
wpSetStatus(final);
|
|
2598
|
+
try {
|
|
2599
|
+
const line = writePassStatusToAuditLine(final);
|
|
2600
|
+
appendAuditLog({
|
|
2601
|
+
timestamp: line.timestamp,
|
|
2602
|
+
sessionId: line.sessionId,
|
|
2603
|
+
toolName: line.toolName,
|
|
2604
|
+
toolInput: line.toolInput,
|
|
2605
|
+
riskScore: line.riskScore,
|
|
2606
|
+
blocked: line.blocked,
|
|
2607
|
+
findings: line.findings,
|
|
2608
|
+
criticalFindings: line.criticalFindings,
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
catch (e) {
|
|
2612
|
+
console.error('[w-pass] audit append failed for response:', e);
|
|
2613
|
+
}
|
|
2614
|
+
res.writeHead(final.kind === 'sent' ? 200 : 502, { 'Content-Type': 'application/json' });
|
|
2615
|
+
res.end(JSON.stringify(final));
|
|
2616
|
+
}
|
|
2617
|
+
catch (e) {
|
|
2618
|
+
console.error('[/api/writepass/decide] failed:', e);
|
|
2619
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2620
|
+
res.end(JSON.stringify({ error: 'decide failed' }));
|
|
2621
|
+
}
|
|
2622
|
+
})();
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
1385
2625
|
// /api/version — small completeness item for ops monitoring (rc.17).
|
|
1386
2626
|
// ESM-friendly: resolve package.json relative to the compiled module's
|
|
1387
2627
|
// own URL via fileURLToPath. Works in both src/ (during dev) and dist/
|
|
@@ -1518,4 +2758,34 @@ export function startMCPServer(options = {}) {
|
|
|
1518
2758
|
});
|
|
1519
2759
|
return server;
|
|
1520
2760
|
}
|
|
2761
|
+
/**
|
|
2762
|
+
* v0.8 S2.5 · response page rendered to the user's browser after the OAuth
|
|
2763
|
+
* provider redirects to /oauth/callback. Tiny styled card with status +
|
|
2764
|
+
* "you can close this tab now" affordance. Auto-closes after 3s if window
|
|
2765
|
+
* was opened via window.open() from our UI.
|
|
2766
|
+
*/
|
|
2767
|
+
function callbackHtml(status, message, providerLabel) {
|
|
2768
|
+
const safe = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2769
|
+
const tone = status === 'ok' ? '#28c840' : '#ff5f57';
|
|
2770
|
+
const icon = status === 'ok' ? '✓' : '✗';
|
|
2771
|
+
const title = status === 'ok' ? `${providerLabel ?? 'Provider'} connected` : 'Connection failed';
|
|
2772
|
+
return `<!doctype html><html lang="en"><head>
|
|
2773
|
+
<meta charset="utf-8"><title>${safe(title)}</title>
|
|
2774
|
+
<style>
|
|
2775
|
+
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; }
|
|
2776
|
+
.card { max-width:480px; padding:32px 36px; border:1px solid rgba(255,255,255,0.08); border-radius:12px; background:#111; }
|
|
2777
|
+
.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; }
|
|
2778
|
+
h1 { font-size:18px; font-weight:600; margin:0 0 8px; }
|
|
2779
|
+
p { color:rgba(255,255,255,0.65); font-size:14px; line-height:1.5; margin:0 0 16px; }
|
|
2780
|
+
.hint { color:rgba(255,255,255,0.4); font-size:12px; font-family:ui-monospace,Menlo,monospace; }
|
|
2781
|
+
</style></head><body>
|
|
2782
|
+
<div class="card">
|
|
2783
|
+
<div class="icon">${icon}</div>
|
|
2784
|
+
<h1>${safe(title)}</h1>
|
|
2785
|
+
<p>${safe(message)}</p>
|
|
2786
|
+
<p class="hint">You can close this tab. Codehere has stored the connection locally.</p>
|
|
2787
|
+
</div>
|
|
2788
|
+
<script>setTimeout(function(){ try{ window.close(); }catch(_){} }, 3000);</script>
|
|
2789
|
+
</body></html>`;
|
|
2790
|
+
}
|
|
1521
2791
|
//# sourceMappingURL=mcp-server-impl.js.map
|