codehere 0.6.0 → 0.6.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.
@@ -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;
@@ -503,41 +527,46 @@ export function startMCPServer(options = {}) {
503
527
  return;
504
528
  }
505
529
  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;
530
+ (async () => {
510
531
  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
- }
531
- 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));
532
+ const body = await readBody(req, res);
533
+ let parsed;
534
+ try {
535
+ parsed = JSON.parse(body || '{}');
536
+ }
537
+ catch (e) {
538
+ res.writeHead(400, { 'Content-Type': 'application/json' });
539
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
540
+ return;
541
+ }
542
+ const cwd = parsed.cwd;
543
+ const pattern = parsed.pattern;
544
+ const bucket = parsed.bucket === 'deny' ? 'deny' : 'allow';
545
+ if (!cwd || typeof cwd !== 'string' || !isAbsolute(cwd) || cwd.includes('\0')) {
546
+ res.writeHead(400, { 'Content-Type': 'application/json' });
547
+ res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
548
+ return;
549
+ }
550
+ if (!pattern || typeof pattern !== 'string') {
551
+ res.writeHead(400, { 'Content-Type': 'application/json' });
552
+ res.end(JSON.stringify({ error: 'pattern (non-empty string) required' }));
553
+ return;
554
+ }
555
+ try {
556
+ const result = addPermissionEntry({ cwd, pattern, bucket, reason: parsed.reason });
557
+ res.writeHead(200, { 'Content-Type': 'application/json' });
558
+ res.end(JSON.stringify(result));
559
+ }
560
+ catch (e) {
561
+ res.writeHead(500, { 'Content-Type': 'application/json' });
562
+ res.end(JSON.stringify({ error: 'write_failed', detail: String(e?.message || e) }));
563
+ }
535
564
  }
536
- catch (e) {
537
- res.writeHead(500, { 'Content-Type': 'application/json' });
538
- res.end(JSON.stringify({ error: 'write_failed', detail: String(e?.message || e) }));
565
+ catch {
566
+ if (process.env.CODEHERE_DEBUG === '1')
567
+ console.error('[codehere] readBody rejected (oversized or aborted)');
539
568
  }
540
- });
569
+ })();
541
570
  return;
542
571
  }
543
572
  if (req.method === 'DELETE' && req.url?.startsWith('/api/permissions?')) {
@@ -807,49 +836,48 @@ export function startMCPServer(options = {}) {
807
836
  // titled "Scan Auth File" forever even after 100+ unrelated tasks.
808
837
  if (req.method === 'POST' && req.url?.match(/^\/api\/projects\/[^/]+\/rename$/)) {
809
838
  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;
839
+ (async () => {
816
840
  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)) {
841
+ const body = await readBody(req, res);
842
+ let parsed;
843
+ try {
844
+ const raw = JSON.parse(body);
845
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
846
+ res.writeHead(400, { 'Content-Type': 'application/json' });
847
+ res.end(JSON.stringify({ error: 'invalid_body' }));
848
+ return;
849
+ }
850
+ parsed = raw;
851
+ }
852
+ catch {
822
853
  res.writeHead(400, { 'Content-Type': 'application/json' });
823
- res.end(JSON.stringify({ error: 'invalid_body' }));
854
+ res.end(JSON.stringify({ error: 'invalid_json' }));
824
855
  return;
825
856
  }
826
- parsed = raw;
857
+ if (typeof parsed.title !== 'string') {
858
+ res.writeHead(400, { 'Content-Type': 'application/json' });
859
+ res.end(JSON.stringify({ error: 'title_required' }));
860
+ return;
861
+ }
862
+ const result = renameProject(slug, parsed.title);
863
+ if (!result.ok) {
864
+ let status = 400;
865
+ if (result.error === 'Project not found')
866
+ status = 404;
867
+ else if (result.error === 'Write failed')
868
+ status = 500;
869
+ res.writeHead(status, { 'Content-Type': 'application/json' });
870
+ res.end(JSON.stringify({ error: result.error }));
871
+ return;
872
+ }
873
+ res.writeHead(200, { 'Content-Type': 'application/json' });
874
+ res.end(JSON.stringify({ ok: true, slug, title: parsed.title.trim() }));
827
875
  }
828
876
  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;
877
+ if (process.env.CODEHERE_DEBUG === '1')
878
+ console.error('[codehere] readBody rejected (oversized or aborted)');
837
879
  }
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;
849
- }
850
- res.writeHead(200, { 'Content-Type': 'application/json' });
851
- res.end(JSON.stringify({ ok: true, slug, title: parsed.title.trim() }));
852
- });
880
+ })();
853
881
  return;
