@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maintainer-pro/ai-bridge",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Local bridge daemon that pairs a machine to Maintainer Pro and configures multiple client sandboxes.",
5
5
  "keywords": [
6
6
  "maintainer-pro",
@@ -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),