sliccy 4.3.1 → 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.
Files changed (85) hide show
  1. package/dist/node-server/index.js +645 -1550
  2. package/dist/node-server/routes/fetch-proxy.d.ts +12 -0
  3. package/dist/node-server/routes/fetch-proxy.js +316 -0
  4. package/dist/node-server/routes/handoff.d.ts +48 -0
  5. package/dist/node-server/routes/handoff.js +60 -0
  6. package/dist/node-server/routes/lick-api.d.ts +7 -0
  7. package/dist/node-server/routes/lick-api.js +129 -0
  8. package/dist/node-server/routes/lick-bridge.d.ts +16 -0
  9. package/dist/node-server/routes/lick-bridge.js +73 -0
  10. package/dist/node-server/routes/oauth-callback.d.ts +11 -0
  11. package/dist/node-server/routes/oauth-callback.js +62 -0
  12. package/dist/node-server/routes/secrets.d.ts +17 -0
  13. package/dist/node-server/routes/secrets.js +204 -0
  14. package/dist/node-server/ui-serving.d.ts +18 -0
  15. package/dist/node-server/ui-serving.js +91 -0
  16. package/dist/ui/assets/{account-store-DAZSl2RR.js → account-store-DDmdZHbw.js} +2 -2
  17. package/dist/ui/assets/{account-store-BJJ5koV6.js → account-store-DYwow2TA.js} +2 -2
  18. package/dist/ui/assets/{adobe-BEppKv52.js → adobe-Yy9fISE-.js} +1 -1
  19. package/dist/ui/assets/{adobe-CQg9LKPF.js → adobe-hTPIFNJv.js} +1 -1
  20. package/dist/ui/assets/{agent-message-to-chat-CXgyVOmd.js → agent-message-to-chat-9I5BpCy9.js} +1 -1
  21. package/dist/ui/assets/{apps-RfBlK6cX.js → apps-D8Gdju73.js} +1 -1
  22. package/dist/ui/assets/{azure-openai-KwIWdsss.js → azure-openai-C2W9g3IJ.js} +1 -1
  23. package/dist/ui/assets/{azure-openai-CoX9vWu2.js → azure-openai-DRB4IDfJ.js} +1 -1
  24. package/dist/ui/assets/{bsh-watchdog-DA6OkxnZ.js → bsh-watchdog-Bt9CqKVQ.js} +1 -1
  25. package/dist/ui/assets/{connect-surface-43hJgHGT.js → connect-surface-DQzchqkg.js} +1 -1
  26. package/dist/ui/assets/dip-DFaUkk4F.js +1 -0
  27. package/dist/ui/assets/{dist-BRutuC1w.js → dist-CO9fGFcy.js} +1 -1
  28. package/dist/ui/assets/{dist-BfDQL5YL.js → dist-Doek6FYy.js} +1 -1
  29. package/dist/ui/assets/{es-B21LREST.js → es-DIdFAR_x.js} +1 -1
  30. package/dist/ui/assets/{fs-BUjc4jrN.js → fs-B11hxMFT.js} +2 -2
  31. package/dist/ui/assets/{fs-CNNIA2b2.js → fs-Tyk_pbIn.js} +1 -1
  32. package/dist/ui/assets/{github-DX27yfOl.js → github-B71DA8E3.js} +2 -2
  33. package/dist/ui/assets/{github-CW8Tthe2.js → github-CPiFDsp3.js} +1 -1
  34. package/dist/ui/assets/{github-copilot-D3RdyeQI.js → github-copilot-BfiOICFw.js} +1 -1
  35. package/dist/ui/assets/{github-copilot-C-_uEuwb.js → github-copilot-uEeOT_7K.js} +1 -1
  36. package/dist/ui/assets/{hear-BOHY1XgC.js → hear-CfpqqXo5.js} +1 -1
  37. package/dist/ui/assets/{kernel-worker-CBwgvoVr.js → kernel-worker-CJBmf2_H.js} +631 -631
  38. package/dist/ui/assets/{kokoro-engine-BpkUzY1i.js → kokoro-engine-C6AgVvUa.js} +1 -1
  39. package/dist/ui/assets/{lick-ws-bridge-BfFahI9u.js → lick-ws-bridge-BMBeGHRK.js} +1 -1
  40. package/dist/ui/assets/{local-llm-CggFkqmI.js → local-llm-CEaARq5R.js} +1 -1
  41. package/dist/ui/assets/{main-Bj_ZnhJb.js → main-BHrstcdQ.js} +3 -3
  42. package/dist/ui/assets/{mount-CfNqKHdA.js → mount-BwDyizK9.js} +1 -1
  43. package/dist/ui/assets/{mount-CL_CZ0Mw.js → mount-CyEIOV30.js} +2 -2
  44. package/dist/ui/assets/{new-session-DF3Nmlnr.js → new-session-CfhcCQAp.js} +1 -1
  45. package/dist/ui/assets/{oauth-bootstrap-Cm9R5n39.js → oauth-bootstrap-Tcg85q8p.js} +2 -2
  46. package/dist/ui/assets/{openai-codex-BOUDpgXE.js → openai-codex-BDz5Fxit.js} +1 -1
  47. package/dist/ui/assets/{openai-codex-BZaQhZ9p.js → openai-codex-CVcod1ia.js} +1 -1
  48. package/dist/ui/assets/{panel-rpc-handlers-iZ8zKd5I.js → panel-rpc-handlers-DPUizpgv.js} +1 -1
  49. package/dist/ui/assets/{provider-BaMv5vAw.js → provider-DcMNUxfM.js} +1 -1
  50. package/dist/ui/assets/{provider-BEa9VYQR.js → provider-DdXgyWQC.js} +2 -2
  51. package/dist/ui/assets/provider-store-access-BwZ-Ogkc.js +1 -0
  52. package/dist/ui/assets/provider-store-access-gZjBUTYS.js +1 -0
  53. package/dist/ui/assets/{providers-CEXR1HJ-.js → providers-CcWtOmn0.js} +1 -1
  54. package/dist/ui/assets/{quick-llm-Bw9f5yg-.js → quick-llm-BKbreZXe.js} +1 -1
  55. package/dist/ui/assets/session-freezer-DMAACMXD.js +1 -0
  56. package/dist/ui/assets/setup-sudo-CsL0Y3UH.js +1 -0
  57. package/dist/ui/assets/{speak-Gox4T20Z.js → speak-Bv-b3EVQ.js} +1 -1
  58. package/dist/ui/assets/{sprinkle-manager-DhY2ZOCW.js → sprinkle-manager-CBrsHbU3.js} +1 -1
  59. package/dist/ui/assets/{store-Bc7A_0Yc.js → store-BwHhL-tv.js} +1 -1
  60. package/dist/ui/assets/{sudo-BCc4yvx3.js → sudo-DZ9_0JRP.js} +1 -1
  61. package/dist/ui/assets/{transformers-env-C1hwuvV2.js → transformers-env-XuyWgfSl.js} +1 -1
  62. package/dist/ui/assets/{tray-leave-runtime-F226tJcg.js → tray-leave-runtime-Ww3km3lu.js} +1 -1
  63. package/dist/ui/assets/{upgrade-detection-Bo-CGczJ.js → upgrade-detection-_YQCwW8c.js} +1 -1
  64. package/dist/ui/assets/{wc-attach-BLvxK0mJ.js → wc-attach-BGApOJUg.js} +2 -2
  65. package/dist/ui/assets/{wc-detached-C8hfWbpo.js → wc-detached-mQvIrRCJ.js} +1 -1
  66. package/dist/ui/assets/{wc-extension-eovabBSz.js → wc-extension-DmWG-O_B.js} +2 -2
  67. package/dist/ui/assets/{wc-live-CS1gOsoy.js → wc-live-CA1EDWiu.js} +5 -5
  68. package/dist/ui/assets/wc-nav-BF5SsYYe.js +2 -0
  69. package/dist/ui/assets/{wc-onboarding-BVjQVwRF.js → wc-onboarding-CVMo_Eqf.js} +2 -2
  70. package/dist/ui/assets/{wc-placeholder-bzOBNRjt.js → wc-placeholder-BUbREW79.js} +2 -2
  71. package/dist/ui/assets/{wc-settings-Dv17H7zn.js → wc-settings-MQTTeP3O.js} +2 -2
  72. package/dist/ui/assets/{wc-shell-CiUfQN5d.js → wc-shell-CZSwcbtB.js} +9 -5
  73. package/dist/ui/assets/{wc-sprinkles-BlZTOPAT.js → wc-sprinkles-BUD5Miwy.js} +2 -2
  74. package/dist/ui/assets/{wc-tray-CbcIbhiR.js → wc-tray-iifyjNN6.js} +3 -3
  75. package/dist/ui/assets/{xai-grok-C4VwA4iM.js → xai-grok-BrM3dm3w.js} +1 -1
  76. package/dist/ui/assets/{xai-grok-BGM5yYfR.js → xai-grok-CPiFjFiB.js} +1 -1
  77. package/dist/ui/index.html +2 -2
  78. package/dist/ui/packages/webapp/index.html +2 -2
  79. package/package.json +6 -5
  80. package/dist/ui/assets/dip-Cram-Ilm.js +0 -1
  81. package/dist/ui/assets/provider-store-access-BYfwZYtS.js +0 -1
  82. package/dist/ui/assets/provider-store-access-DWZbQzMb.js +0 -1
  83. package/dist/ui/assets/session-freezer-BFN8KSY_.js +0 -1
  84. package/dist/ui/assets/setup-sudo-CsI18ELm.js +0 -1
  85. 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;