@logicseek/hello-cc 0.1.9 → 1.0.0
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/CHANGELOG.md +59 -0
- package/README.md +18 -3
- package/README.zh-CN.md +17 -3
- package/bin/hcc.mjs +3 -5656
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +13 -4
- package/docs/commands.zh-CN.md +13 -4
- package/docs/current-state-review-2026-08.md +341 -0
- package/docs/defect-review-2026-07.md +382 -0
- package/docs/guide.md +39 -3
- package/docs/guide.zh-CN.md +34 -2
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
- package/lib/cli/app.mjs +642 -0
- package/lib/cli/commands/coordination.mjs +247 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +852 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +61 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +233 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +13 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +111 -9
- package/lib/process/identity.mjs +192 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +47 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +60 -7
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +227 -27
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +370 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +201 -0
- package/lib/terminal/tmux.mjs +78 -4
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +135 -6
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +178 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +714 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +333 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +5 -2
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5397 -263
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
// HTTP request routing for the web runtime, extracted from lib/web/runtime-main.mjs.
|
|
2
|
+
// The single handleWebRequest dispatcher: auth/cookie exchange, static UI,
|
|
3
|
+
// projects, sessions, peers, tmux/buffer-GC endpoints, and the status APIs.
|
|
4
|
+
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import { URL } from 'node:url';
|
|
8
|
+
import { publicCliFailure, CliError } from '../shared/errors.mjs';
|
|
9
|
+
import { required } from '../cli-args.mjs';
|
|
10
|
+
import { packageRoot } from '../cli-runtime.mjs';
|
|
11
|
+
import { projectRecord } from '../runtime/projects.mjs';
|
|
12
|
+
import { writeRuntime } from '../runtime/state.mjs';
|
|
13
|
+
import { normalizeStateResources } from '../ui/state-render.mjs';
|
|
14
|
+
import { requestUrl, runtimeBaseUrl } from '../web/runtime.mjs';
|
|
15
|
+
import { authOk, readJsonRequest, requestMatchesProxyOrigin, requestOriginMatches, sendFile, sendJson, tokenMatches } from '../web/http.mjs';
|
|
16
|
+
import { API_VERSION, apiVersionUnsupportedBody, readHttpApiVersion } from '../web/api-version.mjs';
|
|
17
|
+
|
|
18
|
+
export function createHttpRoutes(deps) {
|
|
19
|
+
const {
|
|
20
|
+
ctx, sessions,
|
|
21
|
+
token, host, port, useTls, trustProxy, proxyOrigin,
|
|
22
|
+
broadcast, serializeSession, sessionsForProject, resolveSessionPeerId,
|
|
23
|
+
parseCookieSid, closeWebSession, issueSession, sessionCookieHeader,
|
|
24
|
+
expiredSessionCookieHeader, cookieSessionOk, webAuthMode,
|
|
25
|
+
rememberProject, knownProjects, resolveWebProjectContext,
|
|
26
|
+
connectWebProject, projectFromRequest, getSession, resolveWebActionSession,
|
|
27
|
+
prepareRuntimeBufferGc, applyPreparedRuntimeBufferGc,
|
|
28
|
+
detachTmuxSession, safeTmuxKillPlan, executeTmuxKillPlan,
|
|
29
|
+
killDbProvenTmuxSession, attachTmuxSession, writeSessionInput, startSession,
|
|
30
|
+
webStatusSnapshot, webPeerActionForProject,
|
|
31
|
+
now, addEvent, auditPayload, sendMessage,
|
|
32
|
+
getProcessIdentity, getActualPort,
|
|
33
|
+
shutdown, tlsCredentials,
|
|
34
|
+
renderWebIndex, renderWebLogin, sendWebHtml, webErrorStatus,
|
|
35
|
+
ACTIVE_PEER_TTL, DETECTED_PEER_MAX_AGE, PRODUCT_NAME, VERSION
|
|
36
|
+
} = deps;
|
|
37
|
+
|
|
38
|
+
const handleWebRequest = async (req, res) => {
|
|
39
|
+
const url = requestUrl(req);
|
|
40
|
+
try {
|
|
41
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
42
|
+
const accept = req.headers.accept || '';
|
|
43
|
+
const isBrowserNav = req.headers['sec-fetch-mode'] === 'navigate' || accept.includes('text/html');
|
|
44
|
+
const queryToken = url.searchParams.get('token') || '';
|
|
45
|
+
const hasCookie = cookieSessionOk(req);
|
|
46
|
+
// Browser navigation with a valid ?token → exchange it for a session
|
|
47
|
+
// cookie and redirect to the bare URL (strips the token from the
|
|
48
|
+
// address bar). API-style fetches (Accept: */*) still get the HTML
|
|
49
|
+
// directly so existing CLI/test callers are unaffected.
|
|
50
|
+
if (isBrowserNav && queryToken && token && tokenMatches(queryToken, token)) {
|
|
51
|
+
if (trustProxy && !requestMatchesProxyOrigin(req, { trustProxy, proxyOrigin })) {
|
|
52
|
+
sendJson(res, 403, { ok: false, error: { code: 'PROXY_ORIGIN_MISMATCH', message: 'Trusted proxy headers do not match --proxy-origin' } });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const sid = issueSession();
|
|
56
|
+
const params = new URLSearchParams();
|
|
57
|
+
for (const key of ['project', 'root']) {
|
|
58
|
+
const value = url.searchParams.get(key);
|
|
59
|
+
if (value) params.set(key, value);
|
|
60
|
+
}
|
|
61
|
+
const location = '/' + (params.toString() ? `?${params}` : '');
|
|
62
|
+
res.writeHead(302, { Location: location, 'Set-Cookie': sessionCookieHeader(sid, req) });
|
|
63
|
+
res.end();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// Browser navigation with no credential at all → login page (bare-URL
|
|
67
|
+
// fallback, e.g. a bookmarked URL after the runtime restarted).
|
|
68
|
+
if (isBrowserNav && !hasCookie && !queryToken && token) {
|
|
69
|
+
sendWebHtml(res, renderWebLogin);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
sendWebHtml(res, renderWebIndex);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (req.method === 'GET' && url.pathname === '/assets/xterm.js') {
|
|
76
|
+
sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'xterm', 'lib', 'xterm.js'), 'application/javascript; charset=utf-8');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (req.method === 'GET' && url.pathname === '/assets/xterm.css') {
|
|
80
|
+
sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'xterm', 'css', 'xterm.css'), 'text/css; charset=utf-8');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (req.method === 'POST' && url.pathname === '/login') {
|
|
84
|
+
const input = await readJsonRequest(req);
|
|
85
|
+
const loginToken = String(input.token || '');
|
|
86
|
+
if (!token || !tokenMatches(loginToken, token)) {
|
|
87
|
+
sendJson(res, 401, { ok: false, error: { code: 'UNAUTHORIZED', message: 'Invalid token' } });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (trustProxy && !requestMatchesProxyOrigin(req, { trustProxy, proxyOrigin })) {
|
|
91
|
+
sendJson(res, 403, { ok: false, error: { code: 'PROXY_ORIGIN_MISMATCH', message: 'Trusted proxy headers do not match --proxy-origin' } });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const sid = issueSession();
|
|
95
|
+
res.writeHead(302, { Location: '/', 'Set-Cookie': sessionCookieHeader(sid, req) });
|
|
96
|
+
res.end();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (url.pathname.startsWith('/api/') && !readHttpApiVersion(req).ok) {
|
|
100
|
+
sendJson(res, 426, apiVersionUnsupportedBody());
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const authMode = webAuthMode(url, req);
|
|
104
|
+
if (!authMode) {
|
|
105
|
+
sendJson(res, 401, { ok: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' } });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const safeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(req.method || '');
|
|
109
|
+
// Cookie-authenticated writes require affirmative same-origin evidence;
|
|
110
|
+
// a missing Origin is not sufficient. Token-authenticated CLI requests
|
|
111
|
+
// without cookies remain origin-free, as do tokenless loopback CLI
|
|
112
|
+
// requests; a supplied Origin on the tokenless runtime must still match.
|
|
113
|
+
const cookieAuthenticated = authMode === 'cookie' || cookieSessionOk(req);
|
|
114
|
+
const originRequired = cookieAuthenticated || (!token && Boolean(req.headers.origin));
|
|
115
|
+
if (!safeMethod && originRequired && !requestOriginMatches(req, { trustProxy, proxyOrigin })) {
|
|
116
|
+
sendJson(res, 403, { ok: false, error: { code: 'CSRF_ORIGIN', message: 'Cookie-authenticated writes require a same-origin request' } });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (req.method === 'POST' && url.pathname === '/logout') {
|
|
120
|
+
const sid = parseCookieSid(req);
|
|
121
|
+
if (sid) closeWebSession(sid, 'logged out');
|
|
122
|
+
res.writeHead(204, { 'Set-Cookie': expiredSessionCookieHeader(req) });
|
|
123
|
+
res.end();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const reqCtx = projectFromRequest(req, url);
|
|
127
|
+
if (req.method === 'GET' && url.pathname === '/api/projects') {
|
|
128
|
+
sendJson(res, 200, { projects: knownProjects(), current: projectRecord(reqCtx) });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (req.method === 'POST' && url.pathname === '/api/projects') {
|
|
132
|
+
const input = await readJsonRequest(req);
|
|
133
|
+
const requestedRoot = input.root || reqCtx.root;
|
|
134
|
+
const projectCtx = rememberProject(resolveWebProjectContext(
|
|
135
|
+
requestedRoot,
|
|
136
|
+
input.db || path.join(requestedRoot, '.hello-cc', 'mesh.db')
|
|
137
|
+
), { register: true, nonblocking: true });
|
|
138
|
+
const db = connectWebProject(projectCtx);
|
|
139
|
+
db.close();
|
|
140
|
+
writeRuntime(projectCtx, {
|
|
141
|
+
product: PRODUCT_NAME,
|
|
142
|
+
version: VERSION,
|
|
143
|
+
api_version: API_VERSION,
|
|
144
|
+
pid: process.pid,
|
|
145
|
+
...(getProcessIdentity() ? { process_identity: getProcessIdentity() } : {}),
|
|
146
|
+
root: projectCtx.root,
|
|
147
|
+
db: projectCtx.dbPath,
|
|
148
|
+
host,
|
|
149
|
+
port: getActualPort(),
|
|
150
|
+
base_url: runtimeBaseUrl(host, getActualPort(), useTls),
|
|
151
|
+
token,
|
|
152
|
+
tls: useTls,
|
|
153
|
+
trust_proxy: trustProxy,
|
|
154
|
+
proxy_origin: proxyOrigin,
|
|
155
|
+
tls_cert: useTls ? tlsCredentials.cert : undefined,
|
|
156
|
+
global_runtime: true,
|
|
157
|
+
started_at: now()
|
|
158
|
+
});
|
|
159
|
+
sendJson(res, 200, { project: projectRecord(projectCtx), projects: knownProjects() });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (req.method === 'GET' && url.pathname === '/api/state') {
|
|
163
|
+
const resources = normalizeStateResources([
|
|
164
|
+
...url.searchParams.getAll('resource'),
|
|
165
|
+
url.searchParams.get('resources') || ''
|
|
166
|
+
]);
|
|
167
|
+
sendJson(res, 200, webStatusSnapshot(reqCtx, url.searchParams.get('peer'), {
|
|
168
|
+
resources,
|
|
169
|
+
intent: url.searchParams.get('intent') || null,
|
|
170
|
+
scope: url.searchParams.get('scope') || null
|
|
171
|
+
}));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const peerActionMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/actions\/([^/]+)$/);
|
|
175
|
+
if (peerActionMatch) {
|
|
176
|
+
const peer = decodeURIComponent(peerActionMatch[1]);
|
|
177
|
+
const action = decodeURIComponent(peerActionMatch[2]);
|
|
178
|
+
const readOnly = ['status', 'state', 'inbox'].includes(action);
|
|
179
|
+
if (readOnly && req.method !== 'GET') {
|
|
180
|
+
sendJson(res, 405, { ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'Use GET for read-only peer actions' } });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (!readOnly && req.method !== 'POST') {
|
|
184
|
+
sendJson(res, 405, { ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'Use POST for mutating peer actions' } });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const input = readOnly
|
|
188
|
+
? {
|
|
189
|
+
...Object.fromEntries(url.searchParams.entries()),
|
|
190
|
+
resource: url.searchParams.getAll('resource')
|
|
191
|
+
}
|
|
192
|
+
: await readJsonRequest(req);
|
|
193
|
+
const actionInput = readOnly
|
|
194
|
+
? input
|
|
195
|
+
: { ...input, actorPeer: resolveWebActionSession(reqCtx, peer, input, req) };
|
|
196
|
+
sendJson(res, 200, webPeerActionForProject(reqCtx, peer, action, actionInput));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// Detected sessions: peers registered via hooks/watcher but without PTY
|
|
200
|
+
if (req.method === 'GET' && url.pathname === '/api/detected') {
|
|
201
|
+
const db = connectWebProject(reqCtx);
|
|
202
|
+
let detected = [];
|
|
203
|
+
const managedIds = new Set();
|
|
204
|
+
const t = now();
|
|
205
|
+
try {
|
|
206
|
+
detected = db.prepare(`
|
|
207
|
+
SELECT id, kind, role, status, worktree, branch, pid, capabilities,
|
|
208
|
+
created_at, last_seen_at, (? - last_seen_at) AS age_sec
|
|
209
|
+
FROM peers
|
|
210
|
+
WHERE status != 'exited' AND last_seen_at >= ?
|
|
211
|
+
ORDER BY last_seen_at DESC, id ASC
|
|
212
|
+
LIMIT 100
|
|
213
|
+
`).all(t, t - DETECTED_PEER_MAX_AGE);
|
|
214
|
+
for (const session of sessionsForProject(reqCtx)) {
|
|
215
|
+
managedIds.add(session.id);
|
|
216
|
+
const peerId = resolveSessionPeerId(db, session);
|
|
217
|
+
if (peerId) managedIds.add(peerId);
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
db.close();
|
|
221
|
+
}
|
|
222
|
+
// Exclude peers that are already in the managed sessions Map
|
|
223
|
+
sendJson(res, 200, {
|
|
224
|
+
now: t,
|
|
225
|
+
active_peer_ttl: ACTIVE_PEER_TTL,
|
|
226
|
+
detected: detected.filter(p => !managedIds.has(p.id))
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (req.method === 'GET' && url.pathname === '/api/resumable') {
|
|
231
|
+
// Provider sessions hcc has seen (via hooks/detection) that carry a real
|
|
232
|
+
// provider session id or resumable provider session name.
|
|
233
|
+
const db = connectWebProject(reqCtx);
|
|
234
|
+
let rows = [];
|
|
235
|
+
try {
|
|
236
|
+
rows = db.prepare(`
|
|
237
|
+
SELECT b.provider, b.provider_session_id, b.provider_session_name, b.peer,
|
|
238
|
+
p.last_seen_at
|
|
239
|
+
FROM peer_bindings b
|
|
240
|
+
LEFT JOIN peers p ON p.id = b.peer
|
|
241
|
+
WHERE (b.provider_session_id IS NOT NULL AND b.provider_session_id != '')
|
|
242
|
+
OR (b.provider_session_name IS NOT NULL AND b.provider_session_name != '')
|
|
243
|
+
ORDER BY p.last_seen_at DESC, b.updated_at DESC
|
|
244
|
+
`).all();
|
|
245
|
+
} finally {
|
|
246
|
+
db.close();
|
|
247
|
+
}
|
|
248
|
+
const seen = new Set();
|
|
249
|
+
const resumable = [];
|
|
250
|
+
for (const r of rows) {
|
|
251
|
+
const resumeValue = r.provider_session_id || r.provider_session_name || '';
|
|
252
|
+
if (!resumeValue) continue;
|
|
253
|
+
const key = `${r.provider}:${resumeValue}`;
|
|
254
|
+
if (seen.has(key)) continue;
|
|
255
|
+
seen.add(key);
|
|
256
|
+
resumable.push({
|
|
257
|
+
provider: r.provider,
|
|
258
|
+
session_id: r.provider_session_id,
|
|
259
|
+
session_name: r.provider_session_name || null,
|
|
260
|
+
resume: resumeValue,
|
|
261
|
+
name: r.provider_session_name || null,
|
|
262
|
+
peer: r.peer
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
sendJson(res, 200, { resumable });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (req.method === 'GET' && url.pathname === '/api/runtime') {
|
|
269
|
+
sendJson(res, 200, {
|
|
270
|
+
product: PRODUCT_NAME,
|
|
271
|
+
version: VERSION,
|
|
272
|
+
api_version: API_VERSION,
|
|
273
|
+
pid: process.pid,
|
|
274
|
+
...(getProcessIdentity() ? { process_identity: getProcessIdentity() } : {}),
|
|
275
|
+
root: reqCtx.root,
|
|
276
|
+
db: reqCtx.dbPath,
|
|
277
|
+
projects: knownProjects(),
|
|
278
|
+
sessions: sessionsForProject(reqCtx).length
|
|
279
|
+
});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (req.method === 'POST' && url.pathname === '/api/runtime/gc-buffers') {
|
|
283
|
+
const input = await readJsonRequest(req);
|
|
284
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
285
|
+
throw new CliError('BAD_REQUEST', 'buffer GC request must be an object');
|
|
286
|
+
}
|
|
287
|
+
if (input.phase === 'prepare') {
|
|
288
|
+
const allowed = new Set(['phase', 'retentionSec', 'dryRun']);
|
|
289
|
+
if (Object.keys(input).some((key) => !allowed.has(key))) {
|
|
290
|
+
throw new CliError('BAD_REQUEST', 'buffer GC prepare contains unsupported fields');
|
|
291
|
+
}
|
|
292
|
+
if (typeof input.dryRun !== 'boolean') {
|
|
293
|
+
throw new CliError('BAD_REQUEST', 'dryRun must be a boolean');
|
|
294
|
+
}
|
|
295
|
+
if (!Number.isSafeInteger(input.retentionSec) || input.retentionSec < 0 ||
|
|
296
|
+
Object.is(input.retentionSec, -0) ||
|
|
297
|
+
!Number.isSafeInteger(input.retentionSec * 1000)) {
|
|
298
|
+
throw new CliError('BAD_REQUEST', 'retentionSec must be a canonical non-negative safe integer');
|
|
299
|
+
}
|
|
300
|
+
sendJson(res, 200, prepareRuntimeBufferGc(reqCtx, input));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (input.phase === 'apply') {
|
|
304
|
+
const allowed = new Set(['phase', 'token']);
|
|
305
|
+
if (Object.keys(input).some((key) => !allowed.has(key)) ||
|
|
306
|
+
typeof input.token !== 'string' || input.token.length === 0) {
|
|
307
|
+
throw new CliError('BAD_REQUEST', 'buffer GC apply requires only its token');
|
|
308
|
+
}
|
|
309
|
+
sendJson(res, 200, applyPreparedRuntimeBufferGc(reqCtx, input.token));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
throw new CliError('BAD_REQUEST', 'buffer GC phase must be prepare or apply');
|
|
313
|
+
}
|
|
314
|
+
if (req.method === 'POST' && url.pathname === '/api/runtime/stop') {
|
|
315
|
+
sendJson(res, 200, { ok: true, pid: process.pid });
|
|
316
|
+
setTimeout(shutdown, 10);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (req.method === 'GET' && url.pathname === '/api/sessions') {
|
|
320
|
+
const db = connectWebProject(reqCtx);
|
|
321
|
+
try {
|
|
322
|
+
sendJson(res, 200, {
|
|
323
|
+
sessions: sessionsForProject(reqCtx).map((session) => serializeSession(session, db))
|
|
324
|
+
});
|
|
325
|
+
} finally {
|
|
326
|
+
db.close();
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (req.method === 'POST' && url.pathname === '/api/sessions') {
|
|
331
|
+
const input = await readJsonRequest(req);
|
|
332
|
+
const session = startSession({ ...input, projectCtx: reqCtx, auditActorPeer: 'web', auditSource: 'web' });
|
|
333
|
+
sendJson(res, 200, { session: serializeSession(session) });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (req.method === 'POST' && url.pathname === '/api/sessions/attach') {
|
|
337
|
+
const input = await readJsonRequest(req);
|
|
338
|
+
const session = attachTmuxSession({ ...input, projectCtx: reqCtx, auditActorPeer: 'web', auditSource: 'web' });
|
|
339
|
+
sendJson(res, 200, { session: serializeSession(session) });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const inputMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/input$/);
|
|
343
|
+
if (req.method === 'POST' && inputMatch) {
|
|
344
|
+
// This HTTP input route is the admin injection path used by the CLI
|
|
345
|
+
// (hcc inject / ask / broadcast). It is gated by the runtime token
|
|
346
|
+
// (authOk above) and intentionally does NOT require the per-session
|
|
347
|
+
// action token — the browser types via the WebSocket input frame
|
|
348
|
+
// (which DOES require it), and the CLI cannot obtain the action token.
|
|
349
|
+
const id = decodeURIComponent(inputMatch[1]);
|
|
350
|
+
const lookupDb = connectWebProject(reqCtx);
|
|
351
|
+
let session;
|
|
352
|
+
try {
|
|
353
|
+
session = getSession(reqCtx, id, lookupDb);
|
|
354
|
+
} finally {
|
|
355
|
+
lookupDb.close();
|
|
356
|
+
}
|
|
357
|
+
if (!session) {
|
|
358
|
+
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Session not found' } });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (session.status !== 'running') {
|
|
362
|
+
sendJson(res, 409, { ok: false, error: { code: 'SESSION_NOT_RUNNING', message: 'Session is not running' } });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const input = await readJsonRequest(req);
|
|
366
|
+
const text = String(input.text ?? input.data ?? '');
|
|
367
|
+
const data = input.data !== undefined ? String(input.data) : `${text}${input.enter === false ? '' : '\r'}`;
|
|
368
|
+
writeSessionInput(session, data);
|
|
369
|
+
const db = connectWebProject(session.ctx || reqCtx);
|
|
370
|
+
try {
|
|
371
|
+
addEvent(db, 'web.session.input', 'web', null, auditPayload({
|
|
372
|
+
actor: 'web',
|
|
373
|
+
target: session.peerId || id,
|
|
374
|
+
source: 'web',
|
|
375
|
+
admin: true,
|
|
376
|
+
peer: session.peerId || id,
|
|
377
|
+
runtime_session_id: session.id,
|
|
378
|
+
bytes: data.length,
|
|
379
|
+
enter: input.enter !== false
|
|
380
|
+
}));
|
|
381
|
+
} finally {
|
|
382
|
+
db.close();
|
|
383
|
+
}
|
|
384
|
+
sendJson(res, 200, { session: serializeSession(session), bytes: data.length });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const stopMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/stop$/);
|
|
388
|
+
if (req.method === 'POST' && stopMatch) {
|
|
389
|
+
const id = decodeURIComponent(stopMatch[1]);
|
|
390
|
+
let stopInput = {};
|
|
391
|
+
try { stopInput = await readJsonRequest(req); } catch {}
|
|
392
|
+
const lookupDb = connectWebProject(reqCtx);
|
|
393
|
+
let session;
|
|
394
|
+
try {
|
|
395
|
+
session = getSession(reqCtx, id, lookupDb);
|
|
396
|
+
} finally {
|
|
397
|
+
lookupDb.close();
|
|
398
|
+
}
|
|
399
|
+
if (!session) {
|
|
400
|
+
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Session not found' } });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (session.status === 'running') {
|
|
404
|
+
if (session.type === 'external') {
|
|
405
|
+
// Stop the hcc run wrapper first so it can kill the PTY and clean
|
|
406
|
+
// buffer files; fall back to the child pid for older metadata.
|
|
407
|
+
if (session.wrapperPid) { try { process.kill(session.wrapperPid, 'SIGTERM'); } catch {} }
|
|
408
|
+
if (session.pid && session.pid !== session.wrapperPid) { try { process.kill(session.pid, 'SIGTERM'); } catch {} }
|
|
409
|
+
} else if (session.type === 'tmux') {
|
|
410
|
+
let killPlan = null;
|
|
411
|
+
const stopDb = connectWebProject(reqCtx);
|
|
412
|
+
try {
|
|
413
|
+
const peerId = resolveSessionPeerId(stopDb, session) || session.peerId || session.id;
|
|
414
|
+
if (stopInput.kill_tmux) {
|
|
415
|
+
killPlan = safeTmuxKillPlan(reqCtx, stopDb, peerId, session.pane || null);
|
|
416
|
+
}
|
|
417
|
+
} finally {
|
|
418
|
+
stopDb.close();
|
|
419
|
+
}
|
|
420
|
+
if (killPlan) executeTmuxKillPlan(reqCtx, killPlan);
|
|
421
|
+
detachTmuxSession(session, 'detached');
|
|
422
|
+
} else {
|
|
423
|
+
session.pty.kill();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const eventDb = connectWebProject(reqCtx);
|
|
427
|
+
try {
|
|
428
|
+
const peerId = resolveSessionPeerId(eventDb, session) || session.peerId || id;
|
|
429
|
+
addEvent(eventDb, 'web.session.stop_requested', 'web', null, auditPayload({
|
|
430
|
+
actor: 'web',
|
|
431
|
+
target: peerId,
|
|
432
|
+
source: 'web',
|
|
433
|
+
admin: true,
|
|
434
|
+
peer: peerId,
|
|
435
|
+
runtime_session_id: session.id,
|
|
436
|
+
kill_tmux: Boolean(stopInput.kill_tmux)
|
|
437
|
+
}));
|
|
438
|
+
} finally {
|
|
439
|
+
eventDb.close();
|
|
440
|
+
}
|
|
441
|
+
sendJson(res, 200, { session: serializeSession(session) });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
// Send a message to a detected (non-managed) peer's inbox
|
|
445
|
+
const detectedMsgMatch = url.pathname.match(/^\/api\/detected\/([^/]+)\/msg$/);
|
|
446
|
+
if (req.method === 'POST' && detectedMsgMatch) {
|
|
447
|
+
const peerId = decodeURIComponent(detectedMsgMatch[1]);
|
|
448
|
+
const input = await readJsonRequest(req);
|
|
449
|
+
const body = String(input.body || '');
|
|
450
|
+
const sender = 'web';
|
|
451
|
+
const taskId = input.task ? Number(input.task) : null;
|
|
452
|
+
if (!body) { sendJson(res, 400, { ok: false, error: { code: 'BAD_REQUEST', message: 'body required' } }); return; }
|
|
453
|
+
const db = connectWebProject(reqCtx);
|
|
454
|
+
let msgId;
|
|
455
|
+
try {
|
|
456
|
+
msgId = sendMessage(db, sender, peerId, taskId, 'note', body);
|
|
457
|
+
} finally {
|
|
458
|
+
db.close();
|
|
459
|
+
}
|
|
460
|
+
sendJson(res, 200, { ok: true, id: msgId });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const detectedStopMatch = url.pathname.match(/^\/api\/detected\/([^/]+)\/stop$/);
|
|
464
|
+
if (req.method === 'POST' && detectedStopMatch) {
|
|
465
|
+
const peerId = decodeURIComponent(detectedStopMatch[1]);
|
|
466
|
+
let input = {};
|
|
467
|
+
try { input = await readJsonRequest(req); } catch {}
|
|
468
|
+
const db = connectWebProject(reqCtx);
|
|
469
|
+
try {
|
|
470
|
+
const now_ = now();
|
|
471
|
+
let killPlan = null;
|
|
472
|
+
if (input.kill_tmux) {
|
|
473
|
+
killPlan = killDbProvenTmuxSession(reqCtx, db, peerId);
|
|
474
|
+
}
|
|
475
|
+
// Preserve last_seen_at on death so the just-stopped owner is not
|
|
476
|
+
// misread as freshly active (hb-01); status='exited' drives liveness.
|
|
477
|
+
db.prepare('UPDATE peers SET status = ? WHERE id = ?').run('exited', peerId);
|
|
478
|
+
db.prepare('UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer = ?').run(now_, peerId);
|
|
479
|
+
addEvent(db, 'peer.stopped', 'web', null, auditPayload({
|
|
480
|
+
actor: 'web',
|
|
481
|
+
target: peerId,
|
|
482
|
+
source: 'web',
|
|
483
|
+
admin: true,
|
|
484
|
+
peer: peerId,
|
|
485
|
+
kill_tmux: Boolean(killPlan),
|
|
486
|
+
tmux_session: killPlan?.session || null,
|
|
487
|
+
runtime_target: killPlan?.runtime_target || null
|
|
488
|
+
}));
|
|
489
|
+
} finally {
|
|
490
|
+
db.close();
|
|
491
|
+
}
|
|
492
|
+
sendJson(res, 200, { ok: true, peer: peerId, status: 'exited' });
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const detectedRestartMatch = url.pathname.match(/^\/api\/detected\/([^/]+)\/restart$/);
|
|
496
|
+
if (req.method === 'POST' && detectedRestartMatch) {
|
|
497
|
+
const peerId = decodeURIComponent(detectedRestartMatch[1]);
|
|
498
|
+
const db = connectWebProject(reqCtx);
|
|
499
|
+
try {
|
|
500
|
+
// v1-detected-restart: only flip a real detected peer that has a live
|
|
501
|
+
// process or is not explicitly exited. Do not bump last_seen_at
|
|
502
|
+
// (the peer did not actually heartbeat) so the reaper's age filter
|
|
503
|
+
// still applies and a phantom cannot persist indefinitely.
|
|
504
|
+
const peer = db.prepare('SELECT id, pid, status FROM peers WHERE id = ?').get(peerId);
|
|
505
|
+
if (!peer || peer.status === 'exited') {
|
|
506
|
+
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: `No live detected peer for ${peerId}` } });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
db.prepare('UPDATE peers SET status = ? WHERE id = ?').run('running', peerId);
|
|
510
|
+
addEvent(db, 'peer.restarted', 'web', null, auditPayload({
|
|
511
|
+
actor: 'web',
|
|
512
|
+
target: peerId,
|
|
513
|
+
source: 'web',
|
|
514
|
+
admin: true,
|
|
515
|
+
peer: peerId
|
|
516
|
+
}));
|
|
517
|
+
} finally {
|
|
518
|
+
db.close();
|
|
519
|
+
}
|
|
520
|
+
sendJson(res, 200, { ok: true, peer: peerId, status: 'running' });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Route not found' } });
|
|
524
|
+
} catch (err) {
|
|
525
|
+
const publicFailure = publicCliFailure(err);
|
|
526
|
+
const publicError = publicFailure?.error || err;
|
|
527
|
+
const detail = publicFailure || process.env.HCC_DEBUG
|
|
528
|
+
? publicError.message
|
|
529
|
+
: 'internal server error';
|
|
530
|
+
sendJson(res, webErrorStatus(publicError), {
|
|
531
|
+
ok: false,
|
|
532
|
+
error: {
|
|
533
|
+
code: publicError.code || 'SERVER_ERROR',
|
|
534
|
+
message: detail,
|
|
535
|
+
...(publicFailure?.cleanupFailed ? { cleanup_failed: true } : {})
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
return { handleWebRequest };
|
|
542
|
+
}
|