854
882
  }
855
883
  // ── F1 task orchestration routes ─────────────────────────────────────────
@@ -867,207 +895,198 @@ export function startMCPServer(options = {}) {
867
895
  // order; per-task failures are surfaced as { error, agent } entries
868
896
  // so a partial-failure batch still returns useful info.
869
897
  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;
898
+ (async () => {
874
899
  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;
900
+ const body = await readBody(req, res);
901
+ let parsed;
902
+ try {
903
+ parsed = JSON.parse(body || '{}');
908
904
  }
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;
905
+ catch (e) {
906
+ res.writeHead(400, { 'Content-Type': 'application/json' });
907
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
908
+ return;
909
+ }
910
+ const tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
911
+ if (tasks.length === 0) {
912
+ res.writeHead(400, { 'Content-Type': 'application/json' });
913
+ res.end(JSON.stringify({ error: 'tasks (non-empty array) required' }));
914
+ return;
915
+ }
916
+ if (tasks.length > 5) {
917
+ res.writeHead(400, { 'Content-Type': 'application/json' });
918
+ res.end(JSON.stringify({ error: 'batch size capped at 5', requested: tasks.length }));
919
+ return;
913
920
  }
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 });
921
+ const validAgentIds = new Set(allAdapters().map((a) => a.id));
922
+ // v0.6 S3 slice 2 — pre-spawn budget gate, applied per-task. Fan-out
923
+ // is the most likely way to overshoot a daily budget (5 paid spawns
924
+ // at once); checking once at batch entry then re-checking per task
925
+ // is overkill, so we snapshot the budget + spend ONCE and let each
926
+ // paid spawn that would push us over fail with the same error shape
927
+ // /api/task uses. Free-tier spawns always pass.
928
+ const batchBudget = loadBudget();
929
+ const batchSpendNow = batchBudget.dailyUsd !== null ? computeSpendToday().totalUsd : 0;
930
+ const results = [];
931
+ for (const t of tasks) {
932
+ const prompt = t.prompt;
933
+ if (!prompt || typeof prompt !== 'string') {
934
+ results.push({ error: 'prompt (string) required', agent: t.agent ?? null });
917
935
  continue;
918
936
  }
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)) {
937
+ const agentId = typeof t.agent === 'string' && t.agent.length > 0 ? t.agent : 'conductor';
938
+ if (!validAgentIds.has(agentId)) {
939
+ results.push({ error: `Unknown agent: ${agentId}`, agent: agentId });
940
+ continue;
941
+ }
942
+ if (t.cwd !== undefined) {
943
+ if (typeof t.cwd !== 'string' || t.cwd.includes('\0') || !isAbsolute(t.cwd)) {
944
+ results.push({ error: 'cwd must be absolute, no NUL bytes', agent: agentId });
945
+ continue;
946
+ }
947
+ }
948
+ // Per-task budget check (paid agents only).
949
+ if (batchBudget.dailyUsd !== null) {
950
+ const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
951
+ if (probe > 0 && isOverBudget(batchSpendNow, batchBudget)) {
952
+ results.push({
953
+ error: 'daily_budget_exhausted',
954
+ agent: agentId,
955
+ spentTodayUsd: batchSpendNow,
956
+ dailyBudgetUsd: batchBudget.dailyUsd,
957
+ });
958
+ continue;
959
+ }
960
+ }
961
+ try {
962
+ const session = manager.spawn(prompt, agentId, { cwd: t.cwd });
924
963
  results.push({
925
- error: 'daily_budget_exhausted',
926
- agent: agentId,
927
- spentTodayUsd: batchSpendNow,
928
- dailyBudgetUsd: batchBudget.dailyUsd,
964
+ sessionId: session.sessionId,
965
+ agent: session.agent,
966
+ status: session.status,
967
+ startedAt: session.startedAt,
929
968
  });
930
- continue;
969
+ }
970
+ catch (e) {
971
+ const msg = String(e?.message || e);
972
+ const code = msg.includes('pre-prompt scan blocked') ? 'pre_prompt_blocked' : 'spawn_failed';
973
+ results.push({ error: code, reason: msg, agent: agentId });
931
974
  }
932
975
  }
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
- }
976
+ res.writeHead(200, { 'Content-Type': 'application/json' });
977
+ res.end(JSON.stringify({ tasks: results, count: results.length }));
947
978
  }
