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.
- package/README.md +8 -8
- package/dist/index.js +29 -3
- package/dist/infrastructure/hooks/claude-sdk-hook.js +44 -2
- package/dist/infrastructure/orchestration/opencode-adapter.js +41 -0
- package/dist/infrastructure/orchestration/session-manager.js +131 -54
- package/dist/infrastructure/protocols/mcp-server-impl.js +355 -331
- 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
|
-
|
|
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
|
-
|
|
507
|
-
req.on('data', (c) => { body += c; });
|
|
508
|
-
req.on('end', () => {
|
|
509
|
-
let parsed;
|
|
530
|
+
(async () => {
|
|
510
531
|
try {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
811
|
-
req.on('data', (c) => {
|
|
812
|
-
body += c;
|
|
813
|
-
});
|
|
814
|
-
req.on('end', () => {
|
|
815
|
-
let parsed;
|
|
839
|
+
(async () => {
|
|
816
840
|
try {
|
|
817
|
-
const
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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: '
|
|
854
|
+
res.end(JSON.stringify({ error: 'invalid_json' }));
|
|
824
855
|
return;
|
|
825
856
|
}
|
|
826
|
-
parsed
|
|
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
|
-
|
|
830
|
-
|
|
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
|
-
|
|
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
|
-
|
|
871
|
-
req.on('data', (c) => { body += c; });
|
|
872
|
-
req.on('end', () => {
|
|
873
|
-
let parsed;
|
|
898
|
+
(async () => {
|
|
874
899
|
try {
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
926
|
-
agent:
|
|
927
|
-
|
|
928
|
-
|
|
964
|
+
sessionId: session.sessionId,
|
|
965
|
+
agent: session.agent,
|
|
966
|
+
status: session.status,
|
|
967
|
+
startedAt: session.startedAt,
|
|
929
968
|
});
|
|
930
|
-
|
|
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
|
-
|
|
934
|
-
|
|
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
|
-
|
|
949
|
-
|
|
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
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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: '
|
|
1007
|
+
res.end(JSON.stringify({ error: 'prompt (string) required' }));
|
|
1012
1008
|
return;
|
|
1013
1009
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
if (
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
1212
|
-
req.on('data', (c) => {
|
|
1213
|
-
body += c.toString('utf-8');
|
|
1214
|
-
});
|
|
1215
|
-
req.on('end', async () => {
|
|
1230
|
+
(async () => {
|
|
1216
1231
|
try {
|
|
1217
|
-
const
|
|
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
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
-
|
|
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
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
-
|
|
1282
|
-
req.on('data', (c) => { body += c; });
|
|
1283
|
-
req.on('end', () => {
|
|
1284
|
-
let parsed;
|
|
1297
|
+
(async () => {
|
|
1285
1298
|
try {
|
|
1286
|
-
const
|
|
1287
|
-
|
|
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
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
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
|
|
1316
|
-
|
|
1317
|
-
|
|
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
|
-
|
|
1331
|
-
req.on('data', (chunk) => {
|
|
1332
|
-
body += chunk;
|
|
1333
|
-
});
|
|
1334
|
-
req.on('end', () => {
|
|
1351
|
+
(async () => {
|
|
1335
1352
|
try {
|
|
1336
|
-
const
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
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
|
-
|
|
1346
|
-
|
|
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).
|