@logicseek/hello-cc 1.0.1 → 1.0.2
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 +105 -0
- package/README.md +4 -0
- package/README.zh-CN.md +4 -0
- package/docs/README.md +9 -0
- package/docs/README.zh-CN.md +9 -0
- package/docs/commands.md +53 -0
- package/docs/commands.zh-CN.md +48 -0
- package/docs/dsh.md +137 -0
- package/docs/dsh.zh-CN.md +139 -0
- package/docs/mcp.md +9 -0
- package/docs/mcp.zh-CN.md +23 -0
- package/docs/native.md +295 -0
- package/docs/native.zh-CN.md +253 -0
- package/docs/web-handoff.zh-CN.md +53 -0
- package/lib/cli/app.mjs +25 -6
- package/lib/cli/commands/doctor.mjs +17 -2
- package/lib/cli/commands/dsh.mjs +79 -0
- package/lib/cli/commands/hook.mjs +241 -90
- package/lib/cli/commands/lock.mjs +34 -19
- package/lib/cli/commands/mcp.mjs +28 -0
- package/lib/cli/commands/native.mjs +130 -0
- package/lib/cli/commands/run.mjs +66 -21
- package/lib/cli/commands/task.mjs +6 -3
- package/lib/cli-args.mjs +4 -0
- package/lib/coordination-state.mjs +15 -1
- package/lib/core/coordination/event-retention.mjs +14 -2
- package/lib/core/coordination/gc-plan.mjs +3 -13
- package/lib/core/coordination/locks.mjs +45 -0
- package/lib/core/coordination/results.mjs +79 -0
- package/lib/core/peers/bindings.mjs +9 -3
- package/lib/diagnostics/codex.mjs +121 -0
- package/lib/integrations/codex-interactions.mjs +114 -0
- package/lib/integrations/dsh-collaboration.mjs +170 -0
- package/lib/integrations/dsh-cordis.d.ts +19 -0
- package/lib/integrations/dsh-cordis.mjs +177 -0
- package/lib/integrations/dsh.bundle.yml +7 -0
- package/lib/integrations/dsh.mjs +287 -0
- package/lib/integrations/native/claude.mjs +451 -0
- package/lib/integrations/native/codex.mjs +242 -0
- package/lib/integrations/native/dsh-acp.mjs +343 -0
- package/lib/integrations/native/index.mjs +19 -0
- package/lib/integrations/native/interactions.mjs +53 -0
- package/lib/integrations/native/jsonrpc.mjs +185 -0
- package/lib/integrations/peers/identity.mjs +36 -10
- package/lib/integrations/peers/processes.mjs +22 -8
- package/lib/integrations/providers.mjs +6 -2
- package/lib/mcp/scope.mjs +104 -0
- package/lib/mcp/stdio.mjs +80 -0
- package/lib/mcp/tools.mjs +197 -0
- package/lib/process/identity.mjs +3 -1
- package/lib/release/release-notes.mjs +9 -0
- package/lib/runtime/native/client.mjs +38 -0
- package/lib/runtime/native/service.mjs +511 -0
- package/lib/runtime/native/store.mjs +121 -0
- package/lib/ui/help.mjs +5 -2
- package/lib/web/adoption-state.mjs +55 -0
- package/lib/web/auto-attach.mjs +18 -2
- package/lib/web/codex-app-server.mjs +557 -0
- package/lib/web/codex-sessions.mjs +287 -0
- package/lib/web/control-lease.mjs +204 -0
- package/lib/web/http-routes.mjs +197 -15
- package/lib/web/http.mjs +5 -4
- package/lib/web/native-sessions.mjs +286 -0
- package/lib/web/project-contexts.mjs +2 -1
- package/lib/web/pty-sessions.mjs +60 -33
- package/lib/web/runtime-main.mjs +98 -6
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/task-results.mjs +76 -0
- package/lib/web/tmux-sessions.mjs +92 -11
- package/lib/web/tmux-stream.mjs +2 -2
- package/lib/web/ui-codex.mjs +550 -0
- package/lib/web/ui-command-palette.mjs +119 -0
- package/lib/web/ui-handoff.mjs +102 -0
- package/lib/web/ui-history.mjs +118 -0
- package/lib/web/ui-i18n.mjs +841 -0
- package/lib/web/ui-interactions.mjs +78 -0
- package/lib/web/ui-login.mjs +101 -0
- package/lib/web/ui-native.mjs +165 -0
- package/lib/web/ui-preferences.mjs +278 -0
- package/lib/web/ui-review.mjs +132 -0
- package/lib/web/ui-template.mjs +1106 -576
- package/lib/web/ui-terminal-find.mjs +98 -0
- package/lib/web/ui-workbench.mjs +94 -0
- package/lib/web/ui-workspace.mjs +222 -0
- package/package.json +11 -3
- package/scripts/check-free-identifiers.mjs +67 -8
- package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
- package/scripts/dsh-cordis-acceptance.mjs +200 -0
- package/scripts/dsh-installed-acceptance.mjs +247 -0
- package/scripts/dsh-official-acceptance.mjs +180 -0
- package/scripts/dsh-web-startup-acceptance.mjs +87 -0
- package/scripts/github-release.mjs +3 -1
- package/scripts/native-live-acceptance.mjs +369 -0
- package/scripts/regression.mjs +243 -89
- package/scripts/web-handoff-installed-acceptance.mjs +98 -0
- package/scripts/web-native-interaction-acceptance.mjs +204 -0
- package/scripts/web-native-model-acceptance.mjs +138 -0
package/lib/web/http-routes.mjs
CHANGED
|
@@ -14,6 +14,9 @@ import { normalizeStateResources } from '../ui/state-render.mjs';
|
|
|
14
14
|
import { requestUrl, runtimeBaseUrl } from '../web/runtime.mjs';
|
|
15
15
|
import { authOk, readJsonRequest, requestMatchesProxyOrigin, requestOriginMatches, sendFile, sendJson, tokenMatches } from '../web/http.mjs';
|
|
16
16
|
import { API_VERSION, apiVersionUnsupportedBody, readHttpApiVersion } from '../web/api-version.mjs';
|
|
17
|
+
import { isDshHookBinding } from '../core/peers/bindings.mjs';
|
|
18
|
+
import { writeAdoptionState } from './adoption-state.mjs';
|
|
19
|
+
import { CodexAppServerError } from './codex-app-server.mjs';
|
|
17
20
|
|
|
18
21
|
export function createHttpRoutes(deps) {
|
|
19
22
|
const {
|
|
@@ -27,6 +30,9 @@ export function createHttpRoutes(deps) {
|
|
|
27
30
|
prepareRuntimeBufferGc, applyPreparedRuntimeBufferGc,
|
|
28
31
|
detachTmuxSession, safeTmuxKillPlan, executeTmuxKillPlan,
|
|
29
32
|
killDbProvenTmuxSession, attachTmuxSession, writeSessionInput, startSession,
|
|
33
|
+
startCodexSession, codexAction, stopCodexSession, assertWebWrite,
|
|
34
|
+
listCodexThreads, readCodexThread, forkCodexThread, readSessionResults, writeSessionResult,
|
|
35
|
+
discoverNativeSessions, nativeAction,
|
|
30
36
|
webStatusSnapshot, webPeerActionForProject,
|
|
31
37
|
now, addEvent, auditPayload, sendMessage,
|
|
32
38
|
getProcessIdentity, getActualPort,
|
|
@@ -35,6 +41,12 @@ export function createHttpRoutes(deps) {
|
|
|
35
41
|
ACTIVE_PEER_TTL, DETECTED_PEER_MAX_AGE, PRODUCT_NAME, VERSION
|
|
36
42
|
} = deps;
|
|
37
43
|
|
|
44
|
+
function ownsExecutor(session) {
|
|
45
|
+
return session.status === 'running' || (session.type === 'app-server' &&
|
|
46
|
+
!session.executorReleased && !session.adapter.snapshot().processExited) ||
|
|
47
|
+
(session.type === 'native' && !session.nativeRetired && session.status !== 'exited');
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
const handleWebRequest = async (req, res) => {
|
|
39
51
|
const url = requestUrl(req);
|
|
40
52
|
try {
|
|
@@ -54,7 +66,7 @@ export function createHttpRoutes(deps) {
|
|
|
54
66
|
}
|
|
55
67
|
const sid = issueSession();
|
|
56
68
|
const params = new URLSearchParams();
|
|
57
|
-
for (const key of ['project', 'root']) {
|
|
69
|
+
for (const key of ['project', 'root', 'session']) {
|
|
58
70
|
const value = url.searchParams.get(key);
|
|
59
71
|
if (value) params.set(key, value);
|
|
60
72
|
}
|
|
@@ -80,6 +92,14 @@ export function createHttpRoutes(deps) {
|
|
|
80
92
|
sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'xterm', 'css', 'xterm.css'), 'text/css; charset=utf-8');
|
|
81
93
|
return;
|
|
82
94
|
}
|
|
95
|
+
if (req.method === 'GET' && url.pathname === '/assets/addon-fit.js') {
|
|
96
|
+
sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'addon-fit', 'lib', 'addon-fit.js'), 'application/javascript; charset=utf-8');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (req.method === 'GET' && url.pathname === '/assets/addon-search.js') {
|
|
100
|
+
sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'addon-search', 'lib', 'addon-search.js'), 'application/javascript; charset=utf-8');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
83
103
|
if (req.method === 'POST' && url.pathname === '/login') {
|
|
84
104
|
const input = await readJsonRequest(req);
|
|
85
105
|
const loginToken = String(input.token || '');
|
|
@@ -105,6 +125,16 @@ export function createHttpRoutes(deps) {
|
|
|
105
125
|
sendJson(res, 401, { ok: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' } });
|
|
106
126
|
return;
|
|
107
127
|
}
|
|
128
|
+
if (req.method === 'GET' && url.pathname === '/pane') {
|
|
129
|
+
// Only an existing browser session may create a workspace pane. Never
|
|
130
|
+
// exchange a query token here or make the login page embeddable.
|
|
131
|
+
if (!cookieSessionOk(req)) {
|
|
132
|
+
sendJson(res, 401, { ok: false, error: { code: 'UNAUTHORIZED', message: 'A browser session is required for a workspace pane' } });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
sendWebHtml(res, renderWebIndex, { pane: true });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
108
138
|
const safeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(req.method || '');
|
|
109
139
|
// Cookie-authenticated writes require affirmative same-origin evidence;
|
|
110
140
|
// a missing Origin is not sufficient. Token-authenticated CLI requests
|
|
@@ -190,6 +220,10 @@ export function createHttpRoutes(deps) {
|
|
|
190
220
|
resource: url.searchParams.getAll('resource')
|
|
191
221
|
}
|
|
192
222
|
: await readJsonRequest(req);
|
|
223
|
+
if (!readOnly && nativeAction) {
|
|
224
|
+
const nativeSession = sessionsForProject(reqCtx).find(s => s.type === 'native' && [s.id, s.peerId].includes(peer));
|
|
225
|
+
if (nativeSession) await nativeAction(nativeSession, 'read');
|
|
226
|
+
}
|
|
193
227
|
const actionInput = readOnly
|
|
194
228
|
? input
|
|
195
229
|
: { ...input, actorPeer: resolveWebActionSession(reqCtx, peer, input, req) };
|
|
@@ -198,17 +232,20 @@ export function createHttpRoutes(deps) {
|
|
|
198
232
|
}
|
|
199
233
|
// Detected sessions: peers registered via hooks/watcher but without PTY
|
|
200
234
|
if (req.method === 'GET' && url.pathname === '/api/detected') {
|
|
235
|
+
await discoverNativeSessions?.(reqCtx);
|
|
201
236
|
const db = connectWebProject(reqCtx);
|
|
202
237
|
let detected = [];
|
|
203
238
|
const managedIds = new Set();
|
|
204
239
|
const t = now();
|
|
205
240
|
try {
|
|
206
241
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
242
|
+
SELECT p.id, p.kind, p.role, p.status, p.worktree, p.branch, p.pid, p.capabilities,
|
|
243
|
+
p.created_at, p.last_seen_at, (? - p.last_seen_at) AS age_sec,
|
|
244
|
+
b.provider, b.transport, b.provider_session_id, b.provider_session_name
|
|
245
|
+
FROM peers p
|
|
246
|
+
LEFT JOIN peer_bindings b ON b.peer = p.id
|
|
247
|
+
WHERE p.status != 'exited' AND p.last_seen_at >= ?
|
|
248
|
+
ORDER BY p.last_seen_at DESC, p.id ASC
|
|
212
249
|
LIMIT 100
|
|
213
250
|
`).all(t, t - DETECTED_PEER_MAX_AGE);
|
|
214
251
|
for (const session of sessionsForProject(reqCtx)) {
|
|
@@ -312,11 +349,13 @@ export function createHttpRoutes(deps) {
|
|
|
312
349
|
throw new CliError('BAD_REQUEST', 'buffer GC phase must be prepare or apply');
|
|
313
350
|
}
|
|
314
351
|
if (req.method === 'POST' && url.pathname === '/api/runtime/stop') {
|
|
352
|
+
if (cookieAuthenticated) throw new CliError('RUNTIME_ADMIN_REQUIRED', 'Stop the runtime with the local administrator CLI');
|
|
315
353
|
sendJson(res, 200, { ok: true, pid: process.pid });
|
|
316
354
|
setTimeout(shutdown, 10);
|
|
317
355
|
return;
|
|
318
356
|
}
|
|
319
357
|
if (req.method === 'GET' && url.pathname === '/api/sessions') {
|
|
358
|
+
await discoverNativeSessions?.(reqCtx);
|
|
320
359
|
const db = connectWebProject(reqCtx);
|
|
321
360
|
try {
|
|
322
361
|
sendJson(res, 200, {
|
|
@@ -329,23 +368,131 @@ export function createHttpRoutes(deps) {
|
|
|
329
368
|
}
|
|
330
369
|
if (req.method === 'POST' && url.pathname === '/api/sessions') {
|
|
331
370
|
const input = await readJsonRequest(req);
|
|
332
|
-
const
|
|
371
|
+
const existing = input.id && sessionsForProject(reqCtx).find(s => s.id === input.id || s.peerId === input.id);
|
|
372
|
+
if (existing) assertWebWrite?.(existing, input, authMode, req);
|
|
373
|
+
const start = input.transport === 'app-server' || input.backend === 'app-server' ? startCodexSession : startSession;
|
|
374
|
+
const session = await start({ ...input, projectCtx: reqCtx, auditActorPeer: 'web', auditSource: 'web',
|
|
375
|
+
authorizeMutation: existing => assertWebWrite?.(existing, input, authMode, req) });
|
|
333
376
|
sendJson(res, 200, { session: serializeSession(session) });
|
|
334
377
|
return;
|
|
335
378
|
}
|
|
336
379
|
if (req.method === 'POST' && url.pathname === '/api/sessions/attach') {
|
|
337
380
|
const input = await readJsonRequest(req);
|
|
338
|
-
const
|
|
381
|
+
const bindingDb = connectWebProject(reqCtx);
|
|
382
|
+
let binding;
|
|
383
|
+
try {
|
|
384
|
+
binding = input.id ? bindingDb.prepare('SELECT provider, transport FROM peer_bindings WHERE peer = ?').get(input.id) : null;
|
|
385
|
+
} finally {
|
|
386
|
+
bindingDb.close();
|
|
387
|
+
}
|
|
388
|
+
if (binding?.transport === 'native') throw new CliError('NATIVE_MANAGED_SESSION', 'Open the existing native worker instead of attaching a terminal');
|
|
389
|
+
if (isDshHookBinding(binding) || input.kind === 'dsh' || input.binding?.provider === 'dsh') {
|
|
390
|
+
sendJson(res, 409, { ok: false, error: { code: 'DSH_SESSION_CONTROL_UNSUPPORTED', message: 'Control this agent in the dsh Web UI. A dsh service pane cannot represent an individual agent session.' } });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
for (const existing of sessionsForProject(reqCtx)) {
|
|
394
|
+
if ((input.id && [existing.id, existing.peerId].includes(input.id)) ||
|
|
395
|
+
(input.pane && existing.pane === input.pane)) assertWebWrite?.(existing, input, authMode, req);
|
|
396
|
+
}
|
|
397
|
+
const session = attachTmuxSession({ ...input, projectCtx: reqCtx, auditActorPeer: 'web', auditSource: 'web',
|
|
398
|
+
authorizeMutation: existing => assertWebWrite?.(existing, input, authMode, req) });
|
|
399
|
+
const adoptionDb = connectWebProject(reqCtx);
|
|
400
|
+
try { writeAdoptionState(adoptionDb, session, false, now()); } finally { adoptionDb.close(); }
|
|
339
401
|
sendJson(res, 200, { session: serializeSession(session) });
|
|
340
402
|
return;
|
|
341
403
|
}
|
|
404
|
+
const nativeMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/native\/([^/]+)$/);
|
|
405
|
+
if (nativeMatch) {
|
|
406
|
+
await discoverNativeSessions?.(reqCtx);
|
|
407
|
+
const db = connectWebProject(reqCtx);
|
|
408
|
+
let session;
|
|
409
|
+
try { session = getSession(reqCtx, decodeURIComponent(nativeMatch[1]), db); } finally { db.close(); }
|
|
410
|
+
if (!session || session.type !== 'native') throw new CliError('NOT_FOUND', 'Native worker not found');
|
|
411
|
+
const action = decodeURIComponent(nativeMatch[2]);
|
|
412
|
+
if (req.method === 'GET' && action === 'state') {
|
|
413
|
+
sendJson(res, 200, { state: await nativeAction(session, 'read') }); return;
|
|
414
|
+
}
|
|
415
|
+
if (req.method !== 'POST' || !['send', 'interrupt', 'close', 'respond'].includes(action)) throw new CliError('BAD_REQUEST', 'Unsupported native action');
|
|
416
|
+
const input = await readJsonRequest(req);
|
|
417
|
+
const authorizeMutation = () => assertWebWrite(session, input, 'cookie', req);
|
|
418
|
+
authorizeMutation();
|
|
419
|
+
const receipt = await nativeAction(session, action, { ...input, authorizeMutation });
|
|
420
|
+
sendJson(res, 200, { receipt, state: session.nativeSnapshot() }); return;
|
|
421
|
+
}
|
|
422
|
+
if (req.method === 'GET' && url.pathname === '/api/codex/threads') {
|
|
423
|
+
const limit = url.searchParams.has('limit') ? Number(url.searchParams.get('limit')) : 30;
|
|
424
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new CliError('BAD_REQUEST', 'limit must be from 1 to 100');
|
|
425
|
+
sendJson(res, 200, await listCodexThreads(reqCtx, { cursor: url.searchParams.get('cursor') || null, limit }));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const historyMatch = url.pathname.match(/^\/api\/codex\/threads\/([^/]+)(\/fork)?$/);
|
|
429
|
+
if (historyMatch) {
|
|
430
|
+
const threadId = decodeURIComponent(historyMatch[1]);
|
|
431
|
+
if (req.method === 'GET' && !historyMatch[2]) {
|
|
432
|
+
sendJson(res, 200, await readCodexThread(reqCtx, threadId)); return;
|
|
433
|
+
}
|
|
434
|
+
if (req.method !== 'POST' || !historyMatch[2]) throw new CliError('BAD_REQUEST', 'Unsupported history action');
|
|
435
|
+
const input = await readJsonRequest(req);
|
|
436
|
+
// Fork keeps the source task owner; it is still a mutation of history
|
|
437
|
+
// and must respect a source session's browser control when present.
|
|
438
|
+
for (const source of sessionsForProject(reqCtx)) {
|
|
439
|
+
if (source.binding?.provider === 'codex' && source.binding.provider_session_id === threadId && ownsExecutor(source)) {
|
|
440
|
+
assertWebWrite(source, input, 'cookie', req);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const authorizeMutation = () => {
|
|
444
|
+
for (const source of sessionsForProject(reqCtx)) {
|
|
445
|
+
if (source.binding?.provider === 'codex' && source.binding.provider_session_id === threadId && ownsExecutor(source)) {
|
|
446
|
+
assertWebWrite(source, input, 'cookie', req);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
const forked = await forkCodexThread(reqCtx, threadId, { ...input, authorizeMutation });
|
|
451
|
+
sendJson(res, 200, { session: serializeSession(forked.session), thread: forked.thread, source_thread_id: threadId }); return;
|
|
452
|
+
}
|
|
453
|
+
const resultsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/results$/);
|
|
454
|
+
if (resultsMatch) {
|
|
455
|
+
const db = connectWebProject(reqCtx);
|
|
456
|
+
let session;
|
|
457
|
+
try { session = getSession(reqCtx, decodeURIComponent(resultsMatch[1]), db); } finally { db.close(); }
|
|
458
|
+
if (!session) throw new CliError('NOT_FOUND', 'Session not found');
|
|
459
|
+
if (req.method === 'GET') { sendJson(res, 200, readSessionResults(session)); return; }
|
|
460
|
+
if (req.method !== 'POST') throw new CliError('BAD_REQUEST', 'Unsupported result action');
|
|
461
|
+
const input = await readJsonRequest(req);
|
|
462
|
+
assertWebWrite(session, input, 'cookie', req);
|
|
463
|
+
if (session.type === 'native') {
|
|
464
|
+
await nativeAction(session, 'read');
|
|
465
|
+
assertWebWrite(session, input, 'cookie', req);
|
|
466
|
+
}
|
|
467
|
+
sendJson(res, 200, writeSessionResult(session, input)); return;
|
|
468
|
+
}
|
|
469
|
+
const codexMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/codex\/([^/]+)$/);
|
|
470
|
+
if (codexMatch && ['GET', 'POST'].includes(req.method)) {
|
|
471
|
+
const id = decodeURIComponent(codexMatch[1]);
|
|
472
|
+
const action = decodeURIComponent(codexMatch[2]);
|
|
473
|
+
const db = connectWebProject(reqCtx);
|
|
474
|
+
let session;
|
|
475
|
+
try { session = getSession(reqCtx, id, db); } finally { db.close(); }
|
|
476
|
+
if (!session || session.type !== 'app-server') throw new CliError('NOT_FOUND', 'Codex App Server session not found');
|
|
477
|
+
if (req.method === 'GET' && action === 'state') {
|
|
478
|
+
sendJson(res, 200, { state: session.adapter.snapshot() }); return;
|
|
479
|
+
}
|
|
480
|
+
if (req.method === 'GET' && action === 'read') {
|
|
481
|
+
sendJson(res, 200, await codexAction(session, 'read', {})); return;
|
|
482
|
+
}
|
|
483
|
+
if (req.method !== 'POST' || !['turn', 'steer', 'interrupt', 'approve'].includes(action)) {
|
|
484
|
+
throw new CliError('BAD_REQUEST', 'Unsupported Codex action');
|
|
485
|
+
}
|
|
486
|
+
const input = await readJsonRequest(req);
|
|
487
|
+
// Structured turns and approvals always need a live browser controller,
|
|
488
|
+
// even when the caller also possesses the runtime administrator token.
|
|
489
|
+
assertWebWrite(session, input, 'cookie', req);
|
|
490
|
+
sendJson(res, 200, await codexAction(session, action, input)); return;
|
|
491
|
+
}
|
|
342
492
|
const inputMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/input$/);
|
|
343
493
|
if (req.method === 'POST' && inputMatch) {
|
|
344
|
-
//
|
|
345
|
-
//
|
|
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.
|
|
494
|
+
// CLI injection remains available without a browser controller. While
|
|
495
|
+
// Web owns a lease, all injection paths require that controller's epoch.
|
|
349
496
|
const id = decodeURIComponent(inputMatch[1]);
|
|
350
497
|
const lookupDb = connectWebProject(reqCtx);
|
|
351
498
|
let session;
|
|
@@ -363,6 +510,8 @@ export function createHttpRoutes(deps) {
|
|
|
363
510
|
return;
|
|
364
511
|
}
|
|
365
512
|
const input = await readJsonRequest(req);
|
|
513
|
+
assertWebWrite?.(session, input, authMode, req);
|
|
514
|
+
if (['app-server', 'native'].includes(session.type)) throw new CliError('BAD_REQUEST', 'Use structured worker actions');
|
|
366
515
|
const text = String(input.text ?? input.data ?? '');
|
|
367
516
|
const data = input.data !== undefined ? String(input.data) : `${text}${input.enter === false ? '' : '\r'}`;
|
|
368
517
|
writeSessionInput(session, data);
|
|
@@ -400,7 +549,13 @@ export function createHttpRoutes(deps) {
|
|
|
400
549
|
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Session not found' } });
|
|
401
550
|
return;
|
|
402
551
|
}
|
|
403
|
-
|
|
552
|
+
assertWebWrite?.(session, stopInput, authMode, req);
|
|
553
|
+
if (session.type === 'native') {
|
|
554
|
+
await nativeAction(session, 'close', { ...stopInput,
|
|
555
|
+
authorizeMutation: () => assertWebWrite(session, stopInput, 'cookie', req) });
|
|
556
|
+
} else if (session.type === 'app-server') {
|
|
557
|
+
await stopCodexSession(session);
|
|
558
|
+
} else if (session.status === 'running') {
|
|
404
559
|
if (session.type === 'external') {
|
|
405
560
|
// Stop the hcc run wrapper first so it can kill the PTY and clean
|
|
406
561
|
// buffer files; fall back to the child pid for older metadata.
|
|
@@ -414,6 +569,7 @@ export function createHttpRoutes(deps) {
|
|
|
414
569
|
if (stopInput.kill_tmux) {
|
|
415
570
|
killPlan = safeTmuxKillPlan(reqCtx, stopDb, peerId, session.pane || null);
|
|
416
571
|
}
|
|
572
|
+
if (!stopInput.kill_tmux) writeAdoptionState(stopDb, session, true, now());
|
|
417
573
|
} finally {
|
|
418
574
|
stopDb.close();
|
|
419
575
|
}
|
|
@@ -446,6 +602,10 @@ export function createHttpRoutes(deps) {
|
|
|
446
602
|
if (req.method === 'POST' && detectedMsgMatch) {
|
|
447
603
|
const peerId = decodeURIComponent(detectedMsgMatch[1]);
|
|
448
604
|
const input = await readJsonRequest(req);
|
|
605
|
+
if (['all', '*'].includes(peerId)) throw new CliError('CONTROL_REQUIRED', 'Detected inbox messaging requires an individual peer; use a controlled coordination action for broadcast');
|
|
606
|
+
if (sessionsForProject(reqCtx).some(s => [s.id, s.peerId].includes(peerId))) {
|
|
607
|
+
throw new CliError('CONTROL_REQUIRED', 'Use the managed session message endpoint and its control lease');
|
|
608
|
+
}
|
|
449
609
|
const body = String(input.body || '');
|
|
450
610
|
const sender = 'web';
|
|
451
611
|
const taskId = input.task ? Number(input.task) : null;
|
|
@@ -453,6 +613,8 @@ export function createHttpRoutes(deps) {
|
|
|
453
613
|
const db = connectWebProject(reqCtx);
|
|
454
614
|
let msgId;
|
|
455
615
|
try {
|
|
616
|
+
const binding = db.prepare('SELECT transport FROM peer_bindings WHERE peer = ?').get(peerId);
|
|
617
|
+
if (['native', 'app-server'].includes(binding?.transport)) throw new CliError('STRUCTURED_SESSION_NO_TERMINAL', 'Use the structured worker control endpoint for messages');
|
|
456
618
|
msgId = sendMessage(db, sender, peerId, taskId, 'note', body);
|
|
457
619
|
} finally {
|
|
458
620
|
db.close();
|
|
@@ -463,10 +625,19 @@ export function createHttpRoutes(deps) {
|
|
|
463
625
|
const detectedStopMatch = url.pathname.match(/^\/api\/detected\/([^/]+)\/stop$/);
|
|
464
626
|
if (req.method === 'POST' && detectedStopMatch) {
|
|
465
627
|
const peerId = decodeURIComponent(detectedStopMatch[1]);
|
|
628
|
+
if (sessionsForProject(reqCtx).some(s => [s.id, s.peerId].includes(peerId))) {
|
|
629
|
+
throw new CliError('CONTROL_REQUIRED', 'Use the managed session stop endpoint and its control lease');
|
|
630
|
+
}
|
|
466
631
|
let input = {};
|
|
467
632
|
try { input = await readJsonRequest(req); } catch {}
|
|
468
633
|
const db = connectWebProject(reqCtx);
|
|
469
634
|
try {
|
|
635
|
+
const binding = db.prepare('SELECT provider, transport FROM peer_bindings WHERE peer = ?').get(peerId);
|
|
636
|
+
if (binding?.transport === 'native') throw new CliError('NATIVE_MANAGED_SESSION', 'Use the existing native worker control endpoint');
|
|
637
|
+
if (isDshHookBinding(binding)) {
|
|
638
|
+
sendJson(res, 409, { ok: false, error: { code: 'DSH_SESSION_CONTROL_UNSUPPORTED', message: 'Control this agent in the dsh Web UI. hello-cc hooks support coordination only.' } });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
470
641
|
const now_ = now();
|
|
471
642
|
let killPlan = null;
|
|
472
643
|
if (input.kill_tmux) {
|
|
@@ -495,8 +666,17 @@ export function createHttpRoutes(deps) {
|
|
|
495
666
|
const detectedRestartMatch = url.pathname.match(/^\/api\/detected\/([^/]+)\/restart$/);
|
|
496
667
|
if (req.method === 'POST' && detectedRestartMatch) {
|
|
497
668
|
const peerId = decodeURIComponent(detectedRestartMatch[1]);
|
|
669
|
+
if (sessionsForProject(reqCtx).some(s => [s.id, s.peerId].includes(peerId))) {
|
|
670
|
+
throw new CliError('CONTROL_REQUIRED', 'Open the existing managed session');
|
|
671
|
+
}
|
|
498
672
|
const db = connectWebProject(reqCtx);
|
|
499
673
|
try {
|
|
674
|
+
const binding = db.prepare('SELECT provider, transport FROM peer_bindings WHERE peer = ?').get(peerId);
|
|
675
|
+
if (binding?.transport === 'native') throw new CliError('NATIVE_MANAGED_SESSION', 'Use the existing native worker control endpoint');
|
|
676
|
+
if (isDshHookBinding(binding)) {
|
|
677
|
+
sendJson(res, 409, { ok: false, error: { code: 'DSH_SESSION_CONTROL_UNSUPPORTED', message: 'Resume this agent in the dsh Web UI. hello-cc hooks support coordination only.' } });
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
500
680
|
// v1-detected-restart: only flip a real detected peer that has a live
|
|
501
681
|
// process or is not explicitly exited. Do not bump last_seen_at
|
|
502
682
|
// (the peer did not actually heartbeat) so the reaper's age filter
|
|
@@ -522,12 +702,14 @@ export function createHttpRoutes(deps) {
|
|
|
522
702
|
}
|
|
523
703
|
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Route not found' } });
|
|
524
704
|
} catch (err) {
|
|
705
|
+
if (err instanceof CodexAppServerError) err = new CliError(err.code, err.message);
|
|
525
706
|
const publicFailure = publicCliFailure(err);
|
|
526
707
|
const publicError = publicFailure?.error || err;
|
|
527
708
|
const detail = publicFailure || process.env.HCC_DEBUG
|
|
528
709
|
? publicError.message
|
|
529
710
|
: 'internal server error';
|
|
530
|
-
|
|
711
|
+
const status = publicError.code === 'DSH_SESSION_CONTROL_UNSUPPORTED' ? 409 : webErrorStatus(publicError);
|
|
712
|
+
sendJson(res, status, {
|
|
531
713
|
ok: false,
|
|
532
714
|
error: {
|
|
533
715
|
code: publicError.code || 'SERVER_ERROR',
|
package/lib/web/http.mjs
CHANGED
|
@@ -30,7 +30,7 @@ export async function readJsonRequest(req) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export function contentSecurityPolicy(nonce) {
|
|
33
|
+
export function contentSecurityPolicy(nonce, { pane = false } = {}) {
|
|
34
34
|
const value = String(nonce || '');
|
|
35
35
|
if (!/^[A-Za-z0-9_-]{16,}$/.test(value)) {
|
|
36
36
|
throw new CliError('BAD_ARGS', 'A valid CSP nonce is required');
|
|
@@ -40,7 +40,8 @@ export function contentSecurityPolicy(nonce) {
|
|
|
40
40
|
"style-src 'self' 'unsafe-inline'; " +
|
|
41
41
|
"img-src 'self' data:; " +
|
|
42
42
|
"connect-src 'self' ws: wss:; " +
|
|
43
|
-
"object-src 'none'; base-uri 'none';
|
|
43
|
+
"object-src 'none'; base-uri 'none'; " +
|
|
44
|
+
(pane === true ? "frame-ancestors 'self'; frame-src 'none'" : "frame-ancestors 'none'");
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export function sendHttp(res, status, contentType, body, options = {}) {
|
|
@@ -49,9 +50,9 @@ export function sendHttp(res, status, contentType, body, options = {}) {
|
|
|
49
50
|
'Cache-Control': 'no-store',
|
|
50
51
|
'Referrer-Policy': 'no-referrer',
|
|
51
52
|
'X-Content-Type-Options': 'nosniff',
|
|
52
|
-
'X-Frame-Options': 'DENY',
|
|
53
|
+
'X-Frame-Options': options.pane === true && options.nonce ? 'SAMEORIGIN' : 'DENY',
|
|
53
54
|
'Content-Security-Policy': options.nonce
|
|
54
|
-
? contentSecurityPolicy(options.nonce)
|
|
55
|
+
? contentSecurityPolicy(options.nonce, options)
|
|
55
56
|
: "frame-ancestors 'none'"
|
|
56
57
|
});
|
|
57
58
|
res.end(body);
|