948
- res.writeHead(200, { 'Content-Type': 'application/json' });
949
- res.end(JSON.stringify({ tasks: results, count: results.length }));
950
- });
979
+ catch {
980
+ if (process.env.CODEHERE_DEBUG === '1')
981
+ console.error('[codehere] readBody rejected (oversized or aborted)');
982
+ }
983
+ })();
951
984
  return;
952
985
  }
953
986
  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;
987
+ (async () => {
964
988
  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
- }
972
- 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;
989
+ const body = await readBody(req, res);
990
+ // Two-phase try: JSON parse failures get one error shape, spawn
991
+ // failures get a different one. Pre-fix the catch wrapped every
992
+ // failure as "Invalid JSON body" — including the pre-prompt scan
993
+ // throw — which read as a parse bug to the client.
994
+ let parsed;
995
+ try {
996
+ parsed = JSON.parse(body || '{}');
995
997
  }
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)) {
998
+ catch (e) {
999
999
  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
- }));
1000
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
1004
1001
  return;
1005
1002
  }
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)) {
1003
+ try {
1004
+ const { prompt, cwd, agent } = parsed;
1005
+ if (!prompt || typeof prompt !== 'string') {
1010
1006
  res.writeHead(400, { 'Content-Type': 'application/json' });
1011
- res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
1007
+ res.end(JSON.stringify({ error: 'prompt (string) required' }));
1012
1008
  return;
1013
1009
  }
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
- }));
1010
+ // CRIT-5 fix · whitelist agent ID against the registry BEFORE any
1011
+ // log/spawn. Stops log injection (`agent: "claude-code\n[FAKE]"`),
1012
+ // future-proofs against partial-match exploits, and fails fast
1013
+ // with a clear 400 instead of letting SessionManager.spawn throw.
1014
+ //
1015
+ // Apr 29: default flipped from 'claude-code' to 'conductor'. The web
1016
+ // UI is the Conductor surface; tasks submitted without an explicit
1017
+ // agent route through the supervisor (rule-based routing today,
1018
+ // LLM classifier in v0.6). Empty string is treated like undefined
1019
+ // so the form submitting `agent: ""` (Auto option) hits the same
1020
+ // path. Specialists remain explicit overrides via the dropdown.
1021
+ // Type-check is preserved: non-string non-undefined values still 400.
1022
+ if (agent !== undefined && agent !== null && typeof agent !== 'string') {
1023
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1024
+ res.end(JSON.stringify({ error: 'agent must be a string or omitted' }));
1025
+ return;
1026
+ }
1027
+ const validAgentIds = new Set(allAdapters().map((a) => a.id));
1028
+ const agentId = typeof agent === 'string' && agent.length > 0 ? agent : 'conductor';
1029
+ if (!validAgentIds.has(agentId)) {
1030
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1031
+ res.end(JSON.stringify({
1032
+ error: `Unknown agent: ${typeof agentId === 'string' ? agentId.slice(0, 64) : '<non-string>'}`,
1033
+ valid: [...validAgentIds],
1034
+ }));
1035
+ return;
1036
+ }
1037
+ // Path-traversal + null-byte defense · cwd must be absolute and
1038
+ // free of NUL bytes (some filesystems treat \0 as terminator).
1039
+ if (cwd !== undefined) {
1040
+ if (typeof cwd !== 'string' || cwd.includes('\0') || !isAbsolute(cwd)) {
1041
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1042
+ res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
1036
1043
  return;
1037
1044
  }
1038
1045
  }
