codehere 0.8.0 → 1.0.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 +22 -13
- package/dist/application/services/verdict-service.js +154 -0
- package/dist/application/use-cases/assess-cross-vendor.js +126 -0
- package/dist/application/use-cases/build-cross-vendor-verdict.js +118 -0
- package/dist/application/use-cases/export-aat.js +107 -0
- package/dist/application/use-cases/generate-acceptance-criteria.js +223 -0
- package/dist/application/use-cases/generate-final-report.js +140 -15
- package/dist/application/use-cases/generate-narration.js +5 -3
- package/dist/application/use-cases/generate-thread-title.js +5 -2
- package/dist/application/use-cases/plan-auto-review.js +57 -0
- package/dist/application/use-cases/reconcile-audit.js +100 -0
- package/dist/application/use-cases/rerun-tests.js +226 -0
- package/dist/application/use-cases/search-sessions.js +70 -0
- package/dist/application/use-cases/should-auto-review.js +71 -0
- package/dist/application/use-cases/thread-export.js +90 -0
- package/dist/application/use-cases/verify-claim.js +187 -5
- package/dist/domain/audit.js +11 -0
- package/dist/domain/services/codebase-detector.js +0 -6
- package/dist/domain/task.js +91 -0
- package/dist/error-handler.js +3 -115
- package/dist/index.js +269 -25
- package/dist/infrastructure/completion/bash-completion.js +7 -3
- package/dist/infrastructure/completion/fish-completion.js +7 -12
- package/dist/infrastructure/completion/zsh-completion.js +7 -12
- package/dist/infrastructure/config/config-manager.js +4 -3
- package/dist/infrastructure/config/product-context.js +1 -19
- package/dist/infrastructure/context/adapters/figma-source.js +37 -2
- package/dist/infrastructure/context/adapters/github-issue-source.js +3 -2
- package/dist/infrastructure/context/github-api.js +110 -0
- package/dist/infrastructure/context/github-device-flow.js +65 -0
- package/dist/infrastructure/context/oauth-store.js +5 -2
- package/dist/infrastructure/context/registry-bootstrap.js +0 -4
- package/dist/infrastructure/cost/budget-config.js +3 -2
- package/dist/infrastructure/cost/cost-router.js +16 -23
- package/dist/infrastructure/cost/spend-aggregator.js +90 -15
- package/dist/infrastructure/cost/team-ledger.js +355 -0
- package/dist/infrastructure/discovery/discovery-log.js +85 -0
- package/dist/infrastructure/hooks/audit-tip-signature.js +336 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +133 -3
- package/dist/infrastructure/hooks/hook-mode.js +20 -4
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +8 -2
- package/dist/infrastructure/hooks/pre-prompt-scan.js +3 -3
- package/dist/infrastructure/observability/execution-tracer.js +1 -1
- package/dist/infrastructure/orchestration/agents-config-seed.js +22 -10
- package/dist/infrastructure/orchestration/aider-adapter.js +188 -24
- package/dist/infrastructure/orchestration/artifacts-extractor.js +60 -2
- package/dist/infrastructure/orchestration/claude-code-adapter.js +117 -3
- package/dist/infrastructure/orchestration/codex-adapter.js +52 -16
- package/dist/infrastructure/orchestration/conductor-adapter.js +46 -7
- package/dist/infrastructure/orchestration/context-handoff.js +32 -14
- package/dist/infrastructure/orchestration/detect-ollama.js +75 -0
- package/dist/infrastructure/orchestration/failover-config.js +0 -10
- package/dist/infrastructure/orchestration/gemini-adapter.js +92 -12
- package/dist/infrastructure/orchestration/generic-cli-adapter.js +132 -1
- package/dist/infrastructure/orchestration/graphify-context.js +14 -59
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +164 -9
- package/dist/infrastructure/orchestration/opencode-adapter.js +84 -14
- package/dist/infrastructure/orchestration/progress-aggregator.js +238 -21
- package/dist/infrastructure/orchestration/session-manager.js +593 -94
- package/dist/infrastructure/orchestration/session-persistence.js +177 -41
- package/dist/infrastructure/orchestration/thread-key-store.js +1 -1
- package/dist/infrastructure/orchestration/write-pass-store.js +0 -8
- package/dist/infrastructure/protocols/mcp-helpers.js +1 -1
- package/dist/infrastructure/protocols/mcp-server-impl.js +1462 -380
- package/dist/infrastructure/protocols/mcp-tools.js +401 -0
- package/dist/infrastructure/protocols/ui/app-script-artifacts.js +121 -0
- package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +44 -0
- package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +56 -0
- package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +588 -0
- package/dist/infrastructure/protocols/ui/app-script-context-sources.js +168 -0
- package/dist/infrastructure/protocols/ui/app-script-conversation.js +67 -0
- package/dist/infrastructure/protocols/ui/app-script-drawer.js +115 -0
- package/dist/infrastructure/protocols/ui/app-script-failover.js +133 -0
- package/dist/infrastructure/protocols/ui/app-script-github.js +113 -0
- package/dist/infrastructure/protocols/ui/app-script-observer.js +104 -0
- package/dist/infrastructure/protocols/ui/app-script-onboarding.js +80 -0
- package/dist/infrastructure/protocols/ui/app-script-overlay.js +203 -0
- package/dist/infrastructure/protocols/ui/app-script-recovery.js +136 -0
- package/dist/infrastructure/protocols/ui/app-script-render.js +166 -0
- package/dist/infrastructure/protocols/ui/app-script-share.js +78 -0
- package/dist/infrastructure/protocols/ui/app-script-sidebar.js +194 -0
- package/dist/infrastructure/protocols/ui/app-script-tour.js +78 -0
- package/dist/infrastructure/protocols/ui/app-script-wpass.js +312 -0
- package/dist/infrastructure/protocols/ui/app-script.js +2998 -0
- package/dist/infrastructure/protocols/ui/font-faces.js +7 -0
- package/dist/infrastructure/protocols/ui/styles.js +2147 -0
- package/dist/infrastructure/protocols/ui/tokens.js +96 -0
- package/dist/infrastructure/protocols/ui-page-v2.js +116 -4214
- package/dist/infrastructure/protocols/verify-page.js +849 -155
- package/dist/infrastructure/security/fix-code.js +144 -0
- package/dist/infrastructure/security/fix-secret.js +89 -0
- package/dist/infrastructure/security/permissions-config.js +3 -2
- package/dist/infrastructure/security/scan-types.js +10 -0
- package/dist/infrastructure/security/ship-scan.js +195 -0
- package/dist/infrastructure/security/trust-scorer.js +70 -3
- package/dist/infrastructure/security/vibe-checks.js +99 -0
- package/dist/infrastructure/storage/atomic-write.js +86 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +150 -3
- package/dist/infrastructure/storage/project-thread-reader.js +261 -24
- package/dist/monitoring.js +2 -2
- package/dist/presentation/cli/commands/audit-command.js +79 -4
- package/dist/presentation/cli/commands/budget-command.js +2 -2
- package/dist/presentation/cli/commands/check-command.js +96 -0
- package/dist/presentation/cli/commands/context-source-command.js +20 -1
- package/dist/presentation/cli/commands/help-command.js +5 -5
- package/dist/presentation/cli/commands/hook-command.js +5 -5
- package/dist/presentation/cli/commands/init-command.js +26 -14
- package/dist/presentation/cli/commands/memory-command.js +28 -28
- package/dist/presentation/cli/commands/permission-command.js +1 -12
- package/dist/presentation/cli/commands/protocols-builder.js +2 -2
- package/dist/presentation/cli/commands/protocols-command.js +4 -4
- package/dist/presentation/cli/commands/repo-map-builder.js +1 -1
- package/dist/presentation/cli/commands/report-command.js +48 -32
- package/dist/presentation/cli/commands/scan-command.js +52 -0
- package/dist/presentation/cli/commands/scan-tool-command.js +24 -8
- package/dist/presentation/cli/commands/search-command.js +51 -0
- package/dist/presentation/cli/commands/setup-command.js +31 -8
- package/dist/presentation/cli/commands/status-command.js +39 -86
- package/dist/presentation/cli/commands/team-command.js +256 -0
- package/dist/presentation/cli/commands/thread-command.js +87 -5
- package/dist/presentation/cli/commands/trust-command.js +53 -31
- package/dist/presentation/cli/commands/verify-command.js +74 -9
- package/dist/presentation/cli/error-display.js +0 -8
- package/dist/presentation/cli/repl.js +15 -7
- package/dist/presentation/cli/task-deliverable.js +36 -10
- package/dist/testing/replay-adapter.js +0 -19
- package/dist/ui.js +51 -239
- package/dist/utils/version.js +0 -6
- package/package.json +30 -10
- package/dist/domain/entities/task.js +0 -61
- package/dist/infrastructure/protocols/ui-page.js +0 -2377
- package/dist/infrastructure/security/input-validator.js +0 -152
- package/dist/infrastructure/storage/embeddings-path.js +0 -37
- package/dist/infrastructure/storage/task-repository.js +0 -206
|
@@ -19,103 +19,84 @@
|
|
|
19
19
|
* codehere serve --port 8080 # custom port
|
|
20
20
|
*/
|
|
21
21
|
import http from 'http';
|
|
22
|
-
import { readFileSync, existsSync, statSync, watchFile, unwatchFile } from 'fs';
|
|
23
|
-
import {
|
|
22
|
+
import { readFileSync, existsSync, statSync, watchFile, unwatchFile, rmSync, mkdirSync } from 'fs';
|
|
23
|
+
import { execFile } from 'child_process';
|
|
24
|
+
import { promisify } from 'util';
|
|
25
|
+
import { join, dirname, resolve as resolvePath } from 'path';
|
|
26
|
+
import { shipScan } from '../security/ship-scan.js';
|
|
27
|
+
import { fixExposedSecret } from '../security/fix-secret.js';
|
|
28
|
+
import { fixCodeVuln } from '../security/fix-code.js';
|
|
24
29
|
import { fileURLToPath } from 'url';
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
30
|
+
import { AUDIT_FILE, MOCK_AUDIT_FILE, verifyAuditChain } from '../hooks/claude-sdk-hook.js';
|
|
31
|
+
import { signAuditTip, pruneSignState } from '../hooks/audit-tip-signature.js';
|
|
27
32
|
import { analyzeSessions, isRealHostSession, previewFromToolInput } from '../hooks/session-analyzer.js';
|
|
28
33
|
import { CodexControlSurface } from '../orchestration/control/codex-control-surface.js';
|
|
29
34
|
import { getVersion } from '../../utils/version.js';
|
|
30
|
-
import {
|
|
35
|
+
import { TOOLS, dispatch } from './mcp-tools.js';
|
|
31
36
|
import { TASK_HTML_V2 } from './ui-page-v2.js';
|
|
32
37
|
import { VERIFY_HTML } from './verify-page.js';
|
|
33
|
-
import {
|
|
34
|
-
import { signBundle, verifyBundle, shortFingerprint, } from '../../application/use-cases/thread-export.js';
|
|
38
|
+
import { composeVerdict } from '../../application/services/verdict-service.js';
|
|
39
|
+
import { signBundle, verifyBundle, shortFingerprint, collectAuditEntriesForSession, verifyEmbeddedAuditChain, } from '../../application/use-cases/thread-export.js';
|
|
35
40
|
import { loadOrCreateKeypair, trustedKeySet, trustKey } from '../orchestration/thread-key-store.js';
|
|
36
|
-
import { loadSessionById } from '../orchestration/session-persistence.js';
|
|
41
|
+
import { loadSessionById, appendResolved, appendCompleted } from '../orchestration/session-persistence.js';
|
|
42
|
+
import { buildCrossVendorVerdictById, buildCrossVendorVerdictForWriter, } from '../../application/use-cases/build-cross-vendor-verdict.js';
|
|
37
43
|
import { validateIntent as validateWritePassIntent, validateDecision as validateWritePassDecision, applyDecision as applyWritePassDecision, applyResponse as applyWritePassResponse, statusToAuditLine as writePassStatusToAuditLine, newPassId, WritePassError, } from '../../application/use-cases/write-pass.js';
|
|
38
44
|
import { putPending as wpPutPending, getById as wpGetById, setStatus as wpSetStatus, listForSession as wpListForSession, listPending as wpListPending, } from '../orchestration/write-pass-store.js';
|
|
39
45
|
import { resolveWriteRequest, WritePassResolveError } from '../context/write-pass-resolver.js';
|
|
40
46
|
import { trustDomain as trustDomainStore } from '../../application/use-cases/domain-trust.js';
|
|
41
|
-
import { appendAuditLog, appendOverrideAudit } from '../hooks/claude-sdk-hook.js';
|
|
47
|
+
import { appendAuditLog, appendAuditLogDirect, appendOverrideAudit } from '../hooks/claude-sdk-hook.js';
|
|
42
48
|
import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from 'fs';
|
|
43
49
|
import { resolveContextSourceUrls } from '../context/context-resolver.js';
|
|
44
50
|
import { getDefaultRegistry, createDefaultFetchContextWithPost } from '../context/registry-bootstrap.js';
|
|
45
51
|
import { begin as oauthBegin, complete as oauthComplete, getProvider as getOAuthProvider, listProviders as listOAuthProviders, } from '../context/oauth-flow.js';
|
|
46
|
-
import { saveToken, clearToken, connectionStatus } from '../context/oauth-store.js';
|
|
52
|
+
import { saveToken, clearToken, connectionStatus, loadToken } from '../context/oauth-store.js';
|
|
53
|
+
import { requestDeviceCode, pollForToken, githubClientId } from '../context/github-device-flow.js';
|
|
54
|
+
import { fetchIssue, listIssues, parseRepoRef, issueToTaskPrompt, commentOnIssue, issueRefFromText, } from '../context/github-api.js';
|
|
47
55
|
import { SessionManager, availableAgents, readinessForAllAgents, getAdapter, allAdapters, } from '../orchestration/session-manager.js';
|
|
48
56
|
import { isAbsolute } from 'path';
|
|
49
57
|
import { findProjectRoot } from '../orchestration/memory-injector.js';
|
|
50
58
|
import { scaffoldProjectAt } from '../../presentation/cli/commands/init-command.js';
|
|
51
59
|
import { readCrossToolAgents } from '../storage/cross-tool-agents.js';
|
|
52
|
-
import { listProjects,
|
|
53
|
-
import { mirrorReplyToThread, renameProject } from '../storage/project-thread-mirror.js';
|
|
60
|
+
import { listProjects, readMergedProjectThread, parseGitHubCloneTarget } from '../storage/project-thread-reader.js';
|
|
61
|
+
import { mirrorReplyToThread, renameProject, setTaskArchived } from '../storage/project-thread-mirror.js';
|
|
54
62
|
import { randomUUID, timingSafeEqual } from 'crypto';
|
|
55
63
|
import { installAgent } from '../orchestration/agent-installer.js';
|
|
56
64
|
import { detectReadiness } from '../orchestration/readiness.js';
|
|
57
65
|
import { getHookMode } from '../hooks/hook-mode.js';
|
|
58
66
|
import { addPermissionEntry, loadPermissions, removePermissionEntry } from '../security/permissions-config.js';
|
|
59
67
|
import { computeSpendToday, estimateCostUsd } from '../cost/spend-aggregator.js';
|
|
60
|
-
import { loadBudget, isOverBudget, budgetUsedPct } from '../cost/budget-config.js';
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
type: 'object',
|
|
91
|
-
properties: {
|
|
92
|
-
path: { type: 'string', description: 'Directory path (default: current directory)' },
|
|
93
|
-
topN: { type: 'number', description: 'Return top N riskiest files (default: 10)' },
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
name: 'codehere_audit',
|
|
99
|
-
description: 'Read recent hook audit log entries — shows which tool calls codehere blocked, allowed, or overrode (fail-open paths) in Claude Code sessions. Pass verify:true to instead validate the SHA-256 hash chain and return any tampering detected.',
|
|
100
|
-
inputSchema: {
|
|
101
|
-
type: 'object',
|
|
102
|
-
properties: {
|
|
103
|
-
limit: { type: 'number', description: 'Number of recent entries to return (default: 20)' },
|
|
104
|
-
blockedOnly: { type: 'boolean', description: 'Only return blocked events' },
|
|
105
|
-
overrideOnly: {
|
|
106
|
-
type: 'boolean',
|
|
107
|
-
description: 'Only return trust-override events — cases where the scanner was bypassed (stdin failure, parse failure, --log-only, scan exception). Per CC frontier Q1 (auditable permission trails) — surface every bypass for review.',
|
|
108
|
-
},
|
|
109
|
-
verify: {
|
|
110
|
-
type: 'boolean',
|
|
111
|
-
description: 'Verify the SHA-256 hash chain instead of returning entries. Returns { valid, totalEntries, chainedEntries, legacyEntries, errors[] } where errors lists every detected tampering point (modification / deletion / insertion / parse failure). Per CC frontier Q1 (cryptographic commitment + tamper evidence).',
|
|
112
|
-
},
|
|
113
|
-
},
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
];
|
|
68
|
+
import { loadBudget, saveBudget, isOverBudget, budgetUsedPct } from '../cost/budget-config.js';
|
|
69
|
+
import { loadTeamConfig, saveTeamConfig, initTeamDir, codehereHome, writeMyShare, buildTeamView, } from '../cost/team-ledger.js';
|
|
70
|
+
import { recordOpenOncePerDay, discoverySummary, recruitUrl } from '../discovery/discovery-log.js';
|
|
71
|
+
// ── Brand favicon ────────────────────────────────────────────────────────────
|
|
72
|
+
// Byte-for-byte copy of the landing's app/icon.svg (codehere-landing repo —
|
|
73
|
+
// the brand source of truth, per Ega's standing rule). The gradient-C:
|
|
74
|
+
// orange #ff9a76 → blue #4a90e2, 135° (x1 0%/y1 0% → x2 100%/y2 100%).
|
|
75
|
+
// Embedded so the local-first daemon never hotlinks codehere.uk.
|
|
76
|
+
const BRAND_ICON_SVG = `<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
77
|
+
<defs>
|
|
78
|
+
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
79
|
+
<stop offset="0%" style="stop-color:#ff9a76;stop-opacity:1" />
|
|
80
|
+
<stop offset="100%" style="stop-color:#4a90e2;stop-opacity:1" />
|
|
81
|
+
</linearGradient>
|
|
82
|
+
</defs>
|
|
83
|
+
<text
|
|
84
|
+
x="16"
|
|
85
|
+
y="24"
|
|
86
|
+
font-family="Inter, system-ui, sans-serif"
|
|
87
|
+
font-size="24"
|
|
88
|
+
font-weight="900"
|
|
89
|
+
text-anchor="middle"
|
|
90
|
+
fill="url(#gradient)"
|
|
91
|
+
>
|
|
92
|
+
C
|
|
93
|
+
</text>
|
|
94
|
+
</svg>
|
|
95
|
+
|
|
96
|
+
`;
|
|
97
|
+
// ── MCP message types ────────────────────────────────────────────────────────
|
|
117
98
|
// ── Tool handlers ────────────────────────────────────────────────────────────
|
|
118
|
-
import { homedir } from 'os';
|
|
99
|
+
import { homedir, tmpdir } from 'os';
|
|
119
100
|
const controlSessions = new Map();
|
|
120
101
|
let controlSeq = 0;
|
|
121
102
|
/**
|
|
@@ -125,7 +106,7 @@ let controlSeq = 0;
|
|
|
125
106
|
* public key is meant for client-side embedding (analytics tracking key, not
|
|
126
107
|
* a secret) so committing the URL is safe.
|
|
127
108
|
*
|
|
128
|
-
* Why runtime injection instead of editing
|
|
109
|
+
* Why runtime injection instead of editing the page templates directly:
|
|
129
110
|
* the templates stay identical between dev and release builds. The only
|
|
130
111
|
* delta is the env var. This keeps the release surface clean (zero-telemetry
|
|
131
112
|
* value per PRD §1.5 #2 stays intact) while letting elu observe founder
|
|
@@ -140,203 +121,6 @@ function withDevElu(html) {
|
|
|
140
121
|
return html;
|
|
141
122
|
return html.replace('</head>', `${ELU_DEV_SCRIPT}\n</head>`);
|
|
142
123
|
}
|
|
143
|
-
function handleTrust(params) {
|
|
144
|
-
const { filepath, code, verbose } = params;
|
|
145
|
-
let content;
|
|
146
|
-
let label;
|
|
147
|
-
if (code) {
|
|
148
|
-
content = code;
|
|
149
|
-
label = 'provided code';
|
|
150
|
-
}
|
|
151
|
-
else if (filepath) {
|
|
152
|
-
const resolved = join(process.cwd(), filepath);
|
|
153
|
-
if (!existsSync(resolved)) {
|
|
154
|
-
return JSON.stringify({ error: `File not found: ${filepath}` });
|
|
155
|
-
}
|
|
156
|
-
content = readFileSync(resolved, 'utf-8');
|
|
157
|
-
label = filepath;
|
|
158
|
-
}
|
|
159
|
-
else {
|
|
160
|
-
return JSON.stringify({ error: 'Provide either filepath or code' });
|
|
161
|
-
}
|
|
162
|
-
const result = scoreFileForMCP(label, content);
|
|
163
|
-
if (!verbose) {
|
|
164
|
-
// Compact response
|
|
165
|
-
return JSON.stringify({
|
|
166
|
-
score: result.score,
|
|
167
|
-
grade: result.grade,
|
|
168
|
-
summary: result.summary,
|
|
169
|
-
criticalCount: result.findings.filter((f) => f.severity === 'critical').length,
|
|
170
|
-
highCount: result.findings.filter((f) => f.severity === 'high').length,
|
|
171
|
-
recommendation: result.score >= 75
|
|
172
|
-
? 'Safe — minor issues can be addressed in follow-up'
|
|
173
|
-
: result.score >= 60
|
|
174
|
-
? 'Review recommended before merge'
|
|
175
|
-
: 'Block — resolve critical/high findings first',
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
return JSON.stringify(result, null, 2);
|
|
179
|
-
}
|
|
180
|
-
function handleScan(params) {
|
|
181
|
-
const text = String(params.text ?? '');
|
|
182
|
-
const findings = scanPayload(text);
|
|
183
|
-
const riskScore = calculateRiskScore(findings);
|
|
184
|
-
const criticalFindings = findings.filter((f) => f.severity === 'critical');
|
|
185
|
-
const blocked = criticalFindings.length > 0 || riskScore >= 60;
|
|
186
|
-
return JSON.stringify({
|
|
187
|
-
blocked,
|
|
188
|
-
riskScore,
|
|
189
|
-
decision: blocked ? 'block' : 'allow',
|
|
190
|
-
reason: blocked
|
|
191
|
-
? criticalFindings.length > 0
|
|
192
|
-
? `${criticalFindings[0].category}: ${criticalFindings[0].description}`
|
|
193
|
-
: `Risk score ${riskScore}/100 exceeds threshold`
|
|
194
|
-
: 'No threats detected',
|
|
195
|
-
findings: findings.slice(0, 10), // cap at 10 to keep response small
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
/**
|
|
199
|
-
* Coerce a client-supplied numeric param to a bounded integer. Daemon
|
|
200
|
-
* endpoints take topN/limit straight from request bodies; a negative or NaN
|
|
201
|
-
* value silently corrupts slice() math (limit=-1 → slice keeps all-but-last,
|
|
202
|
-
* NaN → slice returns everything). Clamp to a sane range instead of trusting
|
|
203
|
-
* the wire.
|
|
204
|
-
*/
|
|
205
|
-
export function clampInt(v, def, min, max) {
|
|
206
|
-
// null/undefined = absent → default. Number(null) is 0 (finite), so without
|
|
207
|
-
// this guard a literal null would coerce to the min instead of the default.
|
|
208
|
-
if (v === null || v === undefined)
|
|
209
|
-
return def;
|
|
210
|
-
const n = Math.floor(Number(v));
|
|
211
|
-
if (!Number.isFinite(n))
|
|
212
|
-
return def;
|
|
213
|
-
return Math.max(min, Math.min(n, max));
|
|
214
|
-
}
|
|
215
|
-
function handleReview(params) {
|
|
216
|
-
const targetPath = String(params.path ?? process.cwd());
|
|
217
|
-
const topN = clampInt(params.topN, 10, 1, 100);
|
|
218
|
-
try {
|
|
219
|
-
const files = collectFilesForMCP(targetPath);
|
|
220
|
-
if (files.length === 0) {
|
|
221
|
-
return JSON.stringify({ message: 'No code files found', path: targetPath });
|
|
222
|
-
}
|
|
223
|
-
const results = files.map((f) => scoreFileForMCP(relative(targetPath, f.filepath), f.code));
|
|
224
|
-
results.sort((a, b) => a.score - b.score); // worst first
|
|
225
|
-
const top = results.slice(0, topN);
|
|
226
|
-
const overallScore = Math.round(results.reduce((s, r) => s + r.score, 0) / results.length);
|
|
227
|
-
return JSON.stringify({
|
|
228
|
-
overallScore,
|
|
229
|
-
grade: overallScore >= 90 ? 'A' : overallScore >= 75 ? 'B' : overallScore >= 60 ? 'C' : overallScore >= 40 ? 'D' : 'F',
|
|
230
|
-
totalFiles: results.length,
|
|
231
|
-
riskiestFiles: top.map((r) => ({
|
|
232
|
-
filepath: r.filepath,
|
|
233
|
-
score: r.score,
|
|
234
|
-
grade: r.grade,
|
|
235
|
-
summary: r.summary,
|
|
236
|
-
criticalCount: r.findings.filter((f) => f.severity === 'critical').length,
|
|
237
|
-
highCount: r.findings.filter((f) => f.severity === 'high').length,
|
|
238
|
-
})),
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
catch (e) {
|
|
242
|
-
return JSON.stringify({ error: String(e) });
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
function handleAudit(params) {
|
|
246
|
-
const limit = clampInt(params.limit, 20, 1, 500);
|
|
247
|
-
const blockedOnly = Boolean(params.blockedOnly);
|
|
248
|
-
const overrideOnly = Boolean(params.overrideOnly);
|
|
249
|
-
const verify = Boolean(params.verify);
|
|
250
|
-
if (verify) {
|
|
251
|
-
// Tamper-evidence verification — walks the file, recomputes every hash,
|
|
252
|
-
// checks the chain. Returns full report including ALL break points.
|
|
253
|
-
const result = verifyAuditChain(AUDIT_FILE);
|
|
254
|
-
return JSON.stringify(result, null, 2);
|
|
255
|
-
}
|
|
256
|
-
if (!existsSync(AUDIT_FILE)) {
|
|
257
|
-
return JSON.stringify({
|
|
258
|
-
message: 'No audit log yet — install hooks first with: codehere hook install',
|
|
259
|
-
entries: [],
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
const lines = readFileSync(AUDIT_FILE, 'utf-8')
|
|
263
|
-
.split('\n')
|
|
264
|
-
.filter(Boolean)
|
|
265
|
-
.slice(-Math.min(limit * 3, 500)); // read more than needed to account for filter
|
|
266
|
-
const entries = lines
|
|
267
|
-
.map((l) => {
|
|
268
|
-
try {
|
|
269
|
-
return JSON.parse(l);
|
|
270
|
-
}
|
|
271
|
-
catch {
|
|
272
|
-
return null;
|
|
273
|
-
}
|
|
274
|
-
})
|
|
275
|
-
.filter(Boolean)
|
|
276
|
-
.filter((e) => {
|
|
277
|
-
if (overrideOnly)
|
|
278
|
-
return Boolean(e.overrideReason);
|
|
279
|
-
if (blockedOnly)
|
|
280
|
-
return Boolean(e.blocked);
|
|
281
|
-
return true;
|
|
282
|
-
})
|
|
283
|
-
.slice(-limit);
|
|
284
|
-
return JSON.stringify({ entries, total: entries.length });
|
|
285
|
-
}
|
|
286
|
-
// ── JSON-RPC dispatcher ──────────────────────────────────────────────────────
|
|
287
|
-
function dispatch(req) {
|
|
288
|
-
const id = req.id;
|
|
289
|
-
const params = (req.params ?? {});
|
|
290
|
-
try {
|
|
291
|
-
switch (req.method) {
|
|
292
|
-
case 'initialize':
|
|
293
|
-
return {
|
|
294
|
-
jsonrpc: '2.0',
|
|
295
|
-
id,
|
|
296
|
-
result: {
|
|
297
|
-
protocolVersion: '2024-11-05',
|
|
298
|
-
capabilities: { tools: {} },
|
|
299
|
-
serverInfo: { name: 'codehere', version: getVersion() },
|
|
300
|
-
},
|
|
301
|
-
};
|
|
302
|
-
case 'tools/list':
|
|
303
|
-
return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
|
|
304
|
-
case 'tools/call': {
|
|
305
|
-
const toolName = String(params.name ?? '');
|
|
306
|
-
const toolParams = (params.arguments ?? {});
|
|
307
|
-
let content;
|
|
308
|
-
switch (toolName) {
|
|
309
|
-
case 'codehere_trust':
|
|
310
|
-
content = handleTrust(toolParams);
|
|
311
|
-
break;
|
|
312
|
-
case 'codehere_scan':
|
|
313
|
-
content = handleScan(toolParams);
|
|
314
|
-
break;
|
|
315
|
-
case 'codehere_review':
|
|
316
|
-
content = handleReview(toolParams);
|
|
317
|
-
break;
|
|
318
|
-
case 'codehere_audit':
|
|
319
|
-
content = handleAudit(toolParams);
|
|
320
|
-
break;
|
|
321
|
-
default:
|
|
322
|
-
return { jsonrpc: '2.0', id, error: { code: -32601, message: `Unknown tool: ${toolName}` } };
|
|
323
|
-
}
|
|
324
|
-
return {
|
|
325
|
-
jsonrpc: '2.0',
|
|
326
|
-
id,
|
|
327
|
-
result: { content: [{ type: 'text', text: content }] },
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
case 'ping':
|
|
331
|
-
return { jsonrpc: '2.0', id, result: {} };
|
|
332
|
-
default:
|
|
333
|
-
return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${req.method}` } };
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
catch (e) {
|
|
337
|
-
return { jsonrpc: '2.0', id, error: { code: -32603, message: String(e) } };
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
124
|
const MAX_BODY_SIZE = 2_097_152; // 2 MiB — covers 1MB code-scan payloads + framing
|
|
341
125
|
// v0.8 S2.5 · in-memory map of pending OAuth flows keyed by `state` value.
|
|
342
126
|
// Lifecycle: begin() inserts; callback resolves and removes; entries also
|
|
@@ -377,6 +161,20 @@ export function startMCPServer(options = {}) {
|
|
|
377
161
|
const port = options.port ?? 3773;
|
|
378
162
|
const host = options.host ?? '127.0.0.1'; // localhost only by default
|
|
379
163
|
const token = options.token;
|
|
164
|
+
// S9 · practice mode starts clean and leaves no residue: when the mock
|
|
165
|
+
// chain (stable tmp path) is in use, wipe last session's practice chain
|
|
166
|
+
// and forget its sign-state key — otherwise the stale key reads the fresh
|
|
167
|
+
// chain as an rm-bypass (a false tamper alarm), and dead keys pile up in
|
|
168
|
+
// the real keystore. Explicit CODEHERE_AUDIT_FILE (tests) is untouched.
|
|
169
|
+
if (process.env.CODEHERE_MOCK === '1' && AUDIT_FILE === MOCK_AUDIT_FILE) {
|
|
170
|
+
try {
|
|
171
|
+
rmSync(dirname(MOCK_AUDIT_FILE), { recursive: true, force: true });
|
|
172
|
+
pruneSignState(MOCK_AUDIT_FILE);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
/* best-effort — a leftover practice chain never blocks boot */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
380
178
|
const server = http.createServer((req, res) => {
|
|
381
179
|
// v0.7 Week-2.2 · CORS for read-only public endpoints.
|
|
382
180
|
// The landing-page daemon-status pill (codehere.uk → 127.0.0.1:3773)
|
|
@@ -388,13 +186,22 @@ export function startMCPServer(options = {}) {
|
|
|
388
186
|
// /api/writepass/:id/:decision, etc.) are NOT covered by this · they
|
|
389
187
|
// remain same-origin-only.
|
|
390
188
|
const url = req.url ?? '';
|
|
189
|
+
// Query-stripped path for static brand assets — a cache-busted
|
|
190
|
+
// /icon.svg?v=N must still hit the icon route, not fall to 404.
|
|
191
|
+
const urlPath = url.split('?')[0];
|
|
391
192
|
const isReadOnlyPublic = url === '/health' ||
|
|
392
193
|
url === '/api/version' ||
|
|
393
194
|
url === '/api/tasks' ||
|
|
394
195
|
url === '/api/host-sessions' ||
|
|
395
196
|
url.startsWith('/api/host-sessions/') ||
|
|
396
|
-
|
|
397
|
-
|
|
197
|
+
// MED #32 (atomic audit) · this allowlisted '/api/audit/stats', a route
|
|
198
|
+
// that never existed (CORS headers on a 404). The real read-only route
|
|
199
|
+
// the footer polls is /api/audit/summary.
|
|
200
|
+
url.startsWith('/api/audit/summary') ||
|
|
201
|
+
url.startsWith('/api/readiness/agents') ||
|
|
202
|
+
// Brand favicon — static public bytes, no state.
|
|
203
|
+
urlPath === '/icon.svg' ||
|
|
204
|
+
urlPath === '/favicon.ico';
|
|
398
205
|
if (isReadOnlyPublic) {
|
|
399
206
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
400
207
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
@@ -448,7 +255,24 @@ export function startMCPServer(options = {}) {
|
|
|
448
255
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
449
256
|
// `mock` lets a no-spend tool (verify-all) confirm a daemon is mock BEFORE
|
|
450
257
|
// attaching — so it can refuse to drive a LIVE daemon while claiming "no spend".
|
|
451
|
-
res.end(JSON.stringify({
|
|
258
|
+
res.end(JSON.stringify({
|
|
259
|
+
status: 'ok',
|
|
260
|
+
server: 'codehere-mcp',
|
|
261
|
+
version: getVersion(),
|
|
262
|
+
tools: TOOLS.length,
|
|
263
|
+
mock: process.env.CODEHERE_MOCK === '1',
|
|
264
|
+
}));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// Brand favicon · S16 one-brand-everywhere. Byte-for-byte copy of the
|
|
268
|
+
// landing's app/icon.svg (codehere.uk = brand source of truth) — embedded,
|
|
269
|
+
// never hotlinked, because the product is local-first/offline.
|
|
270
|
+
// /favicon.ico answers with the same SVG bytes: browsers request it by
|
|
271
|
+
// default on any tab whose HTML they haven't parsed yet, so the brand
|
|
272
|
+
// holds even on surfaces a <link rel=icon> hasn't reached.
|
|
273
|
+
if (req.method === 'GET' && (urlPath === '/icon.svg' || urlPath === '/favicon.ico')) {
|
|
274
|
+
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' });
|
|
275
|
+
res.end(BRAND_ICON_SVG);
|
|
452
276
|
return;
|
|
453
277
|
}
|
|
454
278
|
// v2 Task-Centric PM UI (primary — served at /)
|
|
@@ -474,22 +298,24 @@ export function startMCPServer(options = {}) {
|
|
|
474
298
|
res.end(VERIFY_HTML);
|
|
475
299
|
return;
|
|
476
300
|
}
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
483
|
-
// v0.6.0 M1 — project thread view; consumes /api/projects + /api/projects/:slug
|
|
301
|
+
// Legacy v1-era pages DELETED 2026-06-10 (Ega hit /history and called it
|
|
302
|
+
// what it was). The v1-MVP surface is exactly: / (the v2 dashboard),
|
|
303
|
+
// /verify, the JSON API, and /mcp. /history's audit feed lives on in the
|
|
304
|
+
// v2 AUDIT footer + `codehere audit`; /threads' project view IS the v2
|
|
305
|
+
// goal-first home. Old bookmarks land on the real product, never a relic.
|
|
484
306
|
if (req.method === 'GET' &&
|
|
485
|
-
(req.url === '/
|
|
486
|
-
|
|
487
|
-
|
|
307
|
+
(req.url === '/history' ||
|
|
308
|
+
req.url === '/history/' ||
|
|
309
|
+
req.url === '/threads' ||
|
|
310
|
+
req.url === '/threads/' ||
|
|
311
|
+
req.url?.startsWith('/threads?'))) {
|
|
312
|
+
res.writeHead(301, { Location: '/' });
|
|
313
|
+
res.end();
|
|
488
314
|
return;
|
|
489
315
|
}
|
|
490
|
-
// Backwards-compat: /ui used to be the Mission Control audit view
|
|
316
|
+
// Backwards-compat: /ui used to be the Mission Control audit view
|
|
491
317
|
if (req.method === 'GET' && (req.url === '/ui' || req.url === '/ui/')) {
|
|
492
|
-
res.writeHead(302, { Location: '/
|
|
318
|
+
res.writeHead(302, { Location: '/' });
|
|
493
319
|
res.end();
|
|
494
320
|
return;
|
|
495
321
|
}
|
|
@@ -592,9 +418,46 @@ export function startMCPServer(options = {}) {
|
|
|
592
418
|
}
|
|
593
419
|
};
|
|
594
420
|
watchFile(SESSIONS_FILE, { interval: 500 }, onChange);
|
|
421
|
+
// Live-progress push: sessions.jsonl is only written on spawn + completion,
|
|
422
|
+
// so disk-watching alone left the room at "0 steps" for the whole run and
|
|
423
|
+
// then jumped to the outcome (user-#1, 2026-06-07). Steps update in memory;
|
|
424
|
+
// SessionManager emits a tick when they do. Push it (throttled to ~400ms so
|
|
425
|
+
// a chatty agent can't flood the stream) so the room watches work live.
|
|
426
|
+
// Leading-edge throttle: push the FIRST step the instant it lands (no
|
|
427
|
+
// 400ms wait), then at most every 250ms with a trailing flush for any
|
|
428
|
+
// steps that arrived during the window. The old trailing-only throttle
|
|
429
|
+
// delayed the first step and collapsed bursts into one "jump" — the
|
|
430
|
+
// running view felt batched, not streamed. (Ega dogfood, watch-it-work)
|
|
431
|
+
let liveTimer = null;
|
|
432
|
+
let livePending = false;
|
|
433
|
+
const pushLive = () => {
|
|
434
|
+
try {
|
|
435
|
+
res.write(`data: ${JSON.stringify({ type: 'change' })}\n\n`);
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
/* transient */
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
const offLive = SessionManager.getInstance().onLiveChange(() => {
|
|
442
|
+
if (liveTimer) {
|
|
443
|
+
livePending = true;
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
pushLive();
|
|
447
|
+
liveTimer = setTimeout(() => {
|
|
448
|
+
liveTimer = null;
|
|
449
|
+
if (livePending) {
|
|
450
|
+
livePending = false;
|
|
451
|
+
pushLive();
|
|
452
|
+
}
|
|
453
|
+
}, 250);
|
|
454
|
+
});
|
|
595
455
|
const ping = setInterval(() => res.write(': ping\n\n'), 15000);
|
|
596
456
|
req.on('close', () => {
|
|
597
457
|
clearInterval(ping);
|
|
458
|
+
if (liveTimer)
|
|
459
|
+
clearTimeout(liveTimer);
|
|
460
|
+
offLive();
|
|
598
461
|
unwatchFile(SESSIONS_FILE, onChange);
|
|
599
462
|
});
|
|
600
463
|
return;
|
|
@@ -611,9 +474,14 @@ export function startMCPServer(options = {}) {
|
|
|
611
474
|
if (req.method === 'GET' && req.url === '/api/audit/verify') {
|
|
612
475
|
try {
|
|
613
476
|
const r = verifyAuditChain();
|
|
477
|
+
// A walk over zero entries trivially succeeds, so valid:true here would tell the
|
|
478
|
+
// dashboard "hash chain verified" about a log that does not exist — the same claim
|
|
479
|
+
// the CLI's --json path was making. Nothing to verify is not the same as verified.
|
|
480
|
+
const verifiable = r.totalEntries > 0;
|
|
614
481
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
615
482
|
res.end(JSON.stringify({
|
|
616
|
-
valid: r.valid,
|
|
483
|
+
valid: verifiable && r.valid,
|
|
484
|
+
verifiable,
|
|
617
485
|
totalEntries: r.totalEntries,
|
|
618
486
|
chainedEntries: r.chainedEntries,
|
|
619
487
|
legacyEntries: r.legacyEntries,
|
|
@@ -622,7 +490,13 @@ export function startMCPServer(options = {}) {
|
|
|
622
490
|
}
|
|
623
491
|
catch {
|
|
624
492
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
625
|
-
res.end(JSON.stringify({
|
|
493
|
+
res.end(JSON.stringify({
|
|
494
|
+
valid: false,
|
|
495
|
+
totalEntries: 0,
|
|
496
|
+
chainedEntries: 0,
|
|
497
|
+
legacyEntries: 0,
|
|
498
|
+
errors: [{ lineNumber: 0, reason: 'unreadable', detail: 'audit verification failed to run' }],
|
|
499
|
+
}));
|
|
626
500
|
}
|
|
627
501
|
return;
|
|
628
502
|
}
|
|
@@ -691,6 +565,118 @@ export function startMCPServer(options = {}) {
|
|
|
691
565
|
})();
|
|
692
566
|
return;
|
|
693
567
|
}
|
|
568
|
+
// Ship-readiness scan — run gitleaks + semgrep on a folder, return the prioritized findings.
|
|
569
|
+
// Deterministic, no model tokens. Powers the home's "is your app safe to ship?" hook.
|
|
570
|
+
if (req.method === 'POST' && req.url === '/api/scan') {
|
|
571
|
+
(async () => {
|
|
572
|
+
try {
|
|
573
|
+
const body = await readBody(req, res);
|
|
574
|
+
let parsed;
|
|
575
|
+
try {
|
|
576
|
+
parsed = JSON.parse(body || '{}');
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
580
|
+
res.end(JSON.stringify({ error: 'Invalid JSON body' }));
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const dir = resolvePath(String(parsed.dir || '.'));
|
|
584
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
|
585
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
586
|
+
res.end(JSON.stringify({ error: `Not a folder on this machine: ${dir}` }));
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const result = shipScan(dir);
|
|
590
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
591
|
+
res.end(JSON.stringify(result));
|
|
592
|
+
}
|
|
593
|
+
catch (e) {
|
|
594
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
595
|
+
res.end(JSON.stringify({ error: 'scan_failed', detail: String(e?.message || e) }));
|
|
596
|
+
}
|
|
597
|
+
})();
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
// Fix + prove closed — for now the deterministic exposed-secret fix (move out of code, re-verify).
|
|
601
|
+
if (req.method === 'POST' && req.url === '/api/fix') {
|
|
602
|
+
(async () => {
|
|
603
|
+
try {
|
|
604
|
+
const body = await readBody(req, res);
|
|
605
|
+
let parsed;
|
|
606
|
+
try {
|
|
607
|
+
parsed = JSON.parse(body || '{}');
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
611
|
+
res.end(JSON.stringify({ ok: false, message: 'Invalid JSON body' }));
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const dir = resolvePath(String(parsed.dir || '.'));
|
|
615
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
|
616
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
617
|
+
res.end(JSON.stringify({ ok: false, message: `Not a folder on this machine: ${dir}` }));
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// Stream progress: the AI code-fix can take a few minutes, so we send live steps over SSE
|
|
621
|
+
// instead of leaving the browser frozen on "Fixing…". The final 'done' event carries the result.
|
|
622
|
+
res.writeHead(200, {
|
|
623
|
+
'Content-Type': 'text/event-stream',
|
|
624
|
+
'Cache-Control': 'no-cache',
|
|
625
|
+
Connection: 'keep-alive',
|
|
626
|
+
'X-Accel-Buffering': 'no',
|
|
627
|
+
});
|
|
628
|
+
res.flushHeaders?.();
|
|
629
|
+
res.socket?.setNoDelay?.(true);
|
|
630
|
+
const send = (obj) => {
|
|
631
|
+
try {
|
|
632
|
+
res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
/* client gone — the fix still runs to completion */
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
// Yield a tick after each send so the chunk actually leaves the socket before the next
|
|
639
|
+
// synchronous scanner (spawnSync) blocks the event loop — otherwise it all flushes at the end.
|
|
640
|
+
const flush = () => new Promise((r) => setImmediate(r));
|
|
641
|
+
send({ type: 'step', label: 'Looking at your code' });
|
|
642
|
+
await flush();
|
|
643
|
+
// Route by the top finding: a leaked secret → deterministic move-out (instant, no spend);
|
|
644
|
+
// a code danger → AI fix + re-scan proof (uses the Claude Code subscription).
|
|
645
|
+
const scan = shipScan(dir);
|
|
646
|
+
const top = scan.top;
|
|
647
|
+
let result;
|
|
648
|
+
if (!top) {
|
|
649
|
+
result = { ok: false, provenClosed: false, message: 'No ship-blockers found — nothing to fix.' };
|
|
650
|
+
}
|
|
651
|
+
else if (top.tool === 'gitleaks') {
|
|
652
|
+
send({ type: 'step', label: 'Moving the secret out of your code' });
|
|
653
|
+
result = fixExposedSecret(dir);
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
result = await fixCodeVuln(dir, top, (label) => send({ type: 'step', label }));
|
|
657
|
+
}
|
|
658
|
+
send({ type: 'done', result });
|
|
659
|
+
res.end();
|
|
660
|
+
}
|
|
661
|
+
catch (e) {
|
|
662
|
+
// If the SSE stream already started, headers are sent — emit a done event instead of a new header.
|
|
663
|
+
if (res.headersSent) {
|
|
664
|
+
try {
|
|
665
|
+
res.write(`data: ${JSON.stringify({ type: 'done', result: { ok: false, provenClosed: false, message: 'fix_failed', detail: String(e?.message || e) } })}\n\n`);
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
/* best effort */
|
|
669
|
+
}
|
|
670
|
+
res.end();
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
674
|
+
res.end(JSON.stringify({ ok: false, message: 'fix_failed', detail: String(e?.message || e) }));
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
})();
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
694
680
|
if (req.method === 'DELETE' && req.url?.startsWith('/api/permissions?')) {
|
|
695
681
|
const params = new URLSearchParams(req.url.split('?')[1] || '');
|
|
696
682
|
const cwd = params.get('cwd') || '';
|
|
@@ -920,7 +906,11 @@ export function startMCPServer(options = {}) {
|
|
|
920
906
|
totalCalls: calls.length,
|
|
921
907
|
blockedCalls: calls.filter((c) => c.blocked).length,
|
|
922
908
|
criticalCalls: calls.filter((c) => c.criticalFindings > 0).length,
|
|
923
|
-
|
|
909
|
+
// Same rule as /api/audit/verify: an empty chain walks clean, so validity is only
|
|
910
|
+
// meaningful once there is something to walk. The panel needs the third state to
|
|
911
|
+
// say "nothing recorded yet" instead of picking between verified and broken.
|
|
912
|
+
chainValid: chain.totalEntries > 0 && chain.valid,
|
|
913
|
+
chainVerifiable: chain.totalEntries > 0,
|
|
924
914
|
chainEntries: chain.chainedEntries,
|
|
925
915
|
}));
|
|
926
916
|
return;
|
|
@@ -1012,6 +1002,156 @@ export function startMCPServer(options = {}) {
|
|
|
1012
1002
|
// returns the new entry's seq + short hash, so the UI can show "logged ·
|
|
1013
1003
|
// chain continues <hash>" with the REAL hash, not a mockup. Same-origin
|
|
1014
1004
|
// only (mutating · not in the CORS read-only list).
|
|
1005
|
+
// (helper for the append route) last-10-lines tail scan by identity tuple.
|
|
1006
|
+
function findRecentAuditSeq(sessionId, toolName, timestamp) {
|
|
1007
|
+
try {
|
|
1008
|
+
if (!existsSync(AUDIT_FILE))
|
|
1009
|
+
return null;
|
|
1010
|
+
const lines = readFileSync(AUDIT_FILE, 'utf-8').trim().split('\n');
|
|
1011
|
+
for (let i = lines.length - 1; i >= 0 && i >= lines.length - 10; i--) {
|
|
1012
|
+
try {
|
|
1013
|
+
const e = JSON.parse(lines[i]);
|
|
1014
|
+
if (e.sessionId === sessionId && e.toolName === toolName && e.timestamp === timestamp) {
|
|
1015
|
+
return typeof e.seq === 'number' ? e.seq : null;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
catch {
|
|
1019
|
+
/* skip malformed */
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
catch {
|
|
1024
|
+
/* unreadable → no match */
|
|
1025
|
+
}
|
|
1026
|
+
return null;
|
|
1027
|
+
}
|
|
1028
|
+
// R1.1 · the daemon as sole audit writer — append endpoint. Loopback-bound
|
|
1029
|
+
// (the server binds 127.0.0.1 by default). Accepts ONLY the pre-chain entry
|
|
1030
|
+
// shape; chain position (seq/prevHash/entryHash) is the DAEMON'S to compute —
|
|
1031
|
+
// a caller supplying any chain field is rejected outright, so an entry
|
|
1032
|
+
// cannot be forge-positioned VIA THIS ENDPOINT (direct file access by the
|
|
1033
|
+
// same uid is R1.2/R1.3's territory). Marks writer:'daemon'. Mirrors
|
|
1034
|
+
// the override route's no-screen-lies rule: success is claimed only after
|
|
1035
|
+
// the entry is CONFIRMED on disk (appendAuditLog is fail-open by design).
|
|
1036
|
+
if (req.method === 'POST' && req.url === '/api/audit/append') {
|
|
1037
|
+
(async () => {
|
|
1038
|
+
try {
|
|
1039
|
+
const text = await readBody(req, res);
|
|
1040
|
+
let raw;
|
|
1041
|
+
try {
|
|
1042
|
+
raw = JSON.parse(text || '{}');
|
|
1043
|
+
}
|
|
1044
|
+
catch {
|
|
1045
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1046
|
+
res.end(JSON.stringify({ error: 'invalid JSON' }));
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
if (typeof raw.auditFile === 'string' && raw.auditFile !== AUDIT_FILE) {
|
|
1050
|
+
// The caller records into a different audit file than this daemon —
|
|
1051
|
+
// we are not its recorder. 409 so the hook falls back to a direct
|
|
1052
|
+
// write into its own chain (R1.2 honest-routing invariant).
|
|
1053
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1054
|
+
res.end(JSON.stringify({ error: 'this daemon records a different audit file' }));
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
delete raw.auditFile; // accepted — not part of the entry shape
|
|
1058
|
+
if ('seq' in raw || 'prevHash' in raw || 'entryHash' in raw || 'writer' in raw) {
|
|
1059
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1060
|
+
res.end(JSON.stringify({
|
|
1061
|
+
error: 'chain position and writer provenance are computed by the daemon — do not supply seq/prevHash/entryHash/writer',
|
|
1062
|
+
}));
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
if (typeof raw.sessionId !== 'string' ||
|
|
1066
|
+
!raw.sessionId ||
|
|
1067
|
+
typeof raw.toolName !== 'string' ||
|
|
1068
|
+
!raw.toolName) {
|
|
1069
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1070
|
+
res.end(JSON.stringify({ error: 'sessionId and toolName are required strings' }));
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
const entry = {
|
|
1074
|
+
timestamp: typeof raw.timestamp === 'string' && raw.timestamp ? raw.timestamp : new Date().toISOString(),
|
|
1075
|
+
sessionId: raw.sessionId,
|
|
1076
|
+
toolName: raw.toolName,
|
|
1077
|
+
toolInput: typeof raw.toolInput === 'string'
|
|
1078
|
+
? raw.toolInput.slice(0, 2000)
|
|
1079
|
+
: JSON.stringify(raw.toolInput ?? {}).slice(0, 2000),
|
|
1080
|
+
riskScore: typeof raw.riskScore === 'number' && Number.isFinite(raw.riskScore) ? raw.riskScore : 0,
|
|
1081
|
+
blocked: raw.blocked === true,
|
|
1082
|
+
findings: typeof raw.findings === 'number' && Number.isFinite(raw.findings) ? raw.findings : 0,
|
|
1083
|
+
criticalFindings: typeof raw.criticalFindings === 'number' && Number.isFinite(raw.criticalFindings)
|
|
1084
|
+
? raw.criticalFindings
|
|
1085
|
+
: 0,
|
|
1086
|
+
// Override provenance is legitimate hook data (R1.2 routes the
|
|
1087
|
+
// hook's bypass-path entries here) — enum-validated, never free-form.
|
|
1088
|
+
...(typeof raw.overrideReason === 'string' &&
|
|
1089
|
+
[
|
|
1090
|
+
'stdin_read_failure',
|
|
1091
|
+
'event_parse_failure',
|
|
1092
|
+
'scan_exception',
|
|
1093
|
+
'log_only_mode',
|
|
1094
|
+
'user_override',
|
|
1095
|
+
'audit_write_failure',
|
|
1096
|
+
].includes(raw.overrideReason)
|
|
1097
|
+
? { overrideReason: raw.overrideReason }
|
|
1098
|
+
: {}),
|
|
1099
|
+
...(typeof raw.overrideNote === 'string' && raw.overrideNote.trim()
|
|
1100
|
+
? { overrideNote: raw.overrideNote.trim().slice(0, 500) }
|
|
1101
|
+
: {}),
|
|
1102
|
+
writer: 'daemon',
|
|
1103
|
+
};
|
|
1104
|
+
// Idempotency: a hook may retry after a client-side abort even though
|
|
1105
|
+
// this daemon already wrote the entry — same (timestamp, sessionId,
|
|
1106
|
+
// toolName) in the tail means it's already recorded; return its seq
|
|
1107
|
+
// instead of minting a duplicate row (refuter-demonstrated risk).
|
|
1108
|
+
const dupSeq = findRecentAuditSeq(entry.sessionId, entry.toolName, entry.timestamp);
|
|
1109
|
+
if (dupSeq !== null) {
|
|
1110
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1111
|
+
res.end(JSON.stringify({ ok: true, seq: dupSeq, idempotent: true }));
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
appendAuditLogDirect(entry);
|
|
1115
|
+
// Confirm on disk before claiming success (append is fail-open).
|
|
1116
|
+
let seq = null;
|
|
1117
|
+
try {
|
|
1118
|
+
const lines = readFileSync(AUDIT_FILE, 'utf-8').trim().split('\n');
|
|
1119
|
+
for (let i = lines.length - 1; i >= 0 && i >= lines.length - 10; i--) {
|
|
1120
|
+
const e = JSON.parse(lines[i]);
|
|
1121
|
+
if (e.sessionId === entry.sessionId && e.toolName === entry.toolName && e.timestamp === entry.timestamp) {
|
|
1122
|
+
seq = typeof e.seq === 'number' ? e.seq : null;
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
catch {
|
|
1128
|
+
/* unreadable → unconfirmed */
|
|
1129
|
+
}
|
|
1130
|
+
if (seq === null) {
|
|
1131
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1132
|
+
res.end(JSON.stringify({ error: 'append could not be confirmed on disk' }));
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
// R1.3 · sign-at-write: every daemon append advances the signed tip,
|
|
1136
|
+
// so the protected prefix grows per WRITE, not per session-completion.
|
|
1137
|
+
// ed25519 sign is sub-ms; fail-open (signing never breaks the append).
|
|
1138
|
+
signAuditTip(AUDIT_FILE);
|
|
1139
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1140
|
+
res.end(JSON.stringify({ ok: true, seq }));
|
|
1141
|
+
}
|
|
1142
|
+
catch (err) {
|
|
1143
|
+
// readBody may have already 413'd the response before rejecting —
|
|
1144
|
+
// re-writing headers then would crash embedded callers with
|
|
1145
|
+
// ERR_HTTP_HEADERS_SENT (refuter caveat c; the /api/permissions
|
|
1146
|
+
// route's pattern).
|
|
1147
|
+
if (!res.headersSent) {
|
|
1148
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1149
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
})();
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1015
1155
|
if (req.method === 'POST' && req.url === '/api/audit/override') {
|
|
1016
1156
|
(async () => {
|
|
1017
1157
|
try {
|
|
@@ -1031,7 +1171,11 @@ export function startMCPServer(options = {}) {
|
|
|
1031
1171
|
res.end(JSON.stringify({ error: 'a reason is required to log an override' }));
|
|
1032
1172
|
return;
|
|
1033
1173
|
}
|
|
1034
|
-
|
|
1174
|
+
// A failed-task recovery acknowledgement is NOT a security override —
|
|
1175
|
+
// record it as its own reason so an audit reader can't conflate the
|
|
1176
|
+
// two (refuter caveat). Default stays user_override.
|
|
1177
|
+
const reasonType = raw.reasonType === 'recovery_ack' ? 'recovery_ack' : 'user_override';
|
|
1178
|
+
appendOverrideAudit(reasonType, {
|
|
1035
1179
|
sessionId: typeof raw.sessionId === 'string' ? raw.sessionId : 'unknown',
|
|
1036
1180
|
toolName: typeof raw.toolName === 'string' ? raw.toolName : 'unknown',
|
|
1037
1181
|
note: reason,
|
|
@@ -1058,7 +1202,11 @@ export function startMCPServer(options = {}) {
|
|
|
1058
1202
|
catch {
|
|
1059
1203
|
continue;
|
|
1060
1204
|
}
|
|
1061
|
-
|
|
1205
|
+
// S6 · match the reasonType we just wrote — a recovery ack is
|
|
1206
|
+
// recorded as 'recovery_ack', and matching only 'user_override'
|
|
1207
|
+
// made the readback miss it: the entry landed but the user was
|
|
1208
|
+
// told "could not be confirmed".
|
|
1209
|
+
if (e.overrideReason === reasonType && e.sessionId === wantSid && e.overrideNote === wantNote) {
|
|
1062
1210
|
seq = typeof e.seq === 'number' ? e.seq : null;
|
|
1063
1211
|
entryHash = typeof e.entryHash === 'string' ? e.entryHash : '';
|
|
1064
1212
|
confirmed = true;
|
|
@@ -1074,6 +1222,14 @@ export function startMCPServer(options = {}) {
|
|
|
1074
1222
|
res.end(JSON.stringify({ ok: false, error: 'override could not be confirmed in the audit log' }));
|
|
1075
1223
|
return;
|
|
1076
1224
|
}
|
|
1225
|
+
// S3 · mirror a confirmed recovery ack into task state so the
|
|
1226
|
+
// resolved badge survives reload (the chain entry alone is not
|
|
1227
|
+
// what the recovery card re-renders from).
|
|
1228
|
+
if (reasonType === 'recovery_ack' && typeof raw.sessionId === 'string') {
|
|
1229
|
+
const resolvedAt = Date.now();
|
|
1230
|
+
appendResolved({ id: raw.sessionId, resolvedAt });
|
|
1231
|
+
SessionManager.getInstance().markPersistedResolved(raw.sessionId, resolvedAt);
|
|
1232
|
+
}
|
|
1077
1233
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1078
1234
|
res.end(JSON.stringify({
|
|
1079
1235
|
ok: true,
|
|
@@ -1172,32 +1328,381 @@ export function startMCPServer(options = {}) {
|
|
|
1172
1328
|
}
|
|
1173
1329
|
return;
|
|
1174
1330
|
}
|
|
1175
|
-
// ── v0.6 S3 — spend-today aggregator + budget state ──────────────────────
|
|
1176
|
-
// GET /api/spend/today — read-only aggregate of today's estimated USD spend
|
|
1177
|
-
// plus budget config (slice 2: dailyUsd, pctUsed, over). Powers the
|
|
1178
|
-
// dashboard's "spent today" pill + budget warn/block colors. Per
|
|
1179
|
-
// claim_market_position v3 capture-strategy edge "cost-bounded execution":
|
|
1180
|
-
// this is the read surface; the actual block happens at /api/task spawn.
|
|
1181
|
-
if (req.method === 'GET' && req.url === '/api/spend/today') {
|
|
1182
|
-
try {
|
|
1183
|
-
const spend = computeSpendToday();
|
|
1184
|
-
const budget = loadBudget();
|
|
1185
|
-
const pctUsed = budgetUsedPct(spend.totalUsd, budget);
|
|
1186
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1187
|
-
res.end(JSON.stringify({
|
|
1188
|
-
...spend,
|
|
1189
|
-
budget: {
|
|
1190
|
-
dailyUsd: budget.dailyUsd,
|
|
1191
|
-
warnAtPct: budget.warnAtPct,
|
|
1192
|
-
pctUsed,
|
|
1193
|
-
over: isOverBudget(spend.totalUsd, budget),
|
|
1194
|
-
},
|
|
1195
|
-
}));
|
|
1196
|
-
}
|
|
1197
|
-
catch (e) {
|
|
1198
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1199
|
-
res.end(JSON.stringify({ error: 'spend_today_failed', detail: String(e) }));
|
|
1200
|
-
}
|
|
1331
|
+
// ── v0.6 S3 — spend-today aggregator + budget state ──────────────────────
|
|
1332
|
+
// GET /api/spend/today — read-only aggregate of today's estimated USD spend
|
|
1333
|
+
// plus budget config (slice 2: dailyUsd, pctUsed, over). Powers the
|
|
1334
|
+
// dashboard's "spent today" pill + budget warn/block colors. Per
|
|
1335
|
+
// claim_market_position v3 capture-strategy edge "cost-bounded execution":
|
|
1336
|
+
// this is the read surface; the actual block happens at /api/task spawn.
|
|
1337
|
+
if (req.method === 'GET' && req.url === '/api/spend/today') {
|
|
1338
|
+
try {
|
|
1339
|
+
const spend = computeSpendToday();
|
|
1340
|
+
const budget = loadBudget();
|
|
1341
|
+
const pctUsed = budgetUsedPct(spend.totalUsd, budget);
|
|
1342
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1343
|
+
res.end(JSON.stringify({
|
|
1344
|
+
...spend,
|
|
1345
|
+
budget: {
|
|
1346
|
+
dailyUsd: budget.dailyUsd,
|
|
1347
|
+
warnAtPct: budget.warnAtPct,
|
|
1348
|
+
pctUsed,
|
|
1349
|
+
over: isOverBudget(spend.totalUsd, budget),
|
|
1350
|
+
},
|
|
1351
|
+
}));
|
|
1352
|
+
}
|
|
1353
|
+
catch (e) {
|
|
1354
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1355
|
+
res.end(JSON.stringify({ error: 'spend_today_failed', detail: String(e) }));
|
|
1356
|
+
}
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
// GET /api/team — merged team cost+audit ledger for the dashboard hero.
|
|
1360
|
+
// Refreshes THIS machine's snapshot in the team dir (no git push; the CLI
|
|
1361
|
+
// `codehere team` is the active pull/push), then merges every machine's
|
|
1362
|
+
// snapshot. Read-only of the network: nothing leaves the box, the merge is
|
|
1363
|
+
// from the team's own dir. { configured:false } when no team dir is set.
|
|
1364
|
+
if (req.method === 'GET' && req.url === '/api/team') {
|
|
1365
|
+
try {
|
|
1366
|
+
const cfg = loadTeamConfig();
|
|
1367
|
+
if (!cfg.dir) {
|
|
1368
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1369
|
+
res.end(JSON.stringify({ configured: false }));
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
try {
|
|
1373
|
+
writeMyShare(cfg.dir); // refresh this machine's live numbers; best-effort
|
|
1374
|
+
}
|
|
1375
|
+
catch {
|
|
1376
|
+
/* dir not writable (e.g. missing) — still try to read what's there */
|
|
1377
|
+
}
|
|
1378
|
+
const view = buildTeamView(cfg.dir, cfg.dailyUsd);
|
|
1379
|
+
recordOpenOncePerDay(); // discovery outcome: the lead opened the shared record (deduped per day, best-effort)
|
|
1380
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1381
|
+
res.end(JSON.stringify({ configured: true, dir: cfg.dir, recruitUrl: recruitUrl(), ...view }));
|
|
1382
|
+
}
|
|
1383
|
+
catch (e) {
|
|
1384
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1385
|
+
res.end(JSON.stringify({ error: 'team_view_failed', detail: String(e) }));
|
|
1386
|
+
}
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
// GET /api/discovery/summary — the continuous-discovery outcome (record-open
|
|
1390
|
+
// behaviour) read back for the weekly cadence. Read-only, local.
|
|
1391
|
+
if (req.method === 'GET' && req.url === '/api/discovery/summary') {
|
|
1392
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1393
|
+
res.end(JSON.stringify(discoverySummary()));
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
// POST /api/team/init { dir? } — create a team from the dashboard: make +
|
|
1397
|
+
// git-init a shared dir, point at it, write the first snapshot. Same core as
|
|
1398
|
+
// `codehere team init`. Local-first: nothing leaves the box.
|
|
1399
|
+
if (req.method === 'POST' && req.url === '/api/team/init') {
|
|
1400
|
+
(async () => {
|
|
1401
|
+
try {
|
|
1402
|
+
const raw = await readBody(req, res);
|
|
1403
|
+
let parsed = {};
|
|
1404
|
+
try {
|
|
1405
|
+
parsed = JSON.parse(raw || '{}');
|
|
1406
|
+
}
|
|
1407
|
+
catch {
|
|
1408
|
+
/* empty/invalid body → default dir */
|
|
1409
|
+
}
|
|
1410
|
+
const rawDir = parsed.dir && String(parsed.dir).trim();
|
|
1411
|
+
// "undefined"/"null" are client-bug tripwires, not paths — a Jul-1
|
|
1412
|
+
// client sent one and six days of shares landed in a cwd-relative
|
|
1413
|
+
// agent/undefined/. Relative inputs resolve to absolute here.
|
|
1414
|
+
if (rawDir === 'undefined' || rawDir === 'null') {
|
|
1415
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1416
|
+
res.end(JSON.stringify({
|
|
1417
|
+
error: 'dir_invalid',
|
|
1418
|
+
dir: rawDir,
|
|
1419
|
+
message: 'That dir looks unset (the literal string "' +
|
|
1420
|
+
rawDir +
|
|
1421
|
+
'"). Send a real path or omit dir for the default.',
|
|
1422
|
+
}));
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
const dir = rawDir ? resolvePath(rawDir) : join(codehereHome(), 'team-shared');
|
|
1426
|
+
initTeamDir(dir);
|
|
1427
|
+
const cfg = loadTeamConfig();
|
|
1428
|
+
cfg.dir = dir;
|
|
1429
|
+
saveTeamConfig(cfg);
|
|
1430
|
+
try {
|
|
1431
|
+
writeMyShare(dir);
|
|
1432
|
+
}
|
|
1433
|
+
catch {
|
|
1434
|
+
/* best-effort first snapshot */
|
|
1435
|
+
}
|
|
1436
|
+
const view = buildTeamView(dir, cfg.dailyUsd);
|
|
1437
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1438
|
+
res.end(JSON.stringify({ configured: true, dir, ...view }));
|
|
1439
|
+
}
|
|
1440
|
+
catch (e) {
|
|
1441
|
+
// readBody may have already sent 413 + ended on an oversized body —
|
|
1442
|
+
// guard so we don't double-write headers (otherwise process-fatal).
|
|
1443
|
+
if (!res.headersSent) {
|
|
1444
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1445
|
+
res.end(JSON.stringify({ error: 'team_init_failed', detail: String(e?.message || e) }));
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
})();
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
// POST /api/team/set-dir { dir } — point at an EXISTING shared team dir (a
|
|
1452
|
+
// member who cloned the team repo, or a lead pointing at a shared drive).
|
|
1453
|
+
if (req.method === 'POST' && req.url === '/api/team/set-dir') {
|
|
1454
|
+
(async () => {
|
|
1455
|
+
try {
|
|
1456
|
+
const raw = await readBody(req, res);
|
|
1457
|
+
let parsed = {};
|
|
1458
|
+
try {
|
|
1459
|
+
parsed = JSON.parse(raw || '{}');
|
|
1460
|
+
}
|
|
1461
|
+
catch {
|
|
1462
|
+
/* fall through to validation */
|
|
1463
|
+
}
|
|
1464
|
+
const rawDir = parsed.dir ? String(parsed.dir).trim() : '';
|
|
1465
|
+
if (!rawDir || rawDir === 'undefined' || rawDir === 'null') {
|
|
1466
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1467
|
+
res.end(JSON.stringify({
|
|
1468
|
+
error: 'dir_required',
|
|
1469
|
+
message: 'Provide { dir } — the shared team folder.' +
|
|
1470
|
+
(rawDir ? ' (got the literal string "' + rawDir + '" — a client-side unset variable)' : ''),
|
|
1471
|
+
}));
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
const dir = resolvePath(rawDir); // relative input would pin the record to a cwd
|
|
1475
|
+
if (!existsSync(dir)) {
|
|
1476
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1477
|
+
res.end(JSON.stringify({
|
|
1478
|
+
error: 'dir_not_found',
|
|
1479
|
+
dir,
|
|
1480
|
+
message: 'That folder does not exist. Clone the team repo first, or use /api/team/init.',
|
|
1481
|
+
}));
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
const cfg = loadTeamConfig();
|
|
1485
|
+
cfg.dir = dir;
|
|
1486
|
+
saveTeamConfig(cfg);
|
|
1487
|
+
try {
|
|
1488
|
+
writeMyShare(dir);
|
|
1489
|
+
}
|
|
1490
|
+
catch {
|
|
1491
|
+
/* best-effort */
|
|
1492
|
+
}
|
|
1493
|
+
const view = buildTeamView(dir, cfg.dailyUsd);
|
|
1494
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1495
|
+
res.end(JSON.stringify({ configured: true, dir, ...view }));
|
|
1496
|
+
}
|
|
1497
|
+
catch (e) {
|
|
1498
|
+
if (!res.headersSent) {
|
|
1499
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1500
|
+
res.end(JSON.stringify({ error: 'team_setdir_failed', detail: String(e?.message || e) }));
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
})();
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
// POST /api/github/connect/start — begin the OAuth device flow. Returns the
|
|
1507
|
+
// user code + verification URL to show the user; the deviceCode round-trips
|
|
1508
|
+
// back on /poll (server stays stateless). Needs codehere's GitHub OAuth App
|
|
1509
|
+
// client_id (CODEHERE_GITHUB_CLIENT_ID); no secret, local-first.
|
|
1510
|
+
if (req.method === 'POST' && req.url === '/api/github/connect/start') {
|
|
1511
|
+
(async () => {
|
|
1512
|
+
const clientId = githubClientId();
|
|
1513
|
+
if (!clientId) {
|
|
1514
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1515
|
+
res.end(JSON.stringify({
|
|
1516
|
+
error: 'github_not_configured',
|
|
1517
|
+
message: "GitHub isn't set up yet: set CODEHERE_GITHUB_CLIENT_ID to codehere's OAuth App client id.",
|
|
1518
|
+
}));
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
try {
|
|
1522
|
+
const dc = await requestDeviceCode(clientId);
|
|
1523
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1524
|
+
res.end(JSON.stringify({
|
|
1525
|
+
userCode: dc.userCode,
|
|
1526
|
+
verificationUri: dc.verificationUri,
|
|
1527
|
+
deviceCode: dc.deviceCode,
|
|
1528
|
+
intervalSec: dc.intervalSec,
|
|
1529
|
+
expiresInSec: dc.expiresInSec,
|
|
1530
|
+
}));
|
|
1531
|
+
}
|
|
1532
|
+
catch (e) {
|
|
1533
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
1534
|
+
res.end(JSON.stringify({ error: 'github_device_code_failed', detail: String(e?.message || e) }));
|
|
1535
|
+
}
|
|
1536
|
+
})();
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
// POST /api/github/connect/poll {deviceCode} — poll once for the token. On
|
|
1540
|
+
// success the token is persisted under 'github' via oauth-store. The client
|
|
1541
|
+
// repeats at intervalSec until status is connected or error.
|
|
1542
|
+
if (req.method === 'POST' && req.url === '/api/github/connect/poll') {
|
|
1543
|
+
(async () => {
|
|
1544
|
+
const clientId = githubClientId();
|
|
1545
|
+
if (!clientId) {
|
|
1546
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1547
|
+
res.end(JSON.stringify({ error: 'github_not_configured' }));
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
try {
|
|
1551
|
+
const body = await readBody(req, res);
|
|
1552
|
+
let parsed;
|
|
1553
|
+
try {
|
|
1554
|
+
parsed = JSON.parse(body || '{}');
|
|
1555
|
+
}
|
|
1556
|
+
catch (e) {
|
|
1557
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1558
|
+
res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
const deviceCode = parsed.deviceCode;
|
|
1562
|
+
if (!deviceCode || typeof deviceCode !== 'string') {
|
|
1563
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1564
|
+
res.end(JSON.stringify({ error: 'deviceCode (string) required' }));
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
const outcome = await pollForToken(clientId, deviceCode);
|
|
1568
|
+
if (outcome.status === 'connected') {
|
|
1569
|
+
saveToken(outcome.token);
|
|
1570
|
+
}
|
|
1571
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1572
|
+
res.end(JSON.stringify(outcome.status === 'connected' ? { status: 'connected', scope: outcome.token.scope } : outcome));
|
|
1573
|
+
}
|
|
1574
|
+
catch {
|
|
1575
|
+
if (process.env.CODEHERE_DEBUG === '1')
|
|
1576
|
+
console.error('[codehere] github poll readBody rejected');
|
|
1577
|
+
}
|
|
1578
|
+
})();
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
// POST /api/github/issue-to-task {ref} | {repo, issueNumber} — fetch the
|
|
1582
|
+
// issue via the stored token and return a ready task prompt + issue metadata.
|
|
1583
|
+
// The client spawns it through the existing POST /api/task (one proven path).
|
|
1584
|
+
if (req.method === 'POST' && req.url === '/api/github/issue-to-task') {
|
|
1585
|
+
(async () => {
|
|
1586
|
+
const tok = loadToken('github');
|
|
1587
|
+
if (!tok?.accessToken) {
|
|
1588
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1589
|
+
res.end(JSON.stringify({ error: 'github_not_connected', message: 'Connect GitHub first.' }));
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
try {
|
|
1593
|
+
const body = await readBody(req, res);
|
|
1594
|
+
let parsed;
|
|
1595
|
+
try {
|
|
1596
|
+
parsed = JSON.parse(body || '{}');
|
|
1597
|
+
}
|
|
1598
|
+
catch (e) {
|
|
1599
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1600
|
+
res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
const refStr = parsed.ref || (parsed.repo && parsed.issueNumber ? `${parsed.repo}#${parsed.issueNumber}` : '');
|
|
1604
|
+
const ref = parseRepoRef(refStr);
|
|
1605
|
+
if (!ref || ref.number === undefined) {
|
|
1606
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1607
|
+
res.end(JSON.stringify({
|
|
1608
|
+
error: 'bad_ref',
|
|
1609
|
+
message: 'Provide ref ("owner/repo#123" or an issue URL) or repo + issueNumber.',
|
|
1610
|
+
}));
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
const issue = await fetchIssue(tok.accessToken, ref.owner, ref.repo, ref.number);
|
|
1614
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1615
|
+
res.end(JSON.stringify({ prompt: issueToTaskPrompt(issue), issue }));
|
|
1616
|
+
}
|
|
1617
|
+
catch (e) {
|
|
1618
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
1619
|
+
res.end(JSON.stringify({ error: 'github_issue_fetch_failed', detail: String(e?.message || e) }));
|
|
1620
|
+
}
|
|
1621
|
+
})();
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
// POST /api/github/post-result {text|ref, body} — comment the agent's result
|
|
1625
|
+
// back on the source GitHub issue. ref ("owner/repo#123") OR text (the task
|
|
1626
|
+
// prompt, from which the issue link is parsed). Non-destructive (no branches).
|
|
1627
|
+
if (req.method === 'POST' && req.url === '/api/github/post-result') {
|
|
1628
|
+
(async () => {
|
|
1629
|
+
const tok = loadToken('github');
|
|
1630
|
+
if (!tok?.accessToken) {
|
|
1631
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1632
|
+
res.end(JSON.stringify({ error: 'github_not_connected', message: 'Connect GitHub first.' }));
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
try {
|
|
1636
|
+
const raw = await readBody(req, res);
|
|
1637
|
+
let parsed;
|
|
1638
|
+
try {
|
|
1639
|
+
parsed = JSON.parse(raw || '{}');
|
|
1640
|
+
}
|
|
1641
|
+
catch (e) {
|
|
1642
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1643
|
+
res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
const ref = parsed.ref ? parseRepoRef(parsed.ref) : issueRefFromText(parsed.text || '');
|
|
1647
|
+
if (!ref || ref.number === undefined) {
|
|
1648
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1649
|
+
res.end(JSON.stringify({ error: 'no_issue_ref', message: 'No GitHub issue found in ref/text.' }));
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
const comment = (parsed.body || '').trim();
|
|
1653
|
+
if (!comment) {
|
|
1654
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1655
|
+
res.end(JSON.stringify({ error: 'empty_body', message: 'body (the result comment) is required.' }));
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
const r = await commentOnIssue(tok.accessToken, ref.owner, ref.repo, ref.number, comment);
|
|
1659
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1660
|
+
res.end(JSON.stringify({ htmlUrl: r.htmlUrl, issue: `${ref.owner}/${ref.repo}#${ref.number}` }));
|
|
1661
|
+
}
|
|
1662
|
+
catch (e) {
|
|
1663
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
1664
|
+
res.end(JSON.stringify({ error: 'github_comment_failed', detail: String(e?.message || e) }));
|
|
1665
|
+
}
|
|
1666
|
+
})();
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
// GET /api/github/status — does the composer/import panel have a connected
|
|
1670
|
+
// GitHub to work with? Cheap, no network: reads the stored token + whether a
|
|
1671
|
+
// client_id is even configured (so the UI can show "connect" vs "set it up").
|
|
1672
|
+
if (req.method === 'GET' && req.url === '/api/github/status') {
|
|
1673
|
+
const tok = loadToken('github');
|
|
1674
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1675
|
+
res.end(JSON.stringify({ connected: !!tok?.accessToken, clientIdConfigured: !!githubClientId() }));
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
// GET /api/github/issues?repo=owner/repo — list open issues so the user can
|
|
1679
|
+
// PICK one instead of typing "owner/repo#123" from memory. Uses the stored
|
|
1680
|
+
// device-flow token (loadToken). PRs are filtered out in listIssues.
|
|
1681
|
+
if (req.method === 'GET' && req.url?.startsWith('/api/github/issues')) {
|
|
1682
|
+
(async () => {
|
|
1683
|
+
const tok = loadToken('github');
|
|
1684
|
+
if (!tok?.accessToken) {
|
|
1685
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1686
|
+
res.end(JSON.stringify({ error: 'github_not_connected', message: 'Connect GitHub first.' }));
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
const repoParam = new URL(req.url || '', 'http://localhost').searchParams.get('repo') || '';
|
|
1690
|
+
const ref = parseRepoRef(repoParam);
|
|
1691
|
+
if (!ref) {
|
|
1692
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1693
|
+
res.end(JSON.stringify({ error: 'bad_repo', message: 'Provide repo as owner/repo.' }));
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
try {
|
|
1697
|
+
const issues = await listIssues(tok.accessToken, ref.owner, ref.repo);
|
|
1698
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1699
|
+
res.end(JSON.stringify({ repo: `${ref.owner}/${ref.repo}`, issues }));
|
|
1700
|
+
}
|
|
1701
|
+
catch (e) {
|
|
1702
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
1703
|
+
res.end(JSON.stringify({ error: 'github_issues_failed', detail: String(e?.message || e) }));
|
|
1704
|
+
}
|
|
1705
|
+
})();
|
|
1201
1706
|
return;
|
|
1202
1707
|
}
|
|
1203
1708
|
// ── F1a readiness check ───────────────────────────────────────────────────
|
|
@@ -1262,7 +1767,12 @@ export function startMCPServer(options = {}) {
|
|
|
1262
1767
|
return;
|
|
1263
1768
|
}
|
|
1264
1769
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1265
|
-
res.end(JSON.stringify({
|
|
1770
|
+
res.end(JSON.stringify({
|
|
1771
|
+
ok: true,
|
|
1772
|
+
created: r.created.length,
|
|
1773
|
+
skipped: r.skipped.length,
|
|
1774
|
+
refused: r.refused.length + r.refusedDirs.length,
|
|
1775
|
+
}));
|
|
1266
1776
|
}
|
|
1267
1777
|
catch (e) {
|
|
1268
1778
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
@@ -1280,7 +1790,19 @@ export function startMCPServer(options = {}) {
|
|
|
1280
1790
|
// populated by migration + forward-mirror). Drives the new thread-view UI.
|
|
1281
1791
|
if (req.method === 'GET' && req.url === '/api/projects') {
|
|
1282
1792
|
try {
|
|
1283
|
-
|
|
1793
|
+
// Drop ephemeral/dead projects from the board: a "project" rooted in a throwaway temp dir
|
|
1794
|
+
// (eval/test harnesses), with no local path at all, or whose dirs are all gone, isn't real
|
|
1795
|
+
// work the user can open. Same temp-dir rule as the /api/tasks filter, plus an existence check.
|
|
1796
|
+
const tmpRoot = tmpdir().replace(/^\/private/, '');
|
|
1797
|
+
const isEphemeralPath = (cwd) => {
|
|
1798
|
+
if (!cwd)
|
|
1799
|
+
return true;
|
|
1800
|
+
const norm = cwd.replace(/^\/private/, '');
|
|
1801
|
+
if (norm === tmpRoot || norm.startsWith(tmpRoot + '/') || norm === '/tmp' || norm.startsWith('/tmp/'))
|
|
1802
|
+
return true;
|
|
1803
|
+
return !existsSync(cwd); // a project whose directory is gone isn't workable
|
|
1804
|
+
};
|
|
1805
|
+
const projects = listProjects().filter((p) => (p.localPaths || []).some((c) => !isEphemeralPath(c)));
|
|
1284
1806
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1285
1807
|
res.end(JSON.stringify({ projects }));
|
|
1286
1808
|
}
|
|
@@ -1301,14 +1823,33 @@ export function startMCPServer(options = {}) {
|
|
|
1301
1823
|
res.end(JSON.stringify({ error: 'invalid_slug' }));
|
|
1302
1824
|
return;
|
|
1303
1825
|
}
|
|
1304
|
-
|
|
1826
|
+
// Merge clones so the room shows the repo's FULL history, and attach per-task
|
|
1827
|
+
// cost (same estimator as /api/tasks) so the conversation rows carry their bill.
|
|
1828
|
+
const thread = readMergedProjectThread(slug);
|
|
1305
1829
|
if (!thread) {
|
|
1306
1830
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1307
1831
|
res.end(JSON.stringify({ error: 'project_not_found', slug }));
|
|
1308
1832
|
return;
|
|
1309
1833
|
}
|
|
1834
|
+
const tasksWithCost = thread.tasks.map((t) => {
|
|
1835
|
+
// F-07 / R11 · the same single verdict object /api/tasks serves — the
|
|
1836
|
+
// room UI reads THIS route, and without it no persisted task could
|
|
1837
|
+
// ever show its trust verdict (found live: 465 tasks, 0 verdicts).
|
|
1838
|
+
const verdict = composeVerdict({ finalReport: t.finalReport ?? undefined, artifacts: t.artifacts, steps: t.steps }, t.auditRecon);
|
|
1839
|
+
const u = t.artifacts?.usage;
|
|
1840
|
+
if (!u || (!u.inputTokens && !u.outputTokens))
|
|
1841
|
+
return { ...t, verdict };
|
|
1842
|
+
const usd = estimateCostUsd(String(t.agent || ''), {
|
|
1843
|
+
inputTokens: u.inputTokens || 0,
|
|
1844
|
+
outputTokens: u.outputTokens || 0,
|
|
1845
|
+
cacheReadTokens: u.cacheReadTokens,
|
|
1846
|
+
cacheCreationTokens: u.cacheCreationTokens,
|
|
1847
|
+
model: u.model,
|
|
1848
|
+
});
|
|
1849
|
+
return { ...t, verdict, cost: { spent: usd * 100, paid: usd > 0 } };
|
|
1850
|
+
});
|
|
1310
1851
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1311
|
-
res.end(JSON.stringify({ thread }));
|
|
1852
|
+
res.end(JSON.stringify({ thread: { ...thread, tasks: tasksWithCost } }));
|
|
1312
1853
|
}
|
|
1313
1854
|
catch (e) {
|
|
1314
1855
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
@@ -1366,6 +1907,171 @@ export function startMCPServer(options = {}) {
|
|
|
1366
1907
|
})();
|
|
1367
1908
|
return;
|
|
1368
1909
|
}
|
|
1910
|
+
// POST /api/projects/:slug/archive — body { sessionId, archived } — soft-delete /
|
|
1911
|
+
// restore a task. Append-only + audit-safe (the task record is never deleted).
|
|
1912
|
+
if (req.method === 'POST' && req.url?.match(/^\/api\/projects\/[^/]+\/archive$/)) {
|
|
1913
|
+
const slug = req.url.split('/')[3];
|
|
1914
|
+
(async () => {
|
|
1915
|
+
try {
|
|
1916
|
+
const body = await readBody(req, res);
|
|
1917
|
+
let parsed;
|
|
1918
|
+
try {
|
|
1919
|
+
const raw = JSON.parse(body);
|
|
1920
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
1921
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1922
|
+
res.end(JSON.stringify({ error: 'invalid_body' }));
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
parsed = raw;
|
|
1926
|
+
}
|
|
1927
|
+
catch {
|
|
1928
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1929
|
+
res.end(JSON.stringify({ error: 'invalid_json' }));
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
if (typeof parsed.sessionId !== 'string') {
|
|
1933
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1934
|
+
res.end(JSON.stringify({ error: 'sessionId_required' }));
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
const result = setTaskArchived(slug, parsed.sessionId, parsed.archived === true);
|
|
1938
|
+
if (!result.ok) {
|
|
1939
|
+
let status = 400;
|
|
1940
|
+
if (result.error === 'Project not found')
|
|
1941
|
+
status = 404;
|
|
1942
|
+
else if (result.error === 'Write failed')
|
|
1943
|
+
status = 500;
|
|
1944
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
1945
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1949
|
+
res.end(JSON.stringify({ ok: true, sessionId: parsed.sessionId, archived: parsed.archived === true }));
|
|
1950
|
+
}
|
|
1951
|
+
catch {
|
|
1952
|
+
if (process.env.CODEHERE_DEBUG === '1')
|
|
1953
|
+
console.error('[codehere] readBody rejected (oversized or aborted)');
|
|
1954
|
+
}
|
|
1955
|
+
})();
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
// POST /api/budget — body { dailyUsd: number | null } — set the daily spend cap
|
|
1959
|
+
// (null/0 disables). Used by first-time setup so the cost-bounded promise is real
|
|
1960
|
+
// from minute one; same store the `codehere budget set` CLI writes.
|
|
1961
|
+
if (req.method === 'POST' && req.url === '/api/budget') {
|
|
1962
|
+
(async () => {
|
|
1963
|
+
try {
|
|
1964
|
+
const body = await readBody(req, res);
|
|
1965
|
+
let parsed;
|
|
1966
|
+
try {
|
|
1967
|
+
const raw = JSON.parse(body);
|
|
1968
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
1969
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1970
|
+
res.end(JSON.stringify({ error: 'invalid_body' }));
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
parsed = raw;
|
|
1974
|
+
}
|
|
1975
|
+
catch {
|
|
1976
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1977
|
+
res.end(JSON.stringify({ error: 'invalid_json' }));
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
let dailyUsd;
|
|
1981
|
+
if (parsed.dailyUsd === null || parsed.dailyUsd === 0) {
|
|
1982
|
+
dailyUsd = null; // disable enforcement
|
|
1983
|
+
}
|
|
1984
|
+
else if (typeof parsed.dailyUsd === 'number' && Number.isFinite(parsed.dailyUsd) && parsed.dailyUsd > 0) {
|
|
1985
|
+
dailyUsd = parsed.dailyUsd;
|
|
1986
|
+
}
|
|
1987
|
+
else {
|
|
1988
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1989
|
+
res.end(JSON.stringify({ error: 'dailyUsd must be a positive number, 0, or null' }));
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
const cfg = loadBudget();
|
|
1993
|
+
cfg.dailyUsd = dailyUsd;
|
|
1994
|
+
saveBudget(cfg);
|
|
1995
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1996
|
+
res.end(JSON.stringify({ ok: true, dailyUsd }));
|
|
1997
|
+
}
|
|
1998
|
+
catch {
|
|
1999
|
+
if (process.env.CODEHERE_DEBUG === '1')
|
|
2000
|
+
console.error('[codehere] readBody rejected (oversized or aborted)');
|
|
2001
|
+
}
|
|
2002
|
+
})();
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
// POST /api/projects/clone — { url } → clone a GitHub repo locally, return its cwd.
|
|
2006
|
+
// The room opens from a cwd; the project record auto-creates on the first task
|
|
2007
|
+
// spawned there. Clone target is ~/codehere-projects/<repo> (visible, predictable).
|
|
2008
|
+
// Idempotent: if that dir already holds the same repo, we open it instead of
|
|
2009
|
+
// re-cloning. Async (execFile, not execFileSync) so a slow clone never freezes
|
|
2010
|
+
// the daemon. Uses the user's existing git auth — fails open with a plain reason.
|
|
2011
|
+
if (req.method === 'POST' && req.url === '/api/projects/clone') {
|
|
2012
|
+
(async () => {
|
|
2013
|
+
try {
|
|
2014
|
+
const body = await readBody(req, res);
|
|
2015
|
+
let parsed;
|
|
2016
|
+
try {
|
|
2017
|
+
const raw = JSON.parse(body);
|
|
2018
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
2019
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2020
|
+
res.end(JSON.stringify({ error: 'invalid_body' }));
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
parsed = raw;
|
|
2024
|
+
}
|
|
2025
|
+
catch {
|
|
2026
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2027
|
+
res.end(JSON.stringify({ error: 'invalid_json' }));
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
const baseDir = join(process.env.CODEHERE_CLONE_DIR || join(homedir(), 'codehere-projects'));
|
|
2031
|
+
const target = parseGitHubCloneTarget(typeof parsed.url === 'string' ? parsed.url : '', baseDir);
|
|
2032
|
+
if (!target.ok) {
|
|
2033
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2034
|
+
res.end(JSON.stringify({ error: target.error }));
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
// Already cloned here? Open it (idempotent), don't re-clone.
|
|
2038
|
+
if (existsSync(join(target.dir, '.git'))) {
|
|
2039
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2040
|
+
res.end(JSON.stringify({ ok: true, cwd: target.dir, repo: target.repo, alreadyHere: true }));
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
if (existsSync(target.dir)) {
|
|
2044
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
2045
|
+
res.end(JSON.stringify({ error: 'dir_exists', detail: target.dir }));
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
mkdirSync(baseDir, { recursive: true });
|
|
2049
|
+
try {
|
|
2050
|
+
// `--` ends option parsing: a cloneUrl beginning with '-' is then a
|
|
2051
|
+
// positional, never read by git as a flag (defense even though execFile
|
|
2052
|
+
// already avoids a shell). --config protocol.ext.allow=never blocks the
|
|
2053
|
+
// ext:: transport (git's RCE vector) belt-and-suspenders to the parser.
|
|
2054
|
+
await promisify(execFile)('git', ['-c', 'protocol.ext.allow=never', 'clone', '--depth', '1', '--', target.cloneUrl, target.dir], {
|
|
2055
|
+
timeout: 120000,
|
|
2056
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
catch (cloneErr) {
|
|
2060
|
+
const msg = String(cloneErr?.stderr || cloneErr?.message || cloneErr).slice(0, 400);
|
|
2061
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
2062
|
+
res.end(JSON.stringify({ error: 'clone_failed', detail: msg }));
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2066
|
+
res.end(JSON.stringify({ ok: true, cwd: target.dir, repo: target.repo }));
|
|
2067
|
+
}
|
|
2068
|
+
catch {
|
|
2069
|
+
if (process.env.CODEHERE_DEBUG === '1')
|
|
2070
|
+
console.error('[codehere] clone readBody rejected');
|
|
2071
|
+
}
|
|
2072
|
+
})();
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
1369
2075
|
// ── F1 task orchestration routes ─────────────────────────────────────────
|
|
1370
2076
|
// POST /api/task — { prompt, cwd? } → { sessionId }
|
|
1371
2077
|
// GET /api/tasks — list all sessions
|
|
@@ -1414,6 +2120,10 @@ export function startMCPServer(options = {}) {
|
|
|
1414
2120
|
const batchBudget = loadBudget();
|
|
1415
2121
|
const batchSpendNow = batchBudget.dailyUsd !== null ? computeSpendToday().totalUsd : 0;
|
|
1416
2122
|
const results = [];
|
|
2123
|
+
const seenCwds = new Set();
|
|
2124
|
+
// It-4 V1-MULTITASK — one batch call = one parent; children share
|
|
2125
|
+
// the batchId so the room can render a live-derived rollup row.
|
|
2126
|
+
const batchId = randomUUID();
|
|
1417
2127
|
for (const t of tasks) {
|
|
1418
2128
|
const prompt = t.prompt;
|
|
1419
2129
|
if (!prompt || typeof prompt !== 'string') {
|
|
@@ -1434,6 +2144,42 @@ export function startMCPServer(options = {}) {
|
|
|
1434
2144
|
results.push({ error: 'cwd must be absolute, no NUL bytes', agent: agentId });
|
|
1435
2145
|
continue;
|
|
1436
2146
|
}
|
|
2147
|
+
let tCwdIsDir = false;
|
|
2148
|
+
try {
|
|
2149
|
+
tCwdIsDir = statSync(t.cwd).isDirectory();
|
|
2150
|
+
}
|
|
2151
|
+
catch {
|
|
2152
|
+
/* missing path */
|
|
2153
|
+
}
|
|
2154
|
+
if (!tCwdIsDir) {
|
|
2155
|
+
results.push({
|
|
2156
|
+
error: `That folder doesn't exist on this machine: ${t.cwd.slice(0, 200)}`,
|
|
2157
|
+
agent: agentId,
|
|
2158
|
+
});
|
|
2159
|
+
continue;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
// The batch contract is "independent files only — non-overlapping
|
|
2163
|
+
// cwds" (W2). It was a comment, not a guard (AUD audit 2026-06-07):
|
|
2164
|
+
// two tasks on the same cwd both spawned, racing writes on one
|
|
2165
|
+
// repo path. Enforce it per task; undefined cwds share one key.
|
|
2166
|
+
// Literal-string guard, not canonicalized — '/tmp' vs '/private/tmp'
|
|
2167
|
+
// aren't deduped; this catches the paste-same-path-twice footgun.
|
|
2168
|
+
// readOnly tasks (the @all fanout: same QUESTION to every ready
|
|
2169
|
+
// adapter) are exempt — read concurrency was never the race this
|
|
2170
|
+
// guard stops. Caller-asserted, like the rest of the batch
|
|
2171
|
+
// contract; write fanout stays blocked until worktree isolation.
|
|
2172
|
+
const readOnly = t.readOnly === true;
|
|
2173
|
+
const cwdKey = t.cwd ?? '<daemon-cwd>';
|
|
2174
|
+
if (!readOnly) {
|
|
2175
|
+
if (seenCwds.has(cwdKey)) {
|
|
2176
|
+
results.push({
|
|
2177
|
+
error: `cwd already used by another task in this batch (${cwdKey.slice(0, 200)}) — batch tasks must target independent folders`,
|
|
2178
|
+
agent: agentId,
|
|
2179
|
+
});
|
|
2180
|
+
continue;
|
|
2181
|
+
}
|
|
2182
|
+
seenCwds.add(cwdKey);
|
|
1437
2183
|
}
|
|
1438
2184
|
// Per-task budget check (paid agents only).
|
|
1439
2185
|
if (batchBudget.dailyUsd !== null) {
|
|
@@ -1449,7 +2195,7 @@ export function startMCPServer(options = {}) {
|
|
|
1449
2195
|
}
|
|
1450
2196
|
}
|
|
1451
2197
|
try {
|
|
1452
|
-
const session = manager.spawn(prompt, agentId, { cwd: t.cwd });
|
|
2198
|
+
const session = manager.spawn(prompt, agentId, { cwd: t.cwd, batchId });
|
|
1453
2199
|
results.push({
|
|
1454
2200
|
sessionId: session.sessionId,
|
|
1455
2201
|
agent: session.agent,
|
|
@@ -1532,6 +2278,27 @@ export function startMCPServer(options = {}) {
|
|
|
1532
2278
|
}));
|
|
1533
2279
|
return;
|
|
1534
2280
|
}
|
|
2281
|
+
// R7 onboarding fix (2026-06-07): an explicitly-picked vendor that
|
|
2282
|
+
// readiness already KNOWS isn't usable (aider without a billed key,
|
|
2283
|
+
// gemini without auth) used to accept the spawn and fail generically
|
|
2284
|
+
// afterwards. Refuse upfront with the same plain-English hint the
|
|
2285
|
+
// squad bar shows. Mock mode skips (mock spawns run regardless);
|
|
2286
|
+
// conductor routes around readiness itself; states readiness can't
|
|
2287
|
+
// detect (claude subscription auth) still rely on post-spawn
|
|
2288
|
+
// error fingerprints by design.
|
|
2289
|
+
if (process.env.CODEHERE_MOCK !== '1' && agentId !== 'conductor') {
|
|
2290
|
+
const ready = readinessForAllAgents().find((a) => a.id === agentId);
|
|
2291
|
+
if (ready && ready.state !== 'ready') {
|
|
2292
|
+
const hint = ready.authHint?.detail;
|
|
2293
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
2294
|
+
res.end(JSON.stringify({
|
|
2295
|
+
error: 'agent_not_ready',
|
|
2296
|
+
state: ready.state,
|
|
2297
|
+
message: `${ready.label} isn't ready on this machine (${String(ready.state).replace(/_/g, ' ')}).${hint ? ' ' + hint : ''} Pick another agent, or fix the setup and retry.`,
|
|
2298
|
+
}));
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
1535
2302
|
// Path-traversal + null-byte defense · cwd must be absolute and
|
|
1536
2303
|
// free of NUL bytes (some filesystems treat \0 as terminator).
|
|
1537
2304
|
if (cwd !== undefined) {
|
|
@@ -1540,11 +2307,33 @@ export function startMCPServer(options = {}) {
|
|
|
1540
2307
|
res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
|
|
1541
2308
|
return;
|
|
1542
2309
|
}
|
|
2310
|
+
// Lane J S3 (2026-06-07 audit): a nonexistent cwd returned 200
|
|
2311
|
+
// and the task hung "running" forever — empty steps, no error,
|
|
2312
|
+
// nothing on the stream. Fail at intake with a human answer.
|
|
2313
|
+
let cwdIsDir = false;
|
|
2314
|
+
try {
|
|
2315
|
+
cwdIsDir = statSync(cwd).isDirectory();
|
|
2316
|
+
}
|
|
2317
|
+
catch {
|
|
2318
|
+
/* missing path */
|
|
2319
|
+
}
|
|
2320
|
+
if (!cwdIsDir) {
|
|
2321
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2322
|
+
res.end(JSON.stringify({
|
|
2323
|
+
error: `That folder doesn't exist on this machine: ${cwd.slice(0, 200)} — check the path and try again.`,
|
|
2324
|
+
}));
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
1543
2327
|
}
|
|
1544
2328
|
const budgetForSpawn = loadBudget();
|
|
1545
2329
|
if (budgetForSpawn.dailyUsd !== null) {
|
|
1546
2330
|
const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
|
|
1547
|
-
|
|
2331
|
+
// Conductor (the auto router) is NOT refused here — it self-manages
|
|
2332
|
+
// by failing over to a free agent when over budget (Ega-approved
|
|
2333
|
+
// 2026-06-08), refusing only if no free agent is ready. Explicit
|
|
2334
|
+
// paid-agent picks ARE refused (respect the choice + never spend
|
|
2335
|
+
// over budget). See spawnConductor's budgetBounded routing.
|
|
2336
|
+
const isPaidSpawn = probe > 0 && agentId !== 'conductor';
|
|
1548
2337
|
if (isPaidSpawn) {
|
|
1549
2338
|
const todaySpend = computeSpendToday();
|
|
1550
2339
|
if (isOverBudget(todaySpend.totalUsd, budgetForSpawn)) {
|
|
@@ -1588,7 +2377,13 @@ export function startMCPServer(options = {}) {
|
|
|
1588
2377
|
// enrichment instead of failing outright.
|
|
1589
2378
|
console.error('[context-source] resolver failed:', e instanceof Error ? e.message : String(e));
|
|
1590
2379
|
}
|
|
1591
|
-
const
|
|
2380
|
+
const parentSessionId = typeof parsed.parentSessionId === 'string' && parsed.parentSessionId
|
|
2381
|
+
? parsed.parentSessionId
|
|
2382
|
+
: undefined;
|
|
2383
|
+
const session = manager.spawn(promptForSpawn, agentId, {
|
|
2384
|
+
cwd,
|
|
2385
|
+
...(parentSessionId ? { parentSessionId } : {}),
|
|
2386
|
+
});
|
|
1592
2387
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1593
2388
|
res.end(JSON.stringify({
|
|
1594
2389
|
sessionId: session.sessionId,
|
|
@@ -1617,52 +2412,144 @@ export function startMCPServer(options = {}) {
|
|
|
1617
2412
|
return;
|
|
1618
2413
|
}
|
|
1619
2414
|
if (req.method === 'GET' && req.url === '/api/tasks') {
|
|
2415
|
+
// Tier 0.1 · a MOCK run fabricates its own artifacts, so verify-claim
|
|
2416
|
+
// compares the synthetic report to the synthetic artifacts and the green
|
|
2417
|
+
// "verified" is tautological — it proves wiring, not a real result. Flag
|
|
2418
|
+
// real mock RUNS so the surface says "wiring only". Seeded demo-* sessions
|
|
2419
|
+
// are deliberate showcases (and the /verify coverage fixture) — left green.
|
|
2420
|
+
// S18 · classification holds on the RECORD: appendSpawned stamps mockRun
|
|
2421
|
+
// at run time, so a daemon restart / mode flip never reclassifies
|
|
2422
|
+
// history. Legacy persisted records written before the stamp are
|
|
2423
|
+
// recovered on read from the mock adapter's own resume token
|
|
2424
|
+
// (recordedMockRun in session-persistence). The env stamp below applies
|
|
2425
|
+
// only to (a) in-memory sessions of THIS daemon (env == record by
|
|
2426
|
+
// definition) and (b) legacy records with neither stamp nor token —
|
|
2427
|
+
// genuinely no other signal exists for those.
|
|
2428
|
+
const mockDaemon = process.env.CODEHERE_MOCK === '1' || process.env.CODEHERE_MOCK === 'true';
|
|
2429
|
+
const isMockRun = (id) => mockDaemon && !id.startsWith('demo-');
|
|
2430
|
+
// Per-task bill · the card already shows steps/files/verdict but never the
|
|
2431
|
+
// cost (it read t.cost, which nothing populated). Derive it from the task's
|
|
2432
|
+
// own token usage so "what each agent did" includes "what it cost" inline.
|
|
2433
|
+
// cents (sub-cent precision kept; costPill formats). undefined when there's
|
|
2434
|
+
// no usage data — never fabricate a $0.
|
|
2435
|
+
const costFor = (agent, artifacts) => {
|
|
2436
|
+
const u = artifacts?.usage;
|
|
2437
|
+
if (!u || (!u.inputTokens && !u.outputTokens))
|
|
2438
|
+
return undefined;
|
|
2439
|
+
const usd = estimateCostUsd(String(agent || ''), {
|
|
2440
|
+
inputTokens: u.inputTokens || 0,
|
|
2441
|
+
outputTokens: u.outputTokens || 0,
|
|
2442
|
+
cacheReadTokens: u.cacheReadTokens,
|
|
2443
|
+
cacheCreationTokens: u.cacheCreationTokens,
|
|
2444
|
+
model: u.model,
|
|
2445
|
+
});
|
|
2446
|
+
return { spent: usd * 100, paid: usd > 0 };
|
|
2447
|
+
};
|
|
1620
2448
|
const spawned = manager.list().map((s) => {
|
|
1621
2449
|
const completedAt = manager.getCompletedAt(s.sessionId);
|
|
1622
2450
|
const finalReport = manager.getFinalReport(s.sessionId);
|
|
2451
|
+
const acceptanceCriteria = manager.getAcceptanceCriteria(s.sessionId);
|
|
1623
2452
|
const cwd = manager.getCwd(s.sessionId);
|
|
1624
2453
|
const continuedFrom = manager.getContinuedFrom(s.sessionId);
|
|
1625
2454
|
const continuedAs = manager.getContinuedAs(s.sessionId);
|
|
2455
|
+
const recoveredFrom = manager.getRecoveredFrom(s.sessionId);
|
|
2456
|
+
const auditRecon = manager.getAuditRecon(s.sessionId);
|
|
2457
|
+
const batchId = manager.getBatchId(s.sessionId);
|
|
2458
|
+
const delegatedTo = manager.getDelegatedTo(s.sessionId);
|
|
1626
2459
|
const failedReport = manager.hasFailedReport(s.sessionId);
|
|
1627
2460
|
const stalledReason = manager.getStallReason(s.sessionId);
|
|
1628
|
-
// F-07 ·
|
|
1629
|
-
//
|
|
1630
|
-
//
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
steps: s.steps,
|
|
1635
|
-
});
|
|
2461
|
+
// F-07 / R11 · the session's honesty verdict, ONE object owned by the
|
|
2462
|
+
// VerdictService: claim-vs-reality + audit-vs-disk reconciliation.
|
|
2463
|
+
// Pure, no I/O, runs sync · cheap to do per request. The UI renders
|
|
2464
|
+
// verdict.claim + verdict.audit without re-joining anything.
|
|
2465
|
+
const verdict = composeVerdict({ finalReport, artifacts: s.artifacts, steps: s.steps }, auditRecon);
|
|
2466
|
+
const cost = costFor(s.agent, s.artifacts);
|
|
1636
2467
|
return {
|
|
1637
2468
|
sessionId: s.sessionId,
|
|
1638
|
-
prompt: s.prompt,
|
|
2469
|
+
prompt: manager.getDisplayPrompt(s.sessionId) ?? s.prompt,
|
|
1639
2470
|
startedAt: s.startedAt,
|
|
1640
2471
|
status: s.status,
|
|
1641
2472
|
agent: s.agent,
|
|
1642
2473
|
steps: s.steps ?? [],
|
|
1643
2474
|
artifacts: s.artifacts,
|
|
2475
|
+
...(cost ? { cost } : {}),
|
|
1644
2476
|
...(completedAt !== undefined ? { completedAt, durationMs: completedAt - s.startedAt } : {}),
|
|
1645
2477
|
...(finalReport !== undefined ? { finalReport } : {}),
|
|
2478
|
+
...(acceptanceCriteria && acceptanceCriteria.length ? { acceptanceCriteria } : {}),
|
|
1646
2479
|
...(failedReport ? { failedReport: true } : {}),
|
|
1647
2480
|
...(stalledReason ? { stalledReason } : {}),
|
|
1648
|
-
|
|
2481
|
+
verdict,
|
|
1649
2482
|
canAppendMessage: s.canAppendMessage === true,
|
|
2483
|
+
// S1 · the server is the single source of truth on whether a
|
|
2484
|
+
// follow-up can actually be DELIVERED — the UI renders from this,
|
|
2485
|
+
// never guesses.
|
|
2486
|
+
canFollowUp: s.canAppendMessage === true,
|
|
1650
2487
|
...(cwd !== undefined ? { cwd } : {}),
|
|
1651
2488
|
...(continuedFrom !== undefined ? { continuedFrom } : {}),
|
|
1652
2489
|
...(continuedAs !== undefined ? { continuedAs } : {}),
|
|
2490
|
+
// Recovery lineage → activates the dashboard "recovered from <vendor>"
|
|
2491
|
+
// badge + the failover kicker (gemini → claude · QUOTA · COMPLETED).
|
|
2492
|
+
...(recoveredFrom !== undefined
|
|
2493
|
+
? { failoverFrom: recoveredFrom.agent, failoverTo: s.agent, failoverReason: recoveredFrom.reason }
|
|
2494
|
+
: {}),
|
|
2495
|
+
...(batchId !== undefined ? { batchId } : {}),
|
|
2496
|
+
...(delegatedTo !== undefined ? { delegatedTo } : {}),
|
|
2497
|
+
...(isMockRun(s.sessionId) ? { mockRun: true } : {}),
|
|
1653
2498
|
provenance: 'spawned',
|
|
1654
2499
|
};
|
|
1655
2500
|
});
|
|
1656
2501
|
const activeAgents = manager.listActiveAgents();
|
|
1657
|
-
// F-07
|
|
2502
|
+
// F-07 / R11 · persisted records get the same single verdict object.
|
|
2503
|
+
// 2.2 · the audit reconciliation now PERSISTS (appendAuditRecon), so a
|
|
2504
|
+
// reloaded session carries its OBSERVED signal and the composite grade
|
|
2505
|
+
// can still earn green after a restart — not just live in-memory tasks.
|
|
1658
2506
|
const persisted = manager.listPersisted().map((p) => ({
|
|
1659
2507
|
...p,
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
}),
|
|
2508
|
+
verdict: composeVerdict(p, p.auditRecon),
|
|
2509
|
+
...(costFor(p.agent, p.artifacts) ? { cost: costFor(p.agent, p.artifacts) } : {}),
|
|
2510
|
+
// S18 · the durable record (carried by ...p) wins; the env stamp is
|
|
2511
|
+
// only the fallback for legacy records that predate the run-time stamp.
|
|
2512
|
+
...(p.mockRun === undefined && isMockRun(p.sessionId) ? { mockRun: true } : {}),
|
|
2513
|
+
// S1 · a dropped-from-memory session can only continue when
|
|
2514
|
+
// reconstructAppendable can rebuild it: claude-code + its native
|
|
2515
|
+
// resume token + cwd. Every other vendor keeps no resume token
|
|
2516
|
+
// codehere can use once the session is gone — say so.
|
|
2517
|
+
canFollowUp: p.agent === 'claude-code' && !!p.nativeSessionId && !!p.cwd,
|
|
1665
2518
|
}));
|
|
2519
|
+
// A REAL (non-mock) dashboard must never show mock/demo sessions. They
|
|
2520
|
+
// are dev fixtures — seeded `demo-*` showcases (the /verify coverage set)
|
|
2521
|
+
// and `mockRun` wiring tests — that can leak into a persisted store
|
|
2522
|
+
// (e.g. a mock run launched without an isolated CODEHERE_HOME). In mock
|
|
2523
|
+
// mode the board needs them, so they stay; in real mode they are dev
|
|
2524
|
+
// pollution and are filtered out so a genuine user only sees real work.
|
|
2525
|
+
// A task run inside a throwaway temp dir (eval/test harnesses use mkdtemp —
|
|
2526
|
+
// e.g. proven-fix's /…/T/pf-XXXX) is NOT the user's real work; it's harness
|
|
2527
|
+
// pollution leaking into ~/.codehere/sessions.jsonl. Strip it from a real
|
|
2528
|
+
// dashboard alongside mock/demo, so "your day so far" shows genuine work.
|
|
2529
|
+
const tmpRoot = tmpdir().replace(/^\/private/, '');
|
|
2530
|
+
const isEphemeralCwd = (cwd) => {
|
|
2531
|
+
if (!cwd)
|
|
2532
|
+
return false;
|
|
2533
|
+
const norm = cwd.replace(/^\/private/, '');
|
|
2534
|
+
return norm === tmpRoot || norm.startsWith(tmpRoot + '/') || norm.startsWith('/tmp/');
|
|
2535
|
+
};
|
|
2536
|
+
// Hide STALE throwaway-dir pollution only — NOT recent/live work. The old
|
|
2537
|
+
// filter dropped any /tmp-cwd task outright, which silently hid a task the
|
|
2538
|
+
// user just spawned AND broke verify-real-run (it sandboxes in mkdtemp under
|
|
2539
|
+
// /tmp). A real run there proved the loop works but was invisible. So: an
|
|
2540
|
+
// ephemeral-cwd task is filtered only once it's older than the window — a
|
|
2541
|
+
// running/just-finished one always shows; only yesterday's eval junk fades.
|
|
2542
|
+
const EPHEMERAL_STALE_MS = 2 * 60 * 60 * 1000; // 2h
|
|
2543
|
+
const nowMs = Date.now();
|
|
2544
|
+
const isStaleEphemeral = (cwd, ts) => isEphemeralCwd(cwd) && (!ts || nowMs - ts > EPHEMERAL_STALE_MS);
|
|
2545
|
+
const realSpawned = mockDaemon
|
|
2546
|
+
? spawned
|
|
2547
|
+
: spawned.filter((s) => !s.mockRun && !String(s.sessionId || '').startsWith('demo-') && !isStaleEphemeral(s.cwd, s.startedAt));
|
|
2548
|
+
const realPersisted = mockDaemon
|
|
2549
|
+
? persisted
|
|
2550
|
+
: persisted.filter((p) => p.mockRun !== true &&
|
|
2551
|
+
!String(p.sessionId || '').startsWith('demo-') &&
|
|
2552
|
+
!isStaleEphemeral(p.cwd, p.completedAt || p.startedAt));
|
|
1666
2553
|
// Layer 7: timestamp of most recent hook-audit line (= "last edit on this machine")
|
|
1667
2554
|
let lastEditAt = null;
|
|
1668
2555
|
try {
|
|
@@ -1674,11 +2561,11 @@ export function startMCPServer(options = {}) {
|
|
|
1674
2561
|
}
|
|
1675
2562
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1676
2563
|
res.end(JSON.stringify({
|
|
1677
|
-
spawned,
|
|
1678
|
-
persisted,
|
|
2564
|
+
spawned: realSpawned,
|
|
2565
|
+
persisted: realPersisted,
|
|
1679
2566
|
activeAgents,
|
|
1680
2567
|
lastEditAt,
|
|
1681
|
-
sessions:
|
|
2568
|
+
sessions: realSpawned, // back-compat for earlier UI code
|
|
1682
2569
|
observed: manager.listObserved(), // legacy, kept for /history or future use
|
|
1683
2570
|
}));
|
|
1684
2571
|
return;
|
|
@@ -1720,6 +2607,27 @@ export function startMCPServer(options = {}) {
|
|
|
1720
2607
|
res.end(JSON.stringify({ blocks }));
|
|
1721
2608
|
return;
|
|
1722
2609
|
}
|
|
2610
|
+
// Cross-vendor verdict — GET /api/task/:id/cross-vendor → the receipt when
|
|
2611
|
+
// this session reviewed an earlier DIFFERENT-vendor writer in the same room
|
|
2612
|
+
// (who wrote, who reviewed, audit attribution, chain integrity, cost). null
|
|
2613
|
+
// when the session isn't a cross-vendor review (the common case). Read-only.
|
|
2614
|
+
const cvMatch = req.url.match(/^\/api\/task\/([^/]+)\/cross-vendor$/);
|
|
2615
|
+
if (cvMatch && req.method === 'GET') {
|
|
2616
|
+
const sessId = decodeURIComponent(cvMatch[1]);
|
|
2617
|
+
let verdict = null;
|
|
2618
|
+
try {
|
|
2619
|
+
// Reviewer-keyed first (the session IS a review); else writer-keyed
|
|
2620
|
+
// (the session was REVIEWED by a different vendor) so opening the
|
|
2621
|
+
// writer's run also shows "a different vendor reviewed this."
|
|
2622
|
+
verdict = buildCrossVendorVerdictById(sessId) ?? buildCrossVendorVerdictForWriter(sessId) ?? null;
|
|
2623
|
+
}
|
|
2624
|
+
catch {
|
|
2625
|
+
/* fail open — null (no verdict shown) */
|
|
2626
|
+
}
|
|
2627
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2628
|
+
res.end(JSON.stringify({ crossVendor: verdict }));
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
1723
2631
|
// v0.6 M3 — GET /api/task/:id/narration → latest mid-task summary.
|
|
1724
2632
|
// Read-only; SessionManager generates the narration in a background
|
|
1725
2633
|
// timer and caches the latest. Frontend polls (~30s) while the
|
|
@@ -1736,8 +2644,65 @@ export function startMCPServer(options = {}) {
|
|
|
1736
2644
|
const m = req.url.match(/^\/api\/task\/([^/]+)\/(events|kill|message)$/);
|
|
1737
2645
|
if (m) {
|
|
1738
2646
|
const [, sessionId, verb] = m;
|
|
1739
|
-
|
|
2647
|
+
let session = manager.get(sessionId);
|
|
2648
|
+
// BUG-2 (E2E 2.5) · verify accepts git-style 7+-char short ids; kill/
|
|
2649
|
+
// events/message 404'd on the same prefix with a false "no running task
|
|
2650
|
+
// found" while the task WAS running. Resolve a UNIQUE prefix against
|
|
2651
|
+
// live sessions; ambiguous prefixes still fall through to the honest 404.
|
|
2652
|
+
if (!session && sessionId.length >= 7) {
|
|
2653
|
+
const matches = manager.list().filter((s) => s.sessionId.startsWith(sessionId));
|
|
2654
|
+
if (matches.length === 1)
|
|
2655
|
+
session = matches[0];
|
|
2656
|
+
}
|
|
2657
|
+
// resume-after-restart: a completed session dropped from memory (a daemon
|
|
2658
|
+
// restart, or eviction past MAX_COMPLETED) can still be CONTINUED — rebuild
|
|
2659
|
+
// an appendable handle from its persisted native resume token + cwd. Only
|
|
2660
|
+
// for the message verb (events/kill need a live process).
|
|
2661
|
+
if (!session && verb === 'message') {
|
|
2662
|
+
session = manager.reconstructAppendable(sessionId);
|
|
2663
|
+
}
|
|
1740
2664
|
if (!session) {
|
|
2665
|
+
// S13 refuter · a ZOMBIE running record (daemon restarted, process
|
|
2666
|
+
// long gone, status still 'running' on disk) was unstoppable: kill
|
|
2667
|
+
// 404'd forever while the row inflated the RUNNING count. There is
|
|
2668
|
+
// no live process to signal — record the honest bookkeeping kill so
|
|
2669
|
+
// the record stops claiming a life it doesn't have.
|
|
2670
|
+
if (verb === 'kill') {
|
|
2671
|
+
const zrec = loadSessionById(sessionId);
|
|
2672
|
+
const zLive = zrec && ['running', 'queued', 'paused', 'blocked'].includes(zrec.status);
|
|
2673
|
+
if (zrec && zLive) {
|
|
2674
|
+
appendCompleted({
|
|
2675
|
+
id: zrec.sessionId,
|
|
2676
|
+
status: 'killed',
|
|
2677
|
+
completedAt: Date.now(),
|
|
2678
|
+
durationMs: zrec.durationMs || 0,
|
|
2679
|
+
steps: zrec.steps ?? [],
|
|
2680
|
+
artifacts: zrec.artifacts ?? {},
|
|
2681
|
+
});
|
|
2682
|
+
SessionManager.getInstance().markPersistedKilled(zrec.sessionId);
|
|
2683
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2684
|
+
res.end(JSON.stringify({
|
|
2685
|
+
sessionId,
|
|
2686
|
+
status: 'killed',
|
|
2687
|
+
note: 'no live process · the stale record was marked killed',
|
|
2688
|
+
}));
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
// S1 · honest reason over a generic 404: if the task EXISTS but its
|
|
2693
|
+
// agent can't continue a finished session, say exactly that — the
|
|
2694
|
+
// composer should already be disabled, but the server is the gate.
|
|
2695
|
+
if (verb === 'message') {
|
|
2696
|
+
const rec = loadSessionById(sessionId);
|
|
2697
|
+
if (rec) {
|
|
2698
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
2699
|
+
res.end(JSON.stringify({
|
|
2700
|
+
error: `${rec.agent} can't continue a finished task — its session can't be resumed by codehere. Start a new task; the project thread carries the context.`,
|
|
2701
|
+
canFollowUp: false,
|
|
2702
|
+
}));
|
|
2703
|
+
return;
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
1741
2706
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1742
2707
|
res.end(JSON.stringify({ error: `No session: ${sessionId}` }));
|
|
1743
2708
|
return;
|
|
@@ -1788,7 +2753,12 @@ export function startMCPServer(options = {}) {
|
|
|
1788
2753
|
const out = await session.appendMessage(text);
|
|
1789
2754
|
const replyCompletedAt = Date.now();
|
|
1790
2755
|
try {
|
|
1791
|
-
|
|
2756
|
+
// Live sessions: cwd from the in-memory map. Reconstructed
|
|
2757
|
+
// (resume-after-restart) sessions: the map is empty — fall
|
|
2758
|
+
// back to the cwd the handle carried from the persisted
|
|
2759
|
+
// record, so the resumed turn still lands in thread.jsonl
|
|
2760
|
+
// (no invisible agent activity).
|
|
2761
|
+
const replyCwd = manager.getCwd(sessionId) ?? session.cwd;
|
|
1792
2762
|
if (replyCwd) {
|
|
1793
2763
|
mirrorReplyToThread({
|
|
1794
2764
|
parentSessionId: sessionId,
|
|
@@ -1895,26 +2865,71 @@ export function startMCPServer(options = {}) {
|
|
|
1895
2865
|
// MCP endpoint — POST jsonrpc; reject other verbs with 405 + Allow header
|
|
1896
2866
|
// (rc.17: was 404, which is wrong HTTP semantic — UX_OBSERVATIONS P3).
|
|
1897
2867
|
if (req.url === '/mcp') {
|
|
2868
|
+
// MCP Streamable HTTP transport. Plain-JSON POST (Claude Desktop, Claude
|
|
2869
|
+
// Code) AND SSE-framed POST + a GET stream + Mcp-Session-Id, so strict
|
|
2870
|
+
// clients (Codex, Cursor) connect too. One server, every agent.
|
|
2871
|
+
const mcpSessionId = 'codehere-mcp-' + process.pid;
|
|
2872
|
+
// GET opens the server->client SSE channel some clients require.
|
|
2873
|
+
if (req.method === 'GET') {
|
|
2874
|
+
res.writeHead(200, {
|
|
2875
|
+
'Content-Type': 'text/event-stream',
|
|
2876
|
+
'Cache-Control': 'no-cache',
|
|
2877
|
+
Connection: 'keep-alive',
|
|
2878
|
+
'Mcp-Session-Id': mcpSessionId,
|
|
2879
|
+
});
|
|
2880
|
+
res.write(': codehere mcp stream open\n\n');
|
|
2881
|
+
const ka = setInterval(() => {
|
|
2882
|
+
try {
|
|
2883
|
+
res.write(': keep-alive\n\n');
|
|
2884
|
+
}
|
|
2885
|
+
catch {
|
|
2886
|
+
/* socket closed */
|
|
2887
|
+
}
|
|
2888
|
+
}, 25000);
|
|
2889
|
+
req.on('close', () => clearInterval(ka));
|
|
2890
|
+
return;
|
|
2891
|
+
}
|
|
1898
2892
|
if (req.method !== 'POST') {
|
|
1899
|
-
res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'POST' });
|
|
1900
|
-
res.end(JSON.stringify({ error: 'MCP endpoint accepts POST
|
|
2893
|
+
res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'GET, POST' });
|
|
2894
|
+
res.end(JSON.stringify({ error: 'MCP endpoint accepts GET (stream) or POST', method: req.method }));
|
|
1901
2895
|
return;
|
|
1902
2896
|
}
|
|
1903
2897
|
(async () => {
|
|
1904
2898
|
try {
|
|
1905
2899
|
const body = await readBody(req, res);
|
|
2900
|
+
let mcpReq;
|
|
1906
2901
|
try {
|
|
1907
|
-
|
|
1908
|
-
const mcpRes = dispatch(mcpReq);
|
|
1909
|
-
res.writeHead(200, {
|
|
1910
|
-
'Content-Type': 'application/json',
|
|
1911
|
-
'Access-Control-Allow-Origin': 'http://localhost',
|
|
1912
|
-
});
|
|
1913
|
-
res.end(JSON.stringify(mcpRes));
|
|
2902
|
+
mcpReq = JSON.parse(body);
|
|
1914
2903
|
}
|
|
1915
2904
|
catch {
|
|
1916
2905
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1917
2906
|
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
// Notifications (no id, e.g. notifications/initialized) get a 202 with
|
|
2910
|
+
// no body, per the JSON-RPC / MCP Streamable HTTP spec.
|
|
2911
|
+
if (mcpReq.id === undefined || mcpReq.id === null) {
|
|
2912
|
+
res.writeHead(202, { 'Mcp-Session-Id': mcpSessionId });
|
|
2913
|
+
res.end();
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
const mcpRes = dispatch(mcpReq);
|
|
2917
|
+
// Strict Streamable-HTTP clients send Accept: text/event-stream and
|
|
2918
|
+
// want the response as a single SSE message; lenient ones take JSON.
|
|
2919
|
+
const accept = String(req.headers['accept'] || '');
|
|
2920
|
+
if (accept.includes('text/event-stream')) {
|
|
2921
|
+
res.writeHead(200, {
|
|
2922
|
+
'Content-Type': 'text/event-stream',
|
|
2923
|
+
'Cache-Control': 'no-cache',
|
|
2924
|
+
Connection: 'keep-alive',
|
|
2925
|
+
'Mcp-Session-Id': mcpSessionId,
|
|
2926
|
+
});
|
|
2927
|
+
res.write('event: message\ndata: ' + JSON.stringify(mcpRes) + '\n\n');
|
|
2928
|
+
res.end();
|
|
2929
|
+
}
|
|
2930
|
+
else {
|
|
2931
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Mcp-Session-Id': mcpSessionId });
|
|
2932
|
+
res.end(JSON.stringify(mcpRes));
|
|
1918
2933
|
}
|
|
1919
2934
|
}
|
|
1920
2935
|
catch {
|
|
@@ -1947,12 +2962,31 @@ export function startMCPServer(options = {}) {
|
|
|
1947
2962
|
clientIdConfigured: !!process.env[p.clientIdEnv],
|
|
1948
2963
|
};
|
|
1949
2964
|
});
|
|
2965
|
+
// GitHub is special: it authenticates via the device flow (/api/github/connect/*),
|
|
2966
|
+
// not the generic auth-code OAuth above. Surface it as ONE connectable entry
|
|
2967
|
+
// with its real device-flow status, so the panel stops showing the
|
|
2968
|
+
// contradictory "public · no auth needed" chip alongside a Connect button
|
|
2969
|
+
// elsewhere. authType 'github-device' tells the UI to drive the device flow.
|
|
2970
|
+
const ghStatus = connectionStatus('github');
|
|
2971
|
+
const githubItem = {
|
|
2972
|
+
id: 'github',
|
|
2973
|
+
label: 'GitHub',
|
|
2974
|
+
scheme: 'github',
|
|
2975
|
+
authType: 'github-device',
|
|
2976
|
+
status: ghStatus.status,
|
|
2977
|
+
userLabel: ghStatus.userLabel ?? null,
|
|
2978
|
+
connectedAt: ghStatus.connectedAt ?? null,
|
|
2979
|
+
expiresAt: ghStatus.expiresAt ?? null,
|
|
2980
|
+
clientIdConfigured: !!githubClientId(),
|
|
2981
|
+
description: 'Import issues and post results back. Connect once.',
|
|
2982
|
+
};
|
|
1950
2983
|
// v0.8 S5 · public-by-URL adapters appear with status='available'
|
|
1951
|
-
// (no Connect button needed · they work out of the box).
|
|
2984
|
+
// (no Connect button needed · they work out of the box). GitHub is
|
|
2985
|
+
// excluded here — it's rendered as the connectable githubItem above.
|
|
1952
2986
|
const oauthIds = new Set(oauthItems.map((i) => i.id));
|
|
1953
2987
|
const reg = getDefaultRegistry();
|
|
1954
2988
|
const noAuthItems = [...reg.bySchemeOnly.values()]
|
|
1955
|
-
.filter((s) => !oauthIds.has(s.id))
|
|
2989
|
+
.filter((s) => !oauthIds.has(s.id) && s.id !== 'github')
|
|
1956
2990
|
.map((s) => ({
|
|
1957
2991
|
id: s.id,
|
|
1958
2992
|
label: s.label,
|
|
@@ -1966,7 +3000,7 @@ export function startMCPServer(options = {}) {
|
|
|
1966
3000
|
description: s.description,
|
|
1967
3001
|
}));
|
|
1968
3002
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1969
|
-
res.end(JSON.stringify({ providers: [...oauthItems, ...noAuthItems] }));
|
|
3003
|
+
res.end(JSON.stringify({ providers: [...oauthItems, githubItem, ...noAuthItems] }));
|
|
1970
3004
|
}
|
|
1971
3005
|
catch (e) {
|
|
1972
3006
|
console.error('[/api/context-sources] failed:', e);
|
|
@@ -2155,26 +3189,9 @@ export function startMCPServer(options = {}) {
|
|
|
2155
3189
|
})();
|
|
2156
3190
|
return;
|
|
2157
3191
|
}
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2162
|
-
res.end(JSON.stringify({
|
|
2163
|
-
path: AUDIT_FILE,
|
|
2164
|
-
valid: r.valid,
|
|
2165
|
-
totalEntries: r.totalEntries,
|
|
2166
|
-
chainedEntries: r.chainedEntries,
|
|
2167
|
-
legacyEntries: r.legacyEntries,
|
|
2168
|
-
errors: r.errors,
|
|
2169
|
-
}));
|
|
2170
|
-
}
|
|
2171
|
-
catch (e) {
|
|
2172
|
-
console.error('[/api/audit/verify] failed:', e);
|
|
2173
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2174
|
-
res.end(JSON.stringify({ error: 'Failed to verify audit chain' }));
|
|
2175
|
-
}
|
|
2176
|
-
return;
|
|
2177
|
-
}
|
|
3192
|
+
// (MED #34, atomic audit) · a second GET /api/audit/verify handler lived
|
|
3193
|
+
// here — unreachable dead code (the earlier handler wins the dispatch)
|
|
3194
|
+
// with a DIFFERENT response shape. Deleted; one route, one truth.
|
|
2178
3195
|
// v0.8 S4 · share-thread · POST /api/thread/:id/export returns a signed
|
|
2179
3196
|
// bundle of one session that the recipient can replay locally. No relay
|
|
2180
3197
|
// through codehere's infrastructure · the response stays on-machine.
|
|
@@ -2194,7 +3211,12 @@ export function startMCPServer(options = {}) {
|
|
|
2194
3211
|
kind: 'thread',
|
|
2195
3212
|
sessionId: session.sessionId,
|
|
2196
3213
|
session,
|
|
2197
|
-
|
|
3214
|
+
// Embed the tool-call audit so a teammate can inspect what the agent
|
|
3215
|
+
// did (signer-attested; see ThreadBundleBody.auditChain trust model).
|
|
3216
|
+
auditChain: collectAuditEntriesForSession(session.sessionId),
|
|
3217
|
+
// LOW #36 (atomic audit) · was hardcoded '0.7.0' while the build is 0.9.x —
|
|
3218
|
+
// a stale version stamped into every exported bundle's metadata.
|
|
3219
|
+
exporter: { codehereVersion: getVersion() },
|
|
2198
3220
|
exportedAt: new Date().toISOString(),
|
|
2199
3221
|
};
|
|
2200
3222
|
const bundle = signBundle(body, keys);
|
|
@@ -2247,10 +3269,33 @@ export function startMCPServer(options = {}) {
|
|
|
2247
3269
|
}
|
|
2248
3270
|
const finalBundle = verdict.bundle;
|
|
2249
3271
|
const ses = finalBundle.body.session;
|
|
3272
|
+
const importFp = shortFingerprint(finalBundle.publicKey);
|
|
3273
|
+
// No-clobber / idempotent import (Phase 2.5 multi-human): a re-import
|
|
3274
|
+
// must not duplicate the session in a shared room.
|
|
3275
|
+
if (loadSessionById(ses.sessionId)) {
|
|
3276
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3277
|
+
res.end(JSON.stringify({ status: 'already_imported', sessionId: ses.sessionId, fingerprint: importFp }));
|
|
3278
|
+
return;
|
|
3279
|
+
}
|
|
2250
3280
|
const home = process.env.CODEHERE_HOME || homedir();
|
|
2251
3281
|
const sessionsPath = join(home, '.codehere', 'sessions.jsonl');
|
|
2252
3282
|
const fs = await import('fs');
|
|
2253
3283
|
fs.mkdirSync(dirname(sessionsPath), { recursive: true });
|
|
3284
|
+
// Re-verify the embedded tool-call audit so the importer SEES what the
|
|
3285
|
+
// agent did. attested gated on verified>0 + no tamper (never bare .valid,
|
|
3286
|
+
// true for an empty chain). Computed BEFORE persistence so it can be
|
|
3287
|
+
// stored on the session — the bundle isn't kept, so the room needs this.
|
|
3288
|
+
const auditCheck = finalBundle.body.auditChain
|
|
3289
|
+
? verifyEmbeddedAuditChain(finalBundle.body.auditChain)
|
|
3290
|
+
: undefined;
|
|
3291
|
+
const importAudit = auditCheck
|
|
3292
|
+
? {
|
|
3293
|
+
total: auditCheck.total,
|
|
3294
|
+
verified: auditCheck.verified,
|
|
3295
|
+
tampered: auditCheck.tampered.length,
|
|
3296
|
+
attested: auditCheck.verified > 0 && auditCheck.tampered.length === 0,
|
|
3297
|
+
}
|
|
3298
|
+
: undefined;
|
|
2254
3299
|
const lines = [];
|
|
2255
3300
|
lines.push(JSON.stringify({
|
|
2256
3301
|
t: 'spawned',
|
|
@@ -2259,6 +3304,16 @@ export function startMCPServer(options = {}) {
|
|
|
2259
3304
|
prompt: ses.prompt,
|
|
2260
3305
|
cwd: ses.cwd,
|
|
2261
3306
|
startedAt: ses.startedAt,
|
|
3307
|
+
// S18 · practice classification is a property of the RUN on the
|
|
3308
|
+
// exporter's machine, carried by the bundle — never the importing
|
|
3309
|
+
// daemon's env. Absent in a legacy bundle = the work was real and
|
|
3310
|
+
// audit-attested; an import performed on a mock daemon must never
|
|
3311
|
+
// demote a teammate's real work to practice. Stamped explicitly so
|
|
3312
|
+
// no spawned-record writer leaves the field env-classified.
|
|
3313
|
+
mockRun: ses.mockRun === true,
|
|
3314
|
+
// Provenance: attribute the imported session to its signer.
|
|
3315
|
+
importedFrom: importFp,
|
|
3316
|
+
...(importAudit ? { importAudit } : {}),
|
|
2262
3317
|
}));
|
|
2263
3318
|
lines.push(JSON.stringify({
|
|
2264
3319
|
t: 'completed',
|
|
@@ -2279,11 +3334,14 @@ export function startMCPServer(options = {}) {
|
|
|
2279
3334
|
}));
|
|
2280
3335
|
}
|
|
2281
3336
|
fs.appendFileSync(sessionsPath, lines.join('\n') + '\n', 'utf-8');
|
|
3337
|
+
// auditCheck was computed above (before persistence); reuse it for the
|
|
3338
|
+
// response so the importer sees the same attestation that got stored.
|
|
2282
3339
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2283
3340
|
res.end(JSON.stringify({
|
|
2284
3341
|
status: 'imported',
|
|
2285
3342
|
sessionId: ses.sessionId,
|
|
2286
3343
|
fingerprint: shortFingerprint(finalBundle.publicKey),
|
|
3344
|
+
audit: auditCheck ? { ...importAudit, legacy: auditCheck.legacy } : null,
|
|
2287
3345
|
}));
|
|
2288
3346
|
}
|
|
2289
3347
|
catch (e) {
|
|
@@ -2375,7 +3433,11 @@ export function startMCPServer(options = {}) {
|
|
|
2375
3433
|
return;
|
|
2376
3434
|
}
|
|
2377
3435
|
// Server assigns the id · agents shouldn't pick collisions.
|
|
2378
|
-
|
|
3436
|
+
// S11 refuter caveat · the pass id is the DAEMON's to mint, always.
|
|
3437
|
+
// A caller-supplied id could collide, or match the demo-wp- prefix
|
|
3438
|
+
// the modal renders as not-sendable (a real pass made permanently
|
|
3439
|
+
// un-approvable by its own name).
|
|
3440
|
+
raw.id = newPassId();
|
|
2379
3441
|
raw.emittedAt =
|
|
2380
3442
|
typeof raw.emittedAt === 'number' && Number.isFinite(raw.emittedAt) ? raw.emittedAt : Date.now();
|
|
2381
3443
|
let intent;
|
|
@@ -2712,12 +3774,20 @@ export function startMCPServer(options = {}) {
|
|
|
2712
3774
|
// Per claim_user_persona HIGH non-technical-first: a non-technical user
|
|
2713
3775
|
// who typos a URL gets a useful page back, not a void.
|
|
2714
3776
|
const requestedPath = (req.url ?? '/').split('?')[0];
|
|
3777
|
+
// API clients are machines: an unknown /api/* path gets JSON, never an
|
|
3778
|
+
// HTML page a fetch().json() chokes on (2026-06-07 audit, Lane API).
|
|
3779
|
+
if (requestedPath.startsWith('/api/')) {
|
|
3780
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
3781
|
+
res.end(JSON.stringify({ error: `Unknown API route: ${requestedPath.slice(0, 200)}` }));
|
|
3782
|
+
return;
|
|
3783
|
+
}
|
|
2715
3784
|
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2716
3785
|
res.end(`<!doctype html>
|
|
2717
3786
|
<html lang="en">
|
|
2718
3787
|
<head>
|
|
2719
3788
|
<meta charset="utf-8"/>
|
|
2720
3789
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
3790
|
+
<link rel="icon" type="image/svg+xml" href="/icon.svg"/>
|
|
2721
3791
|
<title>codehere · page not found</title>
|
|
2722
3792
|
<style>
|
|
2723
3793
|
:root {
|
|
@@ -2777,8 +3847,7 @@ export function startMCPServer(options = {}) {
|
|
|
2777
3847
|
<div class="routes">
|
|
2778
3848
|
<div class="routes-label">VALID ROUTES</div>
|
|
2779
3849
|
<a class="route" href="/"><span class="route-name">Dashboard</span><span class="route-path">/</span></a>
|
|
2780
|
-
<a class="route" href="/
|
|
2781
|
-
<a class="route" href="/history"><span class="route-name">Audit history</span><span class="route-path">/history</span></a>
|
|
3850
|
+
<a class="route" href="/verify"><span class="route-name">Verify at the surface</span><span class="route-path">/verify</span></a>
|
|
2782
3851
|
</div>
|
|
2783
3852
|
</main>
|
|
2784
3853
|
</body>
|
|
@@ -2801,6 +3870,19 @@ export function startMCPServer(options = {}) {
|
|
|
2801
3870
|
catch {
|
|
2802
3871
|
/* best-effort */
|
|
2803
3872
|
}
|
|
3873
|
+
// Project identity backfill — detect + cache each project's git remote in
|
|
3874
|
+
// _meta once, so the project list can collapse clones of one repo without
|
|
3875
|
+
// shelling git on every poll. Idempotent (skips already-checked projects).
|
|
3876
|
+
try {
|
|
3877
|
+
const { backfillProjectRemotes } = await import('../storage/project-thread-mirror.js');
|
|
3878
|
+
const r = await backfillProjectRemotes();
|
|
3879
|
+
if (process.env.CODEHERE_DEBUG === '1' && r.detected > 0) {
|
|
3880
|
+
console.error(`[daemon-start] detected ${r.detected}/${r.scanned} project remotes`);
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
catch {
|
|
3884
|
+
/* best-effort */
|
|
3885
|
+
}
|
|
2804
3886
|
// Stale-session reconciliation. Walks per-project thread.jsonl files
|
|
2805
3887
|
// and marks any sessions stuck in 'running' for >60 min as crashed.
|
|
2806
3888
|
// Closes the dogfood-found gap where 3-day-old tasks displayed as
|
|
@@ -2829,10 +3911,10 @@ export function startMCPServer(options = {}) {
|
|
|
2829
3911
|
function callbackHtml(status, message, providerLabel) {
|
|
2830
3912
|
const safe = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2831
3913
|
const tone = status === 'ok' ? '#28c840' : '#ff5f57';
|
|
2832
|
-
const icon = status === 'ok' ? '✓' : '
|
|
3914
|
+
const icon = status === 'ok' ? '✓' : '✕';
|
|
2833
3915
|
const title = status === 'ok' ? `${providerLabel ?? 'Provider'} connected` : 'Connection failed';
|
|
2834
3916
|
return `<!doctype html><html lang="en"><head>
|
|
2835
|
-
<meta charset="utf-8"><title>${safe(title)}</title>
|
|
3917
|
+
<meta charset="utf-8"><link rel="icon" type="image/svg+xml" href="/icon.svg"><title>${safe(title)}</title>
|
|
2836
3918
|
<style>
|
|
2837
3919
|
body { margin:0; background:#0a0a0a; color:#fff; font-family:ui-sans-serif,system-ui,-apple-system,sans-serif; display:flex; align-items:center; justify-content:center; min-height:100vh; }
|
|
2838
3920
|
.card { max-width:480px; padding:32px 36px; border:1px solid rgba(255,255,255,0.08); border-radius:12px; background:#111; }
|