codehere 0.7.1 → 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/narrator-prompt-template.js +1 -1
- 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 +279 -31
- 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 +4 -3
- package/dist/infrastructure/cost/cost-router.js +16 -23
- package/dist/infrastructure/cost/spend-aggregator.js +91 -16
- 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 +139 -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 +23 -11
- package/dist/infrastructure/orchestration/aider-adapter.js +188 -24
- package/dist/infrastructure/orchestration/artifacts-extractor.js +60 -2
- package/dist/infrastructure/orchestration/cassette-recorder.js +1 -1
- 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 +1 -11
- package/dist/infrastructure/orchestration/gemini-adapter.js +92 -12
- package/dist/infrastructure/orchestration/generic-cli-adapter.js +134 -3
- package/dist/infrastructure/orchestration/graphify-context.js +14 -59
- package/dist/infrastructure/orchestration/memory-injector.js +4 -1
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +167 -9
- package/dist/infrastructure/orchestration/opencode-adapter.js +84 -14
- package/dist/infrastructure/orchestration/progress-aggregator.js +275 -23
- 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/cloud-sync.js +2 -2
- package/dist/infrastructure/protocols/mcp-helpers.js +1 -1
- package/dist/infrastructure/protocols/mcp-server-impl.js +1498 -354
- 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 -4126
- package/dist/infrastructure/protocols/verify-page.js +884 -148
- 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 +151 -4
- package/dist/infrastructure/storage/project-thread-reader.js +262 -25
- package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
- 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 +89 -66
- 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,101 +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';
|
|
57
|
+
import { findProjectRoot } from '../orchestration/memory-injector.js';
|
|
58
|
+
import { scaffoldProjectAt } from '../../presentation/cli/commands/init-command.js';
|
|
49
59
|
import { readCrossToolAgents } from '../storage/cross-tool-agents.js';
|
|
50
|
-
import { listProjects,
|
|
51
|
-
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';
|
|
52
62
|
import { randomUUID, timingSafeEqual } from 'crypto';
|
|
53
63
|
import { installAgent } from '../orchestration/agent-installer.js';
|
|
54
64
|
import { detectReadiness } from '../orchestration/readiness.js';
|
|
55
65
|
import { getHookMode } from '../hooks/hook-mode.js';
|
|
56
66
|
import { addPermissionEntry, loadPermissions, removePermissionEntry } from '../security/permissions-config.js';
|
|
57
67
|
import { computeSpendToday, estimateCostUsd } from '../cost/spend-aggregator.js';
|
|
58
|
-
import { loadBudget, isOverBudget, budgetUsedPct } from '../cost/budget-config.js';
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
type: 'object',
|
|
89
|
-
properties: {
|
|
90
|
-
path: { type: 'string', description: 'Directory path (default: current directory)' },
|
|
91
|
-
topN: { type: 'number', description: 'Return top N riskiest files (default: 10)' },
|
|
92
|
-
},
|
|
93
|
-
},
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
name: 'codehere_audit',
|
|
97
|
-
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.',
|
|
98
|
-
inputSchema: {
|
|
99
|
-
type: 'object',
|
|
100
|
-
properties: {
|
|
101
|
-
limit: { type: 'number', description: 'Number of recent entries to return (default: 20)' },
|
|
102
|
-
blockedOnly: { type: 'boolean', description: 'Only return blocked events' },
|
|
103
|
-
overrideOnly: {
|
|
104
|
-
type: 'boolean',
|
|
105
|
-
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.',
|
|
106
|
-
},
|
|
107
|
-
verify: {
|
|
108
|
-
type: 'boolean',
|
|
109
|
-
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).',
|
|
110
|
-
},
|
|
111
|
-
},
|
|
112
|
-
},
|
|
113
|
-
},
|
|
114
|
-
];
|
|
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 ────────────────────────────────────────────────────────
|
|
115
98
|
// ── Tool handlers ────────────────────────────────────────────────────────────
|
|
116
|
-
import { homedir } from 'os';
|
|
99
|
+
import { homedir, tmpdir } from 'os';
|
|
117
100
|
const controlSessions = new Map();
|
|
118
101
|
let controlSeq = 0;
|
|
119
102
|
/**
|
|
@@ -123,7 +106,7 @@ let controlSeq = 0;
|
|
|
123
106
|
* public key is meant for client-side embedding (analytics tracking key, not
|
|
124
107
|
* a secret) so committing the URL is safe.
|
|
125
108
|
*
|
|
126
|
-
* Why runtime injection instead of editing
|
|
109
|
+
* Why runtime injection instead of editing the page templates directly:
|
|
127
110
|
* the templates stay identical between dev and release builds. The only
|
|
128
111
|
* delta is the env var. This keeps the release surface clean (zero-telemetry
|
|
129
112
|
* value per PRD §1.5 #2 stays intact) while letting elu observe founder
|
|
@@ -138,203 +121,6 @@ function withDevElu(html) {
|
|
|
138
121
|
return html;
|
|
139
122
|
return html.replace('</head>', `${ELU_DEV_SCRIPT}\n</head>`);
|
|
140
123
|
}
|
|
141
|
-
function handleTrust(params) {
|
|
142
|
-
const { filepath, code, verbose } = params;
|
|
143
|
-
let content;
|
|
144
|
-
let label;
|
|
145
|
-
if (code) {
|
|
146
|
-
content = code;
|
|
147
|
-
label = 'provided code';
|
|
148
|
-
}
|
|
149
|
-
else if (filepath) {
|
|
150
|
-
const resolved = join(process.cwd(), filepath);
|
|
151
|
-
if (!existsSync(resolved)) {
|
|
152
|
-
return JSON.stringify({ error: `File not found: ${filepath}` });
|
|
153
|
-
}
|
|
154
|
-
content = readFileSync(resolved, 'utf-8');
|
|
155
|
-
label = filepath;
|
|
156
|
-
}
|
|
157
|
-
else {
|
|
158
|
-
return JSON.stringify({ error: 'Provide either filepath or code' });
|
|
159
|
-
}
|
|
160
|
-
const result = scoreFileForMCP(label, content);
|
|
161
|
-
if (!verbose) {
|
|
162
|
-
// Compact response
|
|
163
|
-
return JSON.stringify({
|
|
164
|
-
score: result.score,
|
|
165
|
-
grade: result.grade,
|
|
166
|
-
summary: result.summary,
|
|
167
|
-
criticalCount: result.findings.filter((f) => f.severity === 'critical').length,
|
|
168
|
-
highCount: result.findings.filter((f) => f.severity === 'high').length,
|
|
169
|
-
recommendation: result.score >= 75
|
|
170
|
-
? 'Safe — minor issues can be addressed in follow-up'
|
|
171
|
-
: result.score >= 60
|
|
172
|
-
? 'Review recommended before merge'
|
|
173
|
-
: 'Block — resolve critical/high findings first',
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
return JSON.stringify(result, null, 2);
|
|
177
|
-
}
|
|
178
|
-
function handleScan(params) {
|
|
179
|
-
const text = String(params.text ?? '');
|
|
180
|
-
const findings = scanPayload(text);
|
|
181
|
-
const riskScore = calculateRiskScore(findings);
|
|
182
|
-
const criticalFindings = findings.filter((f) => f.severity === 'critical');
|
|
183
|
-
const blocked = criticalFindings.length > 0 || riskScore >= 60;
|
|
184
|
-
return JSON.stringify({
|
|
185
|
-
blocked,
|
|
186
|
-
riskScore,
|
|
187
|
-
decision: blocked ? 'block' : 'allow',
|
|
188
|
-
reason: blocked
|
|
189
|
-
? criticalFindings.length > 0
|
|
190
|
-
? `${criticalFindings[0].category}: ${criticalFindings[0].description}`
|
|
191
|
-
: `Risk score ${riskScore}/100 exceeds threshold`
|
|
192
|
-
: 'No threats detected',
|
|
193
|
-
findings: findings.slice(0, 10), // cap at 10 to keep response small
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* Coerce a client-supplied numeric param to a bounded integer. Daemon
|
|
198
|
-
* endpoints take topN/limit straight from request bodies; a negative or NaN
|
|
199
|
-
* value silently corrupts slice() math (limit=-1 → slice keeps all-but-last,
|
|
200
|
-
* NaN → slice returns everything). Clamp to a sane range instead of trusting
|
|
201
|
-
* the wire.
|
|
202
|
-
*/
|
|
203
|
-
export function clampInt(v, def, min, max) {
|
|
204
|
-
// null/undefined = absent → default. Number(null) is 0 (finite), so without
|
|
205
|
-
// this guard a literal null would coerce to the min instead of the default.
|
|
206
|
-
if (v === null || v === undefined)
|
|
207
|
-
return def;
|
|
208
|
-
const n = Math.floor(Number(v));
|
|
209
|
-
if (!Number.isFinite(n))
|
|
210
|
-
return def;
|
|
211
|
-
return Math.max(min, Math.min(n, max));
|
|
212
|
-
}
|
|
213
|
-
function handleReview(params) {
|
|
214
|
-
const targetPath = String(params.path ?? process.cwd());
|
|
215
|
-
const topN = clampInt(params.topN, 10, 1, 100);
|
|
216
|
-
try {
|
|
217
|
-
const files = collectFilesForMCP(targetPath);
|
|
218
|
-
if (files.length === 0) {
|
|
219
|
-
return JSON.stringify({ message: 'No code files found', path: targetPath });
|
|
220
|
-
}
|
|
221
|
-
const results = files.map((f) => scoreFileForMCP(relative(targetPath, f.filepath), f.code));
|
|
222
|
-
results.sort((a, b) => a.score - b.score); // worst first
|
|
223
|
-
const top = results.slice(0, topN);
|
|
224
|
-
const overallScore = Math.round(results.reduce((s, r) => s + r.score, 0) / results.length);
|
|
225
|
-
return JSON.stringify({
|
|
226
|
-
overallScore,
|
|
227
|
-
grade: overallScore >= 90 ? 'A' : overallScore >= 75 ? 'B' : overallScore >= 60 ? 'C' : overallScore >= 40 ? 'D' : 'F',
|
|
228
|
-
totalFiles: results.length,
|
|
229
|
-
riskiestFiles: top.map((r) => ({
|
|
230
|
-
filepath: r.filepath,
|
|
231
|
-
score: r.score,
|
|
232
|
-
grade: r.grade,
|
|
233
|
-
summary: r.summary,
|
|
234
|
-
criticalCount: r.findings.filter((f) => f.severity === 'critical').length,
|
|
235
|
-
highCount: r.findings.filter((f) => f.severity === 'high').length,
|
|
236
|
-
})),
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
catch (e) {
|
|
240
|
-
return JSON.stringify({ error: String(e) });
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
function handleAudit(params) {
|
|
244
|
-
const limit = clampInt(params.limit, 20, 1, 500);
|
|
245
|
-
const blockedOnly = Boolean(params.blockedOnly);
|
|
246
|
-
const overrideOnly = Boolean(params.overrideOnly);
|
|
247
|
-
const verify = Boolean(params.verify);
|
|
248
|
-
if (verify) {
|
|
249
|
-
// Tamper-evidence verification — walks the file, recomputes every hash,
|
|
250
|
-
// checks the chain. Returns full report including ALL break points.
|
|
251
|
-
const result = verifyAuditChain(AUDIT_FILE);
|
|
252
|
-
return JSON.stringify(result, null, 2);
|
|
253
|
-
}
|
|
254
|
-
if (!existsSync(AUDIT_FILE)) {
|
|
255
|
-
return JSON.stringify({
|
|
256
|
-
message: 'No audit log yet — install hooks first with: codehere hook install',
|
|
257
|
-
entries: [],
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
const lines = readFileSync(AUDIT_FILE, 'utf-8')
|
|
261
|
-
.split('\n')
|
|
262
|
-
.filter(Boolean)
|
|
263
|
-
.slice(-Math.min(limit * 3, 500)); // read more than needed to account for filter
|
|
264
|
-
const entries = lines
|
|
265
|
-
.map((l) => {
|
|
266
|
-
try {
|
|
267
|
-
return JSON.parse(l);
|
|
268
|
-
}
|
|
269
|
-
catch {
|
|
270
|
-
return null;
|
|
271
|
-
}
|
|
272
|
-
})
|
|
273
|
-
.filter(Boolean)
|
|
274
|
-
.filter((e) => {
|
|
275
|
-
if (overrideOnly)
|
|
276
|
-
return Boolean(e.overrideReason);
|
|
277
|
-
if (blockedOnly)
|
|
278
|
-
return Boolean(e.blocked);
|
|
279
|
-
return true;
|
|
280
|
-
})
|
|
281
|
-
.slice(-limit);
|
|
282
|
-
return JSON.stringify({ entries, total: entries.length });
|
|
283
|
-
}
|
|
284
|
-
// ── JSON-RPC dispatcher ──────────────────────────────────────────────────────
|
|
285
|
-
function dispatch(req) {
|
|
286
|
-
const id = req.id;
|
|
287
|
-
const params = (req.params ?? {});
|
|
288
|
-
try {
|
|
289
|
-
switch (req.method) {
|
|
290
|
-
case 'initialize':
|
|
291
|
-
return {
|
|
292
|
-
jsonrpc: '2.0',
|
|
293
|
-
id,
|
|
294
|
-
result: {
|
|
295
|
-
protocolVersion: '2024-11-05',
|
|
296
|
-
capabilities: { tools: {} },
|
|
297
|
-
serverInfo: { name: 'codehere', version: getVersion() },
|
|
298
|
-
},
|
|
299
|
-
};
|
|
300
|
-
case 'tools/list':
|
|
301
|
-
return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
|
|
302
|
-
case 'tools/call': {
|
|
303
|
-
const toolName = String(params.name ?? '');
|
|
304
|
-
const toolParams = (params.arguments ?? {});
|
|
305
|
-
let content;
|
|
306
|
-
switch (toolName) {
|
|
307
|
-
case 'codehere_trust':
|
|
308
|
-
content = handleTrust(toolParams);
|
|
309
|
-
break;
|
|
310
|
-
case 'codehere_scan':
|
|
311
|
-
content = handleScan(toolParams);
|
|
312
|
-
break;
|
|
313
|
-
case 'codehere_review':
|
|
314
|
-
content = handleReview(toolParams);
|
|
315
|
-
break;
|
|
316
|
-
case 'codehere_audit':
|
|
317
|
-
content = handleAudit(toolParams);
|
|
318
|
-
break;
|
|
319
|
-
default:
|
|
320
|
-
return { jsonrpc: '2.0', id, error: { code: -32601, message: `Unknown tool: ${toolName}` } };
|
|
321
|
-
}
|
|
322
|
-
return {
|
|
323
|
-
jsonrpc: '2.0',
|
|
324
|
-
id,
|
|
325
|
-
result: { content: [{ type: 'text', text: content }] },
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
case 'ping':
|
|
329
|
-
return { jsonrpc: '2.0', id, result: {} };
|
|
330
|
-
default:
|
|
331
|
-
return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${req.method}` } };
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
catch (e) {
|
|
335
|
-
return { jsonrpc: '2.0', id, error: { code: -32603, message: String(e) } };
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
124
|
const MAX_BODY_SIZE = 2_097_152; // 2 MiB — covers 1MB code-scan payloads + framing
|
|
339
125
|
// v0.8 S2.5 · in-memory map of pending OAuth flows keyed by `state` value.
|
|
340
126
|
// Lifecycle: begin() inserts; callback resolves and removes; entries also
|
|
@@ -375,6 +161,20 @@ export function startMCPServer(options = {}) {
|
|
|
375
161
|
const port = options.port ?? 3773;
|
|
376
162
|
const host = options.host ?? '127.0.0.1'; // localhost only by default
|
|
377
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
|
+
}
|
|
378
178
|
const server = http.createServer((req, res) => {
|
|
379
179
|
// v0.7 Week-2.2 · CORS for read-only public endpoints.
|
|
380
180
|
// The landing-page daemon-status pill (codehere.uk → 127.0.0.1:3773)
|
|
@@ -386,13 +186,22 @@ export function startMCPServer(options = {}) {
|
|
|
386
186
|
// /api/writepass/:id/:decision, etc.) are NOT covered by this · they
|
|
387
187
|
// remain same-origin-only.
|
|
388
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];
|
|
389
192
|
const isReadOnlyPublic = url === '/health' ||
|
|
390
193
|
url === '/api/version' ||
|
|
391
194
|
url === '/api/tasks' ||
|
|
392
195
|
url === '/api/host-sessions' ||
|
|
393
196
|
url.startsWith('/api/host-sessions/') ||
|
|
394
|
-
|
|
395
|
-
|
|
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';
|
|
396
205
|
if (isReadOnlyPublic) {
|
|
397
206
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
398
207
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
@@ -446,7 +255,24 @@ export function startMCPServer(options = {}) {
|
|
|
446
255
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
447
256
|
// `mock` lets a no-spend tool (verify-all) confirm a daemon is mock BEFORE
|
|
448
257
|
// attaching — so it can refuse to drive a LIVE daemon while claiming "no spend".
|
|
449
|
-
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);
|
|
450
276
|
return;
|
|
451
277
|
}
|
|
452
278
|
// v2 Task-Centric PM UI (primary — served at /)
|
|
@@ -472,22 +298,24 @@ export function startMCPServer(options = {}) {
|
|
|
472
298
|
res.end(VERIFY_HTML);
|
|
473
299
|
return;
|
|
474
300
|
}
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
481
|
-
// 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.
|
|
482
306
|
if (req.method === 'GET' &&
|
|
483
|
-
(req.url === '/
|
|
484
|
-
|
|
485
|
-
|
|
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();
|
|
486
314
|
return;
|
|
487
315
|
}
|
|
488
|
-
// Backwards-compat: /ui used to be the Mission Control audit view
|
|
316
|
+
// Backwards-compat: /ui used to be the Mission Control audit view
|
|
489
317
|
if (req.method === 'GET' && (req.url === '/ui' || req.url === '/ui/')) {
|
|
490
|
-
res.writeHead(302, { Location: '/
|
|
318
|
+
res.writeHead(302, { Location: '/' });
|
|
491
319
|
res.end();
|
|
492
320
|
return;
|
|
493
321
|
}
|
|
@@ -545,7 +373,7 @@ export function startMCPServer(options = {}) {
|
|
|
545
373
|
// Tails sessions.jsonl and pushes "change" events so the /v2 UI can
|
|
546
374
|
// re-fetch task state instantly instead of waiting 8s for the next poll.
|
|
547
375
|
// Same pattern as /events (audit log tail) but for sessions.
|
|
548
|
-
const SESSIONS_FILE = join(homedir(), '.codehere', 'sessions.jsonl');
|
|
376
|
+
const SESSIONS_FILE = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'sessions.jsonl');
|
|
549
377
|
if (req.method === 'GET' && req.url === '/api/events/sessions') {
|
|
550
378
|
res.writeHead(200, {
|
|
551
379
|
'Content-Type': 'text/event-stream',
|
|
@@ -590,9 +418,46 @@ export function startMCPServer(options = {}) {
|
|
|
590
418
|
}
|
|
591
419
|
};
|
|
592
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
|
+
});
|
|
593
455
|
const ping = setInterval(() => res.write(': ping\n\n'), 15000);
|
|
594
456
|
req.on('close', () => {
|
|
595
457
|
clearInterval(ping);
|
|
458
|
+
if (liveTimer)
|
|
459
|
+
clearTimeout(liveTimer);
|
|
460
|
+
offLive();
|
|
596
461
|
unwatchFile(SESSIONS_FILE, onChange);
|
|
597
462
|
});
|
|
598
463
|
return;
|
|
@@ -609,9 +474,14 @@ export function startMCPServer(options = {}) {
|
|
|
609
474
|
if (req.method === 'GET' && req.url === '/api/audit/verify') {
|
|
610
475
|
try {
|
|
611
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;
|
|
612
481
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
613
482
|
res.end(JSON.stringify({
|
|
614
|
-
valid: r.valid,
|
|
483
|
+
valid: verifiable && r.valid,
|
|
484
|
+
verifiable,
|
|
615
485
|
totalEntries: r.totalEntries,
|
|
616
486
|
chainedEntries: r.chainedEntries,
|
|
617
487
|
legacyEntries: r.legacyEntries,
|
|
@@ -620,7 +490,13 @@ export function startMCPServer(options = {}) {
|
|
|
620
490
|
}
|
|
621
491
|
catch {
|
|
622
492
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
623
|
-
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
|
+
}));
|
|
624
500
|
}
|
|
625
501
|
return;
|
|
626
502
|
}
|
|
@@ -689,6 +565,118 @@ export function startMCPServer(options = {}) {
|
|
|
689
565
|
})();
|
|
690
566
|
return;
|
|
691
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
|
+
}
|
|
692
680
|
if (req.method === 'DELETE' && req.url?.startsWith('/api/permissions?')) {
|
|
693
681
|
const params = new URLSearchParams(req.url.split('?')[1] || '');
|
|
694
682
|
const cwd = params.get('cwd') || '';
|
|
@@ -918,7 +906,11 @@ export function startMCPServer(options = {}) {
|
|
|
918
906
|
totalCalls: calls.length,
|
|
919
907
|
blockedCalls: calls.filter((c) => c.blocked).length,
|
|
920
908
|
criticalCalls: calls.filter((c) => c.criticalFindings > 0).length,
|
|
921
|
-
|
|
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,
|
|
922
914
|
chainEntries: chain.chainedEntries,
|
|
923
915
|
}));
|
|
924
916
|
return;
|
|
@@ -1010,6 +1002,156 @@ export function startMCPServer(options = {}) {
|
|
|
1010
1002
|
// returns the new entry's seq + short hash, so the UI can show "logged ·
|
|
1011
1003
|
// chain continues <hash>" with the REAL hash, not a mockup. Same-origin
|
|
1012
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
|
+
}
|
|
1013
1155
|
if (req.method === 'POST' && req.url === '/api/audit/override') {
|
|
1014
1156
|
(async () => {
|
|
1015
1157
|
try {
|
|
@@ -1029,7 +1171,11 @@ export function startMCPServer(options = {}) {
|
|
|
1029
1171
|
res.end(JSON.stringify({ error: 'a reason is required to log an override' }));
|
|
1030
1172
|
return;
|
|
1031
1173
|
}
|
|
1032
|
-
|
|
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, {
|
|
1033
1179
|
sessionId: typeof raw.sessionId === 'string' ? raw.sessionId : 'unknown',
|
|
1034
1180
|
toolName: typeof raw.toolName === 'string' ? raw.toolName : 'unknown',
|
|
1035
1181
|
note: reason,
|
|
@@ -1056,7 +1202,11 @@ export function startMCPServer(options = {}) {
|
|
|
1056
1202
|
catch {
|
|
1057
1203
|
continue;
|
|
1058
1204
|
}
|
|
1059
|
-
|
|
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) {
|
|
1060
1210
|
seq = typeof e.seq === 'number' ? e.seq : null;
|
|
1061
1211
|
entryHash = typeof e.entryHash === 'string' ? e.entryHash : '';
|
|
1062
1212
|
confirmed = true;
|
|
@@ -1072,6 +1222,14 @@ export function startMCPServer(options = {}) {
|
|
|
1072
1222
|
res.end(JSON.stringify({ ok: false, error: 'override could not be confirmed in the audit log' }));
|
|
1073
1223
|
return;
|
|
1074
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
|
+
}
|
|
1075
1233
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1076
1234
|
res.end(JSON.stringify({
|
|
1077
1235
|
ok: true,
|
|
@@ -1198,6 +1356,355 @@ export function startMCPServer(options = {}) {
|
|
|
1198
1356
|
}
|
|
1199
1357
|
return;
|
|
1200
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
|
+
})();
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1201
1708
|
// ── F1a readiness check ───────────────────────────────────────────────────
|
|
1202
1709
|
if (req.method === 'GET' && req.url === '/api/readiness') {
|
|
1203
1710
|
try {
|
|
@@ -1211,6 +1718,71 @@ export function startMCPServer(options = {}) {
|
|
|
1211
1718
|
}
|
|
1212
1719
|
return;
|
|
1213
1720
|
}
|
|
1721
|
+
// ── v1 project-memory routes (the room's init nudge) ─────────────────────
|
|
1722
|
+
// Found by the stranger walk: web-only users never run `codehere init`, so
|
|
1723
|
+
// the spec's "agent remembers the project" half of nothing-lost silently
|
|
1724
|
+
// doesn't apply. Status reports the SAME readiness the injector acts on
|
|
1725
|
+
// (shared findProjectRoot); init runs the SAME hardened scaffold the CLI
|
|
1726
|
+
// does (shared scaffoldProjectAt — symlink defenses included).
|
|
1727
|
+
if (req.method === 'GET' && req.url?.startsWith('/api/memory/status')) {
|
|
1728
|
+
try {
|
|
1729
|
+
const u = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`);
|
|
1730
|
+
const cwd = u.searchParams.get('cwd') || '';
|
|
1731
|
+
if (!cwd || !isAbsolute(cwd) || cwd.includes('\0')) {
|
|
1732
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1733
|
+
res.end(JSON.stringify({ error: 'cwd (absolute path) required' }));
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
const root = findProjectRoot(cwd);
|
|
1737
|
+
const ready = !!root && existsSync(join(root, '.codehere', 'memory', 'working'));
|
|
1738
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1739
|
+
res.end(JSON.stringify({ ready, projectRoot: root }));
|
|
1740
|
+
}
|
|
1741
|
+
catch (e) {
|
|
1742
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1743
|
+
res.end(JSON.stringify({ error: 'memory_status_failed', detail: String(e) }));
|
|
1744
|
+
}
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
if (req.method === 'POST' && req.url === '/api/memory/init') {
|
|
1748
|
+
(async () => {
|
|
1749
|
+
try {
|
|
1750
|
+
const body = await readBody(req, res);
|
|
1751
|
+
let cwd = '';
|
|
1752
|
+
try {
|
|
1753
|
+
cwd = String(JSON.parse(body || '{}').cwd || '');
|
|
1754
|
+
}
|
|
1755
|
+
catch {
|
|
1756
|
+
/* falls through to the validation below */
|
|
1757
|
+
}
|
|
1758
|
+
if (!cwd || !isAbsolute(cwd) || cwd.includes('\0') || !existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
|
1759
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1760
|
+
res.end(JSON.stringify({ error: 'cwd (absolute path to an existing directory) required' }));
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
const r = scaffoldProjectAt(cwd);
|
|
1764
|
+
if (!r.ok) {
|
|
1765
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1766
|
+
res.end(JSON.stringify({ ok: false, reason: r.reason }));
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
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
|
+
}));
|
|
1776
|
+
}
|
|
1777
|
+
catch (e) {
|
|
1778
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1779
|
+
res.end(JSON.stringify({ error: 'memory_init_failed', detail: String(e) }));
|
|
1780
|
+
}
|
|
1781
|
+
})().catch(() => {
|
|
1782
|
+
/* handled above — keep the route from rejecting unhandled */
|
|
1783
|
+
});
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1214
1786
|
// ── v0.6.0 M1 project thread routes ──────────────────────────────────────
|
|
1215
1787
|
// GET /api/projects — list all known projects (slug, cwd, title, taskCount, lastActivityAt)
|
|
1216
1788
|
// GET /api/projects/:slug — single project thread (meta + tasks merged by sessionId)
|
|
@@ -1218,7 +1790,19 @@ export function startMCPServer(options = {}) {
|
|
|
1218
1790
|
// populated by migration + forward-mirror). Drives the new thread-view UI.
|
|
1219
1791
|
if (req.method === 'GET' && req.url === '/api/projects') {
|
|
1220
1792
|
try {
|
|
1221
|
-
|
|
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)));
|
|
1222
1806
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1223
1807
|
res.end(JSON.stringify({ projects }));
|
|
1224
1808
|
}
|
|
@@ -1239,14 +1823,33 @@ export function startMCPServer(options = {}) {
|
|
|
1239
1823
|
res.end(JSON.stringify({ error: 'invalid_slug' }));
|
|
1240
1824
|
return;
|
|
1241
1825
|
}
|
|
1242
|
-
|
|
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);
|
|
1243
1829
|
if (!thread) {
|
|
1244
1830
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1245
1831
|
res.end(JSON.stringify({ error: 'project_not_found', slug }));
|
|
1246
1832
|
return;
|
|
1247
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
|
+
});
|
|
1248
1851
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1249
|
-
res.end(JSON.stringify({ thread }));
|
|
1852
|
+
res.end(JSON.stringify({ thread: { ...thread, tasks: tasksWithCost } }));
|
|
1250
1853
|
}
|
|
1251
1854
|
catch (e) {
|
|
1252
1855
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
@@ -1304,6 +1907,171 @@ export function startMCPServer(options = {}) {
|
|
|
1304
1907
|
})();
|
|
1305
1908
|
return;
|
|
1306
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
|
+
}
|
|
1307
2075
|
// ── F1 task orchestration routes ─────────────────────────────────────────
|
|
1308
2076
|
// POST /api/task — { prompt, cwd? } → { sessionId }
|
|
1309
2077
|
// GET /api/tasks — list all sessions
|
|
@@ -1352,6 +2120,10 @@ export function startMCPServer(options = {}) {
|
|
|
1352
2120
|
const batchBudget = loadBudget();
|
|
1353
2121
|
const batchSpendNow = batchBudget.dailyUsd !== null ? computeSpendToday().totalUsd : 0;
|
|
1354
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();
|
|
1355
2127
|
for (const t of tasks) {
|
|
1356
2128
|
const prompt = t.prompt;
|
|
1357
2129
|
if (!prompt || typeof prompt !== 'string') {
|
|
@@ -1372,6 +2144,42 @@ export function startMCPServer(options = {}) {
|
|
|
1372
2144
|
results.push({ error: 'cwd must be absolute, no NUL bytes', agent: agentId });
|
|
1373
2145
|
continue;
|
|
1374
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);
|
|
1375
2183
|
}
|
|
1376
2184
|
// Per-task budget check (paid agents only).
|
|
1377
2185
|
if (batchBudget.dailyUsd !== null) {
|
|
@@ -1387,7 +2195,7 @@ export function startMCPServer(options = {}) {
|
|
|
1387
2195
|
}
|
|
1388
2196
|
}
|
|
1389
2197
|
try {
|
|
1390
|
-
const session = manager.spawn(prompt, agentId, { cwd: t.cwd });
|
|
2198
|
+
const session = manager.spawn(prompt, agentId, { cwd: t.cwd, batchId });
|
|
1391
2199
|
results.push({
|
|
1392
2200
|
sessionId: session.sessionId,
|
|
1393
2201
|
agent: session.agent,
|
|
@@ -1470,6 +2278,27 @@ export function startMCPServer(options = {}) {
|
|
|
1470
2278
|
}));
|
|
1471
2279
|
return;
|
|
1472
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
|
+
}
|
|
1473
2302
|
// Path-traversal + null-byte defense · cwd must be absolute and
|
|
1474
2303
|
// free of NUL bytes (some filesystems treat \0 as terminator).
|
|
1475
2304
|
if (cwd !== undefined) {
|
|
@@ -1478,11 +2307,33 @@ export function startMCPServer(options = {}) {
|
|
|
1478
2307
|
res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
|
|
1479
2308
|
return;
|
|
1480
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
|
+
}
|
|
1481
2327
|
}
|
|
1482
2328
|
const budgetForSpawn = loadBudget();
|
|
1483
2329
|
if (budgetForSpawn.dailyUsd !== null) {
|
|
1484
2330
|
const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
|
|
1485
|
-
|
|
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';
|
|
1486
2337
|
if (isPaidSpawn) {
|
|
1487
2338
|
const todaySpend = computeSpendToday();
|
|
1488
2339
|
if (isOverBudget(todaySpend.totalUsd, budgetForSpawn)) {
|
|
@@ -1526,7 +2377,13 @@ export function startMCPServer(options = {}) {
|
|
|
1526
2377
|
// enrichment instead of failing outright.
|
|
1527
2378
|
console.error('[context-source] resolver failed:', e instanceof Error ? e.message : String(e));
|
|
1528
2379
|
}
|
|
1529
|
-
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
|
+
});
|
|
1530
2387
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1531
2388
|
res.end(JSON.stringify({
|
|
1532
2389
|
sessionId: session.sessionId,
|
|
@@ -1555,52 +2412,144 @@ export function startMCPServer(options = {}) {
|
|
|
1555
2412
|
return;
|
|
1556
2413
|
}
|
|
1557
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
|
+
};
|
|
1558
2448
|
const spawned = manager.list().map((s) => {
|
|
1559
2449
|
const completedAt = manager.getCompletedAt(s.sessionId);
|
|
1560
2450
|
const finalReport = manager.getFinalReport(s.sessionId);
|
|
2451
|
+
const acceptanceCriteria = manager.getAcceptanceCriteria(s.sessionId);
|
|
1561
2452
|
const cwd = manager.getCwd(s.sessionId);
|
|
1562
2453
|
const continuedFrom = manager.getContinuedFrom(s.sessionId);
|
|
1563
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);
|
|
1564
2459
|
const failedReport = manager.hasFailedReport(s.sessionId);
|
|
1565
2460
|
const stalledReason = manager.getStallReason(s.sessionId);
|
|
1566
|
-
// F-07 ·
|
|
1567
|
-
//
|
|
1568
|
-
//
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
steps: s.steps,
|
|
1573
|
-
});
|
|
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);
|
|
1574
2467
|
return {
|
|
1575
2468
|
sessionId: s.sessionId,
|
|
1576
|
-
prompt: s.prompt,
|
|
2469
|
+
prompt: manager.getDisplayPrompt(s.sessionId) ?? s.prompt,
|
|
1577
2470
|
startedAt: s.startedAt,
|
|
1578
2471
|
status: s.status,
|
|
1579
2472
|
agent: s.agent,
|
|
1580
2473
|
steps: s.steps ?? [],
|
|
1581
2474
|
artifacts: s.artifacts,
|
|
2475
|
+
...(cost ? { cost } : {}),
|
|
1582
2476
|
...(completedAt !== undefined ? { completedAt, durationMs: completedAt - s.startedAt } : {}),
|
|
1583
2477
|
...(finalReport !== undefined ? { finalReport } : {}),
|
|
2478
|
+
...(acceptanceCriteria && acceptanceCriteria.length ? { acceptanceCriteria } : {}),
|
|
1584
2479
|
...(failedReport ? { failedReport: true } : {}),
|
|
1585
2480
|
...(stalledReason ? { stalledReason } : {}),
|
|
1586
|
-
|
|
2481
|
+
verdict,
|
|
1587
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,
|
|
1588
2487
|
...(cwd !== undefined ? { cwd } : {}),
|
|
1589
2488
|
...(continuedFrom !== undefined ? { continuedFrom } : {}),
|
|
1590
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 } : {}),
|
|
1591
2498
|
provenance: 'spawned',
|
|
1592
2499
|
};
|
|
1593
2500
|
});
|
|
1594
2501
|
const activeAgents = manager.listActiveAgents();
|
|
1595
|
-
// 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.
|
|
1596
2506
|
const persisted = manager.listPersisted().map((p) => ({
|
|
1597
2507
|
...p,
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
}),
|
|
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,
|
|
1603
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));
|
|
1604
2553
|
// Layer 7: timestamp of most recent hook-audit line (= "last edit on this machine")
|
|
1605
2554
|
let lastEditAt = null;
|
|
1606
2555
|
try {
|
|
@@ -1612,11 +2561,11 @@ export function startMCPServer(options = {}) {
|
|
|
1612
2561
|
}
|
|
1613
2562
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1614
2563
|
res.end(JSON.stringify({
|
|
1615
|
-
spawned,
|
|
1616
|
-
persisted,
|
|
2564
|
+
spawned: realSpawned,
|
|
2565
|
+
persisted: realPersisted,
|
|
1617
2566
|
activeAgents,
|
|
1618
2567
|
lastEditAt,
|
|
1619
|
-
sessions:
|
|
2568
|
+
sessions: realSpawned, // back-compat for earlier UI code
|
|
1620
2569
|
observed: manager.listObserved(), // legacy, kept for /history or future use
|
|
1621
2570
|
}));
|
|
1622
2571
|
return;
|
|
@@ -1658,6 +2607,27 @@ export function startMCPServer(options = {}) {
|
|
|
1658
2607
|
res.end(JSON.stringify({ blocks }));
|
|
1659
2608
|
return;
|
|
1660
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
|
+
}
|
|
1661
2631
|
// v0.6 M3 — GET /api/task/:id/narration → latest mid-task summary.
|
|
1662
2632
|
// Read-only; SessionManager generates the narration in a background
|
|
1663
2633
|
// timer and caches the latest. Frontend polls (~30s) while the
|
|
@@ -1674,8 +2644,65 @@ export function startMCPServer(options = {}) {
|
|
|
1674
2644
|
const m = req.url.match(/^\/api\/task\/([^/]+)\/(events|kill|message)$/);
|
|
1675
2645
|
if (m) {
|
|
1676
2646
|
const [, sessionId, verb] = m;
|
|
1677
|
-
|
|
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
|
+
}
|
|
1678
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
|
+
}
|
|
1679
2706
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1680
2707
|
res.end(JSON.stringify({ error: `No session: ${sessionId}` }));
|
|
1681
2708
|
return;
|
|
@@ -1726,7 +2753,12 @@ export function startMCPServer(options = {}) {
|
|
|
1726
2753
|
const out = await session.appendMessage(text);
|
|
1727
2754
|
const replyCompletedAt = Date.now();
|
|
1728
2755
|
try {
|
|
1729
|
-
|
|
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;
|
|
1730
2762
|
if (replyCwd) {
|
|
1731
2763
|
mirrorReplyToThread({
|
|
1732
2764
|
parentSessionId: sessionId,
|
|
@@ -1833,26 +2865,71 @@ export function startMCPServer(options = {}) {
|
|
|
1833
2865
|
// MCP endpoint — POST jsonrpc; reject other verbs with 405 + Allow header
|
|
1834
2866
|
// (rc.17: was 404, which is wrong HTTP semantic — UX_OBSERVATIONS P3).
|
|
1835
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
|
+
}
|
|
1836
2892
|
if (req.method !== 'POST') {
|
|
1837
|
-
res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'POST' });
|
|
1838
|
-
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 }));
|
|
1839
2895
|
return;
|
|
1840
2896
|
}
|
|
1841
2897
|
(async () => {
|
|
1842
2898
|
try {
|
|
1843
2899
|
const body = await readBody(req, res);
|
|
2900
|
+
let mcpReq;
|
|
1844
2901
|
try {
|
|
1845
|
-
|
|
1846
|
-
const mcpRes = dispatch(mcpReq);
|
|
1847
|
-
res.writeHead(200, {
|
|
1848
|
-
'Content-Type': 'application/json',
|
|
1849
|
-
'Access-Control-Allow-Origin': 'http://localhost',
|
|
1850
|
-
});
|
|
1851
|
-
res.end(JSON.stringify(mcpRes));
|
|
2902
|
+
mcpReq = JSON.parse(body);
|
|
1852
2903
|
}
|
|
1853
2904
|
catch {
|
|
1854
2905
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1855
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));
|
|
1856
2933
|
}
|
|
1857
2934
|
}
|
|
1858
2935
|
catch {
|
|
@@ -1885,12 +2962,31 @@ export function startMCPServer(options = {}) {
|
|
|
1885
2962
|
clientIdConfigured: !!process.env[p.clientIdEnv],
|
|
1886
2963
|
};
|
|
1887
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
|
+
};
|
|
1888
2983
|
// v0.8 S5 · public-by-URL adapters appear with status='available'
|
|
1889
|
-
// (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.
|
|
1890
2986
|
const oauthIds = new Set(oauthItems.map((i) => i.id));
|
|
1891
2987
|
const reg = getDefaultRegistry();
|
|
1892
2988
|
const noAuthItems = [...reg.bySchemeOnly.values()]
|
|
1893
|
-
.filter((s) => !oauthIds.has(s.id))
|
|
2989
|
+
.filter((s) => !oauthIds.has(s.id) && s.id !== 'github')
|
|
1894
2990
|
.map((s) => ({
|
|
1895
2991
|
id: s.id,
|
|
1896
2992
|
label: s.label,
|
|
@@ -1904,7 +3000,7 @@ export function startMCPServer(options = {}) {
|
|
|
1904
3000
|
description: s.description,
|
|
1905
3001
|
}));
|
|
1906
3002
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1907
|
-
res.end(JSON.stringify({ providers: [...oauthItems, ...noAuthItems] }));
|
|
3003
|
+
res.end(JSON.stringify({ providers: [...oauthItems, githubItem, ...noAuthItems] }));
|
|
1908
3004
|
}
|
|
1909
3005
|
catch (e) {
|
|
1910
3006
|
console.error('[/api/context-sources] failed:', e);
|
|
@@ -2093,26 +3189,9 @@ export function startMCPServer(options = {}) {
|
|
|
2093
3189
|
})();
|
|
2094
3190
|
return;
|
|
2095
3191
|
}
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2100
|
-
res.end(JSON.stringify({
|
|
2101
|
-
path: AUDIT_FILE,
|
|
2102
|
-
valid: r.valid,
|
|
2103
|
-
totalEntries: r.totalEntries,
|
|
2104
|
-
chainedEntries: r.chainedEntries,
|
|
2105
|
-
legacyEntries: r.legacyEntries,
|
|
2106
|
-
errors: r.errors,
|
|
2107
|
-
}));
|
|
2108
|
-
}
|
|
2109
|
-
catch (e) {
|
|
2110
|
-
console.error('[/api/audit/verify] failed:', e);
|
|
2111
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2112
|
-
res.end(JSON.stringify({ error: 'Failed to verify audit chain' }));
|
|
2113
|
-
}
|
|
2114
|
-
return;
|
|
2115
|
-
}
|
|
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.
|
|
2116
3195
|
// v0.8 S4 · share-thread · POST /api/thread/:id/export returns a signed
|
|
2117
3196
|
// bundle of one session that the recipient can replay locally. No relay
|
|
2118
3197
|
// through codehere's infrastructure · the response stays on-machine.
|
|
@@ -2132,7 +3211,12 @@ export function startMCPServer(options = {}) {
|
|
|
2132
3211
|
kind: 'thread',
|
|
2133
3212
|
sessionId: session.sessionId,
|
|
2134
3213
|
session,
|
|
2135
|
-
|
|
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() },
|
|
2136
3220
|
exportedAt: new Date().toISOString(),
|
|
2137
3221
|
};
|
|
2138
3222
|
const bundle = signBundle(body, keys);
|
|
@@ -2185,10 +3269,33 @@ export function startMCPServer(options = {}) {
|
|
|
2185
3269
|
}
|
|
2186
3270
|
const finalBundle = verdict.bundle;
|
|
2187
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
|
+
}
|
|
2188
3280
|
const home = process.env.CODEHERE_HOME || homedir();
|
|
2189
3281
|
const sessionsPath = join(home, '.codehere', 'sessions.jsonl');
|
|
2190
3282
|
const fs = await import('fs');
|
|
2191
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;
|
|
2192
3299
|
const lines = [];
|
|
2193
3300
|
lines.push(JSON.stringify({
|
|
2194
3301
|
t: 'spawned',
|
|
@@ -2197,6 +3304,16 @@ export function startMCPServer(options = {}) {
|
|
|
2197
3304
|
prompt: ses.prompt,
|
|
2198
3305
|
cwd: ses.cwd,
|
|
2199
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 } : {}),
|
|
2200
3317
|
}));
|
|
2201
3318
|
lines.push(JSON.stringify({
|
|
2202
3319
|
t: 'completed',
|
|
@@ -2217,11 +3334,14 @@ export function startMCPServer(options = {}) {
|
|
|
2217
3334
|
}));
|
|
2218
3335
|
}
|
|
2219
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.
|
|
2220
3339
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2221
3340
|
res.end(JSON.stringify({
|
|
2222
3341
|
status: 'imported',
|
|
2223
3342
|
sessionId: ses.sessionId,
|
|
2224
3343
|
fingerprint: shortFingerprint(finalBundle.publicKey),
|
|
3344
|
+
audit: auditCheck ? { ...importAudit, legacy: auditCheck.legacy } : null,
|
|
2225
3345
|
}));
|
|
2226
3346
|
}
|
|
2227
3347
|
catch (e) {
|
|
@@ -2313,7 +3433,11 @@ export function startMCPServer(options = {}) {
|
|
|
2313
3433
|
return;
|
|
2314
3434
|
}
|
|
2315
3435
|
// Server assigns the id · agents shouldn't pick collisions.
|
|
2316
|
-
|
|
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();
|
|
2317
3441
|
raw.emittedAt =
|
|
2318
3442
|
typeof raw.emittedAt === 'number' && Number.isFinite(raw.emittedAt) ? raw.emittedAt : Date.now();
|
|
2319
3443
|
let intent;
|
|
@@ -2650,12 +3774,20 @@ export function startMCPServer(options = {}) {
|
|
|
2650
3774
|
// Per claim_user_persona HIGH non-technical-first: a non-technical user
|
|
2651
3775
|
// who typos a URL gets a useful page back, not a void.
|
|
2652
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
|
+
}
|
|
2653
3784
|
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2654
3785
|
res.end(`<!doctype html>
|
|
2655
3786
|
<html lang="en">
|
|
2656
3787
|
<head>
|
|
2657
3788
|
<meta charset="utf-8"/>
|
|
2658
3789
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
3790
|
+
<link rel="icon" type="image/svg+xml" href="/icon.svg"/>
|
|
2659
3791
|
<title>codehere · page not found</title>
|
|
2660
3792
|
<style>
|
|
2661
3793
|
:root {
|
|
@@ -2715,8 +3847,7 @@ export function startMCPServer(options = {}) {
|
|
|
2715
3847
|
<div class="routes">
|
|
2716
3848
|
<div class="routes-label">VALID ROUTES</div>
|
|
2717
3849
|
<a class="route" href="/"><span class="route-name">Dashboard</span><span class="route-path">/</span></a>
|
|
2718
|
-
<a class="route" href="/
|
|
2719
|
-
<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>
|
|
2720
3851
|
</div>
|
|
2721
3852
|
</main>
|
|
2722
3853
|
</body>
|
|
@@ -2739,6 +3870,19 @@ export function startMCPServer(options = {}) {
|
|
|
2739
3870
|
catch {
|
|
2740
3871
|
/* best-effort */
|
|
2741
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
|
+
}
|
|
2742
3886
|
// Stale-session reconciliation. Walks per-project thread.jsonl files
|
|
2743
3887
|
// and marks any sessions stuck in 'running' for >60 min as crashed.
|
|
2744
3888
|
// Closes the dogfood-found gap where 3-day-old tasks displayed as
|
|
@@ -2767,10 +3911,10 @@ export function startMCPServer(options = {}) {
|
|
|
2767
3911
|
function callbackHtml(status, message, providerLabel) {
|
|
2768
3912
|
const safe = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2769
3913
|
const tone = status === 'ok' ? '#28c840' : '#ff5f57';
|
|
2770
|
-
const icon = status === 'ok' ? '✓' : '
|
|
3914
|
+
const icon = status === 'ok' ? '✓' : '✕';
|
|
2771
3915
|
const title = status === 'ok' ? `${providerLabel ?? 'Provider'} connected` : 'Connection failed';
|
|
2772
3916
|
return `<!doctype html><html lang="en"><head>
|
|
2773
|
-
<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>
|
|
2774
3918
|
<style>
|
|
2775
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; }
|
|
2776
3920
|
.card { max-width:480px; padding:32px 36px; border:1px solid rgba(255,255,255,0.08); border-radius:12px; background:#111; }
|