sliccy 4.22.0 → 4.23.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/bridge-security.d.ts +94 -0
- package/dist/node-server/bridge-security.js +132 -0
- package/dist/node-server/index.js +149 -15
- package/dist/node-server/launch-url.d.ts +7 -0
- package/dist/node-server/launch-url.js +12 -3
- package/dist/ui/assets/{account-store-IyPhtzhJ.js → account-store-9y5Ab4vV.js} +2 -2
- package/dist/ui/assets/{account-store-BbOugMGQ.js → account-store-CSLAx3pa.js} +2 -2
- package/dist/ui/assets/{adobe-DgwUObWJ.js → adobe-Ba8U6Soe.js} +1 -1
- package/dist/ui/assets/{adobe-VPKLgSWT.js → adobe-BtvdR5I0.js} +1 -1
- package/dist/ui/assets/{agent-message-to-chat-y24E_cxL.js → agent-message-to-chat-bEHX4iF4.js} +1 -1
- package/dist/ui/assets/{apps-DUeem2tA.js → apps-Dh-H9LYE.js} +1 -1
- package/dist/ui/assets/{azure-openai-DoRxmkwZ.js → azure-openai-BpIYfrM-.js} +1 -1
- package/dist/ui/assets/{azure-openai-BZIgRwqd.js → azure-openai-C4YFzA3o.js} +1 -1
- package/dist/ui/assets/{bsh-watchdog-C9h2IaS0.js → bsh-watchdog-CNmrTRAy.js} +1 -1
- package/dist/ui/assets/{cdp-Cbi5Katl.js → cdp-BGgdiko1.js} +2 -2
- package/dist/ui/assets/{connect-surface-CVGFqUbD.js → connect-surface-DTsPWNDo.js} +1 -1
- package/dist/ui/assets/dip-Fvfzs0-q.js +1 -0
- package/dist/ui/assets/{dist-BRXlUPtR.js → dist-B5wGuNlW.js} +1 -1
- package/dist/ui/assets/{dist-CFUSBJZQ.js → dist-CjQIX13Y.js} +1 -1
- package/dist/ui/assets/{es-B9Xns6VP.js → es-t4MB1uY0.js} +1 -1
- package/dist/ui/assets/{fs-BCZV-DeG.js → fs-BlhR8i_q.js} +2 -2
- package/dist/ui/assets/{fs-BWEPe_lI.js → fs-ry06ziaM.js} +1 -1
- package/dist/ui/assets/{github-D1-sIysd.js → github-6R3ONWs4.js} +2 -2
- package/dist/ui/assets/{github-BD8TM8E4.js → github-Cr1bhcIq.js} +1 -1
- package/dist/ui/assets/{github-copilot-BeL7S_RM.js → github-copilot-CPpSIAl_.js} +1 -1
- package/dist/ui/assets/{github-copilot-CVOOYEif.js → github-copilot-_vAY24oI.js} +1 -1
- package/dist/ui/assets/{hear-DD8kXpu3.js → hear-DLqP08Yk.js} +1 -1
- package/dist/ui/assets/{kernel-worker-B1z5Mep_.js → kernel-worker-ReGrzvdD.js} +24 -24
- package/dist/ui/assets/{kokoro-engine-BAKhtzKA.js → kokoro-engine-BeiKy8PB.js} +1 -1
- package/dist/ui/assets/{lick-ws-bridge-C76WNbyz.js → lick-ws-bridge-DZy9C70j.js} +1 -1
- package/dist/ui/assets/{local-llm-Bx2yDR6Y.js → local-llm-ehhQ9AeC.js} +1 -1
- package/dist/ui/assets/{main-cherry-Bap_NTtV.js → main-cherry-X1IxoqNY.js} +1 -1
- package/dist/ui/assets/{main-ZPWrsiEH.js → main-zx9b4e1t.js} +3 -3
- package/dist/ui/assets/{mount-CJTp3rsY.js → mount-Cf2e3Vu8.js} +2 -2
- package/dist/ui/assets/{mount-BZGOqIjJ.js → mount-Crd-I-lS.js} +1 -1
- package/dist/ui/assets/{new-session-Boyp0jbl.js → new-session-PJxD5xz-.js} +2 -2
- package/dist/ui/assets/{oauth-bootstrap-B-FGqNIA.js → oauth-bootstrap-38UC9ABk.js} +2 -2
- package/dist/ui/assets/{openai-codex-kCbaxA3p.js → openai-codex-4vY4IfjZ.js} +1 -1
- package/dist/ui/assets/{openai-codex-2NIZy82T.js → openai-codex-CcWEg2Id.js} +1 -1
- package/dist/ui/assets/{panel-rpc-handlers-DB-Ug9qU.js → panel-rpc-handlers-CyLUoTYQ.js} +1 -1
- package/dist/ui/assets/{provider-Dz3gmrdh.js → provider-BGI7Ytuv.js} +2 -2
- package/dist/ui/assets/{provider-C-QlPwy9.js → provider-BRZxUH2o.js} +1 -1
- package/dist/ui/assets/provider-store-access-BiJN5bAM.js +1 -0
- package/dist/ui/assets/provider-store-access-Dp0MGqAc.js +1 -0
- package/dist/ui/assets/{providers-wvXqnMMs.js → providers-CcDiXx1v.js} +1 -1
- package/dist/ui/assets/{quick-llm-BrfU7BId.js → quick-llm-CpcpayIL.js} +1 -1
- package/dist/ui/assets/session-freezer-DQ--9MwT.js +1 -0
- package/dist/ui/assets/setup-sudo-I4S39zW5.js +1 -0
- package/dist/ui/assets/{speak-BfxsOvBC.js → speak-gvgsbAbr.js} +1 -1
- package/dist/ui/assets/{sprinkle-manager-D9bGw9q8.js → sprinkle-manager-am-b2nZK.js} +1 -1
- package/dist/ui/assets/{store-CvNWCf_K.js → store-2Y1B359N.js} +1 -1
- package/dist/ui/assets/{sudo-DNg2E0j8.js → sudo-CXuD9JQi.js} +1 -1
- package/dist/ui/assets/{transformers-env-Vns8FfCl.js → transformers-env-RCjmw9ix.js} +1 -1
- package/dist/ui/assets/{tray-leave-runtime-Dblg03Av.js → tray-leave-runtime-EjSnz0hB.js} +1 -1
- package/dist/ui/assets/{upgrade-detection-CWy_ieEK.js → upgrade-detection-CHScEQnq.js} +1 -1
- package/dist/ui/assets/{wc-attach-D2l7z2MI.js → wc-attach-BbBcc9d7.js} +2 -2
- package/dist/ui/assets/{wc-detached-CNJ_gW__.js → wc-detached-BsGgVyQ2.js} +1 -1
- package/dist/ui/assets/{wc-extension-BwrsCjZl.js → wc-extension-pjc1MPFN.js} +2 -2
- package/dist/ui/assets/{wc-live-DjrJGzlN.js → wc-live-BAWrPhr9.js} +5 -5
- package/dist/ui/assets/{wc-nav-BmtdkU_z.js → wc-nav-D1GUd0Mt.js} +2 -2
- package/dist/ui/assets/{wc-onboarding-Ct6Arm5m.js → wc-onboarding-Bt02HM9-.js} +2 -2
- package/dist/ui/assets/{wc-placeholder-CWOudEXO.js → wc-placeholder-CEMVuHe3.js} +3 -3
- package/dist/ui/assets/{wc-settings-3CEFgt8u.js → wc-settings-DP3TVlWk.js} +2 -2
- package/dist/ui/assets/{wc-shell-CjN33a2b.js → wc-shell-tnKegXuB.js} +3 -3
- package/dist/ui/assets/{wc-sprinkles-smmPR4Ja.js → wc-sprinkles-DEgP8Ihj.js} +2 -2
- package/dist/ui/assets/{wc-tray-CVAaAkxz.js → wc-tray-BsL3c-FP.js} +3 -3
- package/dist/ui/assets/{xai-grok-CcpOkuUW.js → xai-grok-BK-WZwtZ.js} +1 -1
- package/dist/ui/assets/{xai-grok-DxDfANmg.js → xai-grok-CqrCTXgB.js} +1 -1
- package/dist/ui/index.html +2 -2
- package/dist/ui/packages/webapp/index.html +2 -2
- package/dist/ui/slicc-diff.js +1 -1
- package/dist/ui/slicc-editor.js +1 -1
- package/package.json +2 -2
- package/dist/ui/assets/dip-CvR0Cqr1.js +0 -1
- package/dist/ui/assets/provider-store-access-DoN2yB7H.js +0 -1
- package/dist/ui/assets/provider-store-access-KBTPjy08.js +0 -1
- package/dist/ui/assets/session-freezer-BXDSvoMT.js +0 -1
- package/dist/ui/assets/setup-sudo-DQpQyGGT.js +0 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge security primitives for the standalone thin /cdp bridge.
|
|
3
|
+
*
|
|
4
|
+
* The standalone CLI runs a thin Express server that proxies CDP from a
|
|
5
|
+
* sliccy.ai-hosted leader tab to the local Chrome over `/cdp`. Full CDP
|
|
6
|
+
* pass-through = full control of the user's Chrome, so the WebSocket
|
|
7
|
+
* upgrade is gated by two factors plus PNA:
|
|
8
|
+
* 1. Origin allowlist (`isAllowedBridgeOrigin`).
|
|
9
|
+
* 2. Per-process subprotocol token in `Sec-WebSocket-Protocol`
|
|
10
|
+
* (`SUBPROTOCOL_PREFIX` + token). Never appears in a query string,
|
|
11
|
+
* so it does not leak into Referer / logs.
|
|
12
|
+
* 3. PNA preflight (`buildPnaPreflightHeaders`) — Chrome blocks
|
|
13
|
+
* public→private WS upgrades without `Access-Control-Allow-Private-Network`.
|
|
14
|
+
*
|
|
15
|
+
* Cross-origin /api calls from the hosted leader also need CORS; see
|
|
16
|
+
* `buildCorsHeaders` for the per-request response set.
|
|
17
|
+
*
|
|
18
|
+
* Pure module (no node-only imports) so the WS gate is trivially unit
|
|
19
|
+
* testable from `tests/bridge-security.test.ts`.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Origin allowlist. Production + staging worker plus the dev-mode loopback
|
|
23
|
+
* origins (parallel to chrome-extension's `BRIDGE_DEV_ORIGINS`). Add a new
|
|
24
|
+
* origin here and in the extension allowlist together — they MUST stay in
|
|
25
|
+
* sync, otherwise extension and standalone disagree on what's a leader.
|
|
26
|
+
*/
|
|
27
|
+
export declare const BRIDGE_ALLOWED_ORIGINS: readonly string[];
|
|
28
|
+
/** Subprotocol prefix advertised by the leader; the per-process token is appended. */
|
|
29
|
+
export declare const BRIDGE_SUBPROTOCOL_PREFIX = "slicc.bridge.v1.";
|
|
30
|
+
/** Query-param name the launch URL uses to forward the subprotocol token to the leader. */
|
|
31
|
+
export declare const BRIDGE_TOKEN_QUERY_PARAM = "bridgeToken";
|
|
32
|
+
/** Query-param name the launch URL uses to forward the local /cdp WebSocket URL. */
|
|
33
|
+
export declare const BRIDGE_WS_QUERY_PARAM = "bridge";
|
|
34
|
+
/** True iff `origin` is in the bridge allowlist. Case-sensitive (origins are normalized lowercase). */
|
|
35
|
+
export declare function isAllowedBridgeOrigin(origin: string | undefined | null): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Mint a per-process bridge token. Embedded in the leader launch URL and
|
|
38
|
+
* required as the WebSocket subprotocol on /cdp. Use `crypto.randomUUID()`
|
|
39
|
+
* — 122 bits of entropy is plenty for a session-scoped capability.
|
|
40
|
+
*/
|
|
41
|
+
export declare function mintBridgeToken(): string;
|
|
42
|
+
/**
|
|
43
|
+
* Parse the `Sec-WebSocket-Protocol` request header into a trimmed list. The
|
|
44
|
+
* header is a comma-separated list per RFC 6455; `ws` exposes it raw.
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseSubprotocolHeader(header: string | string[] | undefined): string[];
|
|
47
|
+
/**
|
|
48
|
+
* Pick the bridge subprotocol matching `expectedToken`, or null if absent.
|
|
49
|
+
* The matching protocol is what we MUST echo back in the upgrade response
|
|
50
|
+
* (RFC 6455 §1.9) — otherwise the browser closes the socket.
|
|
51
|
+
*/
|
|
52
|
+
export declare function selectBridgeSubprotocol(protocols: readonly string[], expectedToken: string): string | null;
|
|
53
|
+
export interface BridgeUpgradeGateResult {
|
|
54
|
+
ok: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* The subprotocol to echo back in the 101 response when `ok === true`.
|
|
57
|
+
* Always null when `ok === false`.
|
|
58
|
+
*/
|
|
59
|
+
acceptedSubprotocol: string | null;
|
|
60
|
+
/**
|
|
61
|
+
* Reason exposed in logs for rejection. Intentionally coarse — does not
|
|
62
|
+
* tell the caller WHICH check failed, mirroring `validateBridgePin` in
|
|
63
|
+
* the chrome-extension bridge SW.
|
|
64
|
+
*/
|
|
65
|
+
reason?: 'origin-not-allowed' | 'subprotocol-missing-or-mismatched';
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Combined origin + subprotocol gate for a `/cdp` upgrade request.
|
|
69
|
+
*
|
|
70
|
+
* Returns `{ ok: true, acceptedSubprotocol }` only when BOTH the origin is
|
|
71
|
+
* in the allowlist AND a matching `slicc.bridge.v1.<expectedToken>`
|
|
72
|
+
* subprotocol was offered. Closes-the-socket-before-emit semantics live at
|
|
73
|
+
* the call site in `index.ts`.
|
|
74
|
+
*/
|
|
75
|
+
export declare function validateBridgeUpgrade(input: {
|
|
76
|
+
origin: string | undefined | null;
|
|
77
|
+
subprotocolHeader: string | string[] | undefined;
|
|
78
|
+
expectedToken: string;
|
|
79
|
+
}): BridgeUpgradeGateResult;
|
|
80
|
+
/**
|
|
81
|
+
* CORS headers for an allowlisted `Origin`. Returns `null` when the origin
|
|
82
|
+
* is not in the allowlist (caller should NOT set CORS headers).
|
|
83
|
+
*
|
|
84
|
+
* `Access-Control-Allow-Credentials: true` is included so the hosted leader
|
|
85
|
+
* can carry cookies to /api/* (e.g. auth) when that's added later. Today
|
|
86
|
+
* the bridge token is the auth factor, not cookies.
|
|
87
|
+
*/
|
|
88
|
+
export declare function buildCorsHeaders(origin: string | undefined | null): Record<string, string> | null;
|
|
89
|
+
/**
|
|
90
|
+
* PNA preflight extras. Added on OPTIONS responses for allowlisted origins
|
|
91
|
+
* when the request carries `Access-Control-Request-Private-Network: true`
|
|
92
|
+
* — Chrome blocks public→private fetch / WS otherwise.
|
|
93
|
+
*/
|
|
94
|
+
export declare function buildPnaPreflightHeaders(): Record<string, string>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge security primitives for the standalone thin /cdp bridge.
|
|
3
|
+
*
|
|
4
|
+
* The standalone CLI runs a thin Express server that proxies CDP from a
|
|
5
|
+
* sliccy.ai-hosted leader tab to the local Chrome over `/cdp`. Full CDP
|
|
6
|
+
* pass-through = full control of the user's Chrome, so the WebSocket
|
|
7
|
+
* upgrade is gated by two factors plus PNA:
|
|
8
|
+
* 1. Origin allowlist (`isAllowedBridgeOrigin`).
|
|
9
|
+
* 2. Per-process subprotocol token in `Sec-WebSocket-Protocol`
|
|
10
|
+
* (`SUBPROTOCOL_PREFIX` + token). Never appears in a query string,
|
|
11
|
+
* so it does not leak into Referer / logs.
|
|
12
|
+
* 3. PNA preflight (`buildPnaPreflightHeaders`) — Chrome blocks
|
|
13
|
+
* public→private WS upgrades without `Access-Control-Allow-Private-Network`.
|
|
14
|
+
*
|
|
15
|
+
* Cross-origin /api calls from the hosted leader also need CORS; see
|
|
16
|
+
* `buildCorsHeaders` for the per-request response set.
|
|
17
|
+
*
|
|
18
|
+
* Pure module (no node-only imports) so the WS gate is trivially unit
|
|
19
|
+
* testable from `tests/bridge-security.test.ts`.
|
|
20
|
+
*/
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
22
|
+
/**
|
|
23
|
+
* Origin allowlist. Production + staging worker plus the dev-mode loopback
|
|
24
|
+
* origins (parallel to chrome-extension's `BRIDGE_DEV_ORIGINS`). Add a new
|
|
25
|
+
* origin here and in the extension allowlist together — they MUST stay in
|
|
26
|
+
* sync, otherwise extension and standalone disagree on what's a leader.
|
|
27
|
+
*/
|
|
28
|
+
export const BRIDGE_ALLOWED_ORIGINS = Object.freeze([
|
|
29
|
+
'https://www.sliccy.ai',
|
|
30
|
+
'https://slicc-tray-hub-staging.minivelos.workers.dev',
|
|
31
|
+
'http://localhost:5710',
|
|
32
|
+
'http://127.0.0.1:5710',
|
|
33
|
+
]);
|
|
34
|
+
/** Subprotocol prefix advertised by the leader; the per-process token is appended. */
|
|
35
|
+
export const BRIDGE_SUBPROTOCOL_PREFIX = 'slicc.bridge.v1.';
|
|
36
|
+
/** Query-param name the launch URL uses to forward the subprotocol token to the leader. */
|
|
37
|
+
export const BRIDGE_TOKEN_QUERY_PARAM = 'bridgeToken';
|
|
38
|
+
/** Query-param name the launch URL uses to forward the local /cdp WebSocket URL. */
|
|
39
|
+
export const BRIDGE_WS_QUERY_PARAM = 'bridge';
|
|
40
|
+
/** Headers we allow on cross-origin /api requests from the hosted leader. */
|
|
41
|
+
const CORS_ALLOW_HEADERS = 'Content-Type, X-Slicc-Raw-Body, X-Session-Id, X-Bridge-Token, Authorization';
|
|
42
|
+
/** Methods exposed to the hosted leader. */
|
|
43
|
+
const CORS_ALLOW_METHODS = 'GET, POST, PUT, DELETE, OPTIONS';
|
|
44
|
+
/** True iff `origin` is in the bridge allowlist. Case-sensitive (origins are normalized lowercase). */
|
|
45
|
+
export function isAllowedBridgeOrigin(origin) {
|
|
46
|
+
if (!origin)
|
|
47
|
+
return false;
|
|
48
|
+
return BRIDGE_ALLOWED_ORIGINS.includes(origin);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Mint a per-process bridge token. Embedded in the leader launch URL and
|
|
52
|
+
* required as the WebSocket subprotocol on /cdp. Use `crypto.randomUUID()`
|
|
53
|
+
* — 122 bits of entropy is plenty for a session-scoped capability.
|
|
54
|
+
*/
|
|
55
|
+
export function mintBridgeToken() {
|
|
56
|
+
return randomUUID();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Parse the `Sec-WebSocket-Protocol` request header into a trimmed list. The
|
|
60
|
+
* header is a comma-separated list per RFC 6455; `ws` exposes it raw.
|
|
61
|
+
*/
|
|
62
|
+
export function parseSubprotocolHeader(header) {
|
|
63
|
+
if (!header)
|
|
64
|
+
return [];
|
|
65
|
+
const flat = Array.isArray(header) ? header.join(',') : header;
|
|
66
|
+
return flat
|
|
67
|
+
.split(',')
|
|
68
|
+
.map((s) => s.trim())
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Pick the bridge subprotocol matching `expectedToken`, or null if absent.
|
|
73
|
+
* The matching protocol is what we MUST echo back in the upgrade response
|
|
74
|
+
* (RFC 6455 §1.9) — otherwise the browser closes the socket.
|
|
75
|
+
*/
|
|
76
|
+
export function selectBridgeSubprotocol(protocols, expectedToken) {
|
|
77
|
+
if (!expectedToken)
|
|
78
|
+
return null;
|
|
79
|
+
const expected = `${BRIDGE_SUBPROTOCOL_PREFIX}${expectedToken}`;
|
|
80
|
+
return protocols.includes(expected) ? expected : null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Combined origin + subprotocol gate for a `/cdp` upgrade request.
|
|
84
|
+
*
|
|
85
|
+
* Returns `{ ok: true, acceptedSubprotocol }` only when BOTH the origin is
|
|
86
|
+
* in the allowlist AND a matching `slicc.bridge.v1.<expectedToken>`
|
|
87
|
+
* subprotocol was offered. Closes-the-socket-before-emit semantics live at
|
|
88
|
+
* the call site in `index.ts`.
|
|
89
|
+
*/
|
|
90
|
+
export function validateBridgeUpgrade(input) {
|
|
91
|
+
if (!isAllowedBridgeOrigin(input.origin)) {
|
|
92
|
+
return { ok: false, acceptedSubprotocol: null, reason: 'origin-not-allowed' };
|
|
93
|
+
}
|
|
94
|
+
const protocols = parseSubprotocolHeader(input.subprotocolHeader);
|
|
95
|
+
const accepted = selectBridgeSubprotocol(protocols, input.expectedToken);
|
|
96
|
+
if (!accepted) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
acceptedSubprotocol: null,
|
|
100
|
+
reason: 'subprotocol-missing-or-mismatched',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, acceptedSubprotocol: accepted };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* CORS headers for an allowlisted `Origin`. Returns `null` when the origin
|
|
107
|
+
* is not in the allowlist (caller should NOT set CORS headers).
|
|
108
|
+
*
|
|
109
|
+
* `Access-Control-Allow-Credentials: true` is included so the hosted leader
|
|
110
|
+
* can carry cookies to /api/* (e.g. auth) when that's added later. Today
|
|
111
|
+
* the bridge token is the auth factor, not cookies.
|
|
112
|
+
*/
|
|
113
|
+
export function buildCorsHeaders(origin) {
|
|
114
|
+
if (!isAllowedBridgeOrigin(origin))
|
|
115
|
+
return null;
|
|
116
|
+
return {
|
|
117
|
+
'Access-Control-Allow-Origin': origin,
|
|
118
|
+
'Access-Control-Allow-Credentials': 'true',
|
|
119
|
+
'Access-Control-Allow-Methods': CORS_ALLOW_METHODS,
|
|
120
|
+
'Access-Control-Allow-Headers': CORS_ALLOW_HEADERS,
|
|
121
|
+
'Access-Control-Expose-Headers': 'Link',
|
|
122
|
+
Vary: 'Origin',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* PNA preflight extras. Added on OPTIONS responses for allowlisted origins
|
|
127
|
+
* when the request carries `Access-Control-Request-Private-Network: true`
|
|
128
|
+
* — Chrome blocks public→private fetch / WS otherwise.
|
|
129
|
+
*/
|
|
130
|
+
export function buildPnaPreflightHeaders() {
|
|
131
|
+
return { 'Access-Control-Allow-Private-Network': 'true' };
|
|
132
|
+
}
|
|
@@ -10,6 +10,7 @@ import { homedir } from 'os';
|
|
|
10
10
|
import { basename, dirname, join, resolve } from 'path';
|
|
11
11
|
import { fileURLToPath } from 'url';
|
|
12
12
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
13
|
+
import { buildCorsHeaders, buildPnaPreflightHeaders, mintBridgeToken, selectBridgeSubprotocol, validateBridgeUpgrade, } from './bridge-security.js';
|
|
13
14
|
import { applyCdpUnmask } from './cdp-proxy/cdp-unmask.js';
|
|
14
15
|
import { createCdpSessionUrlTracker } from './cdp-proxy/session-url-tracker.js';
|
|
15
16
|
import { buildChromeLaunchArgs, clearStaleDevToolsActivePort, ensureQaProfileScaffold, findChromeExecutable, legacyChromeCandidates, migrateLegacyDefaultChromeProfile, planChromeSpawn, resolveChromeLaunchProfile, waitForCdpPort, } from './chrome-launch.js';
|
|
@@ -65,6 +66,13 @@ const SERVE_ONLY = RUNTIME_FLAGS.serveOnly;
|
|
|
65
66
|
const ELECTRON_MODE = RUNTIME_FLAGS.electron;
|
|
66
67
|
const ELECTRON_APP = RUNTIME_FLAGS.electronApp;
|
|
67
68
|
const KILL_EXISTING_ELECTRON_APP = RUNTIME_FLAGS.kill;
|
|
69
|
+
/**
|
|
70
|
+
* Standalone thin-bridge mode: production CLI with no bundled UI. The
|
|
71
|
+
* launched Chrome opens a sliccy.ai-hosted leader; node-server is a thin
|
|
72
|
+
* /cdp bridge + /api surface. Dev / electron / serve-only / hosted all
|
|
73
|
+
* keep the bundled UI path (a separate path B / hosted task covers those).
|
|
74
|
+
*/
|
|
75
|
+
const THIN_BRIDGE_MODE = !DEV_MODE && !SERVE_ONLY && !ELECTRON_MODE && !RUNTIME_FLAGS.hosted;
|
|
68
76
|
// ---------------------------------------------------------------------------
|
|
69
77
|
// File logger — persistent log file in ~/.slicc/logs/
|
|
70
78
|
// ---------------------------------------------------------------------------
|
|
@@ -300,6 +308,7 @@ function createServerState() {
|
|
|
300
308
|
overlayInjector: null,
|
|
301
309
|
shuttingDown: false,
|
|
302
310
|
discoveredTrayJoinUrl: RUNTIME_FLAGS.joinUrl ?? null,
|
|
311
|
+
bridgeToken: THIN_BRIDGE_MODE ? mintBridgeToken() : null,
|
|
303
312
|
cdpUrl: null,
|
|
304
313
|
chromeWs: null,
|
|
305
314
|
activeClientWs: null,
|
|
@@ -465,15 +474,33 @@ async function launchElectronTarget(state) {
|
|
|
465
474
|
throw error;
|
|
466
475
|
}
|
|
467
476
|
}
|
|
477
|
+
/**
|
|
478
|
+
* Resolve the leader origin Chrome should open in thin-bridge mode. Prefers
|
|
479
|
+
* an explicit `--lead <url>` / `WORKER_BASE_URL` so dev can point at staging;
|
|
480
|
+
* defaults to production sliccy.ai.
|
|
481
|
+
*/
|
|
482
|
+
function resolveThinLeaderOrigin() {
|
|
483
|
+
const explicit = RUNTIME_FLAGS.leadWorkerBaseUrl ?? process.env['WORKER_BASE_URL'] ?? null;
|
|
484
|
+
if (explicit) {
|
|
485
|
+
return explicit.replace(/\/+$/, '');
|
|
486
|
+
}
|
|
487
|
+
return 'https://www.sliccy.ai';
|
|
488
|
+
}
|
|
468
489
|
/** Build the Chrome launch URL, appending hosted-runtime + prompt query params. */
|
|
469
490
|
function buildBrowserLaunchUrl(state) {
|
|
491
|
+
// Thin-bridge standalone: Chrome opens the hosted leader directly; the
|
|
492
|
+
// local node-server serves no UI at all. Bridge coordinates ride as
|
|
493
|
+
// query params so the leader can discover + authenticate /cdp.
|
|
494
|
+
const serveOriginForLaunch = THIN_BRIDGE_MODE && state.bridgeToken ? resolveThinLeaderOrigin() : state.serveOrigin;
|
|
470
495
|
let url = resolveCliBrowserLaunchUrl({
|
|
471
|
-
serveOrigin:
|
|
496
|
+
serveOrigin: serveOriginForLaunch,
|
|
472
497
|
lead: RUNTIME_FLAGS.lead,
|
|
473
498
|
leadWorkerBaseUrl: RUNTIME_FLAGS.leadWorkerBaseUrl,
|
|
474
499
|
envWorkerBaseUrl: process.env['WORKER_BASE_URL'] ?? null,
|
|
475
500
|
join: RUNTIME_FLAGS.join,
|
|
476
501
|
joinUrl: RUNTIME_FLAGS.joinUrl,
|
|
502
|
+
bridgeWsUrl: THIN_BRIDGE_MODE && state.bridgeToken ? `ws://localhost:${state.servePort}/cdp` : null,
|
|
503
|
+
bridgeToken: state.bridgeToken,
|
|
477
504
|
});
|
|
478
505
|
if (RUNTIME_FLAGS.hosted) {
|
|
479
506
|
url += `${url.includes('?') ? '&' : '?'}runtime=hosted-leader`;
|
|
@@ -487,6 +514,11 @@ function buildBrowserLaunchUrl(state) {
|
|
|
487
514
|
else if (RUNTIME_FLAGS.lead) {
|
|
488
515
|
console.log(`Lead launch URL: ${url}`);
|
|
489
516
|
}
|
|
517
|
+
else if (THIN_BRIDGE_MODE) {
|
|
518
|
+
// Print WITHOUT the bridgeToken — it's a session capability.
|
|
519
|
+
const sanitized = url.replace(/([?&])bridgeToken=[^&]+/, '$1bridgeToken=<redacted>');
|
|
520
|
+
console.log(`Thin-bridge launch URL: ${sanitized}`);
|
|
521
|
+
}
|
|
490
522
|
return url;
|
|
491
523
|
}
|
|
492
524
|
function resolveChromeProfileOrExit(state) {
|
|
@@ -602,11 +634,56 @@ function closeWebSocketQuietly(ws) {
|
|
|
602
634
|
/* ignore */
|
|
603
635
|
}
|
|
604
636
|
}
|
|
605
|
-
/**
|
|
606
|
-
|
|
637
|
+
/**
|
|
638
|
+
* Reject a WebSocket upgrade before `handleUpgrade` runs. Writes a minimal
|
|
639
|
+
* HTTP/1.1 response and destroys the underlying socket. RFC 6455 allows 401
|
|
640
|
+
* / 403 here; we use 401 so a caller that retries with the correct
|
|
641
|
+
* subprotocol/origin doesn't trip permanent CORS-style caches.
|
|
642
|
+
*/
|
|
643
|
+
function rejectUpgradeUnauthorized(socket, reason) {
|
|
644
|
+
try {
|
|
645
|
+
socket.write(`HTTP/1.1 401 Unauthorized\r\n` +
|
|
646
|
+
`Content-Type: text/plain\r\n` +
|
|
647
|
+
`Connection: close\r\n` +
|
|
648
|
+
`Content-Length: ${Buffer.byteLength(reason)}\r\n` +
|
|
649
|
+
`\r\n${reason}`);
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
/* socket may already be gone */
|
|
653
|
+
}
|
|
654
|
+
try {
|
|
655
|
+
socket.destroy();
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
/* ignore */
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Route `/cdp` and `/licks-ws` upgrades to their servers; leave others for Vite HMR.
|
|
663
|
+
*
|
|
664
|
+
* When `bridgeToken !== null` (thin standalone mode) the `/cdp` upgrade is
|
|
665
|
+
* gated by `validateBridgeUpgrade`: bad origin or missing/wrong
|
|
666
|
+
* `Sec-WebSocket-Protocol` token = socket destroyed before
|
|
667
|
+
* `wss.emit('connection', ...)` ever fires. Legacy modes (dev / electron /
|
|
668
|
+
* serve-only / hosted) pass `null` to keep same-origin behavior unchanged.
|
|
669
|
+
* `/licks-ws` is loopback-only and stays ungated.
|
|
670
|
+
*/
|
|
671
|
+
function attachCdpUpgradeRouting(server, wss, lickWss, bridgeToken) {
|
|
607
672
|
server.on('upgrade', (request, socket, head) => {
|
|
608
673
|
const { pathname } = new URL(request.url, `http://${request.headers.host}`);
|
|
609
674
|
if (pathname === '/cdp') {
|
|
675
|
+
if (bridgeToken !== null) {
|
|
676
|
+
const gate = validateBridgeUpgrade({
|
|
677
|
+
origin: request.headers.origin,
|
|
678
|
+
subprotocolHeader: request.headers['sec-websocket-protocol'],
|
|
679
|
+
expectedToken: bridgeToken,
|
|
680
|
+
});
|
|
681
|
+
if (!gate.ok) {
|
|
682
|
+
console.warn(`[cdp-proxy] /cdp upgrade rejected: ${gate.reason}`);
|
|
683
|
+
rejectUpgradeUnauthorized(socket, gate.reason ?? 'rejected');
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
610
687
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
611
688
|
wss.emit('connection', ws, request);
|
|
612
689
|
});
|
|
@@ -877,7 +954,12 @@ async function startOverlayInjector(state, cdpPort, servePort) {
|
|
|
877
954
|
function startCdpServer(state, deps) {
|
|
878
955
|
const { app, server, ctx, fileLogger, servePort, serveOrigin, cdpPort } = deps;
|
|
879
956
|
server.listen(servePort, '127.0.0.1', () => {
|
|
880
|
-
|
|
957
|
+
if (THIN_BRIDGE_MODE) {
|
|
958
|
+
console.log(`Thin /cdp bridge + /api at ${serveOrigin}`);
|
|
959
|
+
}
|
|
960
|
+
else {
|
|
961
|
+
console.log(`Serving UI at ${serveOrigin}`);
|
|
962
|
+
}
|
|
881
963
|
console.log(`CDP proxy at ws://localhost:${servePort}/cdp`);
|
|
882
964
|
fileLogger.log('info', 'CLI server started', {
|
|
883
965
|
port: servePort,
|
|
@@ -889,7 +971,8 @@ function startCdpServer(state, deps) {
|
|
|
889
971
|
if (ELECTRON_MODE) {
|
|
890
972
|
void startOverlayInjector(state, cdpPort, servePort);
|
|
891
973
|
}
|
|
892
|
-
else {
|
|
974
|
+
else if (!THIN_BRIDGE_MODE) {
|
|
975
|
+
// Thin-bridge mode has no local UI page to forward console output from.
|
|
893
976
|
setTimeout(() => {
|
|
894
977
|
attachConsoleForwarder(cdpPort, String(servePort)).catch((err) => {
|
|
895
978
|
console.error('[page] Console forwarder error:', err);
|
|
@@ -924,6 +1007,50 @@ async function bootstrapSecrets() {
|
|
|
924
1007
|
}
|
|
925
1008
|
return { secretStore, secretProxy, oauthStore };
|
|
926
1009
|
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Thin-bridge CORS + PNA middleware. The hosted leader at sliccy.ai is a
|
|
1012
|
+
* cross-origin caller, so headers go on every response for allowlisted
|
|
1013
|
+
* origins (so /cdp's pre-WS preflight and every /api/* call succeed);
|
|
1014
|
+
* OPTIONS from an allowlisted origin short-circuits to 204 with the PNA
|
|
1015
|
+
* opt-in. Non-allowlisted origins fall through with no CORS headers, which
|
|
1016
|
+
* preserves same-origin (localhost) behavior unchanged.
|
|
1017
|
+
*/
|
|
1018
|
+
function createThinBridgeCorsMiddleware() {
|
|
1019
|
+
return (req, res, next) => {
|
|
1020
|
+
const cors = buildCorsHeaders(req.headers.origin);
|
|
1021
|
+
if (cors) {
|
|
1022
|
+
for (const [k, v] of Object.entries(cors))
|
|
1023
|
+
res.setHeader(k, v);
|
|
1024
|
+
}
|
|
1025
|
+
if (req.method === 'OPTIONS' && cors) {
|
|
1026
|
+
for (const [k, v] of Object.entries(buildPnaPreflightHeaders()))
|
|
1027
|
+
res.setHeader(k, v);
|
|
1028
|
+
res.setHeader('Access-Control-Max-Age', '600');
|
|
1029
|
+
res.status(204).end();
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
next();
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Build the `/cdp` `WebSocketServer`. `handleProtocols` echoes the matched
|
|
1037
|
+
* bridge subprotocol back in the 101 response (RFC 6455 §1.9). Returning
|
|
1038
|
+
* `false` makes the server omit the header, which makes the browser fail
|
|
1039
|
+
* the upgrade when a subprotocol was offered. Legacy modes (token=null)
|
|
1040
|
+
* pass the first offered subprotocol through unchanged.
|
|
1041
|
+
*/
|
|
1042
|
+
function createCdpWebSocketServer(bridgeToken) {
|
|
1043
|
+
return new WebSocketServer({
|
|
1044
|
+
noServer: true,
|
|
1045
|
+
handleProtocols: (protocols) => {
|
|
1046
|
+
if (bridgeToken !== null) {
|
|
1047
|
+
return selectBridgeSubprotocol([...protocols], bridgeToken) ?? false;
|
|
1048
|
+
}
|
|
1049
|
+
const first = protocols.values().next();
|
|
1050
|
+
return first.done ? false : first.value;
|
|
1051
|
+
},
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
927
1054
|
async function main() {
|
|
928
1055
|
// Resolve ports, launch the browser, then snapshot the now-final values that
|
|
929
1056
|
// the rest of boot reads. Mutable lifecycle fields stay on `state`.
|
|
@@ -938,6 +1065,9 @@ async function main() {
|
|
|
938
1065
|
app.use(requestLogger);
|
|
939
1066
|
// Append SLICC's standard RFC 8288 Link header set on every /api/* response.
|
|
940
1067
|
app.use(sliccLinksMiddleware());
|
|
1068
|
+
if (THIN_BRIDGE_MODE) {
|
|
1069
|
+
app.use(createThinBridgeCorsMiddleware());
|
|
1070
|
+
}
|
|
941
1071
|
// ---------------------------------------------------------------------------
|
|
942
1072
|
// Lick system — WebSocket bridge for webhooks/crontasks (all logic in browser)
|
|
943
1073
|
// ---------------------------------------------------------------------------
|
|
@@ -1012,17 +1142,21 @@ async function main() {
|
|
|
1012
1142
|
registerFetchProxyRoute(app, { secretProxy });
|
|
1013
1143
|
// Create the HTTP server BEFORE Vite so we can register our upgrade handler first
|
|
1014
1144
|
const server = createServer(app);
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1145
|
+
// Thin-bridge mode: skip UI serving entirely. node-server becomes a
|
|
1146
|
+
// pure /cdp bridge + /api surface; Chrome opens the sliccy.ai-hosted
|
|
1147
|
+
// leader which talks to /cdp + /api cross-origin.
|
|
1148
|
+
if (!THIN_BRIDGE_MODE) {
|
|
1149
|
+
await attachUiServing(app, server, {
|
|
1150
|
+
devMode: DEV_MODE,
|
|
1151
|
+
hosted: RUNTIME_FLAGS.hosted,
|
|
1152
|
+
serveOrigin: SERVE_ORIGIN,
|
|
1153
|
+
uiDir: resolve(Dirname, '..', 'ui'),
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1021
1156
|
// 4. CDP WebSocket proxy at /cdp — noServer mode so Vite's dev middleware
|
|
1022
|
-
// doesn't intercept the upgrade
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
attachCdpUpgradeRouting(server, wss, lickWss);
|
|
1157
|
+
// doesn't intercept the upgrade.
|
|
1158
|
+
const wss = createCdpWebSocketServer(state.bridgeToken);
|
|
1159
|
+
attachCdpUpgradeRouting(server, wss, lickWss, state.bridgeToken);
|
|
1026
1160
|
const cdpCtx = {
|
|
1027
1161
|
wss,
|
|
1028
1162
|
secretProxy,
|
|
@@ -5,5 +5,12 @@ export interface CliLaunchUrlOptions {
|
|
|
5
5
|
envWorkerBaseUrl?: string | null;
|
|
6
6
|
join: boolean;
|
|
7
7
|
joinUrl?: string | null;
|
|
8
|
+
/**
|
|
9
|
+
* Thin-bridge coordinates appended as query params so the hosted leader can
|
|
10
|
+
* discover + authenticate the local /cdp WebSocket. Both must be set
|
|
11
|
+
* together; both unset = no bridge params (legacy / bundled UI path).
|
|
12
|
+
*/
|
|
13
|
+
bridgeWsUrl?: string | null;
|
|
14
|
+
bridgeToken?: string | null;
|
|
8
15
|
}
|
|
9
16
|
export declare function resolveCliBrowserLaunchUrl(options: CliLaunchUrlOptions): string;
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
+
import { BRIDGE_TOKEN_QUERY_PARAM, BRIDGE_WS_QUERY_PARAM } from './bridge-security.js';
|
|
1
2
|
import { buildCanonicalTrayLaunchUrl, normalizeTrayWorkerBaseUrl, parseTrayJoinUrl, } from './tray-url-shared.js';
|
|
3
|
+
function appendBridgeParams(url, opts) {
|
|
4
|
+
if (!opts.bridgeWsUrl || !opts.bridgeToken)
|
|
5
|
+
return url;
|
|
6
|
+
const params = new URLSearchParams();
|
|
7
|
+
params.set(BRIDGE_WS_QUERY_PARAM, opts.bridgeWsUrl);
|
|
8
|
+
params.set(BRIDGE_TOKEN_QUERY_PARAM, opts.bridgeToken);
|
|
9
|
+
return `${url}${url.includes('?') ? '&' : '?'}${params.toString()}`;
|
|
10
|
+
}
|
|
2
11
|
function buildTrayJoinLaunchUrl(locationHref, joinUrl) {
|
|
3
12
|
const parsedJoinUrl = parseTrayJoinUrl(joinUrl);
|
|
4
13
|
if (!parsedJoinUrl) {
|
|
@@ -21,14 +30,14 @@ export function resolveCliBrowserLaunchUrl(options) {
|
|
|
21
30
|
if (!options.joinUrl) {
|
|
22
31
|
throw new Error('The --join launch flow requires a tray join URL via --join <url> or --join=<url>.');
|
|
23
32
|
}
|
|
24
|
-
return buildTrayJoinLaunchUrl(options.serveOrigin, options.joinUrl);
|
|
33
|
+
return appendBridgeParams(buildTrayJoinLaunchUrl(options.serveOrigin, options.joinUrl), options);
|
|
25
34
|
}
|
|
26
35
|
if (!options.lead) {
|
|
27
|
-
return options.serveOrigin;
|
|
36
|
+
return appendBridgeParams(options.serveOrigin, options);
|
|
28
37
|
}
|
|
29
38
|
const workerBaseUrl = normalizeTrayWorkerBaseUrl(options.leadWorkerBaseUrl ?? options.envWorkerBaseUrl ?? null);
|
|
30
39
|
if (!workerBaseUrl) {
|
|
31
40
|
throw new Error('The --lead launch flow requires a tray worker base URL via --lead <url>, --lead=<url>, or WORKER_BASE_URL.');
|
|
32
41
|
}
|
|
33
|
-
return buildTrayLeadLaunchUrl(options.serveOrigin, workerBaseUrl);
|
|
42
|
+
return appendBridgeParams(buildTrayLeadLaunchUrl(options.serveOrigin, workerBaseUrl), options);
|
|
34
43
|
}
|