@logicseek/hello-cc 1.0.2 → 1.1.0-rc.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/CHANGELOG.md +111 -23
- package/README.md +10 -1
- package/README.zh-CN.md +8 -1
- package/docs/commands.md +28 -4
- package/docs/commands.zh-CN.md +20 -4
- package/docs/dsh.md +2 -2
- package/docs/dsh.zh-CN.md +3 -3
- package/docs/native.md +84 -10
- package/docs/native.zh-CN.md +66 -5
- package/docs/web-handoff.zh-CN.md +11 -3
- package/lib/cli/app.mjs +42 -1
- package/lib/cli/commands/coordination.mjs +7 -2
- package/lib/cli/commands/dsh.mjs +1 -1
- package/lib/cli/commands/gc.mjs +9 -4
- package/lib/cli/commands/hook.mjs +6 -1
- package/lib/cli/commands/install.mjs +48 -6
- package/lib/cli/commands/lifecycle.mjs +14 -3
- package/lib/cli/commands/native.mjs +16 -9
- package/lib/cli/commands/query.mjs +15 -10
- package/lib/cli/commands/run.mjs +7 -6
- package/lib/core/coordination/results.mjs +27 -10
- package/lib/db/connection.mjs +135 -10
- package/lib/db/schema.mjs +59 -0
- package/lib/discover.mjs +4 -4
- package/lib/guidance.mjs +3 -2
- package/lib/integrations/codex-interactions.mjs +13 -11
- package/lib/integrations/dsh-collaboration.mjs +6 -12
- package/lib/integrations/dsh-cordis.mjs +17 -13
- package/lib/integrations/dsh.mjs +12 -9
- package/lib/integrations/hooks.mjs +132 -40
- package/lib/integrations/mcp-elicitation.mjs +116 -0
- package/lib/integrations/mcp-url-elicitation.mjs +25 -0
- package/lib/integrations/native/interactions.mjs +2 -1
- package/lib/integrations/peers/identity.mjs +42 -7
- package/lib/process/identity.mjs +15 -14
- package/lib/runtime/buffer-evidence.mjs +2 -1
- package/lib/runtime/native/service.mjs +11 -5
- package/lib/runtime/native/store.mjs +70 -18
- package/lib/runtime/paths.mjs +17 -2
- package/lib/runtime/private-file.mjs +76 -0
- package/lib/runtime/private-state.mjs +546 -0
- package/lib/runtime/project-context.mjs +2 -1
- package/lib/runtime/project-path.mjs +148 -12
- package/lib/runtime/project-trust.mjs +79 -0
- package/lib/runtime/projects.mjs +6 -3
- package/lib/runtime/state-migration.mjs +697 -0
- package/lib/runtime/state.mjs +22 -11
- package/lib/shared/automatic-result-key.mjs +6 -0
- package/lib/shared/json-file.mjs +55 -6
- package/lib/ui/help.mjs +6 -4
- package/lib/web/codex-app-server.mjs +26 -5
- package/lib/web/codex-sessions.mjs +5 -3
- package/lib/web/cookie-auth.mjs +1 -1
- package/lib/web/external-sessions.mjs +54 -5
- package/lib/web/http-routes.mjs +13 -9
- package/lib/web/http.mjs +20 -2
- package/lib/web/native-sessions.mjs +14 -7
- package/lib/web/peer-actions.mjs +27 -27
- package/lib/web/project-contexts.mjs +2 -3
- package/lib/web/runtime-main.mjs +22 -5
- package/lib/web/runtime.mjs +18 -4
- package/lib/web/session-serialize.mjs +1 -1
- package/lib/web/startup.mjs +115 -22
- package/lib/web/task-results.mjs +20 -17
- package/lib/web/terminal-assets.mjs +18 -0
- package/lib/web/tls.mjs +84 -33
- package/lib/web/tmux-sessions.mjs +2 -2
- package/lib/web/tmux-stream.mjs +3 -4
- package/lib/web/ui-codex.mjs +161 -32
- package/lib/web/ui-interactions.mjs +137 -4
- package/lib/web/ui-native.mjs +31 -13
- package/lib/web/ui-template.mjs +10 -3
- package/package.json +1 -1
- package/scripts/mcp-form-live-fixture.mjs +81 -0
- package/scripts/native-installed-acceptance.mjs +413 -0
- package/scripts/regression.mjs +46 -29
- package/scripts/web-mcp-form-acceptance.mjs +119 -0
- package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
- package/scripts/web-native-interaction-acceptance.mjs +77 -10
package/lib/web/peer-actions.mjs
CHANGED
|
@@ -3,10 +3,12 @@ import process from 'node:process';
|
|
|
3
3
|
import { intOpt, positiveSafeIntOpt, required } from '../cli-args.mjs';
|
|
4
4
|
import { CliError } from '../shared/errors.mjs';
|
|
5
5
|
import {
|
|
6
|
+
findRequestedLock,
|
|
6
7
|
lockLabel,
|
|
7
8
|
lockScope,
|
|
8
9
|
locksConflict,
|
|
9
|
-
scopedLockResource
|
|
10
|
+
scopedLockResource,
|
|
11
|
+
upsertRequestedLock
|
|
10
12
|
} from '../core/coordination/locks.mjs';
|
|
11
13
|
import { normalizeStateResources } from '../ui/state-render.mjs';
|
|
12
14
|
import {
|
|
@@ -103,23 +105,31 @@ export function createWebPeerActions(deps = {}) {
|
|
|
103
105
|
function webPeerRegister(projectCtx, peer, input = {}) {
|
|
104
106
|
webMutationPeer(peer, input);
|
|
105
107
|
const db = connect(projectCtx);
|
|
106
|
-
const row = {
|
|
107
|
-
id: peer,
|
|
108
|
-
kind: input.kind || 'other',
|
|
109
|
-
role: input.role || 'peer',
|
|
110
|
-
worktree: path.resolve(input.worktree || projectCtx.cwd),
|
|
111
|
-
branch: input.branch || detectBranch(projectCtx.cwd),
|
|
112
|
-
pid: intOpt(input, 'pid', process.ppid),
|
|
113
|
-
status: input.status || 'idle',
|
|
114
|
-
capabilities: Array.isArray(input.cap) ? input.cap.join(',') : (input.cap || input.capabilities || 'web')
|
|
115
|
-
};
|
|
116
108
|
try {
|
|
109
|
+
const existing = db.prepare('SELECT * FROM peers WHERE id = ?').get(peer) || null;
|
|
110
|
+
const explicitPid = Object.hasOwn(input, 'pid');
|
|
111
|
+
const row = {
|
|
112
|
+
id: peer,
|
|
113
|
+
kind: input.kind || existing?.kind || 'other',
|
|
114
|
+
role: input.role || existing?.role || 'peer',
|
|
115
|
+
worktree: path.resolve(input.worktree || existing?.worktree || projectCtx.cwd),
|
|
116
|
+
branch: input.branch || existing?.branch || detectBranch(projectCtx.cwd),
|
|
117
|
+
pid: explicitPid ? intOpt(input, 'pid') : (existing?.pid ?? null),
|
|
118
|
+
...(!explicitPid ? { processIdentity: existing ? {
|
|
119
|
+
pid: existing.pid,
|
|
120
|
+
startToken: existing.pid_start_token,
|
|
121
|
+
commandHash: existing.pid_command_hash
|
|
122
|
+
} : null } : {}),
|
|
123
|
+
status: input.status || existing?.status || 'idle',
|
|
124
|
+
capabilities: Array.isArray(input.cap) ? input.cap.join(',') :
|
|
125
|
+
(input.cap || input.capabilities || existing?.capabilities || 'web')
|
|
126
|
+
};
|
|
117
127
|
upsertPeer(db, row);
|
|
118
128
|
addEvent(db, 'peer.registered', peer, null, webAuditPayload(peer, input, row));
|
|
129
|
+
return { peer: row, summary: `registered ${row.id} (${row.kind}${row.role ? `, ${row.role}` : ''})` };
|
|
119
130
|
} finally {
|
|
120
131
|
db.close();
|
|
121
132
|
}
|
|
122
|
-
return { peer: row, summary: `registered ${row.id} (${row.kind}${row.role ? `, ${row.role}` : ''})` };
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
function webPeerHeartbeat(projectCtx, peer, input = {}) {
|
|
@@ -294,21 +304,11 @@ export function createWebPeerActions(deps = {}) {
|
|
|
294
304
|
}
|
|
295
305
|
const existing = subject.locks.find((row) => row.resource === requested.resource) || null;
|
|
296
306
|
const expiresAt = leaseDeadline(t, ttl);
|
|
297
|
-
db
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
ON CONFLICT(resource) DO UPDATE SET
|
|
301
|
-
base_resource = excluded.base_resource,
|
|
302
|
-
scope = excluded.scope,
|
|
303
|
-
owner = excluded.owner,
|
|
304
|
-
task_id = excluded.task_id,
|
|
305
|
-
reason = excluded.reason,
|
|
306
|
-
expires_at = excluded.expires_at,
|
|
307
|
-
created_at = excluded.created_at,
|
|
308
|
-
ttl_sec = excluded.ttl_sec
|
|
309
|
-
`).run(requested.resource, requested.base_resource, requested.scope, peer, taskId, reason, expiresAt, t, ttl);
|
|
307
|
+
const acquired = upsertRequestedLock(db, requested, {
|
|
308
|
+
owner: peer, task_id: taskId, reason, expires_at: expiresAt, created_at: t, ttl_sec: ttl
|
|
309
|
+
});
|
|
310
310
|
addEvent(db, 'lock.acquired', peer, taskId, webAuditPayload(peer, input, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null }));
|
|
311
|
-
return { lock:
|
|
311
|
+
return { lock: acquired };
|
|
312
312
|
}
|
|
313
313
|
});
|
|
314
314
|
if (outcome.error) throw outcome.error;
|
|
@@ -327,7 +327,7 @@ export function createWebPeerActions(deps = {}) {
|
|
|
327
327
|
try {
|
|
328
328
|
touchPeer(db, peer, null);
|
|
329
329
|
const result = tx(db, () => {
|
|
330
|
-
const existing = db
|
|
330
|
+
const existing = findRequestedLock(db, requested);
|
|
331
331
|
if (!existing) return { released: false, ...requested };
|
|
332
332
|
if (existing.owner !== peer) {
|
|
333
333
|
throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
|
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
// fail-closed path re-resolution, per-project DB connections, and
|
|
4
4
|
// session/peer lookup helpers used by the HTTP and WS layers.
|
|
5
5
|
|
|
6
|
-
import path from 'node:path';
|
|
7
6
|
import { randomBytes } from 'node:crypto';
|
|
8
7
|
import WebSocket from 'ws';
|
|
9
8
|
import { CliError } from '../shared/errors.mjs';
|
|
10
|
-
import { contextForProject } from '../runtime/paths.mjs';
|
|
9
|
+
import { contextForProject, projectDbPath } from '../runtime/paths.mjs';
|
|
11
10
|
import { resolveProjectDatabase } from '../runtime/project-path.mjs';
|
|
12
11
|
import {
|
|
13
12
|
projectRecord,
|
|
@@ -101,7 +100,7 @@ export function createProjectContexts(deps) {
|
|
|
101
100
|
ctx.root;
|
|
102
101
|
const requestedDb = url.searchParams.get('db') ||
|
|
103
102
|
req.headers['x-hcc-db'] ||
|
|
104
|
-
|
|
103
|
+
projectDbPath(requestedRoot);
|
|
105
104
|
return rememberProject(
|
|
106
105
|
resolveWebProjectContext(requestedRoot, requestedDb),
|
|
107
106
|
{ activity: true }
|
package/lib/web/runtime-main.mjs
CHANGED
|
@@ -97,7 +97,8 @@ import {
|
|
|
97
97
|
requestOriginMatches,
|
|
98
98
|
sendFile,
|
|
99
99
|
sendJson,
|
|
100
|
-
tokenMatches
|
|
100
|
+
tokenMatches,
|
|
101
|
+
tokenlessRequestAllowed
|
|
101
102
|
} from '../web/http.mjs';
|
|
102
103
|
import {
|
|
103
104
|
API_VERSION,
|
|
@@ -433,9 +434,9 @@ const {
|
|
|
433
434
|
listCodexThreads, readCodexThread, forkCodexThread } = createCodexSessions({
|
|
434
435
|
sessions, sessionKey, nextProjectSessionId, connectWebProject, upsertPeer,
|
|
435
436
|
upsertCanonicalPeerBinding, observePeerEvidence,
|
|
436
|
-
broadcast: (session, payload) => {
|
|
437
|
+
broadcast: (session, payload, event) => {
|
|
437
438
|
if (payload.type === 'codex_state') {
|
|
438
|
-
try { observeCodexResults(session, payload.state); }
|
|
439
|
+
try { observeCodexResults(session, payload.state, event); }
|
|
439
440
|
catch (error) { console.error(redactedLogText(`Result evidence could not be saved: ${error.code || 'UNKNOWN'}`)); }
|
|
440
441
|
}
|
|
441
442
|
broadcast(session, payload);
|
|
@@ -488,14 +489,30 @@ const {
|
|
|
488
489
|
ACTIVE_PEER_TTL, DETECTED_PEER_MAX_AGE, PRODUCT_NAME, VERSION
|
|
489
490
|
});
|
|
490
491
|
|
|
492
|
+
const dispatchHttp = (req, res) => {
|
|
493
|
+
void handleWebRequest(req, res).catch((error) => {
|
|
494
|
+
try {
|
|
495
|
+
console.error(redactedLogText(`HTTP request failed: ${error.code || 'UNKNOWN'}`));
|
|
496
|
+
if (!res.headersSent) sendJson(res, 500, { ok: false, error: { code: 'SERVER_ERROR', message: 'internal server error' } });
|
|
497
|
+
else res.destroy();
|
|
498
|
+
} catch {
|
|
499
|
+
try { res.destroy(); } catch {}
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
};
|
|
491
503
|
const server = useTls
|
|
492
|
-
? https.createServer({ key: tlsCredentials.key, cert: tlsCredentials.cert },
|
|
493
|
-
: http.createServer(
|
|
504
|
+
? https.createServer({ key: tlsCredentials.key, cert: tlsCredentials.cert }, dispatchHttp)
|
|
505
|
+
: http.createServer(dispatchHttp);
|
|
494
506
|
|
|
495
507
|
const wss = new WebSocketServer({ noServer: true });
|
|
496
508
|
server.on('upgrade', (req, socket, head) => {
|
|
497
509
|
try {
|
|
498
510
|
const url = requestUrl(req);
|
|
511
|
+
if (!token && !tokenlessRequestAllowed(req, { trustProxy, proxyOrigin })) {
|
|
512
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
513
|
+
socket.destroy();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
499
516
|
if (!readWebSocketApiVersion(url).ok) {
|
|
500
517
|
const body = JSON.stringify(apiVersionUnsupportedBody());
|
|
501
518
|
socket.end([
|
package/lib/web/runtime.mjs
CHANGED
|
@@ -14,8 +14,12 @@ export function runtimeConnectHost(host) {
|
|
|
14
14
|
return host;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function urlHost(host) {
|
|
18
|
+
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
export function runtimeBaseUrl(host, port, tls = false) {
|
|
18
|
-
return `${tls ? 'https' : 'http'}://${runtimeConnectHost(host)}:${port}`;
|
|
22
|
+
return `${tls ? 'https' : 'http'}://${urlHost(runtimeConnectHost(host))}:${port}`;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
export function runtimeApiUrl(runtime, route) {
|
|
@@ -24,7 +28,17 @@ export function runtimeApiUrl(runtime, route) {
|
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export function requestUrl(req) {
|
|
27
|
-
|
|
31
|
+
try {
|
|
32
|
+
const base = new URL(`http://${req.headers.host || 'localhost'}`);
|
|
33
|
+
if (base.username || base.password || base.pathname !== '/' || base.search || base.hash) {
|
|
34
|
+
throw new Error('Invalid Host authority');
|
|
35
|
+
}
|
|
36
|
+
const url = new URL(req.url || '/', base);
|
|
37
|
+
if (url.origin !== base.origin) throw new Error('Request target changes authority');
|
|
38
|
+
return url;
|
|
39
|
+
} catch {
|
|
40
|
+
throw new CliError('BAD_REQUEST', 'Invalid request URL or Host header');
|
|
41
|
+
}
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
export function isLoopbackHost(host) {
|
|
@@ -84,13 +98,13 @@ export function publicRuntimeUrl(runtime, projectRoot = null) {
|
|
|
84
98
|
? '<machine-ip>'
|
|
85
99
|
: runtime.host || runtimeConnectHost(runtime.host || '127.0.0.1');
|
|
86
100
|
const scheme = runtime.tls ? 'https' : 'http';
|
|
87
|
-
return `${scheme}://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
|
|
101
|
+
return `${scheme}://${urlHost(host)}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
export function localRuntimeUrl(runtime, projectRoot = null) {
|
|
91
105
|
const host = runtimeConnectHost(runtime.host || '127.0.0.1');
|
|
92
106
|
const scheme = runtime.tls ? 'https' : 'http';
|
|
93
|
-
return `${scheme}://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
|
|
107
|
+
return `${scheme}://${urlHost(host)}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
|
|
94
108
|
}
|
|
95
109
|
|
|
96
110
|
// Issue a request to a runtime API route. Returns { ok, status, text }.
|
|
@@ -105,7 +105,7 @@ export function createSessionSerialize({
|
|
|
105
105
|
: session.type === 'native' ? ['chat', 'delivery-receipts', 'results',
|
|
106
106
|
...(session.nativeState?.capabilities?.interrupt ? ['turn-control'] : [])] : ['terminal', 'results'],
|
|
107
107
|
local_clients: session.type === 'tmux' ? localClientObservation(session) : null,
|
|
108
|
-
executor_id: session.adapter?.snapshot().executorId || session.nativeState?.executorId || null,
|
|
108
|
+
executor_id: session.executorId || session.adapter?.snapshot().executorId || session.nativeState?.executorId || null,
|
|
109
109
|
native_connected: session.type === 'native' ? Boolean(session.nativeState?.connected) : null,
|
|
110
110
|
created_at: session.createdAt,
|
|
111
111
|
exited_at: session.exitedAt || null,
|
package/lib/web/startup.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import process from 'node:process';
|
|
8
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
8
9
|
import { URL } from 'node:url';
|
|
9
10
|
import { spawn, spawnSync } from 'node:child_process';
|
|
10
11
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
@@ -13,10 +14,15 @@ import { redactCliArgs } from '../shared/redact.mjs';
|
|
|
13
14
|
import { intOpt, parseOpts, splitGlobalArgs, validateOpts } from '../cli-args.mjs';
|
|
14
15
|
import { printResult } from '../format.mjs';
|
|
15
16
|
import { commandPath, tailFile } from '../cli-runtime.mjs';
|
|
17
|
+
import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
|
|
16
18
|
import { registerProject } from '../runtime/projects.mjs';
|
|
17
|
-
import { runtimePath, globalRuntimePath, webLogPath } from '../runtime/paths.mjs';
|
|
19
|
+
import { runtimePath, globalRuntimePath, globalStateDir, webLogPath } from '../runtime/paths.mjs';
|
|
20
|
+
import { secureExistingProjectStateDirectory } from '../runtime/project-path.mjs';
|
|
21
|
+
import { openPrivateAppendFile } from '../runtime/private-file.mjs';
|
|
18
22
|
import { readHealthyGlobalRuntime, writeRuntime } from '../runtime/state.mjs';
|
|
19
23
|
import { runtimeRequest } from '../runtime/client.mjs';
|
|
24
|
+
import { withFileLock } from '../shared/file-lock.mjs';
|
|
25
|
+
import { writeJsonSafe } from '../shared/json-file.mjs';
|
|
20
26
|
import {
|
|
21
27
|
expectedWebHost,
|
|
22
28
|
isLoopbackHost,
|
|
@@ -32,6 +38,56 @@ import { API_VERSION } from '../web/api-version.mjs';
|
|
|
32
38
|
import { ensureTmuxAvailable } from '../tmux.mjs';
|
|
33
39
|
|
|
34
40
|
export const WEB_RUNTIME_START_TIMEOUT_MS = 60_000;
|
|
41
|
+
const WEB_START_LOCK_WAIT_MS = WEB_RUNTIME_START_TIMEOUT_MS + 30_000;
|
|
42
|
+
|
|
43
|
+
function webStartOwnerAlive(owner) {
|
|
44
|
+
const pid = owner?.pid;
|
|
45
|
+
if (!Number.isInteger(pid) || pid <= 0) return true;
|
|
46
|
+
const observed = inspectProcessIdentity(pid);
|
|
47
|
+
if (observed.state === 'dead') return false;
|
|
48
|
+
if (owner.process_identity && observed.state === 'live') {
|
|
49
|
+
return compareProcessIdentity(owner.process_identity, observed.identity) !== 'dead';
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function withWebStartLock(fn) {
|
|
55
|
+
const lockFile = path.join(globalStateDir(), 'web-start.lock');
|
|
56
|
+
const owner = {
|
|
57
|
+
pid: process.pid,
|
|
58
|
+
process_identity: inspectProcessIdentity(process.pid).identity,
|
|
59
|
+
nonce: randomUUID()
|
|
60
|
+
};
|
|
61
|
+
const deadline = performance.now() + WEB_START_LOCK_WAIT_MS;
|
|
62
|
+
while (true) {
|
|
63
|
+
const acquired = withFileLock(lockFile, (lockedFile) => {
|
|
64
|
+
if (fs.existsSync(lockedFile)) {
|
|
65
|
+
let previous = null;
|
|
66
|
+
try { previous = JSON.parse(fs.readFileSync(lockedFile, 'utf8')); } catch {}
|
|
67
|
+
if (!previous || webStartOwnerAlive(previous)) return false;
|
|
68
|
+
}
|
|
69
|
+
writeJsonSafe(lockedFile, owner, { mode: 0o600 });
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
if (acquired) break;
|
|
73
|
+
if (performance.now() >= deadline) {
|
|
74
|
+
throw new CliError('WEB_START_LOCK_TIMEOUT',
|
|
75
|
+
'Timed out waiting for another web startup to finish.');
|
|
76
|
+
}
|
|
77
|
+
await sleep(100);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return await fn();
|
|
81
|
+
} finally {
|
|
82
|
+
withFileLock(lockFile, (lockedFile) => {
|
|
83
|
+
let current = null;
|
|
84
|
+
try { current = JSON.parse(fs.readFileSync(lockedFile, 'utf8')); } catch {}
|
|
85
|
+
if (current?.nonce === owner.nonce && current.pid === owner.pid) {
|
|
86
|
+
fs.rmSync(lockedFile, { force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
35
91
|
|
|
36
92
|
export function createWebStartup(deps) {
|
|
37
93
|
const {
|
|
@@ -84,25 +140,41 @@ async function stopOrphanWebRuntimes(ctx, keepPid = null) {
|
|
|
84
140
|
const ppid = Number.parseInt(match[2], 10);
|
|
85
141
|
if (!Number.isFinite(pid)) continue;
|
|
86
142
|
if (Number.isFinite(ppid)) ppidByPid.set(pid, ppid);
|
|
87
|
-
rows.push({ pid, args: match[3] });
|
|
143
|
+
rows.push({ pid, ppid, args: match[3] });
|
|
88
144
|
}
|
|
89
145
|
|
|
90
146
|
const ancestorPids = currentProcessAncestorPids(ppidByPid);
|
|
91
|
-
const
|
|
147
|
+
const candidates = [];
|
|
92
148
|
for (const row of rows) {
|
|
149
|
+
// A live parent still owns its web command. In particular, another CLI
|
|
150
|
+
// invocation waiting for the startup lock is not an orphan runtime.
|
|
151
|
+
if (row.ppid !== 1) continue;
|
|
93
152
|
if (row.pid === process.pid || row.pid === keepPid || ancestorPids.has(row.pid)) continue;
|
|
94
|
-
if (hccWebProcessMatches(row.args, ctx))
|
|
153
|
+
if (!hccWebProcessMatches(row.args, ctx)) continue;
|
|
154
|
+
const observed = inspectProcessIdentity(row.pid);
|
|
155
|
+
if (observed.state === 'live' &&
|
|
156
|
+
observed.identity.commandHash === createHash('sha256').update(row.args.trim()).digest('hex')) {
|
|
157
|
+
candidates.push(observed.identity);
|
|
158
|
+
}
|
|
95
159
|
}
|
|
96
|
-
if (!
|
|
160
|
+
if (!candidates.length) return;
|
|
161
|
+
|
|
162
|
+
const stillSameProcess = (identity) => {
|
|
163
|
+
const observed = inspectProcessIdentity(identity.pid);
|
|
164
|
+
return observed.state === 'live' &&
|
|
165
|
+
compareProcessIdentity(identity, observed.identity) === 'live' &&
|
|
166
|
+
identity.commandHash === observed.identity.commandHash;
|
|
167
|
+
};
|
|
97
168
|
|
|
98
|
-
for (const
|
|
99
|
-
|
|
169
|
+
for (const identity of candidates) {
|
|
170
|
+
if (!stillSameProcess(identity)) continue;
|
|
171
|
+
try { process.kill(identity.pid, 'SIGTERM'); } catch {}
|
|
100
172
|
}
|
|
101
173
|
await sleep(250);
|
|
102
|
-
for (const
|
|
174
|
+
for (const identity of candidates) {
|
|
175
|
+
if (!stillSameProcess(identity)) continue;
|
|
103
176
|
try {
|
|
104
|
-
process.kill(pid,
|
|
105
|
-
process.kill(pid, 'SIGKILL');
|
|
177
|
+
process.kill(identity.pid, 'SIGKILL');
|
|
106
178
|
} catch {}
|
|
107
179
|
}
|
|
108
180
|
}
|
|
@@ -160,10 +232,15 @@ async function startWebBackground(ctx, args) {
|
|
|
160
232
|
assertWebTokenForHost(requestedHost, !opts['no-token']);
|
|
161
233
|
if (!isLoopbackHost(requestedHost)) console.error(redactedLogText(webExposureWarning(requestedHost, intOpt(opts, 'port', 8787)) + (opts.tls ? '' : ' Consider --tls to encrypt this connection.')));
|
|
162
234
|
ensureTmuxAvailable({ autoInstall: true });
|
|
235
|
+
return withWebStartLock(() => startWebBackgroundLocked(ctx, args, opts));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function startWebBackgroundLocked(ctx, args, opts) {
|
|
163
239
|
const setup = await prepareLocalBus(ctx, {
|
|
164
240
|
...opts,
|
|
165
241
|
installShims: process.env.HCC_SKIP_SHIM_INSTALL === '1' ? false : true
|
|
166
242
|
});
|
|
243
|
+
secureExistingProjectStateDirectory(ctx.root);
|
|
167
244
|
registerProject(ctx);
|
|
168
245
|
|
|
169
246
|
const existing = await readHealthyGlobalRuntime();
|
|
@@ -201,26 +278,42 @@ async function startWebBackground(ctx, args) {
|
|
|
201
278
|
try { await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, existing); } catch {}
|
|
202
279
|
await sleep(250);
|
|
203
280
|
}
|
|
281
|
+
secureExistingProjectStateDirectory(ctx.root);
|
|
204
282
|
await stopOrphanWebRuntimes(ctx);
|
|
205
283
|
|
|
284
|
+
// A project path can be reused for a different directory after setup. Do
|
|
285
|
+
// not remove its old private runtime pointer or append to its old Web log.
|
|
286
|
+
secureExistingProjectStateDirectory(ctx.root);
|
|
206
287
|
try { fs.rmSync(runtimePath(ctx), { force: true }); } catch {}
|
|
207
288
|
try { fs.rmSync(globalRuntimePath(), { force: true }); } catch {}
|
|
208
289
|
|
|
209
290
|
const logFile = webLogPath(ctx);
|
|
210
|
-
|
|
211
|
-
// bg-05: rotate web.log once it grows past 5 MB (keep the previous .1).
|
|
291
|
+
let logFd = openPrivateAppendFile(logFile);
|
|
212
292
|
try {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
293
|
+
// bg-05: rotate web.log once it grows past 5 MB (keep the previous .1).
|
|
294
|
+
if (fs.fstatSync(logFd).size > 5 * 1024 * 1024) {
|
|
295
|
+
const previous = `${logFile}.1`;
|
|
296
|
+
try {
|
|
297
|
+
const stat = fs.lstatSync(previous);
|
|
298
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 ||
|
|
299
|
+
(typeof process.getuid === 'function' && stat.uid !== process.getuid())) {
|
|
300
|
+
throw new CliError('PROJECT_PATH_FORBIDDEN', `Rotated Web log is not owned: ${previous}`);
|
|
301
|
+
}
|
|
302
|
+
fs.unlinkSync(previous);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
305
|
+
}
|
|
306
|
+
fs.closeSync(logFd);
|
|
307
|
+
logFd = null;
|
|
308
|
+
fs.renameSync(logFile, previous);
|
|
309
|
+
logFd = openPrivateAppendFile(logFile);
|
|
216
310
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const logFd = fs.openSync(logFile, 'a');
|
|
311
|
+
const redactedStart = redactedLogText(`${CLI_NAME} web ${redactCliArgs(args).join(' ')}`);
|
|
312
|
+
fs.writeSync(logFd, `\n[${new Date().toISOString()}] ${redactedStart}\n`);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (logFd !== null) fs.closeSync(logFd);
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
224
317
|
|
|
225
318
|
const childArgs = [
|
|
226
319
|
commandPath(),
|
package/lib/web/task-results.mjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
1
|
import { selectCurrentTask } from '../core/coordination/automation.mjs';
|
|
3
2
|
import { listTaskResults, recordTaskResult, summarizeTaskResults } from '../core/coordination/results.mjs';
|
|
4
3
|
import { CliError } from '../shared/errors.mjs';
|
|
@@ -30,8 +29,8 @@ export function createTaskResults({ connectWebProject, resolveSessionPeerId, now
|
|
|
30
29
|
return { result };
|
|
31
30
|
} finally { db.close(); }
|
|
32
31
|
}
|
|
33
|
-
function observeCodexResults(session, state) {
|
|
34
|
-
if (!session.binding?.provider_session_id || state.executorId !== session.adapter?.snapshot().executorId) return;
|
|
32
|
+
function observeCodexResults(session, state, event) {
|
|
33
|
+
if (!session.binding?.provider_session_id || state.executorId !== (session.executorId || session.adapter?.snapshot().executorId)) return;
|
|
35
34
|
const thread = state.threads?.find(t => t.id === session.binding.provider_session_id);
|
|
36
35
|
if (!thread?.turns?.length) return;
|
|
37
36
|
const db = connectWebProject(session.ctx);
|
|
@@ -39,9 +38,17 @@ export function createTaskResults({ connectWebProject, resolveSessionPeerId, now
|
|
|
39
38
|
const peer = resolveSessionPeerId(db, session);
|
|
40
39
|
let scopes = turnScopes.get(session);
|
|
41
40
|
if (!scopes) { scopes = new Map(); turnScopes.set(session, scopes); }
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
// The snapshot retains old events. Only the adapter's fresh event may bind
|
|
42
|
+
// a turn to the task that is current at this instant.
|
|
43
|
+
if (event?.method === 'turn/started' && event.params?.threadId === thread.id &&
|
|
44
|
+
event.params.turn?.id && !scopes.has(event.params.turn.id)) {
|
|
45
|
+
scopes.set(event.params.turn.id, currentTask(db, peer)?.id || null);
|
|
46
|
+
}
|
|
47
|
+
function recordAutomatic(input) {
|
|
48
|
+
try { recordTaskResult(db, input, { now, addEvent, automatic: true }); return true; }
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error instanceof CliError && ['TASK_OWNER_MISMATCH', 'NOT_FOUND'].includes(error.code)) return false;
|
|
51
|
+
throw error;
|
|
45
52
|
}
|
|
46
53
|
}
|
|
47
54
|
for (const turn of thread.turns) {
|
|
@@ -49,27 +56,23 @@ export function createTaskResults({ connectWebProject, resolveSessionPeerId, now
|
|
|
49
56
|
if (!taskId) continue;
|
|
50
57
|
const common = { peer, taskId, executorId: state.executorId, threadId: thread.id,
|
|
51
58
|
turnId: turn.id, source: 'executor', stage: 'local' };
|
|
59
|
+
let taskUnavailable = false;
|
|
52
60
|
for (const item of turn.items || []) {
|
|
53
61
|
if (item.type !== 'commandExecution' || !['completed', 'failed', 'declined'].includes(item.status)) continue;
|
|
54
62
|
const exitCode = item.exitCode;
|
|
55
63
|
const status = exitCode === 0 && item.status === 'completed' ? 'passed'
|
|
56
64
|
: item.status === 'failed' || item.status === 'declined' || (Number.isSafeInteger(exitCode) && exitCode !== 0) ? 'failed' : 'pending';
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
details: String(item.aggregatedOutput || '').slice(0, 64000), evidence: [] }, { now, addEvent });
|
|
65
|
+
if (!recordAutomatic({ ...common, itemId: item.id, kind: 'command', status, exitCode })) {
|
|
66
|
+
taskUnavailable = true;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
62
69
|
}
|
|
70
|
+
if (taskUnavailable) continue;
|
|
63
71
|
if (turn.diff && ['completed', 'failed', 'interrupted'].includes(turn.status)) {
|
|
64
|
-
|
|
65
|
-
recordTaskResult(db, { ...common, kind: 'diff', status: 'pending', title: 'Turn changes awaiting review', diff,
|
|
66
|
-
resultKey: `${state.executorId}:${turn.id}:diff:${createHash('sha256').update(diff).digest('hex')}` }, { now, addEvent });
|
|
72
|
+
recordAutomatic({ ...common, kind: 'diff', status: 'pending' });
|
|
67
73
|
}
|
|
68
74
|
}
|
|
69
75
|
while (scopes.size > 100) scopes.delete(scopes.keys().next().value);
|
|
70
|
-
} catch (error) {
|
|
71
|
-
// A delayed executor result must not attach to a task after ownership changed.
|
|
72
|
-
if (!(error instanceof CliError) || !['TASK_OWNER_MISMATCH', 'NOT_FOUND'].includes(error.code)) throw error;
|
|
73
76
|
} finally { db.close(); }
|
|
74
77
|
}
|
|
75
78
|
return { readSessionResults, writeSessionResult, observeCodexResults };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const packages = Object.freeze({
|
|
6
|
+
'xterm.js': '@xterm/xterm',
|
|
7
|
+
'xterm.css': '@xterm/xterm',
|
|
8
|
+
'addon-fit.js': '@xterm/addon-fit',
|
|
9
|
+
'addon-search.js': '@xterm/addon-search'
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// npm may place dependencies above the HCC package directory. Resolve from
|
|
13
|
+
// this module's installation rather than assuming a nested node_modules.
|
|
14
|
+
export function terminalAssetPath(name) {
|
|
15
|
+
if (!Object.hasOwn(packages, name)) throw new TypeError('Unknown terminal asset');
|
|
16
|
+
const entry = require.resolve(packages[name]);
|
|
17
|
+
return name === 'xterm.css' ? path.resolve(path.dirname(entry), '..', 'css', name) : entry;
|
|
18
|
+
}
|