codehere 0.6.0 → 0.6.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 (30) hide show
  1. package/README.md +8 -8
  2. package/dist/application/use-cases/generate-final-report.js +57 -2
  3. package/dist/application/use-cases/generate-narration.js +9 -3
  4. package/dist/index.js +34 -4
  5. package/dist/infrastructure/cost/budget-config.js +1 -1
  6. package/dist/infrastructure/cost/spend-aggregator.js +5 -5
  7. package/dist/infrastructure/hooks/claude-sdk-hook.js +48 -2
  8. package/dist/infrastructure/orchestration/codex-adapter.js +11 -6
  9. package/dist/infrastructure/orchestration/context-handoff.js +1 -1
  10. package/dist/infrastructure/orchestration/gemini-adapter.js +3 -15
  11. package/dist/infrastructure/orchestration/graphify-context.js +6 -13
  12. package/dist/infrastructure/orchestration/memory-injector.js +11 -16
  13. package/dist/infrastructure/orchestration/opencode-adapter.js +42 -1
  14. package/dist/infrastructure/orchestration/progress-aggregator.js +1 -2
  15. package/dist/infrastructure/orchestration/session-manager.js +178 -76
  16. package/dist/infrastructure/protocols/cloud-sync.js +9 -5
  17. package/dist/infrastructure/protocols/mcp-server-impl.js +368 -334
  18. package/dist/infrastructure/storage/cross-tool-agents.js +19 -2
  19. package/dist/infrastructure/storage/project-thread-migration.js +12 -7
  20. package/dist/infrastructure/storage/project-thread-reader.js +11 -1
  21. package/dist/infrastructure/storage/project-thread-title-backfill.js +6 -2
  22. package/dist/presentation/cli/commands/budget-command.js +2 -2
  23. package/dist/presentation/cli/commands/init-command.js +5 -4
  24. package/dist/presentation/cli/commands/memory-command.js +29 -15
  25. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  26. package/dist/presentation/cli/commands/repo-map-builder.js +4 -4
  27. package/dist/presentation/cli/repl.js +20 -1
  28. package/dist/ui.js +36 -28
  29. package/dist/utils/typo-guard.js +1 -1
  30. package/package.json +2 -1
@@ -32,7 +32,7 @@ import { isAbsolute } from 'path';
32
32
  import { readCrossToolAgents } from '../storage/cross-tool-agents.js';
33
33
  import { listProjects, readProjectThread } from '../storage/project-thread-reader.js';
34
34
  import { mirrorReplyToThread, renameProject } from '../storage/project-thread-mirror.js';
35
- import { randomUUID } from 'crypto';
35
+ import { randomUUID, timingSafeEqual } from 'crypto';
36
36
  import { installAgent } from '../orchestration/agent-installer.js';
37
37
  import { detectReadiness } from '../orchestration/readiness.js';
38
38
  import { getHookMode } from '../hooks/hook-mode.js';
@@ -300,15 +300,39 @@ function dispatch(req) {
300
300
  return { jsonrpc: '2.0', id, error: { code: -32603, message: String(e) } };
301
301
  }
302
302
  }
