@logicseek/hello-cc 1.0.2 → 1.0.4
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 +63 -0
- package/docs/dsh.md +3 -3
- package/docs/dsh.zh-CN.md +3 -3
- package/lib/cli/app.mjs +1 -1
- package/lib/cli/commands/hook.mjs +3 -1
- package/lib/cli/commands/lifecycle.mjs +14 -3
- package/lib/cli/commands/query.mjs +15 -10
- package/lib/cli/commands/run.mjs +7 -6
- package/lib/db/connection.mjs +82 -9
- package/lib/discover.mjs +4 -4
- package/lib/integrations/hooks.mjs +132 -40
- package/lib/integrations/peers/identity.mjs +42 -7
- package/lib/process/identity.mjs +36 -15
- package/lib/runtime/client.mjs +9 -1
- package/lib/runtime/native/service.mjs +107 -24
- package/lib/runtime/native/store.mjs +63 -6
- package/lib/runtime/project-path.mjs +90 -7
- package/lib/shared/file-lock.mjs +62 -17
- package/lib/shared/socket-lock-worker.mjs +15 -6
- package/lib/web/codex-app-server.mjs +19 -1
- package/lib/web/codex-sessions.mjs +2 -0
- package/lib/web/cookie-auth.mjs +1 -1
- package/lib/web/http-routes.mjs +11 -7
- package/lib/web/http.mjs +20 -2
- package/lib/web/native-sessions.mjs +13 -6
- package/lib/web/peer-actions.mjs +27 -27
- package/lib/web/runtime-main.mjs +24 -7
- package/lib/web/runtime.mjs +18 -4
- package/lib/web/session-serialize.mjs +1 -1
- package/lib/web/startup.mjs +84 -10
- package/lib/web/task-results.mjs +8 -5
- package/lib/web/terminal-assets.mjs +18 -0
- package/lib/web/tmux-client-snapshot.mjs +85 -0
- package/lib/web/tmux-stream.mjs +3 -4
- package/lib/web/ui-codex.mjs +137 -20
- package/lib/web/ui-interactions.mjs +37 -3
- package/lib/web/ui-native.mjs +21 -9
- package/lib/web/ui-template.mjs +8 -2
- package/package.json +1 -1
- package/scripts/regression.mjs +212 -47
|
@@ -139,14 +139,14 @@ export function runSocketLockWorker(workerData) {
|
|
|
139
139
|
let received = '';
|
|
140
140
|
let settled = false;
|
|
141
141
|
let socket;
|
|
142
|
-
const settle = (matching) => {
|
|
142
|
+
const settle = (matching, legacy = false) => {
|
|
143
143
|
if (settled) return;
|
|
144
144
|
settled = true;
|
|
145
145
|
clearTimeout(timer);
|
|
146
146
|
try { socket?.destroy(); } catch {}
|
|
147
|
-
callback(matching);
|
|
147
|
+
callback(matching, legacy);
|
|
148
148
|
};
|
|
149
|
-
const timer = setTimeout(() => settle(false), PROBE_TIMEOUT_MS);
|
|
149
|
+
const timer = setTimeout(() => settle(false, received.length === 0), PROBE_TIMEOUT_MS);
|
|
150
150
|
try {
|
|
151
151
|
socket = net.createConnection({ host: '127.0.0.1', port });
|
|
152
152
|
socket.setEncoding('utf8');
|
|
@@ -156,7 +156,10 @@ export function runSocketLockWorker(workerData) {
|
|
|
156
156
|
settle(received === expected);
|
|
157
157
|
}
|
|
158
158
|
});
|
|
159
|
-
|
|
159
|
+
// The previous native runtime owner accepted and immediately ended the
|
|
160
|
+
// connection with no banner. Native leases must not treat it as an
|
|
161
|
+
// unrelated service and start a second runtime on another candidate.
|
|
162
|
+
socket.once('end', () => settle(received === expected, received.length === 0));
|
|
160
163
|
socket.once('error', () => settle(false));
|
|
161
164
|
} catch {
|
|
162
165
|
settle(false);
|
|
@@ -184,9 +187,15 @@ export function runSocketLockWorker(workerData) {
|
|
|
184
187
|
requestTerminalFailure();
|
|
185
188
|
return;
|
|
186
189
|
}
|
|
187
|
-
probeOccupant(port, target.identity, (matching) => {
|
|
190
|
+
probeOccupant(port, target.identity, (matching, legacy) => {
|
|
188
191
|
if (finished || attemptGeneration !== generation) return;
|
|
189
|
-
if (
|
|
192
|
+
if (legacy && workerData.rejectLegacyOwner) {
|
|
193
|
+
generation += 1;
|
|
194
|
+
closeServers((error) => {
|
|
195
|
+
if (error) requestTerminalFailure();
|
|
196
|
+
else finish(states.BUSY, 2);
|
|
197
|
+
});
|
|
198
|
+
} else if (matching) retryCleanly(attemptGeneration);
|
|
190
199
|
else bindCandidate(targetIndex, candidateIndex + 1, boundForTarget, attemptGeneration);
|
|
191
200
|
});
|
|
192
201
|
};
|
|
@@ -10,6 +10,7 @@ const APPROVAL_METHODS = CODEX_INTERACTION_METHODS;
|
|
|
10
10
|
const TERMINAL_TURNS = new Set(['completed', 'interrupted', 'failed']);
|
|
11
11
|
const MAX_LINE_BYTES = 8 * 1024 * 1024;
|
|
12
12
|
const MAX_TEXT = 65536;
|
|
13
|
+
const STREAM_UPDATE_MS = 50;
|
|
13
14
|
|
|
14
15
|
export class CodexAppServerError extends Error {
|
|
15
16
|
constructor(code, message, extra = {}) {
|
|
@@ -70,6 +71,8 @@ export function createCodexAppServer({
|
|
|
70
71
|
let buffer = '';
|
|
71
72
|
let truncated = false;
|
|
72
73
|
let closePromise = null;
|
|
74
|
+
let streamTimer = null;
|
|
75
|
+
let streamEvent = null;
|
|
73
76
|
const threads = new Map();
|
|
74
77
|
const pending = new Map();
|
|
75
78
|
const approvals = new Map();
|
|
@@ -111,11 +114,26 @@ export function createCodexAppServer({
|
|
|
111
114
|
});
|
|
112
115
|
}
|
|
113
116
|
|
|
117
|
+
function publishChange(event) {
|
|
118
|
+
clearTimeout(streamTimer); streamTimer = null; streamEvent = null;
|
|
119
|
+
try { onChange(snapshot(), structuredClone(event)); } catch { /* UI observers do not own transport. */ }
|
|
120
|
+
}
|
|
121
|
+
|
|
114
122
|
function changed(method, params = {}) {
|
|
115
123
|
const event = { method, params: bounded(params, 8192), at: Date.now() };
|
|
116
124
|
events.push(event);
|
|
117
125
|
if (events.length > 100) events.shift();
|
|
118
|
-
|
|
126
|
+
// Keep protocol state current on every token, but copy/broadcast its complete
|
|
127
|
+
// retained history at most once per interval during a text-only burst.
|
|
128
|
+
// Approval, completion, error and connection changes publish immediately;
|
|
129
|
+
// their snapshot includes all pending text and cancels the trailing timer.
|
|
130
|
+
if (method === 'item/agentMessage/delta') {
|
|
131
|
+
streamEvent = event;
|
|
132
|
+
if (!streamTimer) {
|
|
133
|
+
streamTimer = setTimeout(() => publishChange(streamEvent), STREAM_UPDATE_MS);
|
|
134
|
+
streamTimer.unref?.();
|
|
135
|
+
}
|
|
136
|
+
} else publishChange(event);
|
|
119
137
|
}
|
|
120
138
|
|
|
121
139
|
function ensureThread(id) {
|
|
@@ -81,6 +81,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
81
81
|
binding: { transport: 'app-server', runtimeSessionId: id } });
|
|
82
82
|
return { mcp_servers: { hello_cc_scoped: session.mcpCapability.config } };
|
|
83
83
|
}, onChange(state) {
|
|
84
|
+
session.executorId ||= state.executorId;
|
|
84
85
|
broadcast(session, { type: 'codex_state', state });
|
|
85
86
|
if (state.status === 'disconnected' && !closeScheduled) {
|
|
86
87
|
closeScheduled = true;
|
|
@@ -114,6 +115,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
114
115
|
throw new CliError('CODEX_DISCONNECTED', 'Executor disconnected while opening its thread; no session was registered');
|
|
115
116
|
}
|
|
116
117
|
session.pid = executorState.pid || null;
|
|
118
|
+
session.executorId = executorState.executorId;
|
|
117
119
|
const save = connectWebProject(pctx);
|
|
118
120
|
try {
|
|
119
121
|
tx(save, () => {
|
package/lib/web/cookie-auth.mjs
CHANGED
|
@@ -99,7 +99,7 @@ export function createCookieAuth({
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
function webAuthMode(url, req) {
|
|
102
|
-
if (authOk(url, req, token)) return 'token';
|
|
102
|
+
if (authOk(url, req, token, { trustProxy, proxyOrigin })) return 'token';
|
|
103
103
|
return cookieSessionOk(req) ? 'cookie' : null;
|
|
104
104
|
}
|
|
105
105
|
|
package/lib/web/http-routes.mjs
CHANGED
|
@@ -7,12 +7,12 @@ import process from 'node:process';
|
|
|
7
7
|
import { URL } from 'node:url';
|
|
8
8
|
import { publicCliFailure, CliError } from '../shared/errors.mjs';
|
|
9
9
|
import { required } from '../cli-args.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import { terminalAssetPath } from './terminal-assets.mjs';
|
|
11
11
|
import { projectRecord } from '../runtime/projects.mjs';
|
|
12
12
|
import { writeRuntime } from '../runtime/state.mjs';
|
|
13
13
|
import { normalizeStateResources } from '../ui/state-render.mjs';
|
|
14
14
|
import { requestUrl, runtimeBaseUrl } from '../web/runtime.mjs';
|
|
15
|
-
import { authOk, readJsonRequest, requestMatchesProxyOrigin, requestOriginMatches, sendFile, sendJson, tokenMatches } from '../web/http.mjs';
|
|
15
|
+
import { authOk, readJsonRequest, requestMatchesProxyOrigin, requestOriginMatches, sendFile, sendJson, tokenMatches, tokenlessRequestAllowed } from '../web/http.mjs';
|
|
16
16
|
import { API_VERSION, apiVersionUnsupportedBody, readHttpApiVersion } from '../web/api-version.mjs';
|
|
17
17
|
import { isDshHookBinding } from '../core/peers/bindings.mjs';
|
|
18
18
|
import { writeAdoptionState } from './adoption-state.mjs';
|
|
@@ -48,8 +48,12 @@ export function createHttpRoutes(deps) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const handleWebRequest = async (req, res) => {
|
|
51
|
-
const url = requestUrl(req);
|
|
52
51
|
try {
|
|
52
|
+
const url = requestUrl(req);
|
|
53
|
+
if (!token && !tokenlessRequestAllowed(req, { trustProxy, proxyOrigin })) {
|
|
54
|
+
sendJson(res, 403, { ok: false, error: { code: 'UNTRUSTED_HOST', message: 'Tokenless access requires a trusted local Host' } });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
53
57
|
if (req.method === 'GET' && url.pathname === '/') {
|
|
54
58
|
const accept = req.headers.accept || '';
|
|
55
59
|
const isBrowserNav = req.headers['sec-fetch-mode'] === 'navigate' || accept.includes('text/html');
|
|
@@ -85,19 +89,19 @@ export function createHttpRoutes(deps) {
|
|
|
85
89
|
return;
|
|
86
90
|
}
|
|
87
91
|
if (req.method === 'GET' && url.pathname === '/assets/xterm.js') {
|
|
88
|
-
sendFile(res,
|
|
92
|
+
sendFile(res, terminalAssetPath('xterm.js'), 'application/javascript; charset=utf-8');
|
|
89
93
|
return;
|
|
90
94
|
}
|
|
91
95
|
if (req.method === 'GET' && url.pathname === '/assets/xterm.css') {
|
|
92
|
-
sendFile(res,
|
|
96
|
+
sendFile(res, terminalAssetPath('xterm.css'), 'text/css; charset=utf-8');
|
|
93
97
|
return;
|
|
94
98
|
}
|
|
95
99
|
if (req.method === 'GET' && url.pathname === '/assets/addon-fit.js') {
|
|
96
|
-
sendFile(res,
|
|
100
|
+
sendFile(res, terminalAssetPath('addon-fit.js'), 'application/javascript; charset=utf-8');
|
|
97
101
|
return;
|
|
98
102
|
}
|
|
99
103
|
if (req.method === 'GET' && url.pathname === '/assets/addon-search.js') {
|
|
100
|
-
sendFile(res,
|
|
104
|
+
sendFile(res, terminalAssetPath('addon-search.js'), 'application/javascript; charset=utf-8');
|
|
101
105
|
return;
|
|
102
106
|
}
|
|
103
107
|
if (req.method === 'POST' && url.pathname === '/login') {
|
package/lib/web/http.mjs
CHANGED
|
@@ -84,6 +84,24 @@ export function isLoopbackRemote(req) {
|
|
|
84
84
|
return addr === '::1' || addr.startsWith('127.') || addr.startsWith('::ffff:127.');
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function localHostAuthority(req) {
|
|
88
|
+
const host = req?.headers?.host;
|
|
89
|
+
if (typeof host !== 'string') return false;
|
|
90
|
+
const match = /^(?:localhost|127\.0\.0\.1|\[::1\])(?::(\d{1,5}))?$/i.exec(host);
|
|
91
|
+
if (!match) return false;
|
|
92
|
+
return !match[1] || (Number(match[1]) > 0 && Number(match[1]) <= 65535);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function tokenlessRequestAllowed(req, options = {}) {
|
|
96
|
+
if (!isLoopbackRemote(req)) return false;
|
|
97
|
+
if (localHostAuthority(req)) return true;
|
|
98
|
+
if (!options.trustProxy || !requestMatchesProxyOrigin(req, options)) return false;
|
|
99
|
+
const expected = normalizedProxyOrigin(options.proxyOrigin);
|
|
100
|
+
const actual = normalizedOriginAuthority(req?.headers?.host, expected?.protocol);
|
|
101
|
+
return Boolean(expected && actual && actual.hostname === expected.authority.hostname &&
|
|
102
|
+
actual.port === expected.authority.port);
|
|
103
|
+
}
|
|
104
|
+
|
|
87
105
|
export function tokenMatches(provided, expected) {
|
|
88
106
|
if (!provided || !expected || provided.length !== expected.length) return false;
|
|
89
107
|
try {
|
|
@@ -174,10 +192,10 @@ export function requestOriginMatches(req, options = {}) {
|
|
|
174
192
|
}
|
|
175
193
|
}
|
|
176
194
|
|
|
177
|
-
export function authOk(url, req, token) {
|
|
195
|
+
export function authOk(url, req, token, options = {}) {
|
|
178
196
|
// No token configured: only trust loopback clients. A tokenless runtime is
|
|
179
197
|
// refused on non-loopback binds at startup, so this is defense-in-depth.
|
|
180
|
-
if (!token) return
|
|
198
|
+
if (!token) return tokenlessRequestAllowed(req, options);
|
|
181
199
|
// v1-token-query-csrf-bypass: the query token is accepted ONLY on the cookie
|
|
182
200
|
// exchange paths (the console page and the login endpoint). Everywhere else
|
|
183
201
|
// — including all API routes and the WS upgrade — a leaked ?token= URL must
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
4
|
import { CliError } from '../shared/errors.mjs';
|
|
4
5
|
import { redactSecrets } from '../shared/redact.mjs';
|
|
5
6
|
import { tx } from '../db/schema.mjs';
|
|
@@ -29,10 +30,17 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
29
30
|
return redactSecrets({ code: error.code || 'NATIVE_RUNTIME_OFFLINE',
|
|
30
31
|
message: error.message || 'Native runtime is disconnected' });
|
|
31
32
|
}
|
|
33
|
+
function publishState(session, state) {
|
|
34
|
+
// Event cursors do not cover approvals, delivery receipts or capabilities.
|
|
35
|
+
// Compare the complete normalized state; validation and lease revocation
|
|
36
|
+
// still run on every read, including reads that need no new UI frame.
|
|
37
|
+
const changed = !isDeepStrictEqual(session.nativeState, state);
|
|
38
|
+
session.nativeState = state;
|
|
39
|
+
if (changed) broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
|
|
40
|
+
}
|
|
32
41
|
function disconnect(session, error, retire = false) {
|
|
33
42
|
session.status = 'disconnected';
|
|
34
|
-
session
|
|
35
|
-
broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
|
|
43
|
+
publishState(session, { ...session.nativeState, connected: false, status: 'disconnected', error: errorInfo(error) });
|
|
36
44
|
if (retire && !session.nativeRetired) {
|
|
37
45
|
session.nativeRetired = true;
|
|
38
46
|
closeSessionClients(session);
|
|
@@ -100,7 +108,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
100
108
|
const turnId = value.snapshot.turnId || value.snapshot.activeTurnId || value.active_delivery?.turn_id || null;
|
|
101
109
|
session.binding = binding;
|
|
102
110
|
session.cwd = value.cwd;
|
|
103
|
-
|
|
111
|
+
const state = redactSecrets({ backend: 'native', connected: true, root: value.root,
|
|
104
112
|
peer: value.peer, provider: value.provider, generation: value.generation, owner: value.owner,
|
|
105
113
|
executorId: value.owner, sessionId: value.snapshot.sessionId || null, turnId,
|
|
106
114
|
status: value.snapshot.status, cwd: value.cwd, capabilities: value.snapshot.capabilities || {}, pendingApprovals: value.snapshot.pendingApprovals || [],
|
|
@@ -111,7 +119,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
111
119
|
session.status = CLOSED.has(value.snapshot.status) ? 'exited'
|
|
112
120
|
: value.snapshot.status === 'disconnected' ? 'disconnected' : 'running';
|
|
113
121
|
if (session.status === 'exited') session.exitedAt = now();
|
|
114
|
-
|
|
122
|
+
publishState(session, state);
|
|
115
123
|
return session.nativeSnapshot();
|
|
116
124
|
}
|
|
117
125
|
function makeSession(ctx, checked) {
|
|
@@ -261,8 +269,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
261
269
|
if (action === 'respond') { await readState(session); current(session); }
|
|
262
270
|
if (action === 'close') {
|
|
263
271
|
session.status = 'exited'; session.exitedAt = now();
|
|
264
|
-
session
|
|
265
|
-
broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
|
|
272
|
+
publishState(session, { ...session.nativeState, connected: false, status: 'closed', closing: false });
|
|
266
273
|
closeSessionClients(session);
|
|
267
274
|
session.actionTokens.clear();
|
|
268
275
|
}
|
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 });
|
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,
|
|
@@ -122,6 +123,7 @@ import { createAutoAttach } from '../web/auto-attach.mjs';
|
|
|
122
123
|
import { createPtySessions } from '../web/pty-sessions.mjs';
|
|
123
124
|
import { createHttpRoutes } from '../web/http-routes.mjs';
|
|
124
125
|
import { createSessionSerialize } from '../web/session-serialize.mjs';
|
|
126
|
+
import { createTmuxClientSnapshot } from '../web/tmux-client-snapshot.mjs';
|
|
125
127
|
import { createTmuxStream } from '../web/tmux-stream.mjs';
|
|
126
128
|
import {
|
|
127
129
|
ensureTmuxAvailable,
|
|
@@ -230,6 +232,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
230
232
|
const { WebSocketServer } = await import('ws');
|
|
231
233
|
const pty = ptyModule.default || ptyModule;
|
|
232
234
|
const sessions = new Map();
|
|
235
|
+
const tmuxClientSnapshot = createTmuxClientSnapshot();
|
|
233
236
|
|
|
234
237
|
const {
|
|
235
238
|
sessionKey, sessionsForProject,
|
|
@@ -237,10 +240,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
237
240
|
serializeBindingSummary, serializeSession,
|
|
238
241
|
broadcast, hasOpenClients, closeSessionClients: closeSerializedSessionClients
|
|
239
242
|
} = createSessionSerialize({ sessions, cookieSocketValid, ctx, sameResolvedPath,
|
|
240
|
-
localClientObservation:
|
|
241
|
-
try { return tmuxClientObservation(tmuxSessionNameForPane(session.pane)); }
|
|
242
|
-
catch { return { state: 'unknown', count: null }; }
|
|
243
|
-
}
|
|
243
|
+
localClientObservation: tmuxClientSnapshot.observe
|
|
244
244
|
});
|
|
245
245
|
const controlLease = createControlLease({ broadcast: (session) => {
|
|
246
246
|
for (const [actionToken, socket] of session.actionTokenSockets || []) {
|
|
@@ -488,14 +488,30 @@ const {
|
|
|
488
488
|
ACTIVE_PEER_TTL, DETECTED_PEER_MAX_AGE, PRODUCT_NAME, VERSION
|
|
489
489
|
});
|
|
490
490
|
|
|
491
|
+
const dispatchHttp = (req, res) => {
|
|
492
|
+
void handleWebRequest(req, res).catch((error) => {
|
|
493
|
+
try {
|
|
494
|
+
console.error(redactedLogText(`HTTP request failed: ${error.code || 'UNKNOWN'}`));
|
|
495
|
+
if (!res.headersSent) sendJson(res, 500, { ok: false, error: { code: 'SERVER_ERROR', message: 'internal server error' } });
|
|
496
|
+
else res.destroy();
|
|
497
|
+
} catch {
|
|
498
|
+
try { res.destroy(); } catch {}
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
};
|
|
491
502
|
const server = useTls
|
|
492
|
-
? https.createServer({ key: tlsCredentials.key, cert: tlsCredentials.cert },
|
|
493
|
-
: http.createServer(
|
|
503
|
+
? https.createServer({ key: tlsCredentials.key, cert: tlsCredentials.cert }, dispatchHttp)
|
|
504
|
+
: http.createServer(dispatchHttp);
|
|
494
505
|
|
|
495
506
|
const wss = new WebSocketServer({ noServer: true });
|
|
496
507
|
server.on('upgrade', (req, socket, head) => {
|
|
497
508
|
try {
|
|
498
509
|
const url = requestUrl(req);
|
|
510
|
+
if (!token && !tokenlessRequestAllowed(req, { trustProxy, proxyOrigin })) {
|
|
511
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
512
|
+
socket.destroy();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
499
515
|
if (!readWebSocketApiVersion(url).ok) {
|
|
500
516
|
const body = JSON.stringify(apiVersionUnsupportedBody());
|
|
501
517
|
socket.end([
|
|
@@ -621,6 +637,7 @@ const {
|
|
|
621
637
|
let cleanupPromise = null;
|
|
622
638
|
let shutdownExitCode = 0;
|
|
623
639
|
async function performRuntimeCleanup() {
|
|
640
|
+
tmuxClientSnapshot.close();
|
|
624
641
|
clearRuntime(ctx);
|
|
625
642
|
// rob-07: clear this runtime's per-project pointers for every registered
|
|
626
643
|
// project, not just the primary ctx, so sibling projects don't keep
|
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,13 @@ 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';
|
|
18
20
|
import { readHealthyGlobalRuntime, writeRuntime } from '../runtime/state.mjs';
|
|
19
21
|
import { runtimeRequest } from '../runtime/client.mjs';
|
|
22
|
+
import { withFileLock } from '../shared/file-lock.mjs';
|
|
23
|
+
import { writeJsonSafe } from '../shared/json-file.mjs';
|
|
20
24
|
import {
|
|
21
25
|
expectedWebHost,
|
|
22
26
|
isLoopbackHost,
|
|
@@ -32,6 +36,56 @@ import { API_VERSION } from '../web/api-version.mjs';
|
|
|
32
36
|
import { ensureTmuxAvailable } from '../tmux.mjs';
|
|
33
37
|
|
|
34
38
|
export const WEB_RUNTIME_START_TIMEOUT_MS = 60_000;
|
|
39
|
+
const WEB_START_LOCK_WAIT_MS = WEB_RUNTIME_START_TIMEOUT_MS + 30_000;
|
|
40
|
+
|
|
41
|
+
function webStartOwnerAlive(owner) {
|
|
42
|
+
const pid = owner?.pid;
|
|
43
|
+
if (!Number.isInteger(pid) || pid <= 0) return true;
|
|
44
|
+
const observed = inspectProcessIdentity(pid);
|
|
45
|
+
if (observed.state === 'dead') return false;
|
|
46
|
+
if (owner.process_identity && observed.state === 'live') {
|
|
47
|
+
return compareProcessIdentity(owner.process_identity, observed.identity) !== 'dead';
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function withWebStartLock(fn) {
|
|
53
|
+
const lockFile = path.join(globalStateDir(), 'web-start.lock');
|
|
54
|
+
const owner = {
|
|
55
|
+
pid: process.pid,
|
|
56
|
+
process_identity: inspectProcessIdentity(process.pid).identity,
|
|
57
|
+
nonce: randomUUID()
|
|
58
|
+
};
|
|
59
|
+
const deadline = performance.now() + WEB_START_LOCK_WAIT_MS;
|
|
60
|
+
while (true) {
|
|
61
|
+
const acquired = withFileLock(lockFile, (lockedFile) => {
|
|
62
|
+
if (fs.existsSync(lockedFile)) {
|
|
63
|
+
let previous = null;
|
|
64
|
+
try { previous = JSON.parse(fs.readFileSync(lockedFile, 'utf8')); } catch {}
|
|
65
|
+
if (!previous || webStartOwnerAlive(previous)) return false;
|
|
66
|
+
}
|
|
67
|
+
writeJsonSafe(lockedFile, owner, { mode: 0o600 });
|
|
68
|
+
return true;
|
|
69
|
+
});
|
|
70
|
+
if (acquired) break;
|
|
71
|
+
if (performance.now() >= deadline) {
|
|
72
|
+
throw new CliError('WEB_START_LOCK_TIMEOUT',
|
|
73
|
+
'Timed out waiting for another web startup to finish.');
|
|
74
|
+
}
|
|
75
|
+
await sleep(100);
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
return await fn();
|
|
79
|
+
} finally {
|
|
80
|
+
withFileLock(lockFile, (lockedFile) => {
|
|
81
|
+
let current = null;
|
|
82
|
+
try { current = JSON.parse(fs.readFileSync(lockedFile, 'utf8')); } catch {}
|
|
83
|
+
if (current?.nonce === owner.nonce && current.pid === owner.pid) {
|
|
84
|
+
fs.rmSync(lockedFile, { force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
35
89
|
|
|
36
90
|
export function createWebStartup(deps) {
|
|
37
91
|
const {
|
|
@@ -84,25 +138,41 @@ async function stopOrphanWebRuntimes(ctx, keepPid = null) {
|
|
|
84
138
|
const ppid = Number.parseInt(match[2], 10);
|
|
85
139
|
if (!Number.isFinite(pid)) continue;
|
|
86
140
|
if (Number.isFinite(ppid)) ppidByPid.set(pid, ppid);
|
|
87
|
-
rows.push({ pid, args: match[3] });
|
|
141
|
+
rows.push({ pid, ppid, args: match[3] });
|
|
88
142
|
}
|
|
89
143
|
|
|
90
144
|
const ancestorPids = currentProcessAncestorPids(ppidByPid);
|
|
91
|
-
const
|
|
145
|
+
const candidates = [];
|
|
92
146
|
for (const row of rows) {
|
|
147
|
+
// A live parent still owns its web command. In particular, another CLI
|
|
148
|
+
// invocation waiting for the startup lock is not an orphan runtime.
|
|
149
|
+
if (row.ppid !== 1) continue;
|
|
93
150
|
if (row.pid === process.pid || row.pid === keepPid || ancestorPids.has(row.pid)) continue;
|
|
94
|
-
if (hccWebProcessMatches(row.args, ctx))
|
|
151
|
+
if (!hccWebProcessMatches(row.args, ctx)) continue;
|
|
152
|
+
const observed = inspectProcessIdentity(row.pid);
|
|
153
|
+
if (observed.state === 'live' &&
|
|
154
|
+
observed.identity.commandHash === createHash('sha256').update(row.args.trim()).digest('hex')) {
|
|
155
|
+
candidates.push(observed.identity);
|
|
156
|
+
}
|
|
95
157
|
}
|
|
96
|
-
if (!
|
|
158
|
+
if (!candidates.length) return;
|
|
159
|
+
|
|
160
|
+
const stillSameProcess = (identity) => {
|
|
161
|
+
const observed = inspectProcessIdentity(identity.pid);
|
|
162
|
+
return observed.state === 'live' &&
|
|
163
|
+
compareProcessIdentity(identity, observed.identity) === 'live' &&
|
|
164
|
+
identity.commandHash === observed.identity.commandHash;
|
|
165
|
+
};
|
|
97
166
|
|
|
98
|
-
for (const
|
|
99
|
-
|
|
167
|
+
for (const identity of candidates) {
|
|
168
|
+
if (!stillSameProcess(identity)) continue;
|
|
169
|
+
try { process.kill(identity.pid, 'SIGTERM'); } catch {}
|
|
100
170
|
}
|
|
101
171
|
await sleep(250);
|
|
102
|
-
for (const
|
|
172
|
+
for (const identity of candidates) {
|
|
173
|
+
if (!stillSameProcess(identity)) continue;
|
|
103
174
|
try {
|
|
104
|
-
process.kill(pid,
|
|
105
|
-
process.kill(pid, 'SIGKILL');
|
|
175
|
+
process.kill(identity.pid, 'SIGKILL');
|
|
106
176
|
} catch {}
|
|
107
177
|
}
|
|
108
178
|
}
|
|
@@ -160,6 +230,10 @@ async function startWebBackground(ctx, args) {
|
|
|
160
230
|
assertWebTokenForHost(requestedHost, !opts['no-token']);
|
|
161
231
|
if (!isLoopbackHost(requestedHost)) console.error(redactedLogText(webExposureWarning(requestedHost, intOpt(opts, 'port', 8787)) + (opts.tls ? '' : ' Consider --tls to encrypt this connection.')));
|
|
162
232
|
ensureTmuxAvailable({ autoInstall: true });
|
|
233
|
+
return withWebStartLock(() => startWebBackgroundLocked(ctx, args, opts));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function startWebBackgroundLocked(ctx, args, opts) {
|
|
163
237
|
const setup = await prepareLocalBus(ctx, {
|
|
164
238
|
...opts,
|
|
165
239
|
installShims: process.env.HCC_SKIP_SHIM_INSTALL === '1' ? false : true
|