sliccy 4.3.1 → 4.5.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-BJJ5koV6.js → account-store-C1ZfhTK1.js} +2 -2
- package/dist/ui/assets/{account-store-DAZSl2RR.js → account-store-DRL4A-Ru.js} +2 -2
- package/dist/ui/assets/{adobe-BEppKv52.js → adobe-CMdazkCY.js} +1 -1
- package/dist/ui/assets/{adobe-CQg9LKPF.js → adobe-DvNK5WQI.js} +1 -1
- package/dist/ui/assets/{agent-message-to-chat-CXgyVOmd.js → agent-message-to-chat-CL5EAbmJ.js} +1 -1
- package/dist/ui/assets/{apps-RfBlK6cX.js → apps-DBq6U-lx.js} +1 -1
- package/dist/ui/assets/{azure-openai-CoX9vWu2.js → azure-openai-C1UBrpLV.js} +1 -1
- package/dist/ui/assets/{azure-openai-KwIWdsss.js → azure-openai-QjjnqvLS.js} +1 -1
- package/dist/ui/assets/{bsh-watchdog-DA6OkxnZ.js → bsh-watchdog-efC4nmy5.js} +1 -1
- package/dist/ui/assets/{connect-surface-43hJgHGT.js → connect-surface-yQ76sy4W.js} +1 -1
- package/dist/ui/assets/dip-CsTIFJgu.js +1 -0
- package/dist/ui/assets/{dist-BfDQL5YL.js → dist-C61m0s11.js} +1 -1
- package/dist/ui/assets/{dist-BRutuC1w.js → dist-DgvPtJle.js} +1 -1
- package/dist/ui/assets/{es-B21LREST.js → es-CIdEpLFA.js} +1 -1
- package/dist/ui/assets/{fs-BUjc4jrN.js → fs-DLPuUNHV.js} +2 -2
- package/dist/ui/assets/{fs-CNNIA2b2.js → fs-DONXnCO6.js} +1 -1
- package/dist/ui/assets/{github-CW8Tthe2.js → github-DSWrD8bq.js} +1 -1
- package/dist/ui/assets/{github-DX27yfOl.js → github-Dw4y8tJ1.js} +2 -2
- package/dist/ui/assets/{github-copilot-C-_uEuwb.js → github-copilot-5bS-76bh.js} +1 -1
- package/dist/ui/assets/{github-copilot-D3RdyeQI.js → github-copilot-BGPlopaI.js} +1 -1
- package/dist/ui/assets/{hear-BOHY1XgC.js → hear-CFhY5lnj.js} +1 -1
- package/dist/ui/assets/{kernel-worker-CBwgvoVr.js → kernel-worker-U2pdGEcm.js} +631 -631
- package/dist/ui/assets/{kokoro-engine-BpkUzY1i.js → kokoro-engine-CWlN3DkD.js} +1 -1
- package/dist/ui/assets/{lick-ws-bridge-BfFahI9u.js → lick-ws-bridge-D-5VQrxa.js} +1 -1
- package/dist/ui/assets/{local-llm-CggFkqmI.js → local-llm-x5RnDA7B.js} +1 -1
- package/dist/ui/assets/{main-Bj_ZnhJb.js → main-B3wJxNAn.js} +3 -3
- package/dist/ui/assets/{mount-CL_CZ0Mw.js → mount-CXCPRGEQ.js} +2 -2
- package/dist/ui/assets/{mount-CfNqKHdA.js → mount-DGFNLTRM.js} +1 -1
- package/dist/ui/assets/{new-session-DF3Nmlnr.js → new-session-BG0Fak9U.js} +1 -1
- package/dist/ui/assets/{oauth-bootstrap-Cm9R5n39.js → oauth-bootstrap-Q3TH-iaj.js} +2 -2
- package/dist/ui/assets/{openai-codex-BZaQhZ9p.js → openai-codex-DZr2BkNe.js} +1 -1
- package/dist/ui/assets/{openai-codex-BOUDpgXE.js → openai-codex-oVa9_iUN.js} +1 -1
- package/dist/ui/assets/{panel-rpc-handlers-iZ8zKd5I.js → panel-rpc-handlers-f_RVs4A8.js} +1 -1
- package/dist/ui/assets/{provider-BaMv5vAw.js → provider-BCCx6rSj.js} +1 -1
- package/dist/ui/assets/{provider-BEa9VYQR.js → provider-Cq7CZBnu.js} +2 -2
- package/dist/ui/assets/provider-store-access-Bj2CShPs.js +1 -0
- package/dist/ui/assets/provider-store-access-BsBKI0HK.js +1 -0
- package/dist/ui/assets/{providers-CEXR1HJ-.js → providers-CfaSIe2i.js} +1 -1
- package/dist/ui/assets/{quick-llm-Bw9f5yg-.js → quick-llm-DAxgX-Q-.js} +1 -1
- package/dist/ui/assets/session-freezer-BtOs5CVA.js +1 -0
- package/dist/ui/assets/setup-sudo-DIFOUA5_.js +1 -0
- package/dist/ui/assets/{speak-Gox4T20Z.js → speak-B9udBKl2.js} +1 -1
- package/dist/ui/assets/{sprinkle-manager-DhY2ZOCW.js → sprinkle-manager-BjVP7wBM.js} +1 -1
- package/dist/ui/assets/{store-Bc7A_0Yc.js → store-RUY0kc-Y.js} +1 -1
- package/dist/ui/assets/{sudo-BCc4yvx3.js → sudo-DSWs7-95.js} +1 -1
- package/dist/ui/assets/{transformers-env-C1hwuvV2.js → transformers-env-EeU3AbXE.js} +1 -1
- package/dist/ui/assets/{tray-leave-runtime-F226tJcg.js → tray-leave-runtime-BeZf8bPZ.js} +1 -1
- package/dist/ui/assets/{upgrade-detection-Bo-CGczJ.js → upgrade-detection-Ac_hsZDf.js} +1 -1
- package/dist/ui/assets/{wc-attach-BLvxK0mJ.js → wc-attach-BsFPgqLi.js} +2 -2
- package/dist/ui/assets/{wc-detached-C8hfWbpo.js → wc-detached-Dx6f7WQ-.js} +1 -1
- package/dist/ui/assets/{wc-extension-eovabBSz.js → wc-extension-DCUk4lv_.js} +2 -2
- package/dist/ui/assets/{wc-live-CS1gOsoy.js → wc-live-CQyyOWQK.js} +5 -5
- package/dist/ui/assets/wc-nav-DYDZQQ9U.js +2 -0
- package/dist/ui/assets/{wc-onboarding-BVjQVwRF.js → wc-onboarding-VBsrKnAX.js} +2 -2
- package/dist/ui/assets/{wc-placeholder-bzOBNRjt.js → wc-placeholder-BCSsNKoJ.js} +2 -2
- package/dist/ui/assets/{wc-settings-Dv17H7zn.js → wc-settings-B-LMAhMh.js} +2 -2
- package/dist/ui/assets/{wc-shell-CiUfQN5d.js → wc-shell-sjVj57Ki.js} +162 -99
- package/dist/ui/assets/{wc-sprinkles-BlZTOPAT.js → wc-sprinkles-CV303QxU.js} +2 -2
- package/dist/ui/assets/{wc-tray-CbcIbhiR.js → wc-tray-CEyMfUTG.js} +3 -3
- package/dist/ui/assets/{xai-grok-C4VwA4iM.js → xai-grok-CW5FvgTv.js} +1 -1
- package/dist/ui/assets/{xai-grok-BGM5yYfR.js → xai-grok-okkOqw0L.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-Cram-Ilm.js +0 -1
- package/dist/ui/assets/provider-store-access-BYfwZYtS.js +0 -1
- package/dist/ui/assets/provider-store-access-DWZbQzMb.js +0 -1
- package/dist/ui/assets/session-freezer-BFN8KSY_.js +0 -1
- package/dist/ui/assets/setup-sudo-CsI18ELm.js +0 -1
- package/dist/ui/assets/wc-nav-DEAIjLZF.js +0 -2
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Express } from 'express';
|
|
2
|
+
import type { SecretProxyManager } from '../secrets/proxy-manager.js';
|
|
3
|
+
export interface FetchProxyDeps {
|
|
4
|
+
secretProxy: SecretProxyManager;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Fetch proxy — forwards cross-origin requests from the browser to bypass
|
|
8
|
+
* CORS (used by just-bash's curl, which calls the browser's fetch() API).
|
|
9
|
+
* Note: express.json() may already have parsed the body, so collectRawBody
|
|
10
|
+
* checks req.body first.
|
|
11
|
+
*/
|
|
12
|
+
export declare function registerFetchProxyRoute(app: Express, deps: FetchProxyDeps): void;
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { Readable, Transform } from 'node:stream';
|
|
2
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
3
|
+
import { FETCH_PROXY_SKIP_HEADERS } from '../fetch-proxy-headers.js';
|
|
4
|
+
/** Pick the first value of a possibly-multi-valued request header. */
|
|
5
|
+
function firstHeaderValue(value) {
|
|
6
|
+
if (value === undefined)
|
|
7
|
+
return undefined;
|
|
8
|
+
return Array.isArray(value) ? value[0] : value;
|
|
9
|
+
}
|
|
10
|
+
/** True when an origin/referer value points at localhost (any family). */
|
|
11
|
+
function isLocalhostOrigin(origin) {
|
|
12
|
+
if (!origin)
|
|
13
|
+
return false;
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(origin);
|
|
16
|
+
return (url.hostname === 'localhost' ||
|
|
17
|
+
url.hostname === '127.0.0.1' ||
|
|
18
|
+
url.hostname === '::1' ||
|
|
19
|
+
url.hostname === '[::1]');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Get the body — either from express.json()'s parsed body or raw chunks. */
|
|
26
|
+
async function collectRawBody(req) {
|
|
27
|
+
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) {
|
|
28
|
+
// Body was already parsed by express.json() — re-serialize it.
|
|
29
|
+
return Buffer.from(JSON.stringify(req.body), 'utf-8');
|
|
30
|
+
}
|
|
31
|
+
const chunks = [];
|
|
32
|
+
for await (const chunk of req) {
|
|
33
|
+
chunks.push(Buffer.from(chunk));
|
|
34
|
+
}
|
|
35
|
+
return Buffer.concat(chunks);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build the forwarded header set: copy non-hop-by-hop headers, then restore
|
|
39
|
+
* the forbidden-header transports (Cookie/Origin/Referer/Proxy-*) the browser
|
|
40
|
+
* could not send via fetch(), and force an identity encoding.
|
|
41
|
+
*/
|
|
42
|
+
function buildForwardHeaders(req, targetUrl) {
|
|
43
|
+
const headers = {};
|
|
44
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
45
|
+
if (!FETCH_PROXY_SKIP_HEADERS.has(key) && typeof value === 'string') {
|
|
46
|
+
headers[key] = value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Forbidden-header transport: browser cannot send Cookie via fetch(),
|
|
50
|
+
// so the client encodes it as X-Proxy-Cookie. Restore it here.
|
|
51
|
+
const proxyCookie = firstHeaderValue(req.headers['x-proxy-cookie']);
|
|
52
|
+
if (proxyCookie)
|
|
53
|
+
headers.cookie = proxyCookie;
|
|
54
|
+
// Forbidden-header transport: restore X-Proxy-Origin → Origin
|
|
55
|
+
const proxyOrigin = firstHeaderValue(req.headers['x-proxy-origin']);
|
|
56
|
+
if (proxyOrigin) {
|
|
57
|
+
headers.origin = proxyOrigin;
|
|
58
|
+
}
|
|
59
|
+
else if (isLocalhostOrigin(headers.origin)) {
|
|
60
|
+
// Only strip the browser's auto-added localhost origin; preserve legitimate origins.
|
|
61
|
+
delete headers.origin;
|
|
62
|
+
}
|
|
63
|
+
// Default-Origin fallback: synthesize one from the target URL so upstream
|
|
64
|
+
// CORS-protected APIs see a real Origin instead of nothing.
|
|
65
|
+
if (!headers.origin) {
|
|
66
|
+
try {
|
|
67
|
+
headers.origin = new URL(targetUrl).origin;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Malformed targetUrl — leave origin unset; the upstream fetch fails anyway.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Forbidden-header transport: restore X-Proxy-Referer → Referer
|
|
74
|
+
const proxyReferer = firstHeaderValue(req.headers['x-proxy-referer']);
|
|
75
|
+
if (proxyReferer) {
|
|
76
|
+
headers.referer = proxyReferer;
|
|
77
|
+
}
|
|
78
|
+
else if (isLocalhostOrigin(headers.referer)) {
|
|
79
|
+
delete headers.referer;
|
|
80
|
+
}
|
|
81
|
+
// Restore any X-Proxy-Proxy-* transport headers as Proxy-* headers
|
|
82
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
83
|
+
if (key.startsWith('x-proxy-proxy-') && typeof value === 'string') {
|
|
84
|
+
headers[key.replace(/^x-proxy-/, '')] = value;
|
|
85
|
+
delete headers[key];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Always request uncompressed responses — the proxy doesn't decompress and
|
|
89
|
+
// the browser→proxy hop is localhost, so compression has no benefit and
|
|
90
|
+
// would arrive as garbage once Content-Encoding is stripped below.
|
|
91
|
+
headers['accept-encoding'] = 'identity';
|
|
92
|
+
return headers;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Apply request-side secret injection: unmask headers and URL-embedded
|
|
96
|
+
* credentials. Mutates `headers` to attach a synthetic Authorization when the
|
|
97
|
+
* URL carried credentials. Returns the forbidden descriptor when a masked
|
|
98
|
+
* secret is used against a domain it is not scoped to, else the cleaned URL.
|
|
99
|
+
*/
|
|
100
|
+
function injectRequestSecrets(secretProxy, headers, targetUrl, targetHostname) {
|
|
101
|
+
if (!secretProxy.hasSecrets())
|
|
102
|
+
return { cleanedUrl: targetUrl };
|
|
103
|
+
const headerResult = secretProxy.unmaskHeaders(headers, targetHostname);
|
|
104
|
+
if (headerResult.forbidden)
|
|
105
|
+
return { forbidden: headerResult.forbidden };
|
|
106
|
+
const credsResult = secretProxy.extractAndUnmaskUrlCredentials(targetUrl);
|
|
107
|
+
if (credsResult.forbidden)
|
|
108
|
+
return { forbidden: credsResult.forbidden };
|
|
109
|
+
// Attach synthetic Authorization if the URL had credentials and the header isn't already set.
|
|
110
|
+
if (credsResult.syntheticAuthorization && !('authorization' in headers)) {
|
|
111
|
+
headers.authorization = credsResult.syntheticAuthorization;
|
|
112
|
+
}
|
|
113
|
+
return { cleanedUrl: credsResult.url };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Unmask masked secrets in a text request body. Non-text bodies (git
|
|
117
|
+
* packfiles, octet-stream, images, …) are left untouched — `toString('utf-8')`
|
|
118
|
+
* on arbitrary bytes corrupts them, and masked values never appear in binary.
|
|
119
|
+
*/
|
|
120
|
+
function unmaskRequestBody(secretProxy, headers, rawBody, targetHostname) {
|
|
121
|
+
const reqCt = (headers['content-type'] ?? headers['Content-Type'] ?? '').toLowerCase();
|
|
122
|
+
const reqIsText = !reqCt ||
|
|
123
|
+
reqCt.startsWith('text/') ||
|
|
124
|
+
reqCt.includes('json') ||
|
|
125
|
+
reqCt.includes('xml') ||
|
|
126
|
+
reqCt.includes('javascript') ||
|
|
127
|
+
reqCt.includes('ecmascript') ||
|
|
128
|
+
reqCt.includes('html') ||
|
|
129
|
+
reqCt.includes('css') ||
|
|
130
|
+
reqCt.includes('svg');
|
|
131
|
+
if (reqIsText && secretProxy.hasSecrets()) {
|
|
132
|
+
const bodyResult = secretProxy.unmaskBody(rawBody.toString('utf-8'), targetHostname);
|
|
133
|
+
return Buffer.from(bodyResult.text, 'utf-8');
|
|
134
|
+
}
|
|
135
|
+
return rawBody;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Forward the upstream status + response headers, stripping hop-by-hop and
|
|
139
|
+
* www-authenticate (so the browser shows no native Basic Auth dialog) and
|
|
140
|
+
* relaying Set-Cookie out-of-band as X-Proxy-Set-Cookie. All header values are
|
|
141
|
+
* secret-scrubbed.
|
|
142
|
+
*/
|
|
143
|
+
function forwardUpstreamHeaders(res, upstream, secretProxy) {
|
|
144
|
+
res.status(upstream.status);
|
|
145
|
+
res.setHeader('Cache-Control', 'no-store, no-cache');
|
|
146
|
+
const setCookieValues = upstream.headers.getSetCookie();
|
|
147
|
+
upstream.headers.forEach((v, k) => {
|
|
148
|
+
const lower = k.toLowerCase();
|
|
149
|
+
if (lower !== 'transfer-encoding' &&
|
|
150
|
+
lower !== 'content-encoding' &&
|
|
151
|
+
lower !== 'content-length' &&
|
|
152
|
+
lower !== 'www-authenticate' &&
|
|
153
|
+
lower !== 'set-cookie' &&
|
|
154
|
+
!lower.startsWith('x-proxy-')) {
|
|
155
|
+
// Headers are small — one-shot scrub, no per-chunk semantics.
|
|
156
|
+
res.setHeader(k, secretProxy.scrubResponse(v));
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
if (setCookieValues.length > 0) {
|
|
160
|
+
res.setHeader('X-Proxy-Set-Cookie', secretProxy.scrubResponse(JSON.stringify(setCookieValues)));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Buffer-aware UTF-8 secret scrubber. A `StringDecoder` keeps trailing partial
|
|
165
|
+
* multi-byte sequences out of the scrub so codepoints straddling a chunk
|
|
166
|
+
* boundary aren't corrupted (fatal for CJK/emoji model output). Pass-through
|
|
167
|
+
* when the body is non-text or no secrets are configured.
|
|
168
|
+
*/
|
|
169
|
+
function createScrubStream(secretProxy, isText) {
|
|
170
|
+
const utf8Decoder = new StringDecoder('utf8');
|
|
171
|
+
return new Transform({
|
|
172
|
+
transform(chunk, _enc, cb) {
|
|
173
|
+
if (!isText || !secretProxy.hasSecrets()) {
|
|
174
|
+
cb(null, chunk);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const decoded = utf8Decoder.write(chunk);
|
|
179
|
+
if (decoded.length === 0) {
|
|
180
|
+
// All bytes buffered as a partial codepoint — no output yet.
|
|
181
|
+
cb(null, Buffer.alloc(0));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
cb(null, Buffer.from(secretProxy.scrubResponse(decoded), 'utf-8'));
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
cb(err);
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
flush(cb) {
|
|
191
|
+
if (!isText || !secretProxy.hasSecrets()) {
|
|
192
|
+
cb();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const tail = utf8Decoder.end();
|
|
197
|
+
if (tail.length === 0) {
|
|
198
|
+
cb();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
cb(null, Buffer.from(secretProxy.scrubResponse(tail), 'utf-8'));
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
cb(err);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** Stream the upstream body to the client through the secret-scrub transform. */
|
|
210
|
+
function streamUpstreamBody(res, upstream, secretProxy, detachClientClose) {
|
|
211
|
+
const ct = (upstream.headers.get('content-type') ?? '').toLowerCase();
|
|
212
|
+
const isText = ct.startsWith('text/') ||
|
|
213
|
+
ct.startsWith('application/json') ||
|
|
214
|
+
ct.includes('charset=') ||
|
|
215
|
+
ct.includes('event-stream');
|
|
216
|
+
const upstreamStream = Readable.fromWeb(upstream.body);
|
|
217
|
+
const scrubChunk = createScrubStream(secretProxy, isText);
|
|
218
|
+
upstreamStream.on('error', (err) => {
|
|
219
|
+
detachClientClose();
|
|
220
|
+
if (!res.headersSent) {
|
|
221
|
+
res.setHeader('X-Proxy-Error', '1');
|
|
222
|
+
res
|
|
223
|
+
.status(502)
|
|
224
|
+
.json({ error: `Proxy stream failed: ${err instanceof Error ? err.message : err}` });
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
res.destroy(err);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
// Belt-and-braces cleanup: 'finish' fires once the response is fully flushed;
|
|
231
|
+
// 'close' fires regardless of how the response ended. Either way the abort
|
|
232
|
+
// listener should be gone.
|
|
233
|
+
res.on('finish', detachClientClose);
|
|
234
|
+
res.on('close', detachClientClose);
|
|
235
|
+
upstreamStream.pipe(scrubChunk).pipe(res);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Fetch proxy — forwards cross-origin requests from the browser to bypass
|
|
239
|
+
* CORS (used by just-bash's curl, which calls the browser's fetch() API).
|
|
240
|
+
* Note: express.json() may already have parsed the body, so collectRawBody
|
|
241
|
+
* checks req.body first.
|
|
242
|
+
*/
|
|
243
|
+
export function registerFetchProxyRoute(app, deps) {
|
|
244
|
+
const { secretProxy } = deps;
|
|
245
|
+
app.all('/api/fetch-proxy', async (req, res) => {
|
|
246
|
+
const rawBody = await collectRawBody(req);
|
|
247
|
+
const targetUrl = req.headers['x-target-url'];
|
|
248
|
+
if (!targetUrl) {
|
|
249
|
+
res.setHeader('X-Proxy-Error', '1');
|
|
250
|
+
res.status(400).json({ error: 'Missing X-Target-URL header' });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// Hoisted so the catch handler can detach it on early failures (e.g. fetch
|
|
254
|
+
// threw before the success-path detach could run).
|
|
255
|
+
let onClientClose = null;
|
|
256
|
+
const detachClientClose = () => {
|
|
257
|
+
if (onClientClose) {
|
|
258
|
+
res.off('close', onClientClose);
|
|
259
|
+
onClientClose = null;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
try {
|
|
263
|
+
const fetchInit = { method: req.method, redirect: 'follow' };
|
|
264
|
+
const headers = buildForwardHeaders(req, targetUrl);
|
|
265
|
+
let targetHostname;
|
|
266
|
+
try {
|
|
267
|
+
targetHostname = new URL(targetUrl).hostname;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
targetHostname = '';
|
|
271
|
+
}
|
|
272
|
+
const injection = injectRequestSecrets(secretProxy, headers, targetUrl, targetHostname);
|
|
273
|
+
if ('forbidden' in injection) {
|
|
274
|
+
res.setHeader('X-Proxy-Error', '1');
|
|
275
|
+
res.status(403).json({
|
|
276
|
+
error: `Secret "${injection.forbidden.secretName}" is not allowed for domain "${injection.forbidden.hostname}"`,
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (Object.keys(headers).length > 0)
|
|
281
|
+
fetchInit.headers = headers;
|
|
282
|
+
if (rawBody.length > 0 && !['GET', 'HEAD'].includes(req.method)) {
|
|
283
|
+
const body = unmaskRequestBody(secretProxy, headers, rawBody, targetHostname);
|
|
284
|
+
// Buffer extends Uint8Array which is a valid fetch body at runtime.
|
|
285
|
+
fetchInit.body = body;
|
|
286
|
+
}
|
|
287
|
+
// Propagate client disconnect to the upstream request so long-lived
|
|
288
|
+
// streams (LLM SSE completions) are torn down promptly. Listen on
|
|
289
|
+
// `res.on('close')`, not `req.on('close')` — Node fires req close as soon
|
|
290
|
+
// as the request body is consumed, which would abort before fetch starts.
|
|
291
|
+
const abortController = new AbortController();
|
|
292
|
+
onClientClose = () => {
|
|
293
|
+
if (!res.writableEnded)
|
|
294
|
+
abortController.abort();
|
|
295
|
+
};
|
|
296
|
+
res.on('close', onClientClose);
|
|
297
|
+
fetchInit.signal = abortController.signal;
|
|
298
|
+
const upstream = await fetch(injection.cleanedUrl, fetchInit);
|
|
299
|
+
forwardUpstreamHeaders(res, upstream, secretProxy);
|
|
300
|
+
if (!upstream.body) {
|
|
301
|
+
res.end();
|
|
302
|
+
detachClientClose();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
streamUpstreamBody(res, upstream, secretProxy, detachClientClose);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
// Best-effort cleanup so an early failure doesn't leave the close
|
|
309
|
+
// listener attached to the response object.
|
|
310
|
+
detachClientClose();
|
|
311
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
312
|
+
res.setHeader('X-Proxy-Error', '1');
|
|
313
|
+
res.status(502).json({ error: `Proxy fetch failed: ${message}` });
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Express } from 'express';
|
|
2
|
+
/**
|
|
3
|
+
* Profile-independent handoff injection.
|
|
4
|
+
*
|
|
5
|
+
* The CDP navigation-watcher only sees tabs inside the Chrome instance
|
|
6
|
+
* SLICC launched (isolated profile keyed by port); similarly the
|
|
7
|
+
* extension's webRequest observer only fires inside the profile where it
|
|
8
|
+
* is installed. External tools (e.g. the slicc-handoff helper) post here
|
|
9
|
+
* so a handoff reaches the cone regardless of which browser profile the
|
|
10
|
+
* user is currently driving.
|
|
11
|
+
*
|
|
12
|
+
* The payload mirrors the parsed RFC 8288 `Link` form used by the
|
|
13
|
+
* observers: `verb` ∈ {handoff, upskill}, `target` is the resolved URL,
|
|
14
|
+
* `instruction` is optional free-form prose (handoff verb).
|
|
15
|
+
*/
|
|
16
|
+
export interface HandoffPayload {
|
|
17
|
+
verb?: unknown;
|
|
18
|
+
target?: unknown;
|
|
19
|
+
instruction?: unknown;
|
|
20
|
+
url?: unknown;
|
|
21
|
+
title?: unknown;
|
|
22
|
+
branch?: unknown;
|
|
23
|
+
path?: unknown;
|
|
24
|
+
sliccHeader?: unknown;
|
|
25
|
+
}
|
|
26
|
+
export interface NavigateEvent {
|
|
27
|
+
type: 'navigate_event';
|
|
28
|
+
verb: 'handoff' | 'upskill';
|
|
29
|
+
target: string;
|
|
30
|
+
instruction?: string;
|
|
31
|
+
url: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
branch?: string;
|
|
34
|
+
path?: string;
|
|
35
|
+
timestamp: string;
|
|
36
|
+
}
|
|
37
|
+
export interface HandoffRouteDeps {
|
|
38
|
+
broadcastLickEvent(event: unknown): void;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Validate an inbound handoff payload. Returns an error message when the
|
|
42
|
+
* payload is malformed, or `null` when it is well-formed and ready to be
|
|
43
|
+
* turned into a navigate event. Pure — no I/O.
|
|
44
|
+
*/
|
|
45
|
+
export declare function validateHandoffPayload(payload: HandoffPayload): string | null;
|
|
46
|
+
/** Build the navigate event broadcast to the browser. Assumes a valid payload. */
|
|
47
|
+
export declare function buildNavigateEvent(payload: HandoffPayload): NavigateEvent;
|
|
48
|
+
export declare function registerHandoffRoute(app: Express, deps: HandoffRouteDeps): void;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate an inbound handoff payload. Returns an error message when the
|
|
3
|
+
* payload is malformed, or `null` when it is well-formed and ready to be
|
|
4
|
+
* turned into a navigate event. Pure — no I/O.
|
|
5
|
+
*/
|
|
6
|
+
export function validateHandoffPayload(payload) {
|
|
7
|
+
if (typeof payload?.sliccHeader === 'string') {
|
|
8
|
+
return 'The legacy `sliccHeader` payload was removed; post `{ verb, target, instruction? }` instead. See docs/slicc-handoff.md.';
|
|
9
|
+
}
|
|
10
|
+
if (payload?.verb !== 'handoff' && payload?.verb !== 'upskill') {
|
|
11
|
+
return 'verb must be "handoff" or "upskill"';
|
|
12
|
+
}
|
|
13
|
+
if (typeof payload.target !== 'string' || payload.target.length === 0) {
|
|
14
|
+
return 'target is required (non-empty string)';
|
|
15
|
+
}
|
|
16
|
+
if (payload.instruction != null && typeof payload.instruction !== 'string') {
|
|
17
|
+
return 'instruction must be a string when provided';
|
|
18
|
+
}
|
|
19
|
+
// `branch` / `path` mirror the upskill rel's Link params and are
|
|
20
|
+
// ignored on the handoff verb (its target is the page itself, not a
|
|
21
|
+
// repo). Reject the wrong-shape combo loudly so emitters notice
|
|
22
|
+
// rather than silently dropping the scope.
|
|
23
|
+
if (payload.branch != null && typeof payload.branch !== 'string') {
|
|
24
|
+
return 'branch must be a string when provided';
|
|
25
|
+
}
|
|
26
|
+
if (payload.path != null && typeof payload.path !== 'string') {
|
|
27
|
+
return 'path must be a string when provided';
|
|
28
|
+
}
|
|
29
|
+
if (payload.verb === 'handoff' && (payload.branch != null || payload.path != null)) {
|
|
30
|
+
return 'branch and path are only valid with verb="upskill"';
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/** Build the navigate event broadcast to the browser. Assumes a valid payload. */
|
|
35
|
+
export function buildNavigateEvent(payload) {
|
|
36
|
+
const optionalString = (value) => typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
37
|
+
return {
|
|
38
|
+
type: 'navigate_event',
|
|
39
|
+
verb: payload.verb,
|
|
40
|
+
target: payload.target,
|
|
41
|
+
instruction: typeof payload.instruction === 'string' ? payload.instruction : undefined,
|
|
42
|
+
url: optionalString(payload.url) ?? 'about:handoff',
|
|
43
|
+
title: typeof payload.title === 'string' ? payload.title : undefined,
|
|
44
|
+
branch: optionalString(payload.branch),
|
|
45
|
+
path: optionalString(payload.path),
|
|
46
|
+
timestamp: new Date().toISOString(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function registerHandoffRoute(app, deps) {
|
|
50
|
+
app.post('/api/handoff', (req, res) => {
|
|
51
|
+
const payload = req.body;
|
|
52
|
+
const error = validateHandoffPayload(payload);
|
|
53
|
+
if (error) {
|
|
54
|
+
res.status(400).json({ error });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
deps.broadcastLickEvent(buildNavigateEvent(payload));
|
|
58
|
+
res.json({ ok: true });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Express } from 'express';
|
|
2
|
+
import type { LickBridge } from './lick-bridge.js';
|
|
3
|
+
/**
|
|
4
|
+
* Routes that forward to the connected browser over the lick bridge:
|
|
5
|
+
* tray status, webhook management + receiver, and cron task management.
|
|
6
|
+
*/
|
|
7
|
+
export declare function registerLickApiRoutes(app: Express, bridge: LickBridge): void;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** 503 with the underlying message, or the standard "Browser not connected". */
|
|
2
|
+
function respondBrowserUnavailable(res, err) {
|
|
3
|
+
res.status(503).json({ error: err instanceof Error ? err.message : 'Browser not connected' });
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Routes that forward to the connected browser over the lick bridge:
|
|
7
|
+
* tray status, webhook management + receiver, and cron task management.
|
|
8
|
+
*/
|
|
9
|
+
export function registerLickApiRoutes(app, bridge) {
|
|
10
|
+
const { sendLickRequest, broadcastLickEvent } = bridge;
|
|
11
|
+
// Tray status API — forwards to browser to get leader tray join info
|
|
12
|
+
app.get('/api/tray-status', async (_req, res) => {
|
|
13
|
+
try {
|
|
14
|
+
const data = await sendLickRequest('tray_status', {});
|
|
15
|
+
res.json(data);
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
respondBrowserUnavailable(res, err);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
// Webhook management API — forwards to browser
|
|
22
|
+
app.get('/api/webhooks', async (_req, res) => {
|
|
23
|
+
try {
|
|
24
|
+
const data = await sendLickRequest('list_webhooks', {});
|
|
25
|
+
res.json(data);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
respondBrowserUnavailable(res, err);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
app.post('/api/webhooks', async (req, res) => {
|
|
32
|
+
try {
|
|
33
|
+
const data = await sendLickRequest('create_webhook', req.body);
|
|
34
|
+
res.json(data);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
38
|
+
res.status(msg.includes('Invalid') ? 400 : 503).json({ error: msg });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
app.delete('/api/webhooks/:id', async (req, res) => {
|
|
42
|
+
try {
|
|
43
|
+
const data = (await sendLickRequest('delete_webhook', { id: req.params.id }));
|
|
44
|
+
if (data.error) {
|
|
45
|
+
res.status(404).json({ error: data.error });
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
res.json(data);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
respondBrowserUnavailable(res, err);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
// Webhook receiver — handle CORS preflight
|
|
56
|
+
app.options('/webhooks/:id', (_req, res) => {
|
|
57
|
+
res.set({
|
|
58
|
+
'Access-Control-Allow-Origin': '*',
|
|
59
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
60
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
61
|
+
});
|
|
62
|
+
res.sendStatus(204);
|
|
63
|
+
});
|
|
64
|
+
// Webhook receiver — forwards POST to browser for processing
|
|
65
|
+
app.post('/webhooks/:id', async (req, res) => {
|
|
66
|
+
res.set({ 'Access-Control-Allow-Origin': '*' });
|
|
67
|
+
const { id } = req.params;
|
|
68
|
+
// Collect body
|
|
69
|
+
let body = req.body;
|
|
70
|
+
if (!body || Object.keys(body).length === 0) {
|
|
71
|
+
const chunks = [];
|
|
72
|
+
for await (const chunk of req) {
|
|
73
|
+
chunks.push(Buffer.from(chunk));
|
|
74
|
+
}
|
|
75
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
76
|
+
try {
|
|
77
|
+
body = JSON.parse(raw);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
body = { raw };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Forward to browser for processing
|
|
84
|
+
broadcastLickEvent({
|
|
85
|
+
type: 'webhook_event',
|
|
86
|
+
webhookId: id,
|
|
87
|
+
timestamp: new Date().toISOString(),
|
|
88
|
+
headers: req.headers,
|
|
89
|
+
body,
|
|
90
|
+
});
|
|
91
|
+
res.json({ ok: true, received: true });
|
|
92
|
+
});
|
|
93
|
+
// Cron task management API — forwards to browser
|
|
94
|
+
app.get('/api/crontasks', async (_req, res) => {
|
|
95
|
+
try {
|
|
96
|
+
const data = await sendLickRequest('list_crontasks', {});
|
|
97
|
+
res.json(data);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
respondBrowserUnavailable(res, err);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
app.post('/api/crontasks', async (req, res) => {
|
|
104
|
+
try {
|
|
105
|
+
const data = await sendLickRequest('create_crontask', req.body);
|
|
106
|
+
res.json(data);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
110
|
+
res
|
|
111
|
+
.status(msg.includes('Invalid') || msg.includes('required') ? 400 : 503)
|
|
112
|
+
.json({ error: msg });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
app.delete('/api/crontasks/:id', async (req, res) => {
|
|
116
|
+
try {
|
|
117
|
+
const data = (await sendLickRequest('delete_crontask', { id: req.params.id }));
|
|
118
|
+
if (data.error) {
|
|
119
|
+
res.status(404).json({ error: data.error });
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
res.json(data);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
respondBrowserUnavailable(res, err);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws';
|
|
2
|
+
/**
|
|
3
|
+
* Lick system — WebSocket bridge for webhooks/crontasks. All the actual
|
|
4
|
+
* logic lives in the browser; this bridge is the request/response and
|
|
5
|
+
* broadcast transport between the CLI's HTTP routes and the connected
|
|
6
|
+
* browser client(s).
|
|
7
|
+
*/
|
|
8
|
+
export interface LickBridge {
|
|
9
|
+
/** noServer WebSocketServer — the caller wires `/licks-ws` upgrades to it. */
|
|
10
|
+
lickWss: WebSocketServer;
|
|
11
|
+
/** Send a request to the browser and wait for its response. */
|
|
12
|
+
sendLickRequest(type: string, data: unknown, timeout?: number): Promise<unknown>;
|
|
13
|
+
/** Broadcast an event to all connected browsers (no response expected). */
|
|
14
|
+
broadcastLickEvent(event: unknown): void;
|
|
15
|
+
}
|
|
16
|
+
export declare function createLickBridge(): LickBridge;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
2
|
+
export function createLickBridge() {
|
|
3
|
+
const lickWss = new WebSocketServer({ noServer: true });
|
|
4
|
+
const lickClients = new Set();
|
|
5
|
+
const pendingRequests = new Map();
|
|
6
|
+
let requestIdCounter = 0;
|
|
7
|
+
lickWss.on('connection', (ws) => {
|
|
8
|
+
lickClients.add(ws);
|
|
9
|
+
console.log('[licks] Browser client connected');
|
|
10
|
+
ws.on('message', (data) => {
|
|
11
|
+
try {
|
|
12
|
+
const msg = JSON.parse(data.toString());
|
|
13
|
+
// Handle responses to pending requests
|
|
14
|
+
if (msg.type === 'response' && msg.requestId) {
|
|
15
|
+
const pending = pendingRequests.get(msg.requestId);
|
|
16
|
+
if (pending) {
|
|
17
|
+
pendingRequests.delete(msg.requestId);
|
|
18
|
+
if (msg.error) {
|
|
19
|
+
pending.reject(new Error(msg.error));
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
pending.resolve(msg.data);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Ignore invalid messages
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
ws.on('close', () => {
|
|
32
|
+
lickClients.delete(ws);
|
|
33
|
+
console.log('[licks] Browser client disconnected');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
function sendLickRequest(type, data, timeout = 5000) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const requestId = `req_${++requestIdCounter}`;
|
|
39
|
+
const msg = JSON.stringify({ type, requestId, ...data });
|
|
40
|
+
// Find a connected client
|
|
41
|
+
const client = Array.from(lickClients).find((c) => c.readyState === WebSocket.OPEN);
|
|
42
|
+
if (!client) {
|
|
43
|
+
reject(new Error('No browser connected'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Set up timeout
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
pendingRequests.delete(requestId);
|
|
49
|
+
reject(new Error('Request timeout'));
|
|
50
|
+
}, timeout);
|
|
51
|
+
pendingRequests.set(requestId, {
|
|
52
|
+
resolve: (data) => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
resolve(data);
|
|
55
|
+
},
|
|
56
|
+
reject: (err) => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
reject(err);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
client.send(msg);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function broadcastLickEvent(event) {
|
|
65
|
+
const msg = JSON.stringify(event);
|
|
66
|
+
for (const client of lickClients) {
|
|
67
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
68
|
+
client.send(msg);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { lickWss, sendLickRequest, broadcastLickEvent };
|
|
73
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Express } from 'express';
|
|
2
|
+
/**
|
|
3
|
+
* Generic OAuth redirect target for OAuth providers (implicit + PKCE).
|
|
4
|
+
*
|
|
5
|
+
* The callback page tries `window.opener.postMessage` first (works in the
|
|
6
|
+
* CLI popup flow). When `window.opener` is null (Electron overlay opens the
|
|
7
|
+
* system browser), it falls back to POSTing the result to
|
|
8
|
+
* `/api/oauth-result`, which the UI polls via the GET counterpart — the
|
|
9
|
+
* server holds the single pending result in memory between the two calls.
|
|
10
|
+
*/
|
|
11
|
+
export declare function registerOAuthCallbackRoutes(app: Express): void;
|