sliccy 4.3.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/node-server/index.js +645 -1550
- package/dist/node-server/routes/fetch-proxy.d.ts +12 -0
- package/dist/node-server/routes/fetch-proxy.js +316 -0
- package/dist/node-server/routes/handoff.d.ts +48 -0
- package/dist/node-server/routes/handoff.js +60 -0
- package/dist/node-server/routes/lick-api.d.ts +7 -0
- package/dist/node-server/routes/lick-api.js +129 -0
- package/dist/node-server/routes/lick-bridge.d.ts +16 -0
- package/dist/node-server/routes/lick-bridge.js +73 -0
- package/dist/node-server/routes/oauth-callback.d.ts +11 -0
- package/dist/node-server/routes/oauth-callback.js +62 -0
- package/dist/node-server/routes/secrets.d.ts +17 -0
- package/dist/node-server/routes/secrets.js +204 -0
- package/dist/node-server/ui-serving.d.ts +18 -0
- package/dist/node-server/ui-serving.js +91 -0
- package/dist/ui/assets/{account-store-B7QAqqi3.js → account-store-DDmdZHbw.js} +2 -2
- package/dist/ui/assets/{account-store-C8na8kHM.js → account-store-DYwow2TA.js} +2 -2
- package/dist/ui/assets/{adobe-CLAtsbgy.js → adobe-Yy9fISE-.js} +1 -1
- package/dist/ui/assets/{adobe-B3FAPCSl.js → adobe-hTPIFNJv.js} +1 -1
- package/dist/ui/assets/{agent-message-to-chat-u5bAg3Dv.js → agent-message-to-chat-9I5BpCy9.js} +1 -1
- package/dist/ui/assets/{apps-BrQb6D54.js → apps-D8Gdju73.js} +1 -1
- package/dist/ui/assets/{azure-openai-DdRohXjQ.js → azure-openai-C2W9g3IJ.js} +1 -1
- package/dist/ui/assets/{azure-openai-BfAHTf5F.js → azure-openai-DRB4IDfJ.js} +1 -1
- package/dist/ui/assets/{bsh-watchdog-Bc7J2Bk7.js → bsh-watchdog-Bt9CqKVQ.js} +1 -1
- package/dist/ui/assets/{connect-surface-FrHkI2dH.js → connect-surface-DQzchqkg.js} +1 -1
- package/dist/ui/assets/dip-DFaUkk4F.js +1 -0
- package/dist/ui/assets/{dist-eq0qrLpe.js → dist-CO9fGFcy.js} +1 -1
- package/dist/ui/assets/{dist-DOo6fKGj.js → dist-Doek6FYy.js} +1 -1
- package/dist/ui/assets/{es-CRDAPvPn.js → es-DIdFAR_x.js} +1 -1
- package/dist/ui/assets/{fs-Bd1RYTR9.js → fs-B11hxMFT.js} +2 -2
- package/dist/ui/assets/{fs-BlUrfzJM.js → fs-Tyk_pbIn.js} +1 -1
- package/dist/ui/assets/{github-Dik4gFKy.js → github-B71DA8E3.js} +2 -2
- package/dist/ui/assets/{github-CqzS1etP.js → github-CPiFDsp3.js} +1 -1
- package/dist/ui/assets/{github-copilot-yPQNbALb.js → github-copilot-BfiOICFw.js} +1 -1
- package/dist/ui/assets/{github-copilot-B2O232DQ.js → github-copilot-uEeOT_7K.js} +1 -1
- package/dist/ui/assets/{hear-UQAn2f9B.js → hear-CfpqqXo5.js} +1 -1
- package/dist/ui/assets/{kernel-worker-BMe--kS1.js → kernel-worker-CJBmf2_H.js} +631 -631
- package/dist/ui/assets/{kokoro-engine-Cu1pI-kR.js → kokoro-engine-C6AgVvUa.js} +1 -1
- package/dist/ui/assets/{lick-ws-bridge-DAuwCMQO.js → lick-ws-bridge-BMBeGHRK.js} +1 -1
- package/dist/ui/assets/{local-llm-l4-XQFY8.js → local-llm-CEaARq5R.js} +1 -1
- package/dist/ui/assets/{main-BDx9HQkN.js → main-BHrstcdQ.js} +3 -3
- package/dist/ui/assets/{mount-CWCvNUcA.js → mount-BwDyizK9.js} +1 -1
- package/dist/ui/assets/{mount-D5vdZ9ZD.js → mount-CyEIOV30.js} +2 -2
- package/dist/ui/assets/{new-session-Gr9WMwtw.js → new-session-CfhcCQAp.js} +1 -1
- package/dist/ui/assets/{oauth-bootstrap-BsfJ9QeQ.js → oauth-bootstrap-Tcg85q8p.js} +2 -2
- package/dist/ui/assets/{openai-codex-kuA3-9C7.js → openai-codex-BDz5Fxit.js} +1 -1
- package/dist/ui/assets/{openai-codex-BaNE798j.js → openai-codex-CVcod1ia.js} +1 -1
- package/dist/ui/assets/{panel-rpc-handlers-BcpEgTGa.js → panel-rpc-handlers-DPUizpgv.js} +1 -1
- package/dist/ui/assets/{provider-CEn9M9_r.js → provider-DcMNUxfM.js} +1 -1
- package/dist/ui/assets/{provider-BmbE_rtX.js → provider-DdXgyWQC.js} +2 -2
- package/dist/ui/assets/provider-store-access-BwZ-Ogkc.js +1 -0
- package/dist/ui/assets/provider-store-access-gZjBUTYS.js +1 -0
- package/dist/ui/assets/{providers-MUCDnWww.js → providers-CcWtOmn0.js} +1 -1
- package/dist/ui/assets/{quick-llm-DB--outF.js → quick-llm-BKbreZXe.js} +1 -1
- package/dist/ui/assets/session-freezer-DMAACMXD.js +1 -0
- package/dist/ui/assets/setup-sudo-CsL0Y3UH.js +1 -0
- package/dist/ui/assets/{speak-BHHbBLx8.js → speak-Bv-b3EVQ.js} +1 -1
- package/dist/ui/assets/{sprinkle-manager-COo-zwx8.js → sprinkle-manager-CBrsHbU3.js} +1 -1
- package/dist/ui/assets/{store-CqhogTXq.js → store-BwHhL-tv.js} +1 -1
- package/dist/ui/assets/{sudo-IiXNo0Z2.js → sudo-DZ9_0JRP.js} +1 -1
- package/dist/ui/assets/{transformers-env-d7AMEyAX.js → transformers-env-XuyWgfSl.js} +1 -1
- package/dist/ui/assets/{tray-leave-runtime-CQ20HzV5.js → tray-leave-runtime-Ww3km3lu.js} +1 -1
- package/dist/ui/assets/{upgrade-detection-CqAg8yKN.js → upgrade-detection-_YQCwW8c.js} +1 -1
- package/dist/ui/assets/{wc-attach-NtXmSLiR.js → wc-attach-BGApOJUg.js} +2 -2
- package/dist/ui/assets/{wc-detached-TITxuKki.js → wc-detached-mQvIrRCJ.js} +1 -1
- package/dist/ui/assets/{wc-extension-Dy7sBRt4.js → wc-extension-DmWG-O_B.js} +2 -2
- package/dist/ui/assets/{wc-live-CKs-VTsl.js → wc-live-CA1EDWiu.js} +5 -5
- package/dist/ui/assets/wc-nav-BF5SsYYe.js +2 -0
- package/dist/ui/assets/{wc-onboarding-D4j2oDHI.js → wc-onboarding-CVMo_Eqf.js} +2 -2
- package/dist/ui/assets/{wc-placeholder-Cg0ggHwo.js → wc-placeholder-BUbREW79.js} +2 -2
- package/dist/ui/assets/{wc-settings-BlGVWM4o.js → wc-settings-MQTTeP3O.js} +2 -2
- package/dist/ui/assets/{wc-shell-RljPbgFJ.js → wc-shell-CZSwcbtB.js} +9 -5
- package/dist/ui/assets/{wc-sprinkles-B791LgwY.js → wc-sprinkles-BUD5Miwy.js} +2 -2
- package/dist/ui/assets/{wc-tray-DCscOwKN.js → wc-tray-iifyjNN6.js} +3 -3
- package/dist/ui/assets/{xai-grok-Dh1WJ9QS.js → xai-grok-BrM3dm3w.js} +1 -1
- package/dist/ui/assets/{xai-grok-Z0dPXLgr.js → xai-grok-CPiFjFiB.js} +1 -1
- package/dist/ui/index.html +2 -2
- package/dist/ui/packages/webapp/index.html +2 -2
- package/package.json +6 -5
- package/dist/ui/assets/dip-CBvyzPyQ.js +0 -1
- package/dist/ui/assets/provider-store-access-BSVI8UyE.js +0 -1
- package/dist/ui/assets/provider-store-access-CWZqsjgW.js +0 -1
- package/dist/ui/assets/session-freezer-DUHrs9By.js +0 -1
- package/dist/ui/assets/setup-sudo-B5JoDQ4W.js +0 -1
- package/dist/ui/assets/wc-nav-BSmxk4Wx.js +0 -2
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { promises as fsPromises } from 'node:fs';
|
|
3
3
|
import { createSubstrate } from './_cloud_core/src/index.js';
|
|
4
|
-
import { previewSecret } from './_shared/index.js';
|
|
5
4
|
import { spawn } from 'child_process';
|
|
6
5
|
import express from 'express';
|
|
7
6
|
import { existsSync, readFileSync } from 'fs';
|
|
8
7
|
import { createServer } from 'http';
|
|
9
8
|
import { createServer as createNetServer } from 'net';
|
|
10
9
|
import { homedir } from 'os';
|
|
11
|
-
import { basename, dirname, join, resolve
|
|
12
|
-
import { Readable, Transform } from 'stream';
|
|
13
|
-
import { StringDecoder } from 'string_decoder';
|
|
10
|
+
import { basename, dirname, join, resolve } from 'path';
|
|
14
11
|
import { fileURLToPath } from 'url';
|
|
15
12
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
16
13
|
import { applyCdpUnmask } from './cdp-proxy/cdp-unmask.js';
|
|
@@ -27,20 +24,25 @@ import { runStart } from './cloud/start.js';
|
|
|
27
24
|
import { registerCloudStatusEndpoint } from './cloud-status.js';
|
|
28
25
|
import { ElectronAppAlreadyRunningError, ElectronOverlayInjector, launchElectronApp, } from './electron-controller.js';
|
|
29
26
|
import { getElectronAppPorts } from './electron-runtime.js';
|
|
30
|
-
import { FETCH_PROXY_SKIP_HEADERS } from './fetch-proxy-headers.js';
|
|
31
27
|
import { FileLogger } from './file-logger.js';
|
|
32
28
|
import { registerHostedBootstrapEndpoint } from './hosted-bootstrap.js';
|
|
33
29
|
import { resolveCliBrowserLaunchUrl } from './launch-url.js';
|
|
34
30
|
import { createHttpCdp, registerLeaderRestartEndpoint } from './leader-restart.js';
|
|
35
31
|
import { buildLocalApiDescriptor, sliccLinksMiddleware } from './links-middleware.js';
|
|
32
|
+
import { registerFetchProxyRoute } from './routes/fetch-proxy.js';
|
|
33
|
+
import { registerHandoffRoute } from './routes/handoff.js';
|
|
34
|
+
import { registerLickApiRoutes } from './routes/lick-api.js';
|
|
35
|
+
import { createLickBridge } from './routes/lick-bridge.js';
|
|
36
|
+
import { registerOAuthCallbackRoutes } from './routes/oauth-callback.js';
|
|
37
|
+
import { registerSecretRoutes } from './routes/secrets.js';
|
|
36
38
|
import { parseCliRuntimeFlags } from './runtime-flags.js';
|
|
37
39
|
import { EnvSecretStore } from './secrets/env-secret-store.js';
|
|
38
40
|
import { OauthSecretStore } from './secrets/oauth-secret-store.js';
|
|
39
41
|
import { SecretProxyManager } from './secrets/proxy-manager.js';
|
|
40
42
|
import { readOrCreateSessionId } from './secrets/session-id-file.js';
|
|
41
|
-
import { handleDaSignAndForward, handleS3SignAndForward } from './secrets/sign-and-forward.js';
|
|
42
43
|
import { registerSecretsReloadEndpoint } from './secrets-reload-endpoint.js';
|
|
43
44
|
import { registerSudoApproveEndpoint } from './sudo/endpoint.js';
|
|
45
|
+
import { attachUiServing } from './ui-serving.js';
|
|
44
46
|
const Dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
45
47
|
const PROJECT_ROOT = resolve(Dirname, '..', '..');
|
|
46
48
|
// ---------------------------------------------------------------------------
|
|
@@ -287,279 +289,624 @@ async function attachConsoleForwarder(cdpPort, pageUrl) {
|
|
|
287
289
|
// ---------------------------------------------------------------------------
|
|
288
290
|
const PREFERRED_SERVE_PORT = parseInt(process.env['PORT'] ?? '5710', 10);
|
|
289
291
|
const PREFERRED_CDP_PORT = RUNTIME_FLAGS.cdpPort;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
292
|
+
function createServerState() {
|
|
293
|
+
return {
|
|
294
|
+
servePort: 0,
|
|
295
|
+
cdpPort: 0,
|
|
296
|
+
requestedCdpPort: 0,
|
|
297
|
+
serveOrigin: '',
|
|
298
|
+
launchedBrowserProcess: null,
|
|
299
|
+
launchedBrowserLabel: 'Browser',
|
|
300
|
+
overlayInjector: null,
|
|
301
|
+
shuttingDown: false,
|
|
302
|
+
discoveredTrayJoinUrl: RUNTIME_FLAGS.joinUrl ?? null,
|
|
303
|
+
cdpUrl: null,
|
|
304
|
+
chromeWs: null,
|
|
305
|
+
activeClientWs: null,
|
|
306
|
+
messageBuffer: null,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Resolve the serve + CDP ports before anything else — the serve port must be
|
|
311
|
+
* known before Chrome launches (the launch URL embeds it). Electron apps may
|
|
312
|
+
* use a hash-derived dynamic port pair; otherwise we probe the preferred serve
|
|
313
|
+
* port and let Chrome pick its own CDP port (requestedCdpPort=0).
|
|
314
|
+
*/
|
|
315
|
+
async function resolvePorts(state) {
|
|
296
316
|
let usingDynamicElectronPorts = false;
|
|
297
317
|
if (ELECTRON_MODE && ELECTRON_APP && !RUNTIME_FLAGS.explicitCdpPort) {
|
|
298
|
-
// Dynamic port allocation for Electron apps (hash-based with fallback)
|
|
299
318
|
const ports = await getElectronAppPorts(ELECTRON_APP);
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
319
|
+
state.cdpPort = ports.cdpPort;
|
|
320
|
+
state.servePort = ports.servePort;
|
|
321
|
+
state.requestedCdpPort = ports.cdpPort;
|
|
303
322
|
usingDynamicElectronPorts = true;
|
|
304
323
|
}
|
|
305
324
|
else {
|
|
306
|
-
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
CDP_PORT = ELECTRON_MODE ? PREFERRED_CDP_PORT : 0;
|
|
325
|
+
state.servePort = await findAvailablePort(PREFERRED_SERVE_PORT);
|
|
326
|
+
// Pass 0 for Chrome CDP so Chrome picks an available port (parsed from its
|
|
327
|
+
// stderr) — avoids a race where Node's probe succeeds but Chrome can't bind.
|
|
328
|
+
// Electron keeps the preferred port (external CDP, not launched by us).
|
|
329
|
+
state.requestedCdpPort = ELECTRON_MODE ? PREFERRED_CDP_PORT : 0;
|
|
330
|
+
state.cdpPort = ELECTRON_MODE ? PREFERRED_CDP_PORT : 0;
|
|
313
331
|
}
|
|
314
|
-
|
|
332
|
+
state.serveOrigin = `http://localhost:${state.servePort}`;
|
|
315
333
|
if (usingDynamicElectronPorts) {
|
|
316
|
-
console.log(`Dynamic port allocation for Electron app: CDP=${
|
|
334
|
+
console.log(`Dynamic port allocation for Electron app: CDP=${state.cdpPort}, serve=${state.servePort}`);
|
|
317
335
|
}
|
|
318
|
-
else if (
|
|
319
|
-
console.log(`Port ${PREFERRED_SERVE_PORT} in use, serving on port ${
|
|
336
|
+
else if (state.servePort !== PREFERRED_SERVE_PORT) {
|
|
337
|
+
console.log(`Port ${PREFERRED_SERVE_PORT} in use, serving on port ${state.servePort}`);
|
|
320
338
|
}
|
|
321
|
-
if (DEV_MODE)
|
|
339
|
+
if (DEV_MODE)
|
|
322
340
|
console.log('Starting in dev mode (Vite HMR enabled)');
|
|
323
|
-
}
|
|
324
341
|
if (SERVE_ONLY) {
|
|
325
|
-
console.log(`Starting in serve-only mode (reusing external CDP on port ${
|
|
342
|
+
console.log(`Starting in serve-only mode (reusing external CDP on port ${state.cdpPort})`);
|
|
326
343
|
}
|
|
327
|
-
if (ELECTRON_MODE)
|
|
344
|
+
if (ELECTRON_MODE)
|
|
328
345
|
console.log('Starting in Electron mode');
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
let overlayInjector = null;
|
|
333
|
-
let shuttingDown = false;
|
|
334
|
-
// Tray join URL discovered from an existing leader on the preferred port.
|
|
335
|
-
// Populated in Electron mode when auto-discovering the leader's tray.
|
|
336
|
-
let discoveredTrayJoinUrl = RUNTIME_FLAGS.joinUrl ?? null;
|
|
337
|
-
// 1. Launch Chrome unless an external CDP provider is already running.
|
|
346
|
+
}
|
|
347
|
+
/** Launch Chrome / Electron unless an external CDP provider is already running. */
|
|
348
|
+
async function launchBrowser(state) {
|
|
338
349
|
if (ELECTRON_MODE && !SERVE_ONLY) {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
350
|
+
await launchElectronTarget(state);
|
|
351
|
+
}
|
|
352
|
+
else if (!SERVE_ONLY) {
|
|
353
|
+
await launchChromeTarget(state);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Poll an existing leader on the preferred port for its tray join URL. The
|
|
358
|
+
* leader may still be minting its tray session, so retry a few times.
|
|
359
|
+
*/
|
|
360
|
+
async function discoverLeaderTrayJoinUrl() {
|
|
361
|
+
const leaderOrigin = `http://localhost:${PREFERRED_SERVE_PORT}`;
|
|
362
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
343
363
|
try {
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
cdpPort: CDP_PORT,
|
|
347
|
-
kill: KILL_EXISTING_ELECTRON_APP,
|
|
364
|
+
const resp = await fetch(`${leaderOrigin}/api/tray-status`, {
|
|
365
|
+
signal: AbortSignal.timeout(3000),
|
|
348
366
|
});
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
let exitResolve = null;
|
|
356
|
-
const exitPromise = new Promise((resolve) => {
|
|
357
|
-
exitResolve = resolve;
|
|
358
|
-
});
|
|
359
|
-
child.on('exit', (code) => {
|
|
360
|
-
exitCode = code;
|
|
361
|
-
exitResolve?.();
|
|
362
|
-
if (shuttingDown)
|
|
363
|
-
return;
|
|
364
|
-
if (cdpConnected) {
|
|
365
|
-
// Normal exit after we connected
|
|
366
|
-
console.log(`${displayName} exited with code ${code}`);
|
|
367
|
-
process.exit(0);
|
|
368
|
-
}
|
|
369
|
-
// If CDP not yet connected, don't exit - let waitForCDP handle it
|
|
370
|
-
});
|
|
371
|
-
console.log(`Waiting for ${displayName} CDP on port ${CDP_PORT}...`);
|
|
372
|
-
try {
|
|
373
|
-
// Race between CDP connection and app exit
|
|
374
|
-
await Promise.race([
|
|
375
|
-
waitForCDP(CDP_PORT, 40, 500).then(() => {
|
|
376
|
-
cdpConnected = true;
|
|
377
|
-
}),
|
|
378
|
-
exitPromise.then(() => {
|
|
379
|
-
if (!cdpConnected) {
|
|
380
|
-
throw new Error('app-exited');
|
|
381
|
-
}
|
|
382
|
-
}),
|
|
383
|
-
]);
|
|
367
|
+
if (!resp.ok)
|
|
368
|
+
break;
|
|
369
|
+
const status = (await resp.json());
|
|
370
|
+
if (status.joinUrl) {
|
|
371
|
+
console.log(`Discovered leader tray join URL: ${status.joinUrl}`);
|
|
372
|
+
return status.joinUrl;
|
|
384
373
|
}
|
|
385
|
-
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
console.error(`\n${displayName} exited with code ${exitCode} before remote debugging was available.`);
|
|
389
|
-
console.error('This usually means the app has disabled remote debugging (EnableNodeCliInspectArguments fuse).');
|
|
390
|
-
console.error('Some Electron apps disable this for security. Check if there is a developer/debug build available.\n');
|
|
391
|
-
process.exit(1);
|
|
392
|
-
}
|
|
393
|
-
throw new Error(`Could not connect to ${displayName} CDP on port ${CDP_PORT}`);
|
|
374
|
+
if (status.state === 'connecting') {
|
|
375
|
+
// Leader is still setting up — wait and retry.
|
|
376
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
394
377
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
if (!discoveredTrayJoinUrl && SERVE_PORT !== PREFERRED_SERVE_PORT) {
|
|
399
|
-
const leaderOrigin = `http://localhost:${PREFERRED_SERVE_PORT}`;
|
|
400
|
-
for (let attempt = 0; attempt < 5 && !discoveredTrayJoinUrl; attempt++) {
|
|
401
|
-
try {
|
|
402
|
-
const resp = await fetch(`${leaderOrigin}/api/tray-status`, {
|
|
403
|
-
signal: AbortSignal.timeout(3000),
|
|
404
|
-
});
|
|
405
|
-
if (resp.ok) {
|
|
406
|
-
const status = (await resp.json());
|
|
407
|
-
if (status.joinUrl) {
|
|
408
|
-
discoveredTrayJoinUrl = status.joinUrl;
|
|
409
|
-
console.log(`Discovered leader tray join URL: ${status.joinUrl}`);
|
|
410
|
-
}
|
|
411
|
-
else if (status.state === 'connecting') {
|
|
412
|
-
// Leader is still setting up — wait and retry
|
|
413
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
414
|
-
}
|
|
415
|
-
else {
|
|
416
|
-
console.log(`Leader on port ${PREFERRED_SERVE_PORT} has no active tray (state: ${status.state ?? 'unknown'})`);
|
|
417
|
-
break;
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
else {
|
|
421
|
-
break;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
catch {
|
|
425
|
-
// Leader not reachable or no tray status endpoint — continue without tray
|
|
426
|
-
break;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
378
|
+
else {
|
|
379
|
+
console.log(`Leader on port ${PREFERRED_SERVE_PORT} has no active tray (state: ${status.state ?? 'unknown'})`);
|
|
380
|
+
break;
|
|
429
381
|
}
|
|
430
382
|
}
|
|
431
|
-
catch
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
process.exit(1);
|
|
435
|
-
}
|
|
436
|
-
throw error;
|
|
383
|
+
catch {
|
|
384
|
+
// Leader not reachable or no tray status endpoint — continue without tray.
|
|
385
|
+
break;
|
|
437
386
|
}
|
|
438
387
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Race the Electron app's CDP becoming available against the app exiting. A
|
|
392
|
+
* quick exit before CDP connects usually means remote debugging is fused off.
|
|
393
|
+
*/
|
|
394
|
+
async function waitForElectronCdp(state, displayName) {
|
|
395
|
+
const child = state.launchedBrowserProcess;
|
|
396
|
+
let cdpConnected = false;
|
|
397
|
+
let exitCode = null;
|
|
398
|
+
let exitResolve = null;
|
|
399
|
+
const exitPromise = new Promise((resolve) => {
|
|
400
|
+
exitResolve = resolve;
|
|
401
|
+
});
|
|
402
|
+
child.on('exit', (code) => {
|
|
403
|
+
exitCode = code;
|
|
404
|
+
exitResolve?.();
|
|
405
|
+
if (state.shuttingDown)
|
|
406
|
+
return;
|
|
407
|
+
if (cdpConnected) {
|
|
408
|
+
console.log(`${displayName} exited with code ${code}`);
|
|
409
|
+
process.exit(0);
|
|
452
410
|
}
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
411
|
+
// CDP not yet connected — let waitForCDP handle it.
|
|
412
|
+
});
|
|
413
|
+
console.log(`Waiting for ${displayName} CDP on port ${state.cdpPort}...`);
|
|
414
|
+
try {
|
|
415
|
+
await Promise.race([
|
|
416
|
+
waitForCDP(state.cdpPort, 40, 500).then(() => {
|
|
417
|
+
cdpConnected = true;
|
|
418
|
+
}),
|
|
419
|
+
exitPromise.then(() => {
|
|
420
|
+
if (!cdpConnected)
|
|
421
|
+
throw new Error('app-exited');
|
|
422
|
+
}),
|
|
423
|
+
]);
|
|
424
|
+
}
|
|
425
|
+
catch (_err) {
|
|
426
|
+
if (exitCode !== null) {
|
|
427
|
+
console.error(`\n${displayName} exited with code ${exitCode} before remote debugging was available.`);
|
|
428
|
+
console.error('This usually means the app has disabled remote debugging (EnableNodeCliInspectArguments fuse).');
|
|
429
|
+
console.error('Some Electron apps disable this for security. Check if there is a developer/debug build available.\n');
|
|
430
|
+
process.exit(1);
|
|
457
431
|
}
|
|
458
|
-
|
|
459
|
-
|
|
432
|
+
throw new Error(`Could not connect to ${displayName} CDP on port ${state.cdpPort}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async function launchElectronTarget(state) {
|
|
436
|
+
if (!ELECTRON_APP) {
|
|
437
|
+
console.error('Electron mode requires an app path. Pass --electron <path> or --electron-app=<path>.');
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
const { child, displayName } = await launchElectronApp({
|
|
442
|
+
appPath: ELECTRON_APP,
|
|
443
|
+
cdpPort: state.cdpPort,
|
|
444
|
+
kill: KILL_EXISTING_ELECTRON_APP,
|
|
445
|
+
});
|
|
446
|
+
state.launchedBrowserProcess = child;
|
|
447
|
+
state.launchedBrowserLabel = displayName;
|
|
448
|
+
pipeChildOutput(child, 'electron-app');
|
|
449
|
+
await waitForElectronCdp(state, displayName);
|
|
450
|
+
console.log(`Connected to ${displayName} on CDP port ${state.cdpPort}`);
|
|
451
|
+
// Auto-discover the leader's tray join URL when another instance is on the preferred port.
|
|
452
|
+
if (!state.discoveredTrayJoinUrl && state.servePort !== PREFERRED_SERVE_PORT) {
|
|
453
|
+
state.discoveredTrayJoinUrl = await discoverLeaderTrayJoinUrl();
|
|
460
454
|
}
|
|
461
|
-
|
|
462
|
-
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
if (error instanceof ElectronAppAlreadyRunningError) {
|
|
458
|
+
console.error(error.message);
|
|
459
|
+
process.exit(1);
|
|
463
460
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/** Build the Chrome launch URL, appending hosted-runtime + prompt query params. */
|
|
465
|
+
function buildBrowserLaunchUrl(state) {
|
|
466
|
+
let url = resolveCliBrowserLaunchUrl({
|
|
467
|
+
serveOrigin: state.serveOrigin,
|
|
468
|
+
lead: RUNTIME_FLAGS.lead,
|
|
469
|
+
leadWorkerBaseUrl: RUNTIME_FLAGS.leadWorkerBaseUrl,
|
|
470
|
+
envWorkerBaseUrl: process.env['WORKER_BASE_URL'] ?? null,
|
|
471
|
+
join: RUNTIME_FLAGS.join,
|
|
472
|
+
joinUrl: RUNTIME_FLAGS.joinUrl,
|
|
473
|
+
});
|
|
474
|
+
if (RUNTIME_FLAGS.hosted) {
|
|
475
|
+
url += `${url.includes('?') ? '&' : '?'}runtime=hosted-leader`;
|
|
476
|
+
}
|
|
477
|
+
if (RUNTIME_FLAGS.prompt) {
|
|
478
|
+
url += `${url.includes('?') ? '&' : '?'}prompt=${encodeURIComponent(RUNTIME_FLAGS.prompt)}`;
|
|
479
|
+
}
|
|
480
|
+
if (RUNTIME_FLAGS.join) {
|
|
481
|
+
console.log(`Join launch URL: ${url}`);
|
|
482
|
+
}
|
|
483
|
+
else if (RUNTIME_FLAGS.lead) {
|
|
484
|
+
console.log(`Lead launch URL: ${url}`);
|
|
485
|
+
}
|
|
486
|
+
return url;
|
|
487
|
+
}
|
|
488
|
+
function resolveChromeProfileOrExit(state) {
|
|
489
|
+
try {
|
|
490
|
+
const resolved = resolveChromeLaunchProfile({
|
|
491
|
+
projectRoot: PROJECT_ROOT,
|
|
492
|
+
profile: RUNTIME_FLAGS.profile,
|
|
493
|
+
servePort: state.servePort,
|
|
484
494
|
});
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
process.
|
|
495
|
+
// Override the user data dir in hosted mode to use a persistent profile.
|
|
496
|
+
if (RUNTIME_FLAGS.hosted) {
|
|
497
|
+
resolved.userDataDir = process.env['CHROME_USER_DATA_DIR'] ?? '/data/profile';
|
|
488
498
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
499
|
+
return resolved;
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async function launchChromeTarget(state) {
|
|
507
|
+
const browserLaunchUrl = buildBrowserLaunchUrl(state);
|
|
508
|
+
const chromeProfile = resolveChromeProfileOrExit(state);
|
|
509
|
+
const chromePath = findChromeExecutable({
|
|
510
|
+
executablePreference: !DEV_MODE && !chromeProfile.id ? 'installed' : 'chrome-for-testing',
|
|
511
|
+
});
|
|
512
|
+
if (!chromePath) {
|
|
513
|
+
console.error('Could not find Chrome/Chromium. Please install Chrome or set CHROME_PATH.');
|
|
514
|
+
process.exit(1);
|
|
515
|
+
}
|
|
516
|
+
console.log(`Found Chrome: ${chromePath}`);
|
|
517
|
+
if (chromeProfile.id) {
|
|
518
|
+
await ensureQaProfileScaffold(PROJECT_ROOT);
|
|
519
|
+
}
|
|
520
|
+
else if (!RUNTIME_FLAGS.hosted) {
|
|
521
|
+
const profileDirName = basename(chromeProfile.userDataDir);
|
|
522
|
+
await migrateLegacyDefaultChromeProfile(chromeProfile.userDataDir, legacyChromeCandidates(profileDirName));
|
|
523
|
+
}
|
|
524
|
+
if (chromeProfile.extensionPath && !existsSync(chromeProfile.extensionPath)) {
|
|
525
|
+
console.error(`Extension profile requires ${chromeProfile.extensionPath}. Run \`npm run build -w @slicc/chrome-extension\` first.`);
|
|
526
|
+
process.exit(1);
|
|
527
|
+
}
|
|
528
|
+
if (chromeProfile.id) {
|
|
529
|
+
console.log(`Using QA Chrome profile: ${chromeProfile.id}`);
|
|
530
|
+
console.log(`Profile directory: ${chromeProfile.userDataDir}`);
|
|
531
|
+
if (chromeProfile.extensionPath) {
|
|
532
|
+
console.log(`Auto-loading unpacked extension from ${chromeProfile.extensionPath}`);
|
|
492
533
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
534
|
+
}
|
|
535
|
+
const chromeArgs = buildChromeLaunchArgs({
|
|
536
|
+
cdpPort: state.requestedCdpPort,
|
|
537
|
+
launchUrl: browserLaunchUrl,
|
|
538
|
+
profile: chromeProfile,
|
|
539
|
+
hosted: RUNTIME_FLAGS.hosted,
|
|
540
|
+
});
|
|
541
|
+
// Chrome never clears DevToolsActivePort on shutdown, so a stale file from a
|
|
542
|
+
// prior crash/SIGKILL would let our active-port poller win the race with the
|
|
543
|
+
// wrong port. Clear it before spawn.
|
|
544
|
+
await clearStaleDevToolsActivePort(chromeProfile.userDataDir);
|
|
545
|
+
// On macOS, route through `/usr/bin/open` so LaunchServices owns the new Chrome
|
|
546
|
+
// process — otherwise the launching terminal stays in Chrome's TCC responsibility
|
|
547
|
+
// chain and silently breaks getUserMedia() camera/mic grants.
|
|
548
|
+
const spawnPlan = planChromeSpawn({ executablePath: chromePath, chromeArgs });
|
|
549
|
+
state.launchedBrowserProcess = spawn(spawnPlan.command, spawnPlan.args, {
|
|
550
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
551
|
+
detached: false,
|
|
552
|
+
env: { ...process.env, GOOGLE_CRASHPAD_DISABLE: '1' },
|
|
553
|
+
});
|
|
554
|
+
state.launchedBrowserLabel = chromeProfile.displayName;
|
|
555
|
+
// Use the stderr-vs-DevToolsActivePort race so this works in both direct-exec
|
|
556
|
+
// mode (Linux/Windows stderr banner) and LaunchServices mode (macOS active-port file).
|
|
557
|
+
state.cdpPort = await waitForCdpPort(state.launchedBrowserProcess, {
|
|
558
|
+
userDataDir: chromeProfile.userDataDir,
|
|
559
|
+
});
|
|
560
|
+
console.log(`Chrome CDP listening on port ${state.cdpPort}`);
|
|
561
|
+
pipeChildOutput(state.launchedBrowserProcess, 'chrome');
|
|
562
|
+
state.launchedBrowserProcess.on('exit', (code) => {
|
|
563
|
+
if (state.shuttingDown)
|
|
564
|
+
return;
|
|
565
|
+
console.log(`Chrome exited with code ${code}`);
|
|
566
|
+
process.exit(0);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
const CDP_PROXY_INSPECT_BYTES = 256 * 1024;
|
|
570
|
+
const CDP_PROXY_HARD_FRAME_CAP = 64 * 1024 * 1024;
|
|
571
|
+
const CDP_LOOP_EVENT_PREFIXES = [
|
|
572
|
+
'{"method":"Network.webSocketFrameReceived"',
|
|
573
|
+
'{"method":"Network.webSocketFrameSent"',
|
|
574
|
+
];
|
|
575
|
+
/**
|
|
576
|
+
* Normalise the `ws` library's polymorphic message payload into a single Buffer
|
|
577
|
+
* we can peek at and forward. Without this, a later `String(data)` would coerce
|
|
578
|
+
* an `ArrayBuffer` to `"[object ArrayBuffer]"` and a `Buffer[]` to comma-joined
|
|
579
|
+
* fragments, corrupting the CDP frame.
|
|
580
|
+
*/
|
|
581
|
+
function cdpFrameToBuffer(data) {
|
|
582
|
+
if (Buffer.isBuffer(data))
|
|
583
|
+
return data;
|
|
584
|
+
if (data instanceof ArrayBuffer)
|
|
585
|
+
return Buffer.from(data);
|
|
586
|
+
if (Array.isArray(data))
|
|
587
|
+
return Buffer.concat(data);
|
|
588
|
+
// Rare fallback — string frames in text mode. Keep bytes faithful.
|
|
589
|
+
return Buffer.from(String(data));
|
|
590
|
+
}
|
|
591
|
+
function closeWebSocketQuietly(ws) {
|
|
592
|
+
if (!ws)
|
|
593
|
+
return;
|
|
594
|
+
try {
|
|
595
|
+
ws.close();
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
/* ignore */
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/** Route `/cdp` and `/licks-ws` upgrades to their servers; leave others for Vite HMR. */
|
|
602
|
+
function attachCdpUpgradeRouting(server, wss, lickWss) {
|
|
603
|
+
server.on('upgrade', (request, socket, head) => {
|
|
604
|
+
const { pathname } = new URL(request.url, `http://${request.headers.host}`);
|
|
605
|
+
if (pathname === '/cdp') {
|
|
606
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
607
|
+
wss.emit('connection', ws, request);
|
|
608
|
+
});
|
|
496
609
|
}
|
|
497
|
-
if (
|
|
498
|
-
|
|
499
|
-
|
|
610
|
+
else if (pathname === '/licks-ws') {
|
|
611
|
+
lickWss.handleUpgrade(request, socket, head, (ws) => {
|
|
612
|
+
lickWss.emit('connection', ws, request);
|
|
613
|
+
});
|
|
500
614
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Apply the Client→Chrome unmask gate to a buffered frame on flush. Shared by
|
|
619
|
+
* both buffer-drain sites (ensureChromeConnection already-open path + the
|
|
620
|
+
* chromeWs 'open' handler); falls back to the original bytes on any error.
|
|
621
|
+
*/
|
|
622
|
+
function flushClientFrame(target, raw, ctx) {
|
|
623
|
+
const original = String(raw);
|
|
624
|
+
const { output } = applyCdpUnmask(original, {
|
|
625
|
+
tracker: ctx.cdpSessionUrls,
|
|
626
|
+
pipeline: ctx.secretProxy.rawPipeline,
|
|
627
|
+
});
|
|
628
|
+
target.send(output);
|
|
629
|
+
}
|
|
630
|
+
function flushBufferedClientFrames(state, target, ctx) {
|
|
631
|
+
if (!state.messageBuffer)
|
|
632
|
+
return;
|
|
633
|
+
for (const msg of state.messageBuffer) {
|
|
634
|
+
flushClientFrame(target, msg, ctx);
|
|
635
|
+
}
|
|
636
|
+
state.messageBuffer = null;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Forward one Chrome→Client frame, dropping the self-amplifying
|
|
640
|
+
* `Network.webSocketFrame*` feedback-loop events (the slicc UI runs inside the
|
|
641
|
+
* Chrome it debugs, so those embed prior frames and blow past V8's string cap)
|
|
642
|
+
* and any frame over the hard cap.
|
|
643
|
+
*/
|
|
644
|
+
function forwardChromeFrame(state, buf, ctx) {
|
|
645
|
+
const byteLen = buf.length;
|
|
646
|
+
// Peek at the first 256 KiB only — enough to identify the event type cheaply.
|
|
647
|
+
const head = buf.subarray(0, CDP_PROXY_INSPECT_BYTES).toString();
|
|
648
|
+
if (CDP_LOOP_EVENT_PREFIXES.some((p) => head.startsWith(p))) {
|
|
649
|
+
const msg = `[cdp-proxy] Dropping Chrome feedback-loop event (${byteLen} bytes, ${head.slice(1, 60)}…)`;
|
|
650
|
+
if (ctx.cdpDedup.shouldLog(msg))
|
|
651
|
+
console.debug(msg);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (byteLen > CDP_PROXY_HARD_FRAME_CAP) {
|
|
655
|
+
const msg = `[cdp-proxy] Dropping oversized Chrome→Client frame (${byteLen} bytes)`;
|
|
656
|
+
if (ctx.cdpDedup.shouldLog(msg))
|
|
657
|
+
console.debug(msg);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const str = buf.toString();
|
|
661
|
+
const msg = `[cdp-proxy] Chrome→Client: ${str.slice(0, 200)}`;
|
|
662
|
+
if (ctx.cdpDedup.shouldLog(msg))
|
|
663
|
+
console.debug(msg);
|
|
664
|
+
// Sniff Target.attachedToTarget / targetInfoChanged / Page.frameNavigated so
|
|
665
|
+
// the Client→Chrome unmask gate can resolve per-session hostnames.
|
|
666
|
+
ctx.cdpSessionUrls.observeChromeToClient(str);
|
|
667
|
+
if (state.activeClientWs && state.activeClientWs.readyState === WebSocket.OPEN) {
|
|
668
|
+
state.activeClientWs.send(str);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function ensureChromeConnection(state, url, ctx) {
|
|
672
|
+
return new Promise((resolve, reject) => {
|
|
673
|
+
if (state.chromeWs && state.chromeWs.readyState === WebSocket.OPEN) {
|
|
674
|
+
// Already connected — flush any buffered messages and go direct.
|
|
675
|
+
flushBufferedClientFrames(state, state.chromeWs, ctx);
|
|
676
|
+
resolve();
|
|
677
|
+
return;
|
|
507
678
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
679
|
+
closeWebSocketQuietly(state.chromeWs);
|
|
680
|
+
state.messageBuffer = [];
|
|
681
|
+
// Disable the ws library's per-message size cap (default 100 MiB). The slicc
|
|
682
|
+
// UI runs INSIDE the Chrome it debugs, so Chrome's Network domain reports
|
|
683
|
+
// every CDP frame back as `Network.webSocketFrame*` events embedding prior
|
|
684
|
+
// payloads — an exponential loop that would trip the cap and close the
|
|
685
|
+
// socket (code 1006). forwardChromeFrame drops those events by method.
|
|
686
|
+
const chromeWs = new WebSocket(url, { maxPayload: 0 });
|
|
687
|
+
state.chromeWs = chromeWs;
|
|
688
|
+
chromeWs.on('open', () => {
|
|
689
|
+
console.log('[cdp-proxy] chromeWs open');
|
|
690
|
+
flushBufferedClientFrames(state, chromeWs, ctx);
|
|
691
|
+
resolve();
|
|
513
692
|
});
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
// `.qa/chrome/<profile>` QA profiles). Chrome never proactively
|
|
517
|
-
// clears `DevToolsActivePort` on shutdown, so a stale file from a
|
|
518
|
-
// previous crash/SIGKILL would let our active-port-file poller win
|
|
519
|
-
// the race instantly with the wrong port. Clear it before spawn.
|
|
520
|
-
await clearStaleDevToolsActivePort(chromeProfile.userDataDir);
|
|
521
|
-
// On macOS, route through `/usr/bin/open` so LaunchServices owns the
|
|
522
|
-
// new Chrome process. Without this hop the terminal that started
|
|
523
|
-
// `node` stays in Chrome's TCC responsibility chain, which silently
|
|
524
|
-
// breaks `getUserMedia()` (camera/mic in Google Meet, Zoom, etc.)
|
|
525
|
-
// whenever the terminal hasn't already been granted camera/microphone
|
|
526
|
-
// access. With LaunchServices in the loop, Chrome becomes its own
|
|
527
|
-
// TCC responsible process and the user's
|
|
528
|
-
// `/Applications/Google Chrome.app` privacy grant applies as expected.
|
|
529
|
-
const spawnPlan = planChromeSpawn({ executablePath: chromePath, chromeArgs });
|
|
530
|
-
launchedBrowserProcess = spawn(spawnPlan.command, spawnPlan.args, {
|
|
531
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
532
|
-
detached: false,
|
|
533
|
-
env: { ...process.env, GOOGLE_CRASHPAD_DISABLE: '1' },
|
|
693
|
+
chromeWs.on('message', (data) => {
|
|
694
|
+
forwardChromeFrame(state, cdpFrameToBuffer(data), ctx);
|
|
534
695
|
});
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
// stderr carries Chrome's banner) and LaunchServices mode (macOS,
|
|
539
|
-
// where stderr belongs to `open` and only the active-port file
|
|
540
|
-
// surfaces the real CDP port).
|
|
541
|
-
const actualCdpPort = await waitForCdpPort(launchedBrowserProcess, {
|
|
542
|
-
userDataDir: chromeProfile.userDataDir,
|
|
696
|
+
chromeWs.on('close', (code, reason) => {
|
|
697
|
+
console.log(`[cdp-proxy] Chrome WS closed. code=${code}, reason=${String(reason)}`);
|
|
698
|
+
state.chromeWs = null;
|
|
543
699
|
});
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
700
|
+
chromeWs.on('error', (err) => {
|
|
701
|
+
console.log(`[cdp-proxy] Chrome WS error: ${err}`);
|
|
702
|
+
state.chromeWs = null;
|
|
703
|
+
reject(err);
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
/** Forward one Client→Chrome frame, buffering it when Chrome isn't ready yet. */
|
|
708
|
+
function forwardClientFrame(state, data, ctx) {
|
|
709
|
+
const original = String(data);
|
|
710
|
+
const preview = original.slice(0, 200);
|
|
711
|
+
if (state.chromeWs &&
|
|
712
|
+
state.chromeWs.readyState === WebSocket.OPEN &&
|
|
713
|
+
state.messageBuffer === null) {
|
|
714
|
+
const msg = `[cdp-proxy] Client→Chrome: ${preview}`;
|
|
715
|
+
if (ctx.cdpDedup.shouldLog(msg))
|
|
716
|
+
console.debug(msg);
|
|
717
|
+
const { output } = applyCdpUnmask(original, {
|
|
718
|
+
tracker: ctx.cdpSessionUrls,
|
|
719
|
+
pipeline: ctx.secretProxy.rawPipeline,
|
|
552
720
|
});
|
|
721
|
+
state.chromeWs.send(output);
|
|
553
722
|
}
|
|
554
|
-
|
|
723
|
+
else if (state.messageBuffer !== null) {
|
|
724
|
+
// Buffer the ORIGINAL bytes; unmask runs on flush so the hostname tracker
|
|
725
|
+
// reflects the state at send time.
|
|
726
|
+
state.messageBuffer.push(data);
|
|
727
|
+
const msg = `[cdp-proxy] Client→Chrome (buffered): ${preview}`;
|
|
728
|
+
if (ctx.cdpDedup.shouldLog(msg))
|
|
729
|
+
console.debug(msg);
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
console.log(`[cdp-proxy] Client→Chrome (DROPPED — no connection): ${preview}`);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
async function handleCdpClient(state, clientWs, ctx, cdpPort) {
|
|
736
|
+
try {
|
|
737
|
+
// Only one client active at a time — close the previous one.
|
|
738
|
+
if (state.activeClientWs && state.activeClientWs.readyState === WebSocket.OPEN) {
|
|
739
|
+
console.log('[cdp-proxy] Closing previous client connection');
|
|
740
|
+
state.activeClientWs.close();
|
|
741
|
+
}
|
|
742
|
+
state.activeClientWs = clientWs;
|
|
743
|
+
console.log('[cdp-proxy] New client connected');
|
|
744
|
+
// Initialise the buffer BEFORE any await so messages arriving during
|
|
745
|
+
// waitForCDP / ensureChromeConnection are captured, not dropped.
|
|
746
|
+
if (state.messageBuffer === null)
|
|
747
|
+
state.messageBuffer = [];
|
|
748
|
+
// Register ALL handlers BEFORE any async work so no messages are lost.
|
|
749
|
+
clientWs.on('message', (data) => {
|
|
750
|
+
forwardClientFrame(state, data, ctx);
|
|
751
|
+
});
|
|
752
|
+
clientWs.on('close', () => {
|
|
753
|
+
console.log('[cdp-proxy] Client disconnected');
|
|
754
|
+
if (state.activeClientWs === clientWs)
|
|
755
|
+
state.activeClientWs = null;
|
|
756
|
+
// Don't close chromeWs — keep it alive for the next client.
|
|
757
|
+
});
|
|
758
|
+
clientWs.on('error', (err) => {
|
|
759
|
+
console.log(`[cdp-proxy] Client WS error: ${err}`);
|
|
760
|
+
if (state.activeClientWs === clientWs)
|
|
761
|
+
state.activeClientWs = null;
|
|
762
|
+
});
|
|
763
|
+
// NOW do async work — messages arriving during these awaits are buffered.
|
|
764
|
+
if (!state.cdpUrl) {
|
|
765
|
+
state.cdpUrl = await waitForCDP(cdpPort);
|
|
766
|
+
console.log(`[cdp-proxy] CDP available at: ${state.cdpUrl}`);
|
|
767
|
+
}
|
|
768
|
+
await ensureChromeConnection(state, state.cdpUrl, ctx);
|
|
769
|
+
}
|
|
770
|
+
catch (err) {
|
|
771
|
+
console.error('[cdp-proxy] Connection error:', err);
|
|
772
|
+
clientWs.close();
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
/** Best-effort graceful close of the launched browser, escalating to SIGKILL. */
|
|
776
|
+
async function closeLaunchedBrowserGracefully(state, cdpPort) {
|
|
777
|
+
const browser = state.launchedBrowserProcess;
|
|
778
|
+
if (!browser)
|
|
779
|
+
return;
|
|
780
|
+
let browserExited = false;
|
|
781
|
+
browser.on('exit', () => {
|
|
782
|
+
browserExited = true;
|
|
783
|
+
});
|
|
784
|
+
try {
|
|
785
|
+
const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
|
|
786
|
+
const json = (await res.json());
|
|
787
|
+
const browserWs = new WebSocket(json.webSocketDebuggerUrl);
|
|
788
|
+
await new Promise((resolve, reject) => {
|
|
789
|
+
browserWs.on('open', () => {
|
|
790
|
+
browserWs.send(JSON.stringify({ id: 1, method: 'Browser.close' }));
|
|
791
|
+
resolve();
|
|
792
|
+
});
|
|
793
|
+
browserWs.on('error', reject);
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
// CDP not available — the launched browser may still be starting up; fall through to kill.
|
|
798
|
+
}
|
|
799
|
+
const deadline = Date.now() + 3000;
|
|
800
|
+
while (!browserExited && Date.now() < deadline) {
|
|
801
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
802
|
+
}
|
|
803
|
+
if (!browserExited) {
|
|
804
|
+
try {
|
|
805
|
+
browser.kill('SIGKILL');
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
/* ignore */
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
console.log(`${state.launchedBrowserLabel} closed`);
|
|
812
|
+
}
|
|
813
|
+
/** Build the idempotent graceful-shutdown handler wired to the process signals. */
|
|
814
|
+
function createGracefulShutdown(state, deps) {
|
|
815
|
+
return async () => {
|
|
816
|
+
if (state.shuttingDown)
|
|
817
|
+
return;
|
|
818
|
+
state.shuttingDown = true;
|
|
819
|
+
console.log('\nShutting down...');
|
|
820
|
+
deps.fileLogger.close();
|
|
821
|
+
state.overlayInjector?.stop();
|
|
822
|
+
state.overlayInjector = null;
|
|
823
|
+
closeWebSocketQuietly(state.chromeWs);
|
|
824
|
+
state.chromeWs = null;
|
|
825
|
+
closeWebSocketQuietly(state.activeClientWs);
|
|
826
|
+
state.activeClientWs = null;
|
|
827
|
+
for (const client of deps.wss.clients) {
|
|
828
|
+
client.close();
|
|
829
|
+
}
|
|
830
|
+
deps.wss.close();
|
|
831
|
+
// Stop accepting new HTTP connections.
|
|
832
|
+
deps.server.close();
|
|
833
|
+
await closeLaunchedBrowserGracefully(state, deps.cdpPort);
|
|
834
|
+
process.exit(0);
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
/** Pre-connect to Chrome so the proxy is warm before the first client; hosted mode registers leader-restart once CDP is ready. */
|
|
838
|
+
async function preconnectCdp(state, ctx, app, servePort, cdpPort) {
|
|
839
|
+
try {
|
|
840
|
+
state.cdpUrl = await waitForCDP(cdpPort);
|
|
841
|
+
console.log(`[cdp-proxy] Pre-connected: CDP available at ${state.cdpUrl}`);
|
|
842
|
+
await ensureChromeConnection(state, state.cdpUrl, ctx);
|
|
843
|
+
console.log('[cdp-proxy] Chrome WebSocket ready (pre-warmed)');
|
|
844
|
+
if (RUNTIME_FLAGS.hosted) {
|
|
845
|
+
registerLeaderRestartEndpoint(app, {
|
|
846
|
+
cdp: createHttpCdp(cdpPort),
|
|
847
|
+
localUrlPrefix: `http://localhost:${servePort}/`,
|
|
848
|
+
});
|
|
849
|
+
console.log('[hosted] /api/leader-restart endpoint registered');
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
catch (err) {
|
|
853
|
+
console.log('[cdp-proxy] Pre-connect failed (will retry on first client):', err);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
async function startOverlayInjector(state, cdpPort, servePort) {
|
|
857
|
+
try {
|
|
858
|
+
state.overlayInjector = await ElectronOverlayInjector.create({
|
|
859
|
+
cdpPort,
|
|
860
|
+
servePort,
|
|
861
|
+
dev: DEV_MODE,
|
|
862
|
+
projectRoot: PROJECT_ROOT,
|
|
863
|
+
});
|
|
864
|
+
await state.overlayInjector.start();
|
|
865
|
+
console.log('[electron-float] Overlay injector is watching Electron page targets');
|
|
866
|
+
}
|
|
867
|
+
catch (error) {
|
|
868
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
869
|
+
console.error('[electron-float] Failed to start overlay injector:', message);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
/** Start listening and warm up the CDP proxy, overlay injector, and console forwarder. */
|
|
873
|
+
function startCdpServer(state, deps) {
|
|
874
|
+
const { app, server, ctx, fileLogger, servePort, serveOrigin, cdpPort } = deps;
|
|
875
|
+
server.listen(servePort, '127.0.0.1', () => {
|
|
876
|
+
console.log(`Serving UI at ${serveOrigin}`);
|
|
877
|
+
console.log(`CDP proxy at ws://localhost:${servePort}/cdp`);
|
|
878
|
+
fileLogger.log('info', 'CLI server started', {
|
|
879
|
+
port: servePort,
|
|
880
|
+
cdpPort,
|
|
881
|
+
devMode: DEV_MODE,
|
|
882
|
+
electronMode: ELECTRON_MODE,
|
|
883
|
+
});
|
|
884
|
+
void preconnectCdp(state, ctx, app, servePort, cdpPort);
|
|
885
|
+
if (ELECTRON_MODE) {
|
|
886
|
+
void startOverlayInjector(state, cdpPort, servePort);
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
setTimeout(() => {
|
|
890
|
+
attachConsoleForwarder(cdpPort, String(servePort)).catch((err) => {
|
|
891
|
+
console.error('[page] Console forwarder error:', err);
|
|
892
|
+
});
|
|
893
|
+
}, 2500);
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Build the secret stores + masking pipeline shared by the fetch-proxy, the
|
|
899
|
+
* /api/secrets routes, and the S3 sign-and-forward handler. Secret-load
|
|
900
|
+
* failures are logged, not fatal.
|
|
901
|
+
*/
|
|
902
|
+
async function bootstrapSecrets() {
|
|
555
903
|
const sessionDir = RUNTIME_FLAGS.envFile
|
|
556
904
|
? dirname(RUNTIME_FLAGS.envFile)
|
|
557
905
|
: join(homedir(), '.slicc');
|
|
558
906
|
const sessionId = readOrCreateSessionId(sessionDir);
|
|
559
907
|
const oauthStore = new OauthSecretStore();
|
|
560
|
-
// Env-file secrets (~/.slicc/secrets.env) feed the fetch-proxy mask
|
|
561
|
-
//
|
|
562
|
-
// for /api/secrets and handleS3SignAndForward.
|
|
908
|
+
// Env-file secrets (~/.slicc/secrets.env) feed the fetch-proxy mask pipeline
|
|
909
|
+
// alongside OAuth tokens.
|
|
563
910
|
const secretStore = new EnvSecretStore(RUNTIME_FLAGS.envFile ?? undefined);
|
|
564
911
|
const secretProxy = new SecretProxyManager(secretStore, sessionId, oauthStore);
|
|
565
912
|
try {
|
|
@@ -571,6 +918,18 @@ async function main() {
|
|
|
571
918
|
catch (err) {
|
|
572
919
|
console.warn('Failed to load secrets:', err instanceof Error ? err.message : err);
|
|
573
920
|
}
|
|
921
|
+
return { secretStore, secretProxy, oauthStore };
|
|
922
|
+
}
|
|
923
|
+
async function main() {
|
|
924
|
+
// Resolve ports, launch the browser, then snapshot the now-final values that
|
|
925
|
+
// the rest of boot reads. Mutable lifecycle fields stay on `state`.
|
|
926
|
+
const state = createServerState();
|
|
927
|
+
await resolvePorts(state);
|
|
928
|
+
await launchBrowser(state);
|
|
929
|
+
const { servePort: SERVE_PORT, cdpPort: CDP_PORT, serveOrigin: SERVE_ORIGIN } = state;
|
|
930
|
+
const discoveredTrayJoinUrl = state.discoveredTrayJoinUrl;
|
|
931
|
+
// 3. Set up express app with request logging
|
|
932
|
+
const { secretStore, secretProxy, oauthStore } = await bootstrapSecrets();
|
|
574
933
|
const app = express();
|
|
575
934
|
app.use(requestLogger);
|
|
576
935
|
// Append SLICC's standard RFC 8288 Link header set on every /api/* response.
|
|
@@ -578,134 +937,10 @@ async function main() {
|
|
|
578
937
|
// ---------------------------------------------------------------------------
|
|
579
938
|
// Lick system — WebSocket bridge for webhooks/crontasks (all logic in browser)
|
|
580
939
|
// ---------------------------------------------------------------------------
|
|
581
|
-
|
|
582
|
-
const lickWss
|
|
583
|
-
const lickClients = new Set();
|
|
584
|
-
const pendingRequests = new Map();
|
|
585
|
-
let requestIdCounter = 0;
|
|
586
|
-
lickWss.on('connection', (ws) => {
|
|
587
|
-
lickClients.add(ws);
|
|
588
|
-
console.log('[licks] Browser client connected');
|
|
589
|
-
ws.on('message', (data) => {
|
|
590
|
-
try {
|
|
591
|
-
const msg = JSON.parse(data.toString());
|
|
592
|
-
// Handle responses to pending requests
|
|
593
|
-
if (msg.type === 'response' && msg.requestId) {
|
|
594
|
-
const pending = pendingRequests.get(msg.requestId);
|
|
595
|
-
if (pending) {
|
|
596
|
-
pendingRequests.delete(msg.requestId);
|
|
597
|
-
if (msg.error) {
|
|
598
|
-
pending.reject(new Error(msg.error));
|
|
599
|
-
}
|
|
600
|
-
else {
|
|
601
|
-
pending.resolve(msg.data);
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
catch {
|
|
607
|
-
// Ignore invalid messages
|
|
608
|
-
}
|
|
609
|
-
});
|
|
610
|
-
ws.on('close', () => {
|
|
611
|
-
lickClients.delete(ws);
|
|
612
|
-
console.log('[licks] Browser client disconnected');
|
|
613
|
-
});
|
|
614
|
-
});
|
|
615
|
-
/** Send a request to the browser and wait for response */
|
|
616
|
-
function sendLickRequest(type, data, timeout = 5000) {
|
|
617
|
-
return new Promise((resolve, reject) => {
|
|
618
|
-
const requestId = `req_${++requestIdCounter}`;
|
|
619
|
-
const msg = JSON.stringify({ type, requestId, ...data });
|
|
620
|
-
// Find a connected client
|
|
621
|
-
const client = Array.from(lickClients).find((c) => c.readyState === WebSocket.OPEN);
|
|
622
|
-
if (!client) {
|
|
623
|
-
reject(new Error('No browser connected'));
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
|
-
// Set up timeout
|
|
627
|
-
const timer = setTimeout(() => {
|
|
628
|
-
pendingRequests.delete(requestId);
|
|
629
|
-
reject(new Error('Request timeout'));
|
|
630
|
-
}, timeout);
|
|
631
|
-
pendingRequests.set(requestId, {
|
|
632
|
-
resolve: (data) => {
|
|
633
|
-
clearTimeout(timer);
|
|
634
|
-
resolve(data);
|
|
635
|
-
},
|
|
636
|
-
reject: (err) => {
|
|
637
|
-
clearTimeout(timer);
|
|
638
|
-
reject(err);
|
|
639
|
-
},
|
|
640
|
-
});
|
|
641
|
-
client.send(msg);
|
|
642
|
-
});
|
|
643
|
-
}
|
|
644
|
-
/** Broadcast an event to all connected browsers (no response expected) */
|
|
645
|
-
function broadcastLickEvent(event) {
|
|
646
|
-
const msg = JSON.stringify(event);
|
|
647
|
-
for (const client of lickClients) {
|
|
648
|
-
if (client.readyState === WebSocket.OPEN) {
|
|
649
|
-
client.send(msg);
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
// ---------------------------------------------------------------------------
|
|
940
|
+
const lickBridge = createLickBridge();
|
|
941
|
+
const { lickWss, broadcastLickEvent } = lickBridge;
|
|
654
942
|
// OAuth callback — generic redirect target for OAuth providers (implicit + PKCE)
|
|
655
|
-
|
|
656
|
-
// Pending OAuth result for server-side relay (Electron overlay can't use window.opener)
|
|
657
|
-
let pendingOAuthResult = null;
|
|
658
|
-
app.get('/auth/callback', (_req, res) => {
|
|
659
|
-
// The callback page tries window.opener.postMessage first (works in CLI popup mode).
|
|
660
|
-
// If window.opener is null (Electron overlay — opens system browser), it falls back
|
|
661
|
-
// to POSTing the result to /api/oauth-result for the UI to poll.
|
|
662
|
-
res.send(`<!DOCTYPE html><html><body><script>
|
|
663
|
-
var q = new URLSearchParams(location.search);
|
|
664
|
-
var h = new URLSearchParams(location.hash.replace(/^#/, ''));
|
|
665
|
-
var payload = {
|
|
666
|
-
type: 'oauth-callback',
|
|
667
|
-
redirectUrl: location.href,
|
|
668
|
-
code: q.get('code'),
|
|
669
|
-
state: q.get('state') || h.get('state'),
|
|
670
|
-
error: q.get('error') || h.get('error'),
|
|
671
|
-
access_token: h.get('access_token'),
|
|
672
|
-
expires_in: h.get('expires_in'),
|
|
673
|
-
token_type: h.get('token_type')
|
|
674
|
-
};
|
|
675
|
-
if (window.opener) {
|
|
676
|
-
window.opener.postMessage(payload, '*');
|
|
677
|
-
} else {
|
|
678
|
-
fetch('/api/oauth-result', {
|
|
679
|
-
method: 'POST',
|
|
680
|
-
headers: { 'Content-Type': 'application/json' },
|
|
681
|
-
body: JSON.stringify(payload)
|
|
682
|
-
}).catch(function(err) { console.error('[oauth-callback] Failed to relay result to server:', err); });
|
|
683
|
-
}
|
|
684
|
-
window.close();
|
|
685
|
-
</script><p>Completing login... you can close this window.</p></body></html>`);
|
|
686
|
-
});
|
|
687
|
-
app.post('/api/oauth-result', express.json(), (req, res) => {
|
|
688
|
-
const body = req.body;
|
|
689
|
-
const redirectUrl = typeof body.redirectUrl === 'string' ? body.redirectUrl : '';
|
|
690
|
-
if (!redirectUrl) {
|
|
691
|
-
console.warn('[oauth-result] Received callback with empty redirectUrl');
|
|
692
|
-
}
|
|
693
|
-
pendingOAuthResult = {
|
|
694
|
-
redirectUrl,
|
|
695
|
-
error: typeof body.error === 'string' ? body.error : undefined,
|
|
696
|
-
};
|
|
697
|
-
res.json({ ok: true });
|
|
698
|
-
});
|
|
699
|
-
app.get('/api/oauth-result', (_req, res) => {
|
|
700
|
-
if (pendingOAuthResult) {
|
|
701
|
-
const result = pendingOAuthResult;
|
|
702
|
-
pendingOAuthResult = null;
|
|
703
|
-
res.json(result);
|
|
704
|
-
}
|
|
705
|
-
else {
|
|
706
|
-
res.status(204).end();
|
|
707
|
-
}
|
|
708
|
-
});
|
|
943
|
+
registerOAuthCallbackRoutes(app);
|
|
709
944
|
// Global JSON body parser. Skipped when the request carries
|
|
710
945
|
// `X-Slicc-Raw-Body: 1`, so SigV4-signed bodies survive into the
|
|
711
946
|
// /api/fetch-proxy handler byte-for-byte (the parser would otherwise
|
|
@@ -746,399 +981,15 @@ async function main() {
|
|
|
746
981
|
timestamp: new Date().toISOString(),
|
|
747
982
|
});
|
|
748
983
|
});
|
|
749
|
-
// Tray status
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
});
|
|
759
|
-
// Webhook management API — forwards to browser
|
|
760
|
-
app.get('/api/webhooks', async (_req, res) => {
|
|
761
|
-
try {
|
|
762
|
-
const data = await sendLickRequest('list_webhooks', {});
|
|
763
|
-
res.json(data);
|
|
764
|
-
}
|
|
765
|
-
catch (err) {
|
|
766
|
-
res.status(503).json({ error: err instanceof Error ? err.message : 'Browser not connected' });
|
|
767
|
-
}
|
|
768
|
-
});
|
|
769
|
-
app.post('/api/webhooks', async (req, res) => {
|
|
770
|
-
try {
|
|
771
|
-
const data = await sendLickRequest('create_webhook', req.body);
|
|
772
|
-
res.json(data);
|
|
773
|
-
}
|
|
774
|
-
catch (err) {
|
|
775
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
776
|
-
res.status(msg.includes('Invalid') ? 400 : 503).json({ error: msg });
|
|
777
|
-
}
|
|
778
|
-
});
|
|
779
|
-
app.delete('/api/webhooks/:id', async (req, res) => {
|
|
780
|
-
try {
|
|
781
|
-
const data = (await sendLickRequest('delete_webhook', { id: req.params.id }));
|
|
782
|
-
if (data.error) {
|
|
783
|
-
res.status(404).json({ error: data.error });
|
|
784
|
-
}
|
|
785
|
-
else {
|
|
786
|
-
res.json(data);
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
catch (err) {
|
|
790
|
-
res.status(503).json({ error: err instanceof Error ? err.message : 'Browser not connected' });
|
|
791
|
-
}
|
|
792
|
-
});
|
|
793
|
-
// Webhook receiver — handle CORS preflight
|
|
794
|
-
app.options('/webhooks/:id', (_req, res) => {
|
|
795
|
-
res.set({
|
|
796
|
-
'Access-Control-Allow-Origin': '*',
|
|
797
|
-
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
798
|
-
'Access-Control-Allow-Headers': 'Content-Type',
|
|
799
|
-
});
|
|
800
|
-
res.sendStatus(204);
|
|
801
|
-
});
|
|
802
|
-
// Webhook receiver — forwards POST to browser for processing
|
|
803
|
-
app.post('/webhooks/:id', async (req, res) => {
|
|
804
|
-
res.set({ 'Access-Control-Allow-Origin': '*' });
|
|
805
|
-
const { id } = req.params;
|
|
806
|
-
// Collect body
|
|
807
|
-
let body = req.body;
|
|
808
|
-
if (!body || Object.keys(body).length === 0) {
|
|
809
|
-
const chunks = [];
|
|
810
|
-
for await (const chunk of req) {
|
|
811
|
-
chunks.push(Buffer.from(chunk));
|
|
812
|
-
}
|
|
813
|
-
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
814
|
-
try {
|
|
815
|
-
body = JSON.parse(raw);
|
|
816
|
-
}
|
|
817
|
-
catch {
|
|
818
|
-
body = { raw };
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
// Forward to browser for processing
|
|
822
|
-
broadcastLickEvent({
|
|
823
|
-
type: 'webhook_event',
|
|
824
|
-
webhookId: id,
|
|
825
|
-
timestamp: new Date().toISOString(),
|
|
826
|
-
headers: req.headers,
|
|
827
|
-
body,
|
|
828
|
-
});
|
|
829
|
-
res.json({ ok: true, received: true });
|
|
830
|
-
});
|
|
831
|
-
// Cron task management API — forwards to browser
|
|
832
|
-
app.get('/api/crontasks', async (_req, res) => {
|
|
833
|
-
try {
|
|
834
|
-
const data = await sendLickRequest('list_crontasks', {});
|
|
835
|
-
res.json(data);
|
|
836
|
-
}
|
|
837
|
-
catch (err) {
|
|
838
|
-
res.status(503).json({ error: err instanceof Error ? err.message : 'Browser not connected' });
|
|
839
|
-
}
|
|
840
|
-
});
|
|
841
|
-
app.post('/api/crontasks', async (req, res) => {
|
|
842
|
-
try {
|
|
843
|
-
const data = await sendLickRequest('create_crontask', req.body);
|
|
844
|
-
res.json(data);
|
|
845
|
-
}
|
|
846
|
-
catch (err) {
|
|
847
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
848
|
-
res
|
|
849
|
-
.status(msg.includes('Invalid') || msg.includes('required') ? 400 : 503)
|
|
850
|
-
.json({ error: msg });
|
|
851
|
-
}
|
|
852
|
-
});
|
|
853
|
-
app.delete('/api/crontasks/:id', async (req, res) => {
|
|
854
|
-
try {
|
|
855
|
-
const data = (await sendLickRequest('delete_crontask', { id: req.params.id }));
|
|
856
|
-
if (data.error) {
|
|
857
|
-
res.status(404).json({ error: data.error });
|
|
858
|
-
}
|
|
859
|
-
else {
|
|
860
|
-
res.json(data);
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
catch (err) {
|
|
864
|
-
res.status(503).json({ error: err instanceof Error ? err.message : 'Browser not connected' });
|
|
865
|
-
}
|
|
866
|
-
});
|
|
867
|
-
// Profile-independent handoff injection.
|
|
868
|
-
//
|
|
869
|
-
// The CDP navigation-watcher only sees tabs inside the Chrome instance
|
|
870
|
-
// SLICC launched (isolated profile keyed by port); similarly the
|
|
871
|
-
// extension's webRequest observer only fires inside the profile where it
|
|
872
|
-
// is installed. External tools (e.g. the slicc-handoff helper) post here
|
|
873
|
-
// so a handoff reaches the cone regardless of which browser profile the
|
|
874
|
-
// user is currently driving.
|
|
875
|
-
//
|
|
876
|
-
// The payload mirrors the parsed RFC 8288 `Link` form used by the
|
|
877
|
-
// observers: `verb` ∈ {handoff, upskill}, `target` is the resolved URL,
|
|
878
|
-
// `instruction` is optional free-form prose (handoff verb).
|
|
879
|
-
app.post('/api/handoff', (req, res) => {
|
|
880
|
-
const payload = req.body;
|
|
881
|
-
if (typeof payload?.sliccHeader === 'string') {
|
|
882
|
-
res.status(400).json({
|
|
883
|
-
error: 'The legacy `sliccHeader` payload was removed; post `{ verb, target, instruction? }` instead. See docs/slicc-handoff.md.',
|
|
884
|
-
});
|
|
885
|
-
return;
|
|
886
|
-
}
|
|
887
|
-
if (payload?.verb !== 'handoff' && payload?.verb !== 'upskill') {
|
|
888
|
-
res.status(400).json({ error: 'verb must be "handoff" or "upskill"' });
|
|
889
|
-
return;
|
|
890
|
-
}
|
|
891
|
-
if (typeof payload.target !== 'string' || payload.target.length === 0) {
|
|
892
|
-
res.status(400).json({ error: 'target is required (non-empty string)' });
|
|
893
|
-
return;
|
|
894
|
-
}
|
|
895
|
-
if (payload.instruction != null && typeof payload.instruction !== 'string') {
|
|
896
|
-
res.status(400).json({ error: 'instruction must be a string when provided' });
|
|
897
|
-
return;
|
|
898
|
-
}
|
|
899
|
-
// `branch` / `path` mirror the upskill rel's Link params and are
|
|
900
|
-
// ignored on the handoff verb (its target is the page itself, not a
|
|
901
|
-
// repo). Reject the wrong-shape combo loudly so emitters notice
|
|
902
|
-
// rather than silently dropping the scope.
|
|
903
|
-
if (payload.branch != null && typeof payload.branch !== 'string') {
|
|
904
|
-
res.status(400).json({ error: 'branch must be a string when provided' });
|
|
905
|
-
return;
|
|
906
|
-
}
|
|
907
|
-
if (payload.path != null && typeof payload.path !== 'string') {
|
|
908
|
-
res.status(400).json({ error: 'path must be a string when provided' });
|
|
909
|
-
return;
|
|
910
|
-
}
|
|
911
|
-
if (payload.verb === 'handoff' && (payload.branch != null || payload.path != null)) {
|
|
912
|
-
res.status(400).json({ error: 'branch and path are only valid with verb="upskill"' });
|
|
913
|
-
return;
|
|
914
|
-
}
|
|
915
|
-
broadcastLickEvent({
|
|
916
|
-
type: 'navigate_event',
|
|
917
|
-
verb: payload.verb,
|
|
918
|
-
target: payload.target,
|
|
919
|
-
instruction: typeof payload.instruction === 'string' ? payload.instruction : undefined,
|
|
920
|
-
url: typeof payload.url === 'string' && payload.url.length > 0 ? payload.url : 'about:handoff',
|
|
921
|
-
title: typeof payload.title === 'string' ? payload.title : undefined,
|
|
922
|
-
branch: typeof payload.branch === 'string' && payload.branch.length > 0
|
|
923
|
-
? payload.branch
|
|
924
|
-
: undefined,
|
|
925
|
-
path: typeof payload.path === 'string' && payload.path.length > 0 ? payload.path : undefined,
|
|
926
|
-
timestamp: new Date().toISOString(),
|
|
927
|
-
});
|
|
928
|
-
res.json({ ok: true });
|
|
929
|
-
});
|
|
930
|
-
// Secret management API — direct .env file access (no browser needed).
|
|
931
|
-
// `secretStore` was created above and wired into `secretProxy` so the
|
|
932
|
-
// fetch-proxy and the management API share one source of truth.
|
|
933
|
-
app.get('/api/secrets', (_req, res) => {
|
|
934
|
-
try {
|
|
935
|
-
const entries = secretStore.list();
|
|
936
|
-
res.json(entries);
|
|
937
|
-
}
|
|
938
|
-
catch (err) {
|
|
939
|
-
res
|
|
940
|
-
.status(500)
|
|
941
|
-
.json({ error: err instanceof Error ? err.message : 'Failed to list secrets' });
|
|
942
|
-
}
|
|
943
|
-
});
|
|
944
|
-
// Persisted-set — write a secret to ~/.slicc/secrets.env. Gated by the agent's
|
|
945
|
-
// intrinsic sudo prompt before the request is ever sent.
|
|
946
|
-
app.post('/api/secrets', express.json(), async (req, res) => {
|
|
947
|
-
const { name, value, domains } = req.body ?? {};
|
|
948
|
-
if (typeof name !== 'string' ||
|
|
949
|
-
typeof value !== 'string' ||
|
|
950
|
-
!Array.isArray(domains) ||
|
|
951
|
-
domains.some((d) => typeof d !== 'string')) {
|
|
952
|
-
return res.status(400).json({ error: 'bad-request' });
|
|
953
|
-
}
|
|
954
|
-
try {
|
|
955
|
-
secretStore.set(name, value, domains);
|
|
956
|
-
await secretProxy.reload();
|
|
957
|
-
res.json({ ok: true });
|
|
958
|
-
}
|
|
959
|
-
catch (err) {
|
|
960
|
-
res.status(500).json({ error: err instanceof Error ? err.message : 'Failed to set secret' });
|
|
961
|
-
}
|
|
962
|
-
});
|
|
963
|
-
// Scope edit — update the allowed domains of an existing secret (persisted or
|
|
964
|
-
// session), preserving the value. Gated by the agent before sending.
|
|
965
|
-
app.post('/api/secrets/scope', express.json(), async (req, res) => {
|
|
966
|
-
const { name, domains } = req.body ?? {};
|
|
967
|
-
if (typeof name !== 'string' ||
|
|
968
|
-
!Array.isArray(domains) ||
|
|
969
|
-
domains.some((d) => typeof d !== 'string')) {
|
|
970
|
-
return res.status(400).json({ error: 'bad-request' });
|
|
971
|
-
}
|
|
972
|
-
try {
|
|
973
|
-
if (secretProxy.sessionStore.has(name)) {
|
|
974
|
-
secretProxy.sessionStore.setDomains(name, domains);
|
|
975
|
-
}
|
|
976
|
-
else {
|
|
977
|
-
const existing = secretStore.get(name);
|
|
978
|
-
if (!existing)
|
|
979
|
-
return res.status(404).json({ error: `no secret named "${name}"` });
|
|
980
|
-
secretStore.set(name, existing.value, domains);
|
|
981
|
-
}
|
|
982
|
-
await secretProxy.reload();
|
|
983
|
-
res.json({ ok: true });
|
|
984
|
-
}
|
|
985
|
-
catch (err) {
|
|
986
|
-
res
|
|
987
|
-
.status(500)
|
|
988
|
-
.json({ error: err instanceof Error ? err.message : 'Failed to update scope' });
|
|
989
|
-
}
|
|
990
|
-
});
|
|
991
|
-
// Session secrets — in-memory only, never written to disk. Free for the agent
|
|
992
|
-
// to create; the masking pipeline picks them up on the reload below.
|
|
993
|
-
app.get('/api/secrets/session', (_req, res) => {
|
|
994
|
-
res.json(secretProxy.sessionStore.list());
|
|
995
|
-
});
|
|
996
|
-
app.post('/api/secrets/session', express.json(), async (req, res) => {
|
|
997
|
-
const { name, value, domains } = req.body ?? {};
|
|
998
|
-
if (typeof name !== 'string' ||
|
|
999
|
-
typeof value !== 'string' ||
|
|
1000
|
-
(domains !== undefined &&
|
|
1001
|
-
(!Array.isArray(domains) || domains.some((d) => typeof d !== 'string')))) {
|
|
1002
|
-
return res.status(400).json({ error: 'bad-request' });
|
|
1003
|
-
}
|
|
1004
|
-
secretProxy.sessionStore.set(name, value, Array.isArray(domains) ? domains : []);
|
|
1005
|
-
await secretProxy.reload();
|
|
1006
|
-
res.json({ ok: true });
|
|
1007
|
-
});
|
|
1008
|
-
// Peek — elided preview of the unmasked value (session or persisted). The
|
|
1009
|
-
// full value never leaves the server.
|
|
1010
|
-
app.get('/api/secrets/peek', (req, res) => {
|
|
1011
|
-
const name = typeof req.query.name === 'string' ? req.query.name : '';
|
|
1012
|
-
if (!name)
|
|
1013
|
-
return res.status(400).json({ error: 'bad-request' });
|
|
1014
|
-
const session = secretProxy.sessionStore.getRecord(name);
|
|
1015
|
-
if (session) {
|
|
1016
|
-
return res.json({ name, preview: previewSecret(session.value), domains: session.domains });
|
|
1017
|
-
}
|
|
1018
|
-
const persisted = secretStore.get(name);
|
|
1019
|
-
if (persisted) {
|
|
1020
|
-
return res.json({
|
|
1021
|
-
name,
|
|
1022
|
-
preview: previewSecret(persisted.value),
|
|
1023
|
-
domains: persisted.domains,
|
|
1024
|
-
});
|
|
1025
|
-
}
|
|
1026
|
-
return res.status(404).json({ error: `no secret named "${name}"` });
|
|
1027
|
-
});
|
|
1028
|
-
// S3 sign-and-forward — browser-side mount backend posts envelopes here;
|
|
1029
|
-
// server resolves the s3.<profile>.* secrets, signs SigV4 v4, forwards to
|
|
1030
|
-
// the upstream, returns the response as a JSON envelope. The browser
|
|
1031
|
-
// never sees access_key_id / secret_access_key. See sign-and-forward.ts
|
|
1032
|
-
// for the envelope contract.
|
|
1033
|
-
app.post('/api/s3-sign-and-forward', async (req, res) => {
|
|
1034
|
-
try {
|
|
1035
|
-
await handleS3SignAndForward(req, res, secretStore);
|
|
1036
|
-
}
|
|
1037
|
-
catch (err) {
|
|
1038
|
-
// Generic log line + trace id only. Avoid logging the err.message
|
|
1039
|
-
// because TypeError stack frames or signing errors can include
|
|
1040
|
-
// profile names, bucket names, or partial URLs — operational secrets
|
|
1041
|
-
// we don't want in shared log aggregators (Sentry, Datadog, etc.).
|
|
1042
|
-
// The trace id lets users correlate a server-side log with the
|
|
1043
|
-
// 500 the client got; the detailed message goes only to the local
|
|
1044
|
-
// file logger above DEBUG, where it's bounded to the operator.
|
|
1045
|
-
const traceId = (globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 10)).slice(0, 8);
|
|
1046
|
-
console.error(`S3 sign-and-forward error [trace=${traceId}]`);
|
|
1047
|
-
if (DEV_MODE) {
|
|
1048
|
-
console.error(err);
|
|
1049
|
-
}
|
|
1050
|
-
if (!res.headersSent) {
|
|
1051
|
-
res.status(500).json({
|
|
1052
|
-
ok: false,
|
|
1053
|
-
error: `internal sign-and-forward error [trace=${traceId}]`,
|
|
1054
|
-
errorCode: 'internal',
|
|
1055
|
-
});
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
});
|
|
1059
|
-
// DA sign-and-forward — same pattern as S3, but for Adobe da.live. The
|
|
1060
|
-
// IMS bearer token is passed transiently in the envelope (browser holds
|
|
1061
|
-
// it via the existing Adobe LLM provider). v2 will move OAuth server-side
|
|
1062
|
-
// to remove the browser exposure entirely.
|
|
1063
|
-
app.post('/api/da-sign-and-forward', async (req, res) => {
|
|
1064
|
-
try {
|
|
1065
|
-
await handleDaSignAndForward(req, res);
|
|
1066
|
-
}
|
|
1067
|
-
catch (err) {
|
|
1068
|
-
const traceId = (globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 10)).slice(0, 8);
|
|
1069
|
-
console.error(`DA sign-and-forward error [trace=${traceId}]`);
|
|
1070
|
-
if (DEV_MODE) {
|
|
1071
|
-
console.error(err);
|
|
1072
|
-
}
|
|
1073
|
-
if (!res.headersSent) {
|
|
1074
|
-
res.status(500).json({
|
|
1075
|
-
ok: false,
|
|
1076
|
-
error: `internal sign-and-forward error [trace=${traceId}]`,
|
|
1077
|
-
errorCode: 'internal',
|
|
1078
|
-
});
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
});
|
|
1082
|
-
// Tool-output real→masked scrub. The browser-side agent realm
|
|
1083
|
-
// never holds real secret values, so the defense-in-depth scrub of
|
|
1084
|
-
// bash / read_file / other tool results runs here against the
|
|
1085
|
-
// node-server-owned `SecretProxyManager`. Direction is real→masked
|
|
1086
|
-
// ONLY (`scrubResponse`), so it is always safe and is idempotent
|
|
1087
|
-
// for already-masked tokens and secret-free output. The caller
|
|
1088
|
-
// (`packages/webapp/src/core/secret-scrub.ts`) treats any non-2xx
|
|
1089
|
-
// or malformed response as "return input unchanged" — the scrub is
|
|
1090
|
-
// defense-in-depth, not the primary defense.
|
|
1091
|
-
app.post('/api/secrets/scrub', express.json({ limit: '32mb' }), (req, res) => {
|
|
1092
|
-
const text = req.body?.text;
|
|
1093
|
-
if (typeof text !== 'string') {
|
|
1094
|
-
return res.status(400).json({ error: 'bad-request' });
|
|
1095
|
-
}
|
|
1096
|
-
try {
|
|
1097
|
-
res.json({ text: secretProxy.scrubResponse(text) });
|
|
1098
|
-
}
|
|
1099
|
-
catch (err) {
|
|
1100
|
-
res.status(500).json({ error: err instanceof Error ? err.message : 'scrub failed', text });
|
|
1101
|
-
}
|
|
1102
|
-
});
|
|
1103
|
-
// Masked secrets endpoint — returns name + maskedValue pairs for shell env population.
|
|
1104
|
-
// The browser fetches this at shell init to populate env vars with masked values.
|
|
1105
|
-
// Real values are never exposed; only deterministic session-scoped masks.
|
|
1106
|
-
app.get('/api/secrets/masked', (_req, res) => {
|
|
1107
|
-
try {
|
|
1108
|
-
const entries = secretProxy.getMaskedEntries();
|
|
1109
|
-
res.json(entries);
|
|
1110
|
-
}
|
|
1111
|
-
catch (err) {
|
|
1112
|
-
res
|
|
1113
|
-
.status(500)
|
|
1114
|
-
.json({ error: err instanceof Error ? err.message : 'Failed to get masked secrets' });
|
|
1115
|
-
}
|
|
1116
|
-
});
|
|
1117
|
-
// OAuth secret update — stores access token from OAuth login flow
|
|
1118
|
-
app.post('/api/secrets/oauth-update', express.json(), async (req, res) => {
|
|
1119
|
-
const { providerId, accessToken, domains } = req.body ?? {};
|
|
1120
|
-
if (typeof providerId !== 'string' ||
|
|
1121
|
-
typeof accessToken !== 'string' ||
|
|
1122
|
-
!Array.isArray(domains) ||
|
|
1123
|
-
domains.length === 0) {
|
|
1124
|
-
return res.status(400).json({ error: 'bad-request' });
|
|
1125
|
-
}
|
|
1126
|
-
const name = `oauth.${providerId}.token`;
|
|
1127
|
-
oauthStore.set(name, accessToken, domains);
|
|
1128
|
-
await secretProxy.reload();
|
|
1129
|
-
const masked = secretProxy.getMaskedEntries().find((e) => e.name === name)?.maskedValue;
|
|
1130
|
-
res.json({ providerId, name, maskedValue: masked, domains });
|
|
1131
|
-
});
|
|
1132
|
-
// OAuth secret deletion — removes access token on logout
|
|
1133
|
-
app.delete('/api/secrets/oauth/:providerId', async (req, res) => {
|
|
1134
|
-
const name = `oauth.${req.params.providerId}.token`;
|
|
1135
|
-
if (!oauthStore.list().some((e) => e.name === name)) {
|
|
1136
|
-
return res.status(404).json({ error: 'not-found' });
|
|
1137
|
-
}
|
|
1138
|
-
oauthStore.delete(name);
|
|
1139
|
-
await secretProxy.reload();
|
|
1140
|
-
res.status(204).end();
|
|
1141
|
-
});
|
|
984
|
+
// Tray status, webhook management + receiver, and cron task routes — all
|
|
985
|
+
// forward to the browser over the lick bridge.
|
|
986
|
+
registerLickApiRoutes(app, lickBridge);
|
|
987
|
+
// Profile-independent handoff injection — external tools post here so a
|
|
988
|
+
// handoff reaches the cone regardless of which browser profile is active.
|
|
989
|
+
registerHandoffRoute(app, { broadcastLickEvent });
|
|
990
|
+
// Secret management API — direct .env file access (no browser needed),
|
|
991
|
+
// plus the S3 / DA sign-and-forward and masked-secret endpoints.
|
|
992
|
+
registerSecretRoutes(app, { secretStore, secretProxy, oauthStore, devMode: DEV_MODE });
|
|
1142
993
|
// Cloud status endpoint (hosted-only) — writes join info to /tmp/slicc-join.json
|
|
1143
994
|
// Register BEFORE Chromium launches. The webapp's first action after
|
|
1144
995
|
// ?runtime=hosted-leader boot is to mint a tray and POST /api/cloud-status.
|
|
@@ -1152,539 +1003,34 @@ async function main() {
|
|
|
1152
1003
|
// trusted process so the in-browser agent can request, but never fabricate,
|
|
1153
1004
|
// an approval. Loopback-only; selects a backend by environment at call time.
|
|
1154
1005
|
registerSudoApproveEndpoint(app);
|
|
1155
|
-
// Fetch proxy — forwards cross-origin requests from the browser to bypass CORS
|
|
1156
|
-
//
|
|
1157
|
-
|
|
1158
|
-
app.all('/api/fetch-proxy', async (req, res) => {
|
|
1159
|
-
// Get the body - either from express.json() parsed body or collect raw chunks
|
|
1160
|
-
let rawBody;
|
|
1161
|
-
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) {
|
|
1162
|
-
// Body was already parsed by express.json() - re-serialize it
|
|
1163
|
-
rawBody = Buffer.from(JSON.stringify(req.body), 'utf-8');
|
|
1164
|
-
}
|
|
1165
|
-
else {
|
|
1166
|
-
// Collect raw body manually (for non-JSON content types)
|
|
1167
|
-
const chunks = [];
|
|
1168
|
-
for await (const chunk of req) {
|
|
1169
|
-
chunks.push(Buffer.from(chunk));
|
|
1170
|
-
}
|
|
1171
|
-
rawBody = Buffer.concat(chunks);
|
|
1172
|
-
}
|
|
1173
|
-
const targetUrl = req.headers['x-target-url'];
|
|
1174
|
-
if (!targetUrl) {
|
|
1175
|
-
res.setHeader('X-Proxy-Error', '1');
|
|
1176
|
-
res.status(400).json({ error: 'Missing X-Target-URL header' });
|
|
1177
|
-
return;
|
|
1178
|
-
}
|
|
1179
|
-
// Hoisted so the catch handler below can detach it on early
|
|
1180
|
-
// failures (e.g. fetch threw before the success-path detach
|
|
1181
|
-
// could run).
|
|
1182
|
-
let onClientClose = null;
|
|
1183
|
-
try {
|
|
1184
|
-
const fetchInit = {
|
|
1185
|
-
method: req.method,
|
|
1186
|
-
redirect: 'follow', // Follow redirects for git protocol compatibility
|
|
1187
|
-
};
|
|
1188
|
-
// Forward relevant headers (excluding hop-by-hop and proxy headers).
|
|
1189
|
-
// Set lives at module scope as FETCH_PROXY_SKIP_HEADERS so tests can
|
|
1190
|
-
// verify the contract without copying it.
|
|
1191
|
-
const headers = {};
|
|
1192
|
-
for (const [key, value] of Object.entries(req.headers)) {
|
|
1193
|
-
if (!FETCH_PROXY_SKIP_HEADERS.has(key) && typeof value === 'string') {
|
|
1194
|
-
headers[key] = value;
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
// Forbidden-header transport: browser cannot send Cookie via fetch(),
|
|
1198
|
-
// so the client encodes it as X-Proxy-Cookie. Restore it here.
|
|
1199
|
-
const proxyCookie = req.headers['x-proxy-cookie'];
|
|
1200
|
-
if (proxyCookie) {
|
|
1201
|
-
headers['cookie'] = Array.isArray(proxyCookie) ? proxyCookie[0] : proxyCookie;
|
|
1202
|
-
}
|
|
1203
|
-
// Helper: check if an origin/referer value is a localhost URL
|
|
1204
|
-
function isLocalhostOrigin(origin) {
|
|
1205
|
-
if (!origin)
|
|
1206
|
-
return false;
|
|
1207
|
-
try {
|
|
1208
|
-
const url = new URL(origin);
|
|
1209
|
-
return (url.hostname === 'localhost' ||
|
|
1210
|
-
url.hostname === '127.0.0.1' ||
|
|
1211
|
-
url.hostname === '::1' ||
|
|
1212
|
-
url.hostname === '[::1]');
|
|
1213
|
-
}
|
|
1214
|
-
catch {
|
|
1215
|
-
return false;
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
// Forbidden-header transport: restore X-Proxy-Origin → Origin
|
|
1219
|
-
const proxyOrigin = req.headers['x-proxy-origin'];
|
|
1220
|
-
if (proxyOrigin) {
|
|
1221
|
-
headers['origin'] = Array.isArray(proxyOrigin) ? proxyOrigin[0] : proxyOrigin;
|
|
1222
|
-
}
|
|
1223
|
-
else if (isLocalhostOrigin(headers['origin'])) {
|
|
1224
|
-
// Only strip browser's auto-added localhost origin, preserve legitimate origins
|
|
1225
|
-
delete headers['origin'];
|
|
1226
|
-
}
|
|
1227
|
-
// Default-Origin fallback: when no explicit caller Origin survives, synthesize
|
|
1228
|
-
// one from the target URL so upstream CORS-protected APIs see a real Origin
|
|
1229
|
-
// instead of nothing. Caller-supplied X-Proxy-Origin (handled above) still wins.
|
|
1230
|
-
if (!headers['origin']) {
|
|
1231
|
-
try {
|
|
1232
|
-
headers['origin'] = new URL(targetUrl).origin;
|
|
1233
|
-
}
|
|
1234
|
-
catch {
|
|
1235
|
-
// Malformed targetUrl — leave origin unset; the upstream fetch will fail anyway.
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
|
-
// Forbidden-header transport: restore X-Proxy-Referer → Referer
|
|
1239
|
-
const proxyReferer = req.headers['x-proxy-referer'];
|
|
1240
|
-
if (proxyReferer) {
|
|
1241
|
-
headers['referer'] = Array.isArray(proxyReferer) ? proxyReferer[0] : proxyReferer;
|
|
1242
|
-
}
|
|
1243
|
-
else if (isLocalhostOrigin(headers['referer'])) {
|
|
1244
|
-
// Only strip browser's auto-added localhost referer, preserve legitimate referers
|
|
1245
|
-
delete headers['referer'];
|
|
1246
|
-
}
|
|
1247
|
-
// Restore any X-Proxy-Proxy-* transport headers as Proxy-* headers
|
|
1248
|
-
for (const [key, value] of Object.entries(req.headers)) {
|
|
1249
|
-
if (key.startsWith('x-proxy-proxy-') && typeof value === 'string') {
|
|
1250
|
-
const restored = key.replace(/^x-proxy-/, '');
|
|
1251
|
-
headers[restored] = value;
|
|
1252
|
-
delete headers[key];
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
// Always request uncompressed responses from upstream — the proxy doesn't
|
|
1256
|
-
// decompress, and the browser→proxy link is localhost (no benefit to compression).
|
|
1257
|
-
// Without this, Cloudflare may Brotli-compress the response, the proxy strips
|
|
1258
|
-
// Content-Encoding (line below), and the browser receives compressed garbage.
|
|
1259
|
-
headers['accept-encoding'] = 'identity';
|
|
1260
|
-
// --- Secret injection: unmask headers ---
|
|
1261
|
-
let targetHostname;
|
|
1262
|
-
try {
|
|
1263
|
-
targetHostname = new URL(targetUrl).hostname;
|
|
1264
|
-
}
|
|
1265
|
-
catch {
|
|
1266
|
-
targetHostname = '';
|
|
1267
|
-
}
|
|
1268
|
-
if (secretProxy.hasSecrets()) {
|
|
1269
|
-
// Unmask request headers (replace masked values with real, validate domain)
|
|
1270
|
-
const headerResult = secretProxy.unmaskHeaders(headers, targetHostname);
|
|
1271
|
-
if (headerResult.forbidden) {
|
|
1272
|
-
res.setHeader('X-Proxy-Error', '1');
|
|
1273
|
-
res.status(403).json({
|
|
1274
|
-
error: `Secret "${headerResult.forbidden.secretName}" is not allowed for domain "${headerResult.forbidden.hostname}"`,
|
|
1275
|
-
});
|
|
1276
|
-
return;
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
// --- Secret injection: unmask URL-embedded credentials ---
|
|
1280
|
-
let cleanedUrl = targetUrl;
|
|
1281
|
-
if (secretProxy.hasSecrets()) {
|
|
1282
|
-
const credsResult = secretProxy.extractAndUnmaskUrlCredentials(targetUrl);
|
|
1283
|
-
if (credsResult.forbidden) {
|
|
1284
|
-
res.setHeader('X-Proxy-Error', '1');
|
|
1285
|
-
res.status(403).json({
|
|
1286
|
-
error: `Secret "${credsResult.forbidden.secretName}" is not allowed for domain "${credsResult.forbidden.hostname}"`,
|
|
1287
|
-
});
|
|
1288
|
-
return;
|
|
1289
|
-
}
|
|
1290
|
-
cleanedUrl = credsResult.url;
|
|
1291
|
-
// Attach synthetic Authorization if the URL had credentials and the header isn't already set
|
|
1292
|
-
if (credsResult.syntheticAuthorization && !('authorization' in headers)) {
|
|
1293
|
-
headers.authorization = credsResult.syntheticAuthorization;
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
if (Object.keys(headers).length > 0)
|
|
1297
|
-
fetchInit.headers = headers;
|
|
1298
|
-
if (rawBody.length > 0 && !['GET', 'HEAD'].includes(req.method)) {
|
|
1299
|
-
// --- Secret injection: unmask request body ---
|
|
1300
|
-
// Body uses unmaskBody: domain mismatches leave the masked value as-is
|
|
1301
|
-
// (safe/meaningless) rather than rejecting. This avoids false 403s when
|
|
1302
|
-
// LLM conversation context contains masked secrets sent to non-matching
|
|
1303
|
-
// domains like Bedrock.
|
|
1304
|
-
//
|
|
1305
|
-
// Skip the unmask for non-text content (git packfiles, octet-stream,
|
|
1306
|
-
// ZIPs, images, …) — `Buffer.toString('utf-8')` on arbitrary bytes
|
|
1307
|
-
// replaces invalid sequences with U+FFFD, silently corrupting the
|
|
1308
|
-
// payload. Masked values are hex strings with known prefixes; they
|
|
1309
|
-
// do not appear in deflated git packfiles or other compressed binary,
|
|
1310
|
-
// so skipping is safe.
|
|
1311
|
-
const reqCt = (headers['content-type'] ?? headers['Content-Type'] ?? '').toLowerCase();
|
|
1312
|
-
const reqIsText = !reqCt ||
|
|
1313
|
-
reqCt.startsWith('text/') ||
|
|
1314
|
-
reqCt.includes('json') ||
|
|
1315
|
-
reqCt.includes('xml') ||
|
|
1316
|
-
reqCt.includes('javascript') ||
|
|
1317
|
-
reqCt.includes('ecmascript') ||
|
|
1318
|
-
reqCt.includes('html') ||
|
|
1319
|
-
reqCt.includes('css') ||
|
|
1320
|
-
reqCt.includes('svg');
|
|
1321
|
-
if (reqIsText && secretProxy.hasSecrets()) {
|
|
1322
|
-
const bodyStr = rawBody.toString('utf-8');
|
|
1323
|
-
const bodyResult = secretProxy.unmaskBody(bodyStr, targetHostname);
|
|
1324
|
-
rawBody = Buffer.from(bodyResult.text, 'utf-8');
|
|
1325
|
-
}
|
|
1326
|
-
// Buffer extends Uint8Array which is a valid fetch body at runtime.
|
|
1327
|
-
fetchInit.body = rawBody;
|
|
1328
|
-
}
|
|
1329
|
-
// Propagate client disconnect to the upstream request so that
|
|
1330
|
-
// long-lived streams (LLM SSE completions) are torn down promptly
|
|
1331
|
-
// when the SW or the page aborts. Listen on `res.on('close')` —
|
|
1332
|
-
// not `req.on('close')` — because Node fires `req` close as soon
|
|
1333
|
-
// as the readable side of the request is consumed (right after
|
|
1334
|
-
// express.json() parses the body), which would abort the upstream
|
|
1335
|
-
// fetch before it could even start. `res` close only fires when
|
|
1336
|
-
// the response is sent OR the connection is killed mid-stream;
|
|
1337
|
-
// in the first case the abort is harmless (the fetch already
|
|
1338
|
-
// settled), in the second it's exactly what we want. Guard with
|
|
1339
|
-
// `res.writableEnded` to be safe.
|
|
1340
|
-
const abortController = new AbortController();
|
|
1341
|
-
onClientClose = () => {
|
|
1342
|
-
if (!res.writableEnded)
|
|
1343
|
-
abortController.abort();
|
|
1344
|
-
};
|
|
1345
|
-
res.on('close', onClientClose);
|
|
1346
|
-
fetchInit.signal = abortController.signal;
|
|
1347
|
-
const upstream = await fetch(cleanedUrl, fetchInit);
|
|
1348
|
-
// Forward status, prevent browser caching of proxy responses
|
|
1349
|
-
res.status(upstream.status);
|
|
1350
|
-
res.setHeader('Cache-Control', 'no-store, no-cache');
|
|
1351
|
-
// Forward response headers (strip www-authenticate to prevent
|
|
1352
|
-
// the browser from showing a native Basic Auth dialog — isomorphic-git
|
|
1353
|
-
// handles 401s through its own onAuth callback). Drop Content-Length
|
|
1354
|
-
// so the response can be chunk-encoded transparently.
|
|
1355
|
-
const setCookieValues = upstream.headers.getSetCookie();
|
|
1356
|
-
upstream.headers.forEach((v, k) => {
|
|
1357
|
-
const lower = k.toLowerCase();
|
|
1358
|
-
if (lower !== 'transfer-encoding' &&
|
|
1359
|
-
lower !== 'content-encoding' &&
|
|
1360
|
-
lower !== 'content-length' &&
|
|
1361
|
-
lower !== 'www-authenticate' &&
|
|
1362
|
-
lower !== 'set-cookie' &&
|
|
1363
|
-
!lower.startsWith('x-proxy-')) {
|
|
1364
|
-
// Scrub real secret values from response headers (one-shot,
|
|
1365
|
-
// headers are always small so per-chunk semantics don't apply).
|
|
1366
|
-
res.setHeader(k, secretProxy.scrubResponse(v));
|
|
1367
|
-
}
|
|
1368
|
-
});
|
|
1369
|
-
if (setCookieValues.length > 0) {
|
|
1370
|
-
res.setHeader('X-Proxy-Set-Cookie', secretProxy.scrubResponse(JSON.stringify(setCookieValues)));
|
|
1371
|
-
}
|
|
1372
|
-
// Stream the upstream body straight through to the client so that
|
|
1373
|
-
// LLM SSE completions reach the browser token-by-token instead of
|
|
1374
|
-
// arriving in one giant burst at the end. Per-chunk secret-scrub
|
|
1375
|
-
// runs on text responses; secrets that span a chunk boundary slip
|
|
1376
|
-
// through unscrubbed (documented limitation — the scrub primitive
|
|
1377
|
-
// is best-effort on streamed bodies).
|
|
1378
|
-
if (!upstream.body) {
|
|
1379
|
-
res.end();
|
|
1380
|
-
if (onClientClose)
|
|
1381
|
-
res.off('close', onClientClose);
|
|
1382
|
-
return;
|
|
1383
|
-
}
|
|
1384
|
-
const ct = (upstream.headers.get('content-type') ?? '').toLowerCase();
|
|
1385
|
-
const isText = ct.startsWith('text/') ||
|
|
1386
|
-
ct.startsWith('application/json') ||
|
|
1387
|
-
ct.includes('charset=') ||
|
|
1388
|
-
ct.includes('event-stream');
|
|
1389
|
-
const upstreamStream = Readable.fromWeb(upstream.body);
|
|
1390
|
-
// Buffer-aware UTF-8 scrubber. Naive `Buffer.from(chunk).toString('utf-8')`
|
|
1391
|
-
// corrupts multi-byte codepoints whenever a sequence straddles a chunk
|
|
1392
|
-
// boundary — Node replaces the partial bytes with U+FFFD, which is fatal
|
|
1393
|
-
// for any non-ASCII model output (CJK, emoji, even some accented Latin).
|
|
1394
|
-
// `StringDecoder` keeps the trailing partial bytes in a private buffer
|
|
1395
|
-
// and prepends them to the next chunk, guaranteeing valid UTF-8 every
|
|
1396
|
-
// call. The flush() in `flush(cb)` releases any tail bytes (replacing
|
|
1397
|
-
// truly invalid trailing bytes with U+FFFD, same as before but only at
|
|
1398
|
-
// EOF where it can't span a real codepoint).
|
|
1399
|
-
const utf8Decoder = new StringDecoder('utf8');
|
|
1400
|
-
const scrubChunk = new Transform({
|
|
1401
|
-
transform(chunk, _enc, cb) {
|
|
1402
|
-
if (!isText || !secretProxy.hasSecrets()) {
|
|
1403
|
-
cb(null, chunk);
|
|
1404
|
-
return;
|
|
1405
|
-
}
|
|
1406
|
-
try {
|
|
1407
|
-
const decoded = utf8Decoder.write(chunk);
|
|
1408
|
-
if (decoded.length === 0) {
|
|
1409
|
-
// All bytes were buffered as a partial codepoint — no output yet.
|
|
1410
|
-
cb(null, Buffer.alloc(0));
|
|
1411
|
-
return;
|
|
1412
|
-
}
|
|
1413
|
-
const scrubbed = secretProxy.scrubResponse(decoded);
|
|
1414
|
-
cb(null, Buffer.from(scrubbed, 'utf-8'));
|
|
1415
|
-
}
|
|
1416
|
-
catch (err) {
|
|
1417
|
-
cb(err);
|
|
1418
|
-
}
|
|
1419
|
-
},
|
|
1420
|
-
flush(cb) {
|
|
1421
|
-
if (!isText || !secretProxy.hasSecrets()) {
|
|
1422
|
-
cb();
|
|
1423
|
-
return;
|
|
1424
|
-
}
|
|
1425
|
-
try {
|
|
1426
|
-
const tail = utf8Decoder.end();
|
|
1427
|
-
if (tail.length === 0) {
|
|
1428
|
-
cb();
|
|
1429
|
-
return;
|
|
1430
|
-
}
|
|
1431
|
-
const scrubbed = secretProxy.scrubResponse(tail);
|
|
1432
|
-
cb(null, Buffer.from(scrubbed, 'utf-8'));
|
|
1433
|
-
}
|
|
1434
|
-
catch (err) {
|
|
1435
|
-
cb(err);
|
|
1436
|
-
}
|
|
1437
|
-
},
|
|
1438
|
-
});
|
|
1439
|
-
const cleanup = () => {
|
|
1440
|
-
if (onClientClose) {
|
|
1441
|
-
res.off('close', onClientClose);
|
|
1442
|
-
onClientClose = null;
|
|
1443
|
-
}
|
|
1444
|
-
};
|
|
1445
|
-
upstreamStream.on('error', (err) => {
|
|
1446
|
-
cleanup();
|
|
1447
|
-
if (!res.headersSent) {
|
|
1448
|
-
res.setHeader('X-Proxy-Error', '1');
|
|
1449
|
-
res
|
|
1450
|
-
.status(502)
|
|
1451
|
-
.json({ error: `Proxy stream failed: ${err instanceof Error ? err.message : err}` });
|
|
1452
|
-
}
|
|
1453
|
-
else {
|
|
1454
|
-
res.destroy(err);
|
|
1455
|
-
}
|
|
1456
|
-
});
|
|
1457
|
-
// Belt-and-braces cleanup: 'finish' fires once the response is fully
|
|
1458
|
-
// flushed; 'close' fires regardless of how the response ended (success,
|
|
1459
|
-
// abort, or pipe error). Either way we want the abort listener gone.
|
|
1460
|
-
res.on('finish', cleanup);
|
|
1461
|
-
res.on('close', cleanup);
|
|
1462
|
-
upstreamStream.pipe(scrubChunk).pipe(res);
|
|
1463
|
-
}
|
|
1464
|
-
catch (err) {
|
|
1465
|
-
// Best-effort cleanup so an early failure (e.g. fetch threw) doesn't
|
|
1466
|
-
// leave the close listener attached to the response object.
|
|
1467
|
-
if (onClientClose) {
|
|
1468
|
-
res.off('close', onClientClose);
|
|
1469
|
-
onClientClose = null;
|
|
1470
|
-
}
|
|
1471
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1472
|
-
res.setHeader('X-Proxy-Error', '1');
|
|
1473
|
-
res.status(502).json({ error: `Proxy fetch failed: ${message}` });
|
|
1474
|
-
}
|
|
1475
|
-
});
|
|
1006
|
+
// Fetch proxy — forwards cross-origin requests from the browser to bypass CORS,
|
|
1007
|
+
// injecting/unmasking secrets and streaming the response with a UTF-8-safe scrub.
|
|
1008
|
+
registerFetchProxyRoute(app, { secretProxy });
|
|
1476
1009
|
// Create the HTTP server BEFORE Vite so we can register our upgrade handler first
|
|
1477
1010
|
const server = createServer(app);
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
configFile: resolve(process.cwd(), 'packages/webapp/vite.config.ts'),
|
|
1484
|
-
server: {
|
|
1485
|
-
middlewareMode: true,
|
|
1486
|
-
hmr: {
|
|
1487
|
-
server, // Share the HTTP server — our upgrade handler routes /cdp and /licks-ws separately
|
|
1488
|
-
path: '/__vite_hmr', // Dedicated path avoids conflicts with /cdp upgrade handler
|
|
1489
|
-
},
|
|
1490
|
-
},
|
|
1491
|
-
appType: 'custom', // We handle index.html serving ourselves via the handler below
|
|
1492
|
-
root: process.cwd(),
|
|
1493
|
-
});
|
|
1494
|
-
app.use(vite.middlewares);
|
|
1495
|
-
app.use(async (req, res, next) => {
|
|
1496
|
-
if (req.method !== 'GET' ||
|
|
1497
|
-
!req.headers.accept?.includes('text/html') ||
|
|
1498
|
-
req.path.includes('.')) {
|
|
1499
|
-
next();
|
|
1500
|
-
return;
|
|
1501
|
-
}
|
|
1502
|
-
try {
|
|
1503
|
-
const template = readFileSync(webappIndexHtml, 'utf-8');
|
|
1504
|
-
const html = await vite.transformIndexHtml(req.originalUrl, template);
|
|
1505
|
-
res.status(200).setHeader('Content-Type', 'text/html');
|
|
1506
|
-
res.end(html);
|
|
1507
|
-
}
|
|
1508
|
-
catch (err) {
|
|
1509
|
-
if (err instanceof Error) {
|
|
1510
|
-
vite.ssrFixStacktrace(err);
|
|
1511
|
-
}
|
|
1512
|
-
next(err);
|
|
1513
|
-
}
|
|
1514
|
-
});
|
|
1515
|
-
console.log(`Vite dev server middleware attached (HMR on ${SERVE_ORIGIN}/__vite_hmr)`);
|
|
1516
|
-
}
|
|
1517
|
-
else {
|
|
1518
|
-
// Production mode: serve built static files
|
|
1519
|
-
const uiDir = resolve(Dirname, '..', 'ui');
|
|
1520
|
-
app.use(express.static(uiDir, {
|
|
1521
|
-
setHeaders: (res, path) => {
|
|
1522
|
-
// Default Cache-Control for anything not classified below:
|
|
1523
|
-
// HTML, manifest, sprinkle-sandbox.html, publicDir fonts/logos,
|
|
1524
|
-
// favicon, etc. None of these are content-hashed and they
|
|
1525
|
-
// reference hashed asset URLs that change on rebuild. If the
|
|
1526
|
-
// browser serves a stale `index.html` out of its heuristic
|
|
1527
|
-
// cache, the referenced `/assets/*` chunks 404 after an update
|
|
1528
|
-
// — the user sees
|
|
1529
|
-
// "Failed to fetch dynamically imported module: …/assets/<old-hash>.js"
|
|
1530
|
-
// on every cone bootstrap until they hard-refresh.
|
|
1531
|
-
// `no-cache` forces a conditional revalidation on every load
|
|
1532
|
-
// (cheap — `serve-static`'s default ETag yields a 304 when
|
|
1533
|
-
// unchanged) so the tab picks up a freshly-built `index.html`
|
|
1534
|
-
// after `npm run build`.
|
|
1535
|
-
//
|
|
1536
|
-
// The single `setHeader` at the end is intentional: each
|
|
1537
|
-
// branch overrides the default by assigning to `cacheControl`.
|
|
1538
|
-
// To add a fourth bucket, add an `else if` ABOVE the final
|
|
1539
|
-
// assignment — never a separate `setHeader` after, or the
|
|
1540
|
-
// catch-all silently wins.
|
|
1541
|
-
let cacheControl = 'no-cache';
|
|
1542
|
-
if (path.endsWith('llm-proxy-sw.js') || path.endsWith('preview-sw.js')) {
|
|
1543
|
-
// Service workers need `Service-Worker-Allowed: /` for the
|
|
1544
|
-
// root-scoped registration `llm-proxy-sw.js` does (the
|
|
1545
|
-
// `preview-sw.js` SW registers at scope `/preview/`, which
|
|
1546
|
-
// is narrower than `/` so the broader allowance is harmless).
|
|
1547
|
-
//
|
|
1548
|
-
// `no-store`, not `no-cache`: the browser only re-checks
|
|
1549
|
-
// the SW script on navigation/registration, so the safest
|
|
1550
|
-
// signal is "always pull the latest bytes." A stale SW
|
|
1551
|
-
// pinned in cache would intercept fetch / dispatch
|
|
1552
|
-
// `preview/*` with outdated logic (e.g. an outdated
|
|
1553
|
-
// forbidden-header encoding scheme that no longer matches
|
|
1554
|
-
// the server-side restoration in `index.ts`, or a stale
|
|
1555
|
-
// `preview-sw` VFS handler) — that's a worse failure mode
|
|
1556
|
-
// than the `no-cache` revalidation cost.
|
|
1557
|
-
res.setHeader('Service-Worker-Allowed', '/');
|
|
1558
|
-
cacheControl = 'no-store';
|
|
1559
|
-
}
|
|
1560
|
-
else if (path.includes(`${sep}assets${sep}`)) {
|
|
1561
|
-
// Vite emits content-hashed filenames into `/assets/` —
|
|
1562
|
-
// the hash changes when content changes, so the file at a
|
|
1563
|
-
// given URL is byte-for-byte immutable. Cache forever to
|
|
1564
|
-
// avoid revalidation round-trips. The `path` parameter is
|
|
1565
|
-
// a filesystem path (uses `sep` on Windows, `/` elsewhere),
|
|
1566
|
-
// hence the platform-aware match.
|
|
1567
|
-
cacheControl = 'public, max-age=31536000, immutable';
|
|
1568
|
-
}
|
|
1569
|
-
res.setHeader('Cache-Control', cacheControl);
|
|
1570
|
-
},
|
|
1571
|
-
}));
|
|
1572
|
-
// SPA fallback — serve index.html for all non-file routes. Same
|
|
1573
|
-
// `no-cache` reasoning as above: the served `index.html` carries
|
|
1574
|
-
// references to the current asset hashes, and stale-cached HTML
|
|
1575
|
-
// is the canonical post-update breakage.
|
|
1576
|
-
app.get('/{*path}', (_req, res) => {
|
|
1577
|
-
res.setHeader('Cache-Control', 'no-cache');
|
|
1578
|
-
res.sendFile(join(uiDir, 'index.html'));
|
|
1579
|
-
});
|
|
1580
|
-
}
|
|
1581
|
-
// 4. CDP WebSocket proxy at /cdp
|
|
1582
|
-
// Use noServer mode so Vite's dev middleware doesn't intercept the
|
|
1583
|
-
// upgrade. Keep the default per-message payload cap on this socket —
|
|
1584
|
-
// the oversized-message feedback loop we have to defend against
|
|
1585
|
-
// (see the chromeWs constructor below for the full writeup) is
|
|
1586
|
-
// purely Chrome-to-proxy, never client-to-proxy, so raising the
|
|
1587
|
-
// cap here would only widen the DoS surface for anything on
|
|
1588
|
-
// localhost that can reach ws://127.0.0.1:PORT/cdp.
|
|
1589
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
1590
|
-
server.on('upgrade', (request, socket, head) => {
|
|
1591
|
-
const { pathname } = new URL(request.url, `http://${request.headers.host}`);
|
|
1592
|
-
if (pathname === '/cdp') {
|
|
1593
|
-
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
1594
|
-
wss.emit('connection', ws, request);
|
|
1595
|
-
});
|
|
1596
|
-
}
|
|
1597
|
-
else if (pathname === '/licks-ws') {
|
|
1598
|
-
lickWss.handleUpgrade(request, socket, head, (ws) => {
|
|
1599
|
-
lickWss.emit('connection', ws, request);
|
|
1600
|
-
});
|
|
1601
|
-
}
|
|
1602
|
-
// For other paths, do nothing — let Vite handle HMR upgrades
|
|
1011
|
+
await attachUiServing(app, server, {
|
|
1012
|
+
devMode: DEV_MODE,
|
|
1013
|
+
hosted: RUNTIME_FLAGS.hosted,
|
|
1014
|
+
serveOrigin: SERVE_ORIGIN,
|
|
1015
|
+
uiDir: resolve(Dirname, '..', 'ui'),
|
|
1603
1016
|
});
|
|
1604
|
-
//
|
|
1605
|
-
//
|
|
1606
|
-
//
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
// Tracks per-session current URL by sniffing Chrome→Client events; feeds
|
|
1615
|
-
// the Client→Chrome unmask gate so per-frame unmasking is scoped to
|
|
1616
|
-
// the target tab's actual hostname (fail-closed when unknown).
|
|
1617
|
-
const cdpSessionUrls = createCdpSessionUrlTracker();
|
|
1618
|
-
// Ensure everything is cleaned up when CLI exits
|
|
1619
|
-
const gracefulShutdown = async () => {
|
|
1620
|
-
if (shuttingDown)
|
|
1621
|
-
return;
|
|
1622
|
-
shuttingDown = true;
|
|
1623
|
-
console.log('\nShutting down...');
|
|
1624
|
-
fileLogger.close();
|
|
1625
|
-
overlayInjector?.stop();
|
|
1626
|
-
overlayInjector = null;
|
|
1627
|
-
// Close the shared Chrome WebSocket and all client connections
|
|
1628
|
-
if (chromeWs) {
|
|
1629
|
-
try {
|
|
1630
|
-
chromeWs.close();
|
|
1631
|
-
}
|
|
1632
|
-
catch {
|
|
1633
|
-
/* ignore */
|
|
1634
|
-
}
|
|
1635
|
-
chromeWs = null;
|
|
1636
|
-
}
|
|
1637
|
-
if (activeClientWs) {
|
|
1638
|
-
try {
|
|
1639
|
-
activeClientWs.close();
|
|
1640
|
-
}
|
|
1641
|
-
catch {
|
|
1642
|
-
/* ignore */
|
|
1643
|
-
}
|
|
1644
|
-
activeClientWs = null;
|
|
1645
|
-
}
|
|
1646
|
-
for (const client of wss.clients) {
|
|
1647
|
-
client.close();
|
|
1648
|
-
}
|
|
1649
|
-
wss.close();
|
|
1650
|
-
// Stop accepting new HTTP connections
|
|
1651
|
-
server.close();
|
|
1652
|
-
if (launchedBrowserProcess) {
|
|
1653
|
-
let browserExited = false;
|
|
1654
|
-
launchedBrowserProcess.on('exit', () => {
|
|
1655
|
-
browserExited = true;
|
|
1656
|
-
});
|
|
1657
|
-
try {
|
|
1658
|
-
const res = await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`);
|
|
1659
|
-
const json = (await res.json());
|
|
1660
|
-
const browserWs = new WebSocket(json.webSocketDebuggerUrl);
|
|
1661
|
-
await new Promise((resolve, reject) => {
|
|
1662
|
-
browserWs.on('open', () => {
|
|
1663
|
-
browserWs.send(JSON.stringify({ id: 1, method: 'Browser.close' }));
|
|
1664
|
-
resolve();
|
|
1665
|
-
});
|
|
1666
|
-
browserWs.on('error', reject);
|
|
1667
|
-
});
|
|
1668
|
-
}
|
|
1669
|
-
catch {
|
|
1670
|
-
// CDP not available — the launched browser may still be starting up; fall through to kill.
|
|
1671
|
-
}
|
|
1672
|
-
const deadline = Date.now() + 3000;
|
|
1673
|
-
while (!browserExited && Date.now() < deadline) {
|
|
1674
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
1675
|
-
}
|
|
1676
|
-
if (!browserExited) {
|
|
1677
|
-
try {
|
|
1678
|
-
launchedBrowserProcess.kill('SIGKILL');
|
|
1679
|
-
}
|
|
1680
|
-
catch {
|
|
1681
|
-
/* ignore */
|
|
1682
|
-
}
|
|
1683
|
-
}
|
|
1684
|
-
console.log(`${launchedBrowserLabel} closed`);
|
|
1685
|
-
}
|
|
1686
|
-
process.exit(0);
|
|
1017
|
+
// 4. CDP WebSocket proxy at /cdp — noServer mode so Vite's dev middleware
|
|
1018
|
+
// doesn't intercept the upgrade; the per-message cap stays default (the
|
|
1019
|
+
// feedback-loop defense is Chrome→proxy only, never client→proxy).
|
|
1020
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
1021
|
+
attachCdpUpgradeRouting(server, wss, lickWss);
|
|
1022
|
+
const cdpCtx = {
|
|
1023
|
+
wss,
|
|
1024
|
+
secretProxy,
|
|
1025
|
+
cdpDedup: new CliLogDedup(),
|
|
1026
|
+
cdpSessionUrls: createCdpSessionUrlTracker(),
|
|
1687
1027
|
};
|
|
1028
|
+
const gracefulShutdown = createGracefulShutdown(state, {
|
|
1029
|
+
fileLogger,
|
|
1030
|
+
wss,
|
|
1031
|
+
server,
|
|
1032
|
+
cdpPort: CDP_PORT,
|
|
1033
|
+
});
|
|
1688
1034
|
process.on('SIGINT', () => {
|
|
1689
1035
|
gracefulShutdown();
|
|
1690
1036
|
});
|
|
@@ -1692,279 +1038,28 @@ async function main() {
|
|
|
1692
1038
|
gracefulShutdown();
|
|
1693
1039
|
});
|
|
1694
1040
|
process.on('exit', () => {
|
|
1695
|
-
// Synchronous last-resort cleanup — kill the launched browser if
|
|
1696
|
-
|
|
1041
|
+
// Synchronous last-resort cleanup — kill the launched browser if still running.
|
|
1042
|
+
const browser = state.launchedBrowserProcess;
|
|
1043
|
+
if (!state.shuttingDown && browser) {
|
|
1697
1044
|
try {
|
|
1698
|
-
|
|
1045
|
+
browser.kill();
|
|
1699
1046
|
}
|
|
1700
1047
|
catch {
|
|
1701
1048
|
/* ignore */
|
|
1702
1049
|
}
|
|
1703
1050
|
}
|
|
1704
1051
|
});
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
// already-open path + chromeWs 'open' handler) share one
|
|
1708
|
-
// implementation; falls back to the original bytes on any error.
|
|
1709
|
-
const flushClientFrame = (target, raw) => {
|
|
1710
|
-
const original = String(raw);
|
|
1711
|
-
const { output } = applyCdpUnmask(original, {
|
|
1712
|
-
tracker: cdpSessionUrls,
|
|
1713
|
-
pipeline: secretProxy.rawPipeline,
|
|
1714
|
-
});
|
|
1715
|
-
target.send(output);
|
|
1716
|
-
};
|
|
1717
|
-
function ensureChromeConnection(url) {
|
|
1718
|
-
return new Promise((resolve, reject) => {
|
|
1719
|
-
if (chromeWs && chromeWs.readyState === WebSocket.OPEN) {
|
|
1720
|
-
// Already connected — flush any buffered messages and go direct
|
|
1721
|
-
if (messageBuffer) {
|
|
1722
|
-
for (const msg of messageBuffer) {
|
|
1723
|
-
flushClientFrame(chromeWs, msg);
|
|
1724
|
-
}
|
|
1725
|
-
messageBuffer = null;
|
|
1726
|
-
}
|
|
1727
|
-
resolve();
|
|
1728
|
-
return;
|
|
1729
|
-
}
|
|
1730
|
-
// Clean up old connection
|
|
1731
|
-
if (chromeWs) {
|
|
1732
|
-
try {
|
|
1733
|
-
chromeWs.close();
|
|
1734
|
-
}
|
|
1735
|
-
catch {
|
|
1736
|
-
/* ignore */
|
|
1737
|
-
}
|
|
1738
|
-
}
|
|
1739
|
-
messageBuffer = [];
|
|
1740
|
-
// Disable the ws library's per-message size cap (default 100 MiB).
|
|
1741
|
-
// The slicc UI runs INSIDE the Chrome instance it's debugging, so
|
|
1742
|
-
// Chrome's Network domain reports every CDP frame — including the
|
|
1743
|
-
// event frames themselves — back to us as `Network.webSocketFrame*`
|
|
1744
|
-
// messages that each embed the prior frame's payload. That produces
|
|
1745
|
-
// an exponential feedback loop which, left unchecked, trips the
|
|
1746
|
-
// default 100 MiB cap and closes the Chrome WebSocket (code 1006).
|
|
1747
|
-
// Without the cap the loop is still bounded by Chrome's own frame
|
|
1748
|
-
// limits, but the proxy no longer dies and later CDP calls like
|
|
1749
|
-
// `Target.getTargets` keep working instead of being DROPPED.
|
|
1750
|
-
chromeWs = new WebSocket(url, { maxPayload: 0 });
|
|
1751
|
-
chromeWs.on('open', () => {
|
|
1752
|
-
console.log('[cdp-proxy] chromeWs open');
|
|
1753
|
-
// Flush buffered messages
|
|
1754
|
-
if (messageBuffer) {
|
|
1755
|
-
for (const msg of messageBuffer) {
|
|
1756
|
-
flushClientFrame(chromeWs, msg);
|
|
1757
|
-
}
|
|
1758
|
-
messageBuffer = null;
|
|
1759
|
-
}
|
|
1760
|
-
resolve();
|
|
1761
|
-
});
|
|
1762
|
-
// The slicc UI runs inside the Chrome instance it's debugging, so
|
|
1763
|
-
// Chrome's Network domain reports every CDP frame back through the
|
|
1764
|
-
// same socket as `Network.webSocketFrameReceived` /
|
|
1765
|
-
// `Network.webSocketFrameSent` events whose `payloadData` embeds
|
|
1766
|
-
// the prior frame's bytes — a self-amplifying feedback loop that,
|
|
1767
|
-
// left alone, drives per-frame sizes past V8's ~512 MiB string
|
|
1768
|
-
// limit and crashes node-server with `ERR_STRING_TOO_LONG`. It
|
|
1769
|
-
// also starves the browser's own debugger UI (the classic
|
|
1770
|
-
// "debugger paused in another window" freeze) because the CDP
|
|
1771
|
-
// event stream fills up with self-referential noise instead of
|
|
1772
|
-
// the events DevTools actually needs.
|
|
1773
|
-
//
|
|
1774
|
-
// Peek at the raw bytes and skip the runaway event types once
|
|
1775
|
-
// they exceed a small sniffing threshold. Legitimate CDP payloads
|
|
1776
|
-
// we care about (screenshots, DOM snapshots, large tool results)
|
|
1777
|
-
// are never `Network.webSocketFrame*` messages, so filtering by
|
|
1778
|
-
// method is far safer than a blanket size cap that would also
|
|
1779
|
-
// drop genuine large events.
|
|
1780
|
-
const CDP_PROXY_INSPECT_BYTES = 256 * 1024;
|
|
1781
|
-
const CDP_PROXY_HARD_FRAME_CAP = 64 * 1024 * 1024;
|
|
1782
|
-
const loopEventPrefixes = [
|
|
1783
|
-
'{"method":"Network.webSocketFrameReceived"',
|
|
1784
|
-
'{"method":"Network.webSocketFrameSent"',
|
|
1785
|
-
];
|
|
1786
|
-
/**
|
|
1787
|
-
* Normalise the `ws` library's polymorphic message payload into a
|
|
1788
|
-
* single Buffer we can safely peek at and forward. Without this,
|
|
1789
|
-
* a later `String(data)` would coerce an `ArrayBuffer` to
|
|
1790
|
-
* `"[object ArrayBuffer]"` and a `Buffer[]` to comma-joined
|
|
1791
|
-
* stringified fragments, corrupting the CDP frame.
|
|
1792
|
-
*/
|
|
1793
|
-
const toBuffer = (data) => {
|
|
1794
|
-
if (Buffer.isBuffer(data))
|
|
1795
|
-
return data;
|
|
1796
|
-
if (data instanceof ArrayBuffer)
|
|
1797
|
-
return Buffer.from(data);
|
|
1798
|
-
if (Array.isArray(data))
|
|
1799
|
-
return Buffer.concat(data);
|
|
1800
|
-
// Rare fallback — string frames in text mode. Keep bytes faithful.
|
|
1801
|
-
return Buffer.from(String(data));
|
|
1802
|
-
};
|
|
1803
|
-
chromeWs.on('message', (data) => {
|
|
1804
|
-
const buf = toBuffer(data);
|
|
1805
|
-
const byteLen = buf.length;
|
|
1806
|
-
// Peek at the first 256 KiB only — enough to identify the event
|
|
1807
|
-
// type cheaply without stringifying the whole runaway buffer.
|
|
1808
|
-
const head = buf.subarray(0, CDP_PROXY_INSPECT_BYTES).toString();
|
|
1809
|
-
if (loopEventPrefixes.some((p) => head.startsWith(p))) {
|
|
1810
|
-
const msg = `[cdp-proxy] Dropping Chrome feedback-loop event (${byteLen} bytes, ${head.slice(1, 60)}…)`;
|
|
1811
|
-
if (cdpDedup.shouldLog(msg))
|
|
1812
|
-
console.debug(msg);
|
|
1813
|
-
return;
|
|
1814
|
-
}
|
|
1815
|
-
// Hard safety net — still refuse anything that would blow past
|
|
1816
|
-
// V8's string length limit (buf.toString throws ERR_STRING_TOO_LONG
|
|
1817
|
-
// for any frame larger than ~512 MiB).
|
|
1818
|
-
if (byteLen > CDP_PROXY_HARD_FRAME_CAP) {
|
|
1819
|
-
const msg = `[cdp-proxy] Dropping oversized Chrome→Client frame (${byteLen} bytes)`;
|
|
1820
|
-
if (cdpDedup.shouldLog(msg))
|
|
1821
|
-
console.debug(msg);
|
|
1822
|
-
return;
|
|
1823
|
-
}
|
|
1824
|
-
const str = buf.toString();
|
|
1825
|
-
const preview = str.slice(0, 200);
|
|
1826
|
-
const msg = `[cdp-proxy] Chrome→Client: ${preview}`;
|
|
1827
|
-
if (cdpDedup.shouldLog(msg))
|
|
1828
|
-
console.debug(msg);
|
|
1829
|
-
// Sniff Target.attachedToTarget / targetInfoChanged / Page.frameNavigated
|
|
1830
|
-
// so the Client→Chrome unmask gate can resolve per-session hostnames.
|
|
1831
|
-
cdpSessionUrls.observeChromeToClient(str);
|
|
1832
|
-
if (activeClientWs && activeClientWs.readyState === WebSocket.OPEN) {
|
|
1833
|
-
activeClientWs.send(str);
|
|
1834
|
-
}
|
|
1835
|
-
});
|
|
1836
|
-
chromeWs.on('close', (code, reason) => {
|
|
1837
|
-
console.log(`[cdp-proxy] Chrome WS closed. code=${code}, reason=${String(reason)}`);
|
|
1838
|
-
chromeWs = null;
|
|
1839
|
-
});
|
|
1840
|
-
chromeWs.on('error', (err) => {
|
|
1841
|
-
console.log(`[cdp-proxy] Chrome WS error: ${err}`);
|
|
1842
|
-
chromeWs = null;
|
|
1843
|
-
reject(err);
|
|
1844
|
-
});
|
|
1845
|
-
});
|
|
1846
|
-
}
|
|
1847
|
-
wss.on('connection', async (clientWs) => {
|
|
1848
|
-
try {
|
|
1849
|
-
// Close previous client connection — only one client active at a time
|
|
1850
|
-
if (activeClientWs && activeClientWs.readyState === WebSocket.OPEN) {
|
|
1851
|
-
console.log('[cdp-proxy] Closing previous client connection');
|
|
1852
|
-
activeClientWs.close();
|
|
1853
|
-
}
|
|
1854
|
-
activeClientWs = clientWs;
|
|
1855
|
-
console.log('[cdp-proxy] New client connected');
|
|
1856
|
-
// Initialize buffer BEFORE any await so messages arriving during
|
|
1857
|
-
// waitForCDP or ensureChromeConnection are captured, not dropped.
|
|
1858
|
-
if (messageBuffer === null) {
|
|
1859
|
-
messageBuffer = [];
|
|
1860
|
-
}
|
|
1861
|
-
// Register ALL handlers BEFORE any async work so no messages are lost
|
|
1862
|
-
clientWs.on('message', (data) => {
|
|
1863
|
-
const original = String(data);
|
|
1864
|
-
const preview = original.slice(0, 200);
|
|
1865
|
-
if (chromeWs && chromeWs.readyState === WebSocket.OPEN && messageBuffer === null) {
|
|
1866
|
-
const msg = `[cdp-proxy] Client→Chrome: ${preview}`;
|
|
1867
|
-
if (cdpDedup.shouldLog(msg))
|
|
1868
|
-
console.debug(msg);
|
|
1869
|
-
const { output } = applyCdpUnmask(original, {
|
|
1870
|
-
tracker: cdpSessionUrls,
|
|
1871
|
-
pipeline: secretProxy.rawPipeline,
|
|
1872
|
-
});
|
|
1873
|
-
chromeWs.send(output);
|
|
1874
|
-
}
|
|
1875
|
-
else if (messageBuffer !== null) {
|
|
1876
|
-
// Buffer the ORIGINAL bytes; unmask runs on flush so the
|
|
1877
|
-
// hostname tracker reflects the state at send time.
|
|
1878
|
-
messageBuffer.push(data);
|
|
1879
|
-
const msg = `[cdp-proxy] Client→Chrome (buffered): ${preview}`;
|
|
1880
|
-
if (cdpDedup.shouldLog(msg))
|
|
1881
|
-
console.debug(msg);
|
|
1882
|
-
}
|
|
1883
|
-
else {
|
|
1884
|
-
// Chrome not connected and no buffer — this shouldn't happen but log it
|
|
1885
|
-
console.log(`[cdp-proxy] Client→Chrome (DROPPED — no connection): ${preview}`);
|
|
1886
|
-
}
|
|
1887
|
-
});
|
|
1888
|
-
clientWs.on('close', () => {
|
|
1889
|
-
console.log('[cdp-proxy] Client disconnected');
|
|
1890
|
-
if (activeClientWs === clientWs) {
|
|
1891
|
-
activeClientWs = null;
|
|
1892
|
-
}
|
|
1893
|
-
// Don't close chromeWs — keep it alive for the next client
|
|
1894
|
-
});
|
|
1895
|
-
clientWs.on('error', (err) => {
|
|
1896
|
-
console.log(`[cdp-proxy] Client WS error: ${err}`);
|
|
1897
|
-
if (activeClientWs === clientWs) {
|
|
1898
|
-
activeClientWs = null;
|
|
1899
|
-
}
|
|
1900
|
-
});
|
|
1901
|
-
// NOW do async work — messages arriving during these awaits are buffered
|
|
1902
|
-
if (!cdpUrl) {
|
|
1903
|
-
cdpUrl = await waitForCDP(CDP_PORT);
|
|
1904
|
-
console.log(`[cdp-proxy] CDP available at: ${cdpUrl}`);
|
|
1905
|
-
}
|
|
1906
|
-
await ensureChromeConnection(cdpUrl);
|
|
1907
|
-
}
|
|
1908
|
-
catch (err) {
|
|
1909
|
-
console.error('[cdp-proxy] Connection error:', err);
|
|
1910
|
-
clientWs.close();
|
|
1911
|
-
}
|
|
1052
|
+
wss.on('connection', (clientWs) => {
|
|
1053
|
+
void handleCdpClient(state, clientWs, cdpCtx, CDP_PORT);
|
|
1912
1054
|
});
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
});
|
|
1922
|
-
// Pre-connect to Chrome's CDP so the proxy is warm when the first client connects.
|
|
1923
|
-
// Without this, the first browser automation command has to wait for CDP discovery + WS handshake.
|
|
1924
|
-
(async () => {
|
|
1925
|
-
try {
|
|
1926
|
-
cdpUrl = await waitForCDP(CDP_PORT);
|
|
1927
|
-
console.log(`[cdp-proxy] Pre-connected: CDP available at ${cdpUrl}`);
|
|
1928
|
-
await ensureChromeConnection(cdpUrl);
|
|
1929
|
-
console.log('[cdp-proxy] Chrome WebSocket ready (pre-warmed)');
|
|
1930
|
-
// Register leader-restart endpoint now that CDP is ready (hosted mode only)
|
|
1931
|
-
if (RUNTIME_FLAGS.hosted) {
|
|
1932
|
-
registerLeaderRestartEndpoint(app, {
|
|
1933
|
-
cdp: createHttpCdp(CDP_PORT),
|
|
1934
|
-
localUrlPrefix: `http://localhost:${SERVE_PORT}/`,
|
|
1935
|
-
});
|
|
1936
|
-
console.log('[hosted] /api/leader-restart endpoint registered');
|
|
1937
|
-
}
|
|
1938
|
-
}
|
|
1939
|
-
catch (err) {
|
|
1940
|
-
console.log('[cdp-proxy] Pre-connect failed (will retry on first client):', err);
|
|
1941
|
-
}
|
|
1942
|
-
})();
|
|
1943
|
-
if (ELECTRON_MODE) {
|
|
1944
|
-
void (async () => {
|
|
1945
|
-
try {
|
|
1946
|
-
overlayInjector = await ElectronOverlayInjector.create({
|
|
1947
|
-
cdpPort: CDP_PORT,
|
|
1948
|
-
servePort: SERVE_PORT,
|
|
1949
|
-
dev: DEV_MODE,
|
|
1950
|
-
projectRoot: PROJECT_ROOT,
|
|
1951
|
-
});
|
|
1952
|
-
await overlayInjector.start();
|
|
1953
|
-
console.log('[electron-float] Overlay injector is watching Electron page targets');
|
|
1954
|
-
}
|
|
1955
|
-
catch (error) {
|
|
1956
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1957
|
-
console.error('[electron-float] Failed to start overlay injector:', message);
|
|
1958
|
-
}
|
|
1959
|
-
})();
|
|
1960
|
-
}
|
|
1961
|
-
if (!ELECTRON_MODE) {
|
|
1962
|
-
setTimeout(() => {
|
|
1963
|
-
attachConsoleForwarder(CDP_PORT, String(SERVE_PORT)).catch((err) => {
|
|
1964
|
-
console.error('[page] Console forwarder error:', err);
|
|
1965
|
-
});
|
|
1966
|
-
}, 2500);
|
|
1967
|
-
}
|
|
1055
|
+
startCdpServer(state, {
|
|
1056
|
+
app,
|
|
1057
|
+
server,
|
|
1058
|
+
ctx: cdpCtx,
|
|
1059
|
+
fileLogger,
|
|
1060
|
+
servePort: SERVE_PORT,
|
|
1061
|
+
serveOrigin: SERVE_ORIGIN,
|
|
1062
|
+
cdpPort: CDP_PORT,
|
|
1968
1063
|
});
|
|
1969
1064
|
}
|
|
1970
1065
|
// ---------------------------------------------------------------------------
|