1046
+ const budgetForSpawn = loadBudget();
1047
+ if (budgetForSpawn.dailyUsd !== null) {
1048
+ const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
1049
+ const isPaidSpawn = probe > 0;
1050
+ if (isPaidSpawn) {
1051
+ const todaySpend = computeSpendToday();
1052
+ if (isOverBudget(todaySpend.totalUsd, budgetForSpawn)) {
1053
+ res.writeHead(429, { 'Content-Type': 'application/json' });
1054
+ res.end(JSON.stringify({
1055
+ error: 'daily_budget_exhausted',
1056
+ 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>\`.`,
1057
+ spentTodayUsd: todaySpend.totalUsd,
1058
+ dailyBudgetUsd: budgetForSpawn.dailyUsd,
1059
+ }));
1060
+ return;
1061
+ }
1062
+ }
1063
+ }
1064
+ const session = manager.spawn(prompt, agentId, { cwd });
1065
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1066
+ res.end(JSON.stringify({
1067
+ sessionId: session.sessionId,
1068
+ startedAt: session.startedAt,
1069
+ status: session.status,
1070
+ agent: session.agent,
1071
+ canAppendMessage: session.canAppendMessage === true,
1072
+ }));
1039
1073
  }
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;
1074
+ catch (e) {
1075
+ const msg = String(e?.message || e);
1076
+ if (msg.includes('pre-prompt scan blocked')) {
1077
+ res.writeHead(422, { 'Content-Type': 'application/json' });
1078
+ res.end(JSON.stringify({ error: 'pre_prompt_blocked', reason: msg }));
1079
+ return;
1080
+ }
1081
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1082
+ res.end(JSON.stringify({ error: 'spawn_failed', detail: msg }));
1066
1083
  }
1067
- res.writeHead(500, { 'Content-Type': 'application/json' });
1068
- res.end(JSON.stringify({ error: 'spawn_failed', detail: msg }));
1069
1084
  }
1070
- });
1085
+ catch {
1086
+ if (process.env.CODEHERE_DEBUG === '1')
1087
+ console.error('[codehere] readBody rejected (oversized or aborted)');
1088
+ }
1089
+ })();
1071
1090
  return;
1072
1091
  }
1073
1092
  if (req.method === 'GET' && req.url === '/api/tasks') {
@@ -1208,54 +1227,51 @@ export function startMCPServer(options = {}) {
1208
1227
  res.end(JSON.stringify({ error: `Agent ${session.agent} does not support follow-up messages yet.` }));
1209
1228
  return;
1210
1229
  }
1211
- let body = '';
1212
- req.on('data', (c) => {
1213
- body += c.toString('utf-8');
1214
- });
1215
- req.on('end', async () => {
1230
+ (async () => {
1216
1231
  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.
1232
+ const body = await readBody(req, res);
1231
1233
  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
- });
1234
+ const parsed = JSON.parse(body || '{}');
1235
+ const text = (parsed.text ?? '').trim();
1236
+ if (!text) {
1237
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1238
+ res.end(JSON.stringify({ error: 'text is required' }));
1239
+ return;
1245
1240
  }
1241
+ const replyStartedAt = Date.now();
1242
+ const out = await session.appendMessage(text);
1243
+ const replyCompletedAt = Date.now();
1244
+ try {
1245
+ const replyCwd = manager.getCwd(sessionId);
1246
+ if (replyCwd) {
1247
+ mirrorReplyToThread({
1248
+ parentSessionId: sessionId,
1249
+ replySessionId: randomUUID(),
1250
+ agent: session.agent,
1251
+ prompt: text,
1252
+ cwd: replyCwd,
1253
+ startedAt: replyStartedAt,
1254
+ completedAt: replyCompletedAt,
1255
+ durationMs: out.durationMs,
1256
+ result: out.result,
1257
+ });
1258
+ }
1259
+ }
1260
+ catch { /* mirror is best-effort */ }
1261
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1262
+ res.end(JSON.stringify({ sessionId, ...out }));
1246
1263
  }
1247
- catch {
1248
- /* mirror is best-effort */
1264
+ catch (err) {
1265
+ const msg = err instanceof Error ? err.message : String(err);
1266
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1267
+ res.end(JSON.stringify({ error: msg }));
1249
1268
  }
1250
- res.writeHead(200, { 'Content-Type': 'application/json' });
1251
- res.end(JSON.stringify({ sessionId, ...out }));
1252
1269
  }
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 }));
1270
+ catch {
1271
+ if (process.env.CODEHERE_DEBUG === '1')
1272
+ console.error('[codehere] readBody rejected (oversized or aborted)');
1257
1273
  }