303
+ const MAX_BODY_SIZE = 2_097_152; // 2 MiB — covers 1MB code-scan payloads + framing
304
+ function readBody(req, res) {
305
+ return new Promise((resolve, reject) => {
306
+ let body = '';
307
+ req.on('data', (c) => {
308
+ body += c;
309
+ if (body.length > MAX_BODY_SIZE) {
310
+ req.pause();
311
+ res.writeHead(413, { 'Content-Type': 'application/json' });
312
+ res.end(JSON.stringify({ error: 'Request body too large' }));
313
+ reject(new Error('oversized'));
314
+ }
315
+ });
316
+ req.on('end', () => resolve(body));
317
+ req.on('error', () => reject(new Error('request aborted')));
318
+ });
319
+ }
303
320
  export function startMCPServer(options = {}) {
304
321
  const port = options.port ?? 3773;
305
322
  const host = options.host ?? '127.0.0.1'; // localhost only by default
306
323
  const token = options.token;
307
324
  const server = http.createServer((req, res) => {
308
- // Auth check
325
+ // Auth check — timing-safe comparison
309
326
  if (token) {
310
327
  const authHeader = req.headers.authorization ?? '';
311
- if (authHeader !== `Bearer ${token}`) {
328
+ const expected = `Bearer ${token}`;
329
+ const cmpLen = Math.max(expected.length, authHeader.length);
330
+ const bufExpected = Buffer.alloc(cmpLen);
331
+ const bufActual = Buffer.alloc(cmpLen);
332
+ bufExpected.write(expected);
333
+ bufActual.write(authHeader);
334
+ const ok = timingSafeEqual(bufExpected, bufActual);
335
+ if (!ok) {
312
336
  res.writeHead(401, { 'Content-Type': 'application/json' });
313
337
  res.end(JSON.stringify({ error: 'Unauthorized' }));
314
338
  return;
@@ -415,7 +439,7 @@ export function startMCPServer(options = {}) {
415
439
  // Send current session state on connect
416
440
  try {
417
441
  const manager = SessionManager.getInstance();
418
- const sessions = manager.list().map(s => ({
442
+ const sessions = manager.list().map((s) => ({
419
443
  sessionId: s.sessionId,
420
444
  status: s.status,
421
445
  agent: s.agent,
@@ -431,7 +455,9 @@ export function startMCPServer(options = {}) {
431
455
  try {
432
456
  lastSize = statSync(SESSIONS_FILE).size;
433
457
  }
434
- catch { /* first time */ }
458
+ catch {
459
+ /* first time */
460
+ }
435
461
  }
436
462
  const onChange = () => {
437
463
  try {
@@ -443,7 +469,9 @@ export function startMCPServer(options = {}) {
443
469
  lastSize = currentSize;
444
470
  res.write(`data: ${JSON.stringify({ type: 'change' })}\n\n`);
445
471
  }
446
- catch { /* transient */ }
472
+ catch {
473
+ /* transient */
474
+ }
447
475
  };
448
476
  watchFile(SESSIONS_FILE, { interval: 500 }, onChange);
449
477
  const ping = setInterval(() => res.write(': ping\n\n'), 15000);
@@ -503,41 +531,46 @@ export function startMCPServer(options = {}) {
503
531
  return;
504
532
  }
505
533
  if (req.method === 'POST' && req.url === '/api/permissions') {
506
- let body = '';
507
- req.on('data', (c) => { body += c; });
508
- req.on('end', () => {
509
- let parsed;
510
- try {
511
- parsed = JSON.parse(body || '{}');
512
- }
513
- catch (e) {
514
- res.writeHead(400, { 'Content-Type': 'application/json' });
515
- res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
516
- return;
517
- }
518
- const cwd = parsed.cwd;
519
- const pattern = parsed.pattern;
520
- const bucket = parsed.bucket === 'deny' ? 'deny' : 'allow';
521
- if (!cwd || typeof cwd !== 'string' || !isAbsolute(cwd) || cwd.includes('\0')) {
522
- res.writeHead(400, { 'Content-Type': 'application/json' });
523
- res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
524
- return;
525
- }
526
- if (!pattern || typeof pattern !== 'string') {
527
- res.writeHead(400, { 'Content-Type': 'application/json' });
528
- res.end(JSON.stringify({ error: 'pattern (non-empty string) required' }));
529
- return;
530
- }
534
+ (async () => {
531
535
  try {
532
- const result = addPermissionEntry({ cwd, pattern, bucket, reason: parsed.reason });
533
- res.writeHead(200, { 'Content-Type': 'application/json' });
534
- res.end(JSON.stringify(result));
536
+ const body = await readBody(req, res);
537
+ let parsed;
538
+ try {
539
+ parsed = JSON.parse(body || '{}');
540
+ }
541
+ catch (e) {
542
+ res.writeHead(400, { 'Content-Type': 'application/json' });
543
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
544
+ return;
545
+ }
546
+ const cwd = parsed.cwd;
547
+ const pattern = parsed.pattern;
548
+ const bucket = parsed.bucket === 'deny' ? 'deny' : 'allow';
549
+ if (!cwd || typeof cwd !== 'string' || !isAbsolute(cwd) || cwd.includes('\0')) {
550
+ res.writeHead(400, { 'Content-Type': 'application/json' });
551
+ res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
552
+ return;
553
+ }
554
+ if (!pattern || typeof pattern !== 'string') {
555
+ res.writeHead(400, { 'Content-Type': 'application/json' });
556
+ res.end(JSON.stringify({ error: 'pattern (non-empty string) required' }));
557
+ return;
558
+ }
559
+ try {
560
+ const result = addPermissionEntry({ cwd, pattern, bucket, reason: parsed.reason });
561
+ res.writeHead(200, { 'Content-Type': 'application/json' });
562
+ res.end(JSON.stringify(result));
563
+ }
564
+ catch (e) {
565
+ res.writeHead(500, { 'Content-Type': 'application/json' });
566
+ res.end(JSON.stringify({ error: 'write_failed', detail: String(e?.message || e) }));
567
+ }
535
568
  }
536
- catch (e) {
537
- res.writeHead(500, { 'Content-Type': 'application/json' });
538
- res.end(JSON.stringify({ error: 'write_failed', detail: String(e?.message || e) }));
569
+ catch {
570
+ if (process.env.CODEHERE_DEBUG === '1')
571
+ console.error('[codehere] readBody rejected (oversized or aborted)');
539
572
  }
540
- });
573
+ })();
541
574
  return;
542
575
  }
543
576
  if (req.method === 'DELETE' && req.url?.startsWith('/api/permissions?')) {
@@ -807,49 +840,48 @@ export function startMCPServer(options = {}) {
807
840
  // titled "Scan Auth File" forever even after 100+ unrelated tasks.
808
841
  if (req.method === 'POST' && req.url?.match(/^\/api\/projects\/[^/]+\/rename$/)) {
809
842
  const slug = req.url.split('/')[3];
810
- let body = '';
811
- req.on('data', (c) => {
812
- body += c;
813
- });
814
- req.on('end', () => {
815
- let parsed;
843
+ (async () => {
816
844
  try {
817
- const raw = JSON.parse(body);
818
- // JSON.parse accepts the literal `null` and primitive bodies as
819
- // valid JSON. Reject anything that isn't a plain object — otherwise
820
- // the .title access below throws and crashes the request callback.
821
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
845
+ const body = await readBody(req, res);
846
+ let parsed;
847
+ try {
848
+ const raw = JSON.parse(body);
849
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
850
+ res.writeHead(400, { 'Content-Type': 'application/json' });
851
+ res.end(JSON.stringify({ error: 'invalid_body' }));
852
+ return;
853
+ }
854
+ parsed = raw;
855
+ }
856
+ catch {
822
857
  res.writeHead(400, { 'Content-Type': 'application/json' });
823
- res.end(JSON.stringify({ error: 'invalid_body' }));
858
+ res.end(JSON.stringify({ error: 'invalid_json' }));
859
+ return;
860
+ }
861
+ if (typeof parsed.title !== 'string') {
862
+ res.writeHead(400, { 'Content-Type': 'application/json' });
863
+ res.end(JSON.stringify({ error: 'title_required' }));
864
+ return;
865
+ }
866
+ const result = renameProject(slug, parsed.title);
867
+ if (!result.ok) {
868
+ let status = 400;
869
+ if (result.error === 'Project not found')
870
+ status = 404;
871
+ else if (result.error === 'Write failed')
872
+ status = 500;
873
+ res.writeHead(status, { 'Content-Type': 'application/json' });
874
+ res.end(JSON.stringify({ error: result.error }));
824
875
  return;
825
876
  }
826
- parsed = raw;
877
+ res.writeHead(200, { 'Content-Type': 'application/json' });
878
+ res.end(JSON.stringify({ ok: true, slug, title: parsed.title.trim() }));
827
879
  }
828
880
  catch {
829
- res.writeHead(400, { 'Content-Type': 'application/json' });
830
- res.end(JSON.stringify({ error: 'invalid_json' }));
831
- return;
832
- }
833
- if (typeof parsed.title !== 'string') {
834
- res.writeHead(400, { 'Content-Type': 'application/json' });
835
- res.end(JSON.stringify({ error: 'title_required' }));
836
- return;
837
- }
838
- const result = renameProject(slug, parsed.title);
839
- if (!result.ok) {
840
- // 404 = project missing; 500 = fs append failed; 400 = validation.
841
- let status = 400;
842
- if (result.error === 'Project not found')
843
- status = 404;
844
- else if (result.error === 'Write failed')
845
- status = 500;
846
- res.writeHead(status, { 'Content-Type': 'application/json' });
847
- res.end(JSON.stringify({ error: result.error }));
848
- return;
881
+ if (process.env.CODEHERE_DEBUG === '1')
882
+ console.error('[codehere] readBody rejected (oversized or aborted)');
849
883
  }
850
- res.writeHead(200, { 'Content-Type': 'application/json' });
851
- res.end(JSON.stringify({ ok: true, slug, title: parsed.title.trim() }));
852
- });
884
+ })();
853
885
  return;
854
886
  }
855
887
  // ── F1 task orchestration routes ─────────────────────────────────────────
@@ -867,207 +899,198 @@ export function startMCPServer(options = {}) {
867
899
  // order; per-task failures are surfaced as { error, agent } entries
868
900
  // so a partial-failure batch still returns useful info.
869
901
  if (req.method === 'POST' && req.url === '/api/task/batch') {
870
- let body = '';
871
- req.on('data', (c) => { body += c; });
872
- req.on('end', () => {
873
- let parsed;
902
+ (async () => {
874
903
  try {
875
- parsed = JSON.parse(body || '{}');
876
- }
877
- catch (e) {
878
- res.writeHead(400, { 'Content-Type': 'application/json' });
879
- res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
880
- return;
881
- }
882
- const tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
883
- if (tasks.length === 0) {
884
- res.writeHead(400, { 'Content-Type': 'application/json' });
885
- res.end(JSON.stringify({ error: 'tasks (non-empty array) required' }));
886
- return;
887
- }
888
- if (tasks.length > 5) {
889
- res.writeHead(400, { 'Content-Type': 'application/json' });
890
- res.end(JSON.stringify({ error: 'batch size capped at 5', requested: tasks.length }));
891
- return;
892
- }
893
- const validAgentIds = new Set(allAdapters().map((a) => a.id));
894
- // v0.6 S3 slice 2 — pre-spawn budget gate, applied per-task. Fan-out
895
- // is the most likely way to overshoot a daily budget (5 paid spawns
896
- // at once); checking once at batch entry then re-checking per task
897
- // is overkill, so we snapshot the budget + spend ONCE and let each
898
- // paid spawn that would push us over fail with the same error shape
899
- // /api/task uses. Free-tier spawns always pass.
900
- const batchBudget = loadBudget();
901
- const batchSpendNow = batchBudget.dailyUsd !== null ? computeSpendToday().totalUsd : 0;
902
- const results = [];
903
- for (const t of tasks) {
904
- const prompt = t.prompt;
905
- if (!prompt || typeof prompt !== 'string') {
906
- results.push({ error: 'prompt (string) required', agent: t.agent ?? null });
907
- continue;
904
+ const body = await readBody(req, res);
905
+ let parsed;
906
+ try {
907
+ parsed = JSON.parse(body || '{}');
908
908
  }
909
- const agentId = typeof t.agent === 'string' && t.agent.length > 0 ? t.agent : 'conductor';
910
- if (!validAgentIds.has(agentId)) {
911
- results.push({ error: `Unknown agent: ${agentId}`, agent: agentId });
912
- continue;
909
+ catch (e) {
910
+ res.writeHead(400, { 'Content-Type': 'application/json' });
911
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
912
+ return;
913
+ }
914
+ const tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
915
+ if (tasks.length === 0) {
916
+ res.writeHead(400, { 'Content-Type': 'application/json' });
917
+ res.end(JSON.stringify({ error: 'tasks (non-empty array) required' }));
918
+ return;
919
+ }
920
+ if (tasks.length > 5) {
921
+ res.writeHead(400, { 'Content-Type': 'application/json' });
922
+ res.end(JSON.stringify({ error: 'batch size capped at 5', requested: tasks.length }));
923
+ return;
913
924
  }
914
- if (t.cwd !== undefined) {
915
- if (typeof t.cwd !== 'string' || t.cwd.includes('\0') || !isAbsolute(t.cwd)) {
916
- results.push({ error: 'cwd must be absolute, no NUL bytes', agent: agentId });
925
+ const validAgentIds = new Set(allAdapters().map((a) => a.id));
926
+ // v0.6 S3 slice 2 — pre-spawn budget gate, applied per-task. Fan-out
927
+ // is the most likely way to overshoot a daily budget (5 paid spawns
928
+ // at once); checking once at batch entry then re-checking per task
929
+ // is overkill, so we snapshot the budget + spend ONCE and let each
930
+ // paid spawn that would push us over fail with the same error shape
931
+ // /api/task uses. Free-tier spawns always pass.
932
+ const batchBudget = loadBudget();
933
+ const batchSpendNow = batchBudget.dailyUsd !== null ? computeSpendToday().totalUsd : 0;
934
+ const results = [];
935
+ for (const t of tasks) {
936
+ const prompt = t.prompt;
937
+ if (!prompt || typeof prompt !== 'string') {
938
+ results.push({ error: 'prompt (string) required', agent: t.agent ?? null });
917
939
  continue;
918
940
  }
919
- }
920
- // Per-task budget check (paid agents only).
921
- if (batchBudget.dailyUsd !== null) {
922
- const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
923
- if (probe > 0 && isOverBudget(batchSpendNow, batchBudget)) {
941
+ const agentId = typeof t.agent === 'string' && t.agent.length > 0 ? t.agent : 'conductor';
942
+ if (!validAgentIds.has(agentId)) {
943
+ results.push({ error: `Unknown agent: ${agentId}`, agent: agentId });
944
+ continue;
945
+ }
946
+ if (t.cwd !== undefined) {
947
+ if (typeof t.cwd !== 'string' || t.cwd.includes('\0') || !isAbsolute(t.cwd)) {
948
+ results.push({ error: 'cwd must be absolute, no NUL bytes', agent: agentId });
949
+ continue;
950
+ }
951
+ }
952
+ // Per-task budget check (paid agents only).
953
+ if (batchBudget.dailyUsd !== null) {
954
+ const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
955
+ if (probe > 0 && isOverBudget(batchSpendNow, batchBudget)) {
956
+ results.push({
957
+ error: 'daily_budget_exhausted',
958
+ agent: agentId,
959
+ spentTodayUsd: batchSpendNow,
960
+ dailyBudgetUsd: batchBudget.dailyUsd,
961
+ });
962
+ continue;
963
+ }
964
+ }
965
+ try {
966
+ const session = manager.spawn(prompt, agentId, { cwd: t.cwd });
924
967
  results.push({
925
- error: 'daily_budget_exhausted',
926
- agent: agentId,
927
- spentTodayUsd: batchSpendNow,
928
- dailyBudgetUsd: batchBudget.dailyUsd,
968
+ sessionId: session.sessionId,
969
+ agent: session.agent,
970
+ status: session.status,
971
+ startedAt: session.startedAt,
929
972
  });
930
- continue;
973
+ }
974
+ catch (e) {
975
+ const msg = String(e?.message || e);
976
+ const code = msg.includes('pre-prompt scan blocked') ? 'pre_prompt_blocked' : 'spawn_failed';
977
+ results.push({ error: code, reason: msg, agent: agentId });
931
978
  }
932
979
  }
933
- try {
934
- const session = manager.spawn(prompt, agentId, { cwd: t.cwd });
935
- results.push({
936
- sessionId: session.sessionId,
937
- agent: session.agent,
938
- status: session.status,
939
- startedAt: session.startedAt,
940
- });
941
- }
942
- catch (e) {
943
- const msg = String(e?.message || e);
944
- const code = msg.includes('pre-prompt scan blocked') ? 'pre_prompt_blocked' : 'spawn_failed';
945
- results.push({ error: code, reason: msg, agent: agentId });
946
- }
980
+ res.writeHead(200, { 'Content-Type': 'application/json' });
981
+ res.end(JSON.stringify({ tasks: results, count: results.length }));
947
982
  }
948
- res.writeHead(200, { 'Content-Type': 'application/json' });
949
- res.end(JSON.stringify({ tasks: results, count: results.length }));
950
- });
983
+ catch {
984
+ if (process.env.CODEHERE_DEBUG === '1')
985
+ console.error('[codehere] readBody rejected (oversized or aborted)');
986
+ }
987
+ })();
951
988
  return;
952
989
  }
953
990
  if (req.method === 'POST' && req.url === '/api/task') {
954
- let body = '';
955
- req.on('data', (c) => {
956
- body += c;
957
- });
958
- req.on('end', () => {
959
- // Two-phase try: JSON parse failures get one error shape, spawn
960
- // failures get a different one. Pre-fix the catch wrapped every
961
- // failure as "Invalid JSON body" — including the pre-prompt scan
962
- // throw — which read as a parse bug to the client.
963
- let parsed;
964
- try {
965
- parsed = JSON.parse(body || '{}');
966
- }
967
- catch (e) {
968
- res.writeHead(400, { 'Content-Type': 'application/json' });
969
- res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
970
- return;
971
- }
991
+ (async () => {
972
992
  try {
973
- const { prompt, cwd, agent } = parsed;
974
- if (!prompt || typeof prompt !== 'string') {
975
- res.writeHead(400, { 'Content-Type': 'application/json' });
976
- res.end(JSON.stringify({ error: 'prompt (string) required' }));
977
- return;
978
- }
979
- // CRIT-5 fix · whitelist agent ID against the registry BEFORE any
980
- // log/spawn. Stops log injection (`agent: "claude-code\n[FAKE]"`),
981
- // future-proofs against partial-match exploits, and fails fast
982
- // with a clear 400 instead of letting SessionManager.spawn throw.
983
- //
984
- // Apr 29: default flipped from 'claude-code' to 'conductor'. The web
985
- // UI is the Conductor surface; tasks submitted without an explicit
986
- // agent route through the supervisor (rule-based routing today,
987
- // LLM classifier in v0.6). Empty string is treated like undefined
988
- // so the form submitting `agent: ""` (Auto option) hits the same
989
- // path. Specialists remain explicit overrides via the dropdown.
990
- // Type-check is preserved: non-string non-undefined values still 400.
991
- if (agent !== undefined && agent !== null && typeof agent !== 'string') {
992
- res.writeHead(400, { 'Content-Type': 'application/json' });
993
- res.end(JSON.stringify({ error: 'agent must be a string or omitted' }));
994
- return;
993
+ const body = await readBody(req, res);
994
+ // Two-phase try: JSON parse failures get one error shape, spawn
995
+ // failures get a different one. Pre-fix the catch wrapped every
996
+ // failure as "Invalid JSON body" — including the pre-prompt scan
997
+ // throw — which read as a parse bug to the client.
998
+ let parsed;
999
+ try {
1000
+ parsed = JSON.parse(body || '{}');
995
1001
  }
996
- const validAgentIds = new Set(allAdapters().map((a) => a.id));
997
- const agentId = typeof agent === 'string' && agent.length > 0 ? agent : 'conductor';
998
- if (!validAgentIds.has(agentId)) {
1002
+ catch (e) {
999
1003
  res.writeHead(400, { 'Content-Type': 'application/json' });
1000
- res.end(JSON.stringify({
1001
- error: `Unknown agent: ${typeof agentId === 'string' ? agentId.slice(0, 64) : '<non-string>'}`,
1002
- valid: [...validAgentIds],
1003
- }));
1004
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
1004
1005
  return;
1005
1006
  }
1006
- // Path-traversal + null-byte defense · cwd must be absolute and
1007
- // free of NUL bytes (some filesystems treat \0 as terminator).
1008
- if (cwd !== undefined) {
1009
- if (typeof cwd !== 'string' || cwd.includes('\0') || !isAbsolute(cwd)) {
1007
+ try {
1008
+ const { prompt, cwd, agent } = parsed;
1009
+ if (!prompt || typeof prompt !== 'string') {
1010
1010
  res.writeHead(400, { 'Content-Type': 'application/json' });
1011
- res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
1011
+ res.end(JSON.stringify({ error: 'prompt (string) required' }));
1012
1012
  return;
1013
1013
  }
1014
- }
1015
- // v0.6 S3 slice 2 — pre-spawn budget gate. Blocks paid spawns
1016
- // when today's estimated spend already meets the user's daily
1017
- // ceiling. Free-tier specialists (gemini, opencode) always pass:
1018
- // "don't let me spend more, but free is fine" matches user intent.
1019
- // Conductor is treated as paid because it routes to whatever's
1020
- // available (typically claude-code). Per
1021
- // claim_market_position v3 cost-bounded-execution edge.
1022
- const budgetForSpawn = loadBudget();
1023
- if (budgetForSpawn.dailyUsd !== null) {
1024
- const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
1025
- const isPaidSpawn = probe > 0;
1026
- if (isPaidSpawn) {
1027
- const todaySpend = computeSpendToday();
1028
- if (isOverBudget(todaySpend.totalUsd, budgetForSpawn)) {
1029
- res.writeHead(429, { 'Content-Type': 'application/json' });
1030
- res.end(JSON.stringify({
1031
- error: 'daily_budget_exhausted',
1032
- 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>\`.`,
1033
- spentTodayUsd: todaySpend.totalUsd,
1034
- dailyBudgetUsd: budgetForSpawn.dailyUsd,
1035
- }));
1014
+ // CRIT-5 fix · whitelist agent ID against the registry BEFORE any
1015
+ // log/spawn. Stops log injection (`agent: "claude-code\n[FAKE]"`),
1016
+ // future-proofs against partial-match exploits, and fails fast
1017
+ // with a clear 400 instead of letting SessionManager.spawn throw.
1018
+ //
1019
+ // Apr 29: default flipped from 'claude-code' to 'conductor'. The web
1020
+ // UI is the Conductor surface; tasks submitted without an explicit
1021
+ // agent route through the supervisor (rule-based routing today,
1022
+ // LLM classifier in v0.6). Empty string is treated like undefined
1023
+ // so the form submitting `agent: ""` (Auto option) hits the same
1024
+ // path. Specialists remain explicit overrides via the dropdown.
1025
+ // Type-check is preserved: non-string non-undefined values still 400.
1026
+ if (agent !== undefined && agent !== null && typeof agent !== 'string') {
1027
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1028
+ res.end(JSON.stringify({ error: 'agent must be a string or omitted' }));
1029
+ return;
1030
+ }
1031
+ const validAgentIds = new Set(allAdapters().map((a) => a.id));
1032
+ const agentId = typeof agent === 'string' && agent.length > 0 ? agent : 'conductor';
1033
+ if (!validAgentIds.has(agentId)) {
1034
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1035
+ res.end(JSON.stringify({
1036
+ error: `Unknown agent: ${typeof agentId === 'string' ? agentId.slice(0, 64) : '<non-string>'}`,
1037
+ valid: [...validAgentIds],
1038
+ }));
1039
+ return;
1040
+ }
1041
+ // Path-traversal + null-byte defense · cwd must be absolute and
1042
+ // free of NUL bytes (some filesystems treat \0 as terminator).
1043
+ if (cwd !== undefined) {
1044
+ if (typeof cwd !== 'string' || cwd.includes('\0') || !isAbsolute(cwd)) {
1045
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1046
+ res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
1036
1047
  return;
1037
1048
  }
1038
1049
  }
1050
+ const budgetForSpawn = loadBudget();
1051
+ if (budgetForSpawn.dailyUsd !== null) {
1052
+ const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
1053
+ const isPaidSpawn = probe > 0;
1054
+ if (isPaidSpawn) {
1055
+ const todaySpend = computeSpendToday();
1056
+ if (isOverBudget(todaySpend.totalUsd, budgetForSpawn)) {
1057
+ res.writeHead(429, { 'Content-Type': 'application/json' });
1058
+ res.end(JSON.stringify({
1059
+ error: 'daily_budget_exhausted',
1060
+ 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>\`.`,
1061
+ spentTodayUsd: todaySpend.totalUsd,
1062
+ dailyBudgetUsd: budgetForSpawn.dailyUsd,
1063
+ }));
1064
+ return;
1065
+ }
1066
+ }
1067
+ }
1068
+ const session = manager.spawn(prompt, agentId, { cwd });
1069
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1070
+ res.end(JSON.stringify({
1071
+ sessionId: session.sessionId,
1072
+ startedAt: session.startedAt,
1073
+ status: session.status,
1074
+ agent: session.agent,
1075
+ canAppendMessage: session.canAppendMessage === true,
1076
+ }));
1039
1077
  }
1040
- const session = manager.spawn(prompt, agentId, { cwd });
1041
- res.writeHead(200, { 'Content-Type': 'application/json' });
1042
- res.end(JSON.stringify({
1043
- sessionId: session.sessionId,
1044
- startedAt: session.startedAt,
1045
- status: session.status,
1046
- agent: session.agent,
1047
- // Iter 13 — include canAppendMessage so the freshly-attached
1048
- // session card can correctly enable/disable reply input on the
1049
- // first render. Without this, ALL just-spawned sessions render
1050
- // with reply disabled even when the adapter supports it.
1051
- canAppendMessage: session.canAppendMessage === true,
1052
- }));
1053
- }
1054
- catch (e) {
1055
- // Spawn-phase failure. Distinguish the cross-vendor pre-prompt
1056
- // scan refusal (#110) — actionable safety block — from generic
1057
- // adapter / install / SessionManager errors. The pre-prompt
1058
- // case gets 422 (Unprocessable Entity, "we understood but
1059
- // refused") + a structured 'pre_prompt_blocked' code so the
1060
- // web UI can surface a recovery affordance.
1061
- const msg = String(e?.message || e);
1062
- if (msg.includes('pre-prompt scan blocked')) {
1063
- res.writeHead(422, { 'Content-Type': 'application/json' });
1064
- res.end(JSON.stringify({ error: 'pre_prompt_blocked', reason: msg }));
1065
- return;
1078
+ catch (e) {
1079
+ const msg = String(e?.message || e);
1080
+ if (msg.includes('pre-prompt scan blocked')) {
1081
+ res.writeHead(422, { 'Content-Type': 'application/json' });
1082
+ res.end(JSON.stringify({ error: 'pre_prompt_blocked', reason: msg }));
1083
+ return;
1084
+ }
1085
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1086
+ res.end(JSON.stringify({ error: 'spawn_failed', detail: msg }));
1066
1087
  }
1067
- res.writeHead(500, { 'Content-Type': 'application/json' });
1068
- res.end(JSON.stringify({ error: 'spawn_failed', detail: msg }));
1069
1088
  }
1070
- });
1089
+ catch {
1090
+ if (process.env.CODEHERE_DEBUG === '1')
1091
+ console.error('[codehere] readBody rejected (oversized or aborted)');
1092
+ }
1093
+ })();
1071
1094
  return;
1072
1095
  }
1073
1096
  if (req.method === 'GET' && req.url === '/api/tasks') {
@@ -1208,54 +1231,53 @@ export function startMCPServer(options = {}) {
1208
1231
  res.end(JSON.stringify({ error: `Agent ${session.agent} does not support follow-up messages yet.` }));
1209
1232
  return;
1210
1233
  }
1211
- let body = '';
1212
- req.on('data', (c) => {
1213
- body += c.toString('utf-8');
1214
- });
1215
- req.on('end', async () => {
1234
+ (async () => {
1216
1235
  try {
1217
- const parsed = JSON.parse(body || '{}');
1218
- const text = (parsed.text ?? '').trim();
1219
- if (!text) {
1220
- res.writeHead(400, { 'Content-Type': 'application/json' });
1221
- res.end(JSON.stringify({ error: 'text is required' }));
1222
- return;
1223
- }
1224
- const replyStartedAt = Date.now();
1225
- const out = await session.appendMessage(text);
1226
- const replyCompletedAt = Date.now();
1227
- // v0.6.x — forward-mirror the reply turn into the project thread
1228
- // so /threads sees follow-ups as visible chronological turns
1229
- // (not just the original spawn). Best-effort: mirror failures
1230
- // never break the reply path.
1236
+ const body = await readBody(req, res);
1231
1237
  try {
1232
- const replyCwd = manager.getCwd(sessionId);
1233
- if (replyCwd) {
1234
- mirrorReplyToThread({
1235
- parentSessionId: sessionId,
1236
- replySessionId: randomUUID(),
1237
- agent: session.agent,
1238
- prompt: text,
1239
- cwd: replyCwd,
1240
- startedAt: replyStartedAt,
1241
- completedAt: replyCompletedAt,
1242
- durationMs: out.durationMs,
1243
- result: out.result,
1244
- });
1238
+ const parsed = JSON.parse(body || '{}');
1239
+ const text = (parsed.text ?? '').trim();
1240
+ if (!text) {
1241
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1242
+ res.end(JSON.stringify({ error: 'text is required' }));
1243
+ return;
1244
+ }
1245
+ const replyStartedAt = Date.now();
1246
+ const out = await session.appendMessage(text);
1247
+ const replyCompletedAt = Date.now();
1248
+ try {
1249
+ const replyCwd = manager.getCwd(sessionId);
1250
+ if (replyCwd) {
1251
+ mirrorReplyToThread({
1252
+ parentSessionId: sessionId,
1253
+ replySessionId: randomUUID(),
1254
+ agent: session.agent,
1255
+ prompt: text,
1256
+ cwd: replyCwd,
1257
+ startedAt: replyStartedAt,
1258
+ completedAt: replyCompletedAt,
1259
+ durationMs: out.durationMs,
1260
+ result: out.result,
1261
+ });
1262
+ }
1245
1263
  }
1264
+ catch {
1265
+ /* mirror is best-effort */
1266
+ }
1267
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1268
+ res.end(JSON.stringify({ sessionId, ...out }));
1246
1269
  }
1247
- catch {
1248
- /* mirror is best-effort */
1270
+ catch (err) {
1271
+ const msg = err instanceof Error ? err.message : String(err);
1272
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1273
+ res.end(JSON.stringify({ error: msg }));
1249
1274
  }
1250
- res.writeHead(200, { 'Content-Type': 'application/json' });
1251
- res.end(JSON.stringify({ sessionId, ...out }));
1252
1275
  }
1253
- catch (err) {
1254
- const msg = err instanceof Error ? err.message : String(err);
1255
- res.writeHead(500, { 'Content-Type': 'application/json' });
1256
- res.end(JSON.stringify({ error: msg }));
1276
+ catch {
1277
+ if (process.env.CODEHERE_DEBUG === '1')
1278
+ console.error('[codehere] readBody rejected (oversized or aborted)');
1257
1279
  }
1258
- });
1280
+ })();
1259
1281
  return;
1260
1282
  }
1261
1283
  }
@@ -1278,45 +1300,54 @@ export function startMCPServer(options = {}) {
1278
1300
  // replacement session on the target agent, preserving the
1279
1301
  // original prompt + cwd.
1280
1302
  if (req.method === 'POST' && req.url === '/api/failover-accept') {
1281
- let body = '';
1282
- req.on('data', (c) => { body += c; });
1283
- req.on('end', () => {
1284
- let parsed;
1303
+ (async () => {
1285
1304
  try {
1286
- const raw = JSON.parse(body);
1287
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
1305
+ const body = await readBody(req, res);
1306
+ let parsed;
1307
+ try {
1308
+ const raw = JSON.parse(body);
1309
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
1310
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1311
+ res.end(JSON.stringify({ error: 'invalid_body' }));
1312
+ return;
1313
+ }
1314
+ parsed = raw;
1315
+ }
1316
+ catch {
1288
1317
  res.writeHead(400, { 'Content-Type': 'application/json' });
1289
1318
  res.end(JSON.stringify({ error: 'invalid_body' }));
1290
1319
  return;
1291
1320
  }
1292
- parsed = raw;
1293
- }
1294
- catch {
1295
- res.writeHead(400, { 'Content-Type': 'application/json' });
1296
- res.end(JSON.stringify({ error: 'invalid_body' }));
1297
- return;
1298
- }
1299
- if (typeof parsed.sessionId !== 'string' || typeof parsed.targetAgent !== 'string') {
1300
- res.writeHead(400, { 'Content-Type': 'application/json' });
1301
- res.end(JSON.stringify({ error: 'missing_sessionId_or_targetAgent' }));
1302
- return;
1303
- }
1304
- try {
1305
- const result = SessionManager.getInstance().acceptFailover(parsed.sessionId, parsed.targetAgent);
1306
- if (result) {
1307
- res.writeHead(200, { 'Content-Type': 'application/json' });
1308
- res.end(JSON.stringify({ ok: true, newSessionId: result.sessionId }));
1321
+ if (typeof parsed.sessionId !== 'string' || typeof parsed.targetAgent !== 'string') {
1322
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1323
+ res.end(JSON.stringify({ error: 'missing_sessionId_or_targetAgent' }));
1324
+ return;
1309
1325
  }
1310
- else {
1311
- res.writeHead(200, { 'Content-Type': 'application/json' });
1312
- res.end(JSON.stringify({ ok: false, error: 'target_unavailable', detail: `Could not spawn ${parsed.targetAgent}. Check if it's installed and authenticated.` }));
1326
+ try {
1327
+ const result = SessionManager.getInstance().acceptFailover(parsed.sessionId, parsed.targetAgent);
1328
+ if (result) {
1329
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1330
+ res.end(JSON.stringify({ ok: true, newSessionId: result.sessionId }));
1331
+ }
1332
+ else {
1333
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1334
+ res.end(JSON.stringify({
1335
+ ok: false,
1336
+ error: 'target_unavailable',
1337
+ detail: `Could not spawn ${parsed.targetAgent}. Check if it's installed and authenticated.`,
1338
+ }));
1339
+ }
1340
+ }
1341
+ catch (e) {
1342
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1343
+ res.end(JSON.stringify({ error: 'failover_failed', detail: String(e) }));
1313
1344
  }
1314
1345
  }
1315
- catch (e) {
1316
- res.writeHead(500, { 'Content-Type': 'application/json' });
1317
- res.end(JSON.stringify({ error: 'failover_failed', detail: String(e) }));
1346
+ catch {
1347
+ if (process.env.CODEHERE_DEBUG === '1')
1348
+ console.error('[codehere] readBody rejected (oversized or aborted)');
1318
1349
  }
1319
- });
1350
+ })();
1320
1351
  return;
1321
1352
  }
1322
1353
  // MCP endpoint — POST jsonrpc; reject other verbs with 405 + Allow header
@@ -1327,25 +1358,28 @@ export function startMCPServer(options = {}) {
1327
1358
  res.end(JSON.stringify({ error: 'MCP endpoint accepts POST only', method: req.method }));
1328
1359
  return;
1329
1360
  }
1330
- let body = '';
1331
- req.on('data', (chunk) => {
1332
- body += chunk;
1333
- });
1334
- req.on('end', () => {
1361
+ (async () => {
1335
1362
  try {
1336
- const mcpReq = JSON.parse(body);
1337
- const mcpRes = dispatch(mcpReq);
1338
- res.writeHead(200, {
1339
- 'Content-Type': 'application/json',
1340
- 'Access-Control-Allow-Origin': 'http://localhost',
1341
- });
1342
- res.end(JSON.stringify(mcpRes));
1363
+ const body = await readBody(req, res);
1364
+ try {
1365
+ const mcpReq = JSON.parse(body);
1366
+ const mcpRes = dispatch(mcpReq);
1367
+ res.writeHead(200, {
1368
+ 'Content-Type': 'application/json',
1369
+ 'Access-Control-Allow-Origin': 'http://localhost',
1370
+ });
1371
+ res.end(JSON.stringify(mcpRes));
1372
+ }
1373
+ catch {
1374
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1375
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
1376
+ }
1343
1377
  }
1344
1378
  catch {
1345
- res.writeHead(400, { 'Content-Type': 'application/json' });
1346
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
1379
+ if (process.env.CODEHERE_DEBUG === '1')
1380
+ console.error('[codehere] readBody rejected (oversized or aborted)');
1347
1381
  }
1348
- });
1382
+ })();
1349
1383
  return;
1350
1384
  }
1351
1385
  // /api/version — small completeness item for ops monitoring (rc.17).