@maintainer-pro/ai-bridge 0.1.24 → 0.1.25
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/package.json +1 -1
- package/src/cors-cookies.mjs +274 -0
- package/src/daemon.mjs +23 -0
package/package.json
CHANGED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie jar for Bypass CORS. The browser talks to /p/{token}/__cors
|
|
3
|
+
* (admin origin), so it never sends the remote API's cookies. The bridge
|
|
4
|
+
* stores Set-Cookie from those hosts and attaches them on the next hop.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
const MAX_COOKIES_PER_SANDBOX = 80;
|
|
11
|
+
const FILE_NAME = "cors-cookies.json";
|
|
12
|
+
|
|
13
|
+
/** @type {Map<string, CookieRecord[]>} */
|
|
14
|
+
const jars = new Map();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {{
|
|
18
|
+
* name: string,
|
|
19
|
+
* value: string,
|
|
20
|
+
* domain: string,
|
|
21
|
+
* path: string,
|
|
22
|
+
* expires: number | null,
|
|
23
|
+
* secure: boolean,
|
|
24
|
+
* hostOnly: boolean,
|
|
25
|
+
* }} CookieRecord
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
function homeDir() {
|
|
29
|
+
const override = String(process.env.MAINTAINER_PRO_HOME || "").trim();
|
|
30
|
+
return override || path.join(os.homedir(), ".maintainer-pro");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sanitizeId(id) {
|
|
34
|
+
return String(id || "")
|
|
35
|
+
.trim()
|
|
36
|
+
.replace(/[^\w.-]+/g, "_")
|
|
37
|
+
.slice(0, 80);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function jarPath(sandboxId) {
|
|
41
|
+
const id = sanitizeId(sandboxId);
|
|
42
|
+
if (!id) return "";
|
|
43
|
+
return path.join(homeDir(), "projects", id, FILE_NAME);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cookieKey(cookie) {
|
|
47
|
+
return `${cookie.name}\0${cookie.domain}\0${cookie.path}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isExpired(cookie, now = Date.now()) {
|
|
51
|
+
return cookie.expires != null && cookie.expires <= now;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hostMatches(hostname, cookie) {
|
|
55
|
+
const host = String(hostname || "").toLowerCase();
|
|
56
|
+
const domain = String(cookie.domain || "").toLowerCase();
|
|
57
|
+
if (!host || !domain) return false;
|
|
58
|
+
if (cookie.hostOnly) return host === domain;
|
|
59
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pathMatches(pathname, cookiePath) {
|
|
63
|
+
const pathName = pathname || "/";
|
|
64
|
+
const prefix = cookiePath || "/";
|
|
65
|
+
if (prefix === "/") return true;
|
|
66
|
+
if (pathName === prefix) return true;
|
|
67
|
+
const dir = prefix.endsWith("/") ? prefix : `${prefix}/`;
|
|
68
|
+
return pathName.startsWith(dir);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function domainAllowedForHost(host, domain) {
|
|
72
|
+
const h = String(host || "").toLowerCase();
|
|
73
|
+
const d = String(domain || "").replace(/^\./, "").toLowerCase();
|
|
74
|
+
if (!h || !d) return false;
|
|
75
|
+
return h === d || h.endsWith(`.${d}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function loadJar(sandboxId) {
|
|
79
|
+
const id = sanitizeId(sandboxId);
|
|
80
|
+
if (!id) return [];
|
|
81
|
+
if (jars.has(id)) return jars.get(id) || [];
|
|
82
|
+
let list = [];
|
|
83
|
+
const file = jarPath(id);
|
|
84
|
+
try {
|
|
85
|
+
if (file && fs.existsSync(file)) {
|
|
86
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
87
|
+
if (Array.isArray(raw)) {
|
|
88
|
+
list = raw.filter((row) => row && typeof row.name === "string");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
list = [];
|
|
93
|
+
}
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
list = list.filter((row) => !isExpired(row, now));
|
|
96
|
+
jars.set(id, list);
|
|
97
|
+
return list;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function saveJar(sandboxId, list) {
|
|
101
|
+
const id = sanitizeId(sandboxId);
|
|
102
|
+
if (!id) return;
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
const next = list.filter((row) => !isExpired(row, now)).slice(-MAX_COOKIES_PER_SANDBOX);
|
|
105
|
+
jars.set(id, next);
|
|
106
|
+
const file = jarPath(id);
|
|
107
|
+
if (!file) return;
|
|
108
|
+
try {
|
|
109
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
110
|
+
fs.writeFileSync(file, `${JSON.stringify(next)}\n`, "utf8");
|
|
111
|
+
} catch {
|
|
112
|
+
/* ignore */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {string} raw
|
|
118
|
+
* @param {URL} requestUrl
|
|
119
|
+
* @returns {CookieRecord | null}
|
|
120
|
+
*/
|
|
121
|
+
export function parseSetCookie(raw, requestUrl) {
|
|
122
|
+
const parts = String(raw || "")
|
|
123
|
+
.split(";")
|
|
124
|
+
.map((part) => part.trim())
|
|
125
|
+
.filter(Boolean);
|
|
126
|
+
if (!parts.length) return null;
|
|
127
|
+
const nv = parts[0];
|
|
128
|
+
const eq = nv.indexOf("=");
|
|
129
|
+
if (eq <= 0) return null;
|
|
130
|
+
const name = nv.slice(0, eq).trim();
|
|
131
|
+
const value = nv.slice(eq + 1).trim();
|
|
132
|
+
if (!name) return null;
|
|
133
|
+
|
|
134
|
+
const host = String(requestUrl.hostname || "").toLowerCase();
|
|
135
|
+
/** @type {CookieRecord} */
|
|
136
|
+
const cookie = {
|
|
137
|
+
name,
|
|
138
|
+
value,
|
|
139
|
+
domain: host,
|
|
140
|
+
path: "/",
|
|
141
|
+
expires: null,
|
|
142
|
+
secure: false,
|
|
143
|
+
hostOnly: true,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
for (let i = 1; i < parts.length; i++) {
|
|
147
|
+
const part = parts[i];
|
|
148
|
+
const ieq = part.indexOf("=");
|
|
149
|
+
const key = (ieq >= 0 ? part.slice(0, ieq) : part).trim().toLowerCase();
|
|
150
|
+
const val = ieq >= 0 ? part.slice(ieq + 1).trim() : "";
|
|
151
|
+
if (key === "domain" && val) {
|
|
152
|
+
const domain = val.replace(/^\./, "").toLowerCase();
|
|
153
|
+
if (domainAllowedForHost(host, domain)) {
|
|
154
|
+
cookie.domain = domain;
|
|
155
|
+
cookie.hostOnly = false;
|
|
156
|
+
}
|
|
157
|
+
} else if (key === "path" && val.startsWith("/")) {
|
|
158
|
+
cookie.path = val;
|
|
159
|
+
} else if (key === "max-age") {
|
|
160
|
+
const n = Number(val);
|
|
161
|
+
if (Number.isFinite(n)) cookie.expires = Date.now() + n * 1000;
|
|
162
|
+
} else if (key === "expires") {
|
|
163
|
+
const t = Date.parse(val);
|
|
164
|
+
if (Number.isFinite(t)) cookie.expires = t;
|
|
165
|
+
} else if (key === "secure") {
|
|
166
|
+
cookie.secure = true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return cookie;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function headerValues(headers, name) {
|
|
173
|
+
if (!headers || typeof headers !== "object") return [];
|
|
174
|
+
const lower = name.toLowerCase();
|
|
175
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
176
|
+
if (String(key).toLowerCase() !== lower || value == null) continue;
|
|
177
|
+
return Array.isArray(value) ? value.map(String) : [String(value)];
|
|
178
|
+
}
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {string} sandboxId
|
|
184
|
+
* @param {URL} requestUrl
|
|
185
|
+
* @param {Record<string, unknown>} headers
|
|
186
|
+
*/
|
|
187
|
+
export function storeCorsCookies(sandboxId, requestUrl, headers) {
|
|
188
|
+
if (!sandboxId || !requestUrl) return;
|
|
189
|
+
const lines = headerValues(headers, "set-cookie");
|
|
190
|
+
if (!lines.length) return;
|
|
191
|
+
const list = loadJar(sandboxId);
|
|
192
|
+
const byKey = new Map(list.map((row) => [cookieKey(row), row]));
|
|
193
|
+
for (const line of lines) {
|
|
194
|
+
const cookie = parseSetCookie(line, requestUrl);
|
|
195
|
+
if (!cookie) continue;
|
|
196
|
+
const key = cookieKey(cookie);
|
|
197
|
+
if (!cookie.value || isExpired(cookie)) {
|
|
198
|
+
byKey.delete(key);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
byKey.set(key, cookie);
|
|
202
|
+
}
|
|
203
|
+
saveJar(sandboxId, [...byKey.values()]);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {string} sandboxId
|
|
208
|
+
* @param {URL} requestUrl
|
|
209
|
+
*/
|
|
210
|
+
export function cookieHeaderForUrl(sandboxId, requestUrl) {
|
|
211
|
+
if (!sandboxId || !requestUrl) return "";
|
|
212
|
+
const list = loadJar(sandboxId);
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
const https = requestUrl.protocol === "https:";
|
|
215
|
+
const pathName = requestUrl.pathname || "/";
|
|
216
|
+
const matching = [];
|
|
217
|
+
const kept = [];
|
|
218
|
+
for (const cookie of list) {
|
|
219
|
+
if (isExpired(cookie, now)) continue;
|
|
220
|
+
kept.push(cookie);
|
|
221
|
+
if (cookie.secure && !https) continue;
|
|
222
|
+
if (!hostMatches(requestUrl.hostname, cookie)) continue;
|
|
223
|
+
if (!pathMatches(pathName, cookie.path)) continue;
|
|
224
|
+
matching.push(cookie);
|
|
225
|
+
}
|
|
226
|
+
if (kept.length !== list.length) saveJar(sandboxId, kept);
|
|
227
|
+
matching.sort((a, b) => String(b.path).length - String(a.path).length);
|
|
228
|
+
return matching.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Parse a Cookie request header into name → value.
|
|
233
|
+
* @param {string} raw
|
|
234
|
+
*/
|
|
235
|
+
export function parseCookieHeader(raw) {
|
|
236
|
+
/** @type {Map<string, string>} */
|
|
237
|
+
const out = new Map();
|
|
238
|
+
for (const part of String(raw || "").split(";")) {
|
|
239
|
+
const eq = part.indexOf("=");
|
|
240
|
+
if (eq <= 0) continue;
|
|
241
|
+
const name = part.slice(0, eq).trim();
|
|
242
|
+
const value = part.slice(eq + 1).trim();
|
|
243
|
+
if (name) out.set(name, value);
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Merge browser cookies (Maintainer Pro share origin, e.g. mp_session)
|
|
250
|
+
* with jar cookies for the remote host. `mp_*` always come from the
|
|
251
|
+
* browser. Other names: jar wins, then any leftover browser cookies.
|
|
252
|
+
* @param {string} incoming
|
|
253
|
+
* @param {string} jar
|
|
254
|
+
*/
|
|
255
|
+
export function mergeCookieHeader(incoming, jar) {
|
|
256
|
+
const browser = parseCookieHeader(incoming);
|
|
257
|
+
const stored = parseCookieHeader(jar);
|
|
258
|
+
/** @type {Map<string, string>} */
|
|
259
|
+
const merged = new Map();
|
|
260
|
+
for (const [name, value] of browser) {
|
|
261
|
+
if (name.toLowerCase().startsWith("mp_")) merged.set(name, value);
|
|
262
|
+
}
|
|
263
|
+
for (const [name, value] of stored) {
|
|
264
|
+
if (name.toLowerCase().startsWith("mp_") && merged.has(name)) continue;
|
|
265
|
+
merged.set(name, value);
|
|
266
|
+
}
|
|
267
|
+
for (const [name, value] of browser) {
|
|
268
|
+
if (merged.has(name)) continue;
|
|
269
|
+
merged.set(name, value);
|
|
270
|
+
}
|
|
271
|
+
return [...merged.entries()]
|
|
272
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
273
|
+
.join("; ");
|
|
274
|
+
}
|
package/src/daemon.mjs
CHANGED
|
@@ -38,6 +38,11 @@ import {
|
|
|
38
38
|
leftoverStateKeys,
|
|
39
39
|
LEGACY_TUNNEL_FILE,
|
|
40
40
|
} from "./discarded-tunnels.mjs";
|
|
41
|
+
import {
|
|
42
|
+
cookieHeaderForUrl,
|
|
43
|
+
mergeCookieHeader,
|
|
44
|
+
storeCorsCookies,
|
|
45
|
+
} from "./cors-cookies.mjs";
|
|
41
46
|
|
|
42
47
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
43
48
|
const requireFromHere = createRequire(import.meta.url);
|
|
@@ -2516,6 +2521,15 @@ function parseExternalProxyUrl(value) {
|
|
|
2516
2521
|
return parsed;
|
|
2517
2522
|
}
|
|
2518
2523
|
|
|
2524
|
+
function incomingCookieHeader(incoming) {
|
|
2525
|
+
if (!incoming || typeof incoming !== "object") return "";
|
|
2526
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
2527
|
+
if (String(key).toLowerCase() !== "cookie") continue;
|
|
2528
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
2529
|
+
}
|
|
2530
|
+
return "";
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2519
2533
|
function externalProxyHeaders(incoming, targetHost) {
|
|
2520
2534
|
/** @type {Record<string, string>} */
|
|
2521
2535
|
const headers = {};
|
|
@@ -2594,6 +2608,12 @@ function fetchExternalProxyUrl(entry, url, method, headers, body, redirectsLeft)
|
|
|
2594
2608
|
const lib = parsed.protocol === "https:" ? https : http;
|
|
2595
2609
|
/** @type {Record<string, string>} */
|
|
2596
2610
|
const reqHeaders = { ...headers, host: parsed.host };
|
|
2611
|
+
const cookie = mergeCookieHeader(
|
|
2612
|
+
entry.incomingCookie || "",
|
|
2613
|
+
cookieHeaderForUrl(entry.sandboxId, parsed)
|
|
2614
|
+
);
|
|
2615
|
+
if (cookie) reqHeaders.cookie = cookie;
|
|
2616
|
+
else delete reqHeaders.cookie;
|
|
2597
2617
|
const sendBody =
|
|
2598
2618
|
body?.length && method !== "GET" && method !== "HEAD" ? body : null;
|
|
2599
2619
|
if (sendBody) reqHeaders["content-length"] = String(sendBody.length);
|
|
@@ -2611,6 +2631,7 @@ function fetchExternalProxyUrl(entry, url, method, headers, body, redirectsLeft)
|
|
|
2611
2631
|
headers: reqHeaders,
|
|
2612
2632
|
},
|
|
2613
2633
|
(res) => {
|
|
2634
|
+
storeCorsCookies(entry.sandboxId, parsed, res.headers);
|
|
2614
2635
|
const status = res.statusCode || 502;
|
|
2615
2636
|
const loc = res.headers.location;
|
|
2616
2637
|
if (
|
|
@@ -2699,6 +2720,8 @@ function handleExternalProxyHttpFromAdmin(msg) {
|
|
|
2699
2720
|
req: null,
|
|
2700
2721
|
external: true,
|
|
2701
2722
|
id,
|
|
2723
|
+
sandboxId: typeof msg.sandboxId === "string" ? msg.sandboxId : "",
|
|
2724
|
+
incomingCookie: incomingCookieHeader(msg.headers),
|
|
2702
2725
|
url: parsed.href,
|
|
2703
2726
|
method,
|
|
2704
2727
|
headers: externalProxyHeaders(msg.headers, parsed.host),
|