1258
- });
1274
+ })();
1259
1275
  return;
1260
1276
  }
1261
1277
  }
@@ -1278,45 +1294,50 @@ export function startMCPServer(options = {}) {
1278
1294
  // replacement session on the target agent, preserving the
1279
1295
  // original prompt + cwd.
1280
1296
  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;
1297
+ (async () => {
1285
1298
  try {
1286
- const raw = JSON.parse(body);
1287
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
1299
+ const body = await readBody(req, res);
1300
+ let parsed;
1301
+ try {
1302
+ const raw = JSON.parse(body);
1303
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
1304
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1305
+ res.end(JSON.stringify({ error: 'invalid_body' }));
1306
+ return;
1307
+ }
1308
+ parsed = raw;
1309
+ }
1310
+ catch {
1288
1311
  res.writeHead(400, { 'Content-Type': 'application/json' });
1289
1312
  res.end(JSON.stringify({ error: 'invalid_body' }));
1290
1313
  return;
1291
1314
  }
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 }));
1315
+ if (typeof parsed.sessionId !== 'string' || typeof parsed.targetAgent !== 'string') {
1316
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1317
+ res.end(JSON.stringify({ error: 'missing_sessionId_or_targetAgent' }));
1318
+ return;
1309
1319
  }
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.` }));
1320
+ try {
1321
+ const result = SessionManager.getInstance().acceptFailover(parsed.sessionId, parsed.targetAgent);
1322
+ if (result) {
1323
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1324
+ res.end(JSON.stringify({ ok: true, newSessionId: result.sessionId }));
1325
+ }
1326
+ else {
1327
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1328
+ res.end(JSON.stringify({ ok: false, error: 'target_unavailable', detail: `Could not spawn ${parsed.targetAgent}. Check if it's installed and authenticated.` }));
1329
+ }
1330
+ }
1331
+ catch (e) {
1332
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1333
+ res.end(JSON.stringify({ error: 'failover_failed', detail: String(e) }));
1313
1334
  }
1314
1335
  }
1315
- catch (e) {
1316
- res.writeHead(500, { 'Content-Type': 'application/json' });
1317
- res.end(JSON.stringify({ error: 'failover_failed', detail: String(e) }));
1336
+ catch {
1337
+ if (process.env.CODEHERE_DEBUG === '1')
1338
+ console.error('[codehere] readBody rejected (oversized or aborted)');
1318
1339
  }
1319
- });
1340
+ })();
1320
1341
  return;
1321
1342
  }
1322
1343
  // MCP endpoint — POST jsonrpc; reject other verbs with 405 + Allow header
@@ -1327,25 +1348,28 @@ export function startMCPServer(options = {}) {
1327
1348
  res.end(JSON.stringify({ error: 'MCP endpoint accepts POST only', method: req.method }));
1328
1349
  return;
1329
1350
  }
1330
- let body = '';
1331
- req.on('data', (chunk) => {
1332
- body += chunk;
1333
- });
1334
- req.on('end', () => {
1351
+ (async () => {
1335
1352
  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));
1353
+ const body = await readBody(req, res);
1354
+ try {
1355
+ const mcpReq = JSON.parse(body);
1356
+ const mcpRes = dispatch(mcpReq);
1357
+ res.writeHead(200, {
1358
+ 'Content-Type': 'application/json',
1359
+ 'Access-Control-Allow-Origin': 'http://localhost',
1360
+ });
1361
+ res.end(JSON.stringify(mcpRes));
1362
+ }
1363
+ catch {
1364
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1365
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
1366
+ }
1343
1367
  }
1344
1368
  catch {
1345
- res.writeHead(400, { 'Content-Type': 'application/json' });
1346
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
1369
+ if (process.env.CODEHERE_DEBUG === '1')
1370
+ console.error('[codehere] readBody rejected (oversized or aborted)');
1347
1371
  }
1348
- });
1372
+ })();
1349
1373
  return;
1350
1374
  }
1351
1375
  // /api/version — small completeness item for ops monitoring (rc.17).