@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
483 lines • 18.5 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// The lines that open every file curl -c writes (curl 8.14.1); readers look
// for the first.
export const JAR_HEADER = '# Netscape HTTP Cookie File\n' +
'# https://curl.se/docs/http-cookies.html\n' +
'# This file was generated by libcurl! Edit at your own risk.\n\n';
// What a cookie file puts before the domain of an HttpOnly cookie.
export const HTTP_ONLY_PREFIX = '#HttpOnly_';
// No cookie outlives 400 days from its arrival (RFC 6265bis); curl rounds
// the capped time up to a whole minute.
export const MAX_LIFETIME = 400 * 24 * 60 * 60;
// The hosts that take and get a Secure cookie over plain http.
export const SECURE_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
// RFC 822's zones, hours east of UTC.
export const ZONES = {
gmt: 0,
ut: 0,
utc: 0,
z: 0,
est: -5,
edt: -4,
cst: -6,
cdt: -5,
mst: -7,
mdt: -6,
pst: -8,
pdt: -7,
};
const DATE_TOKEN = /[+-][0-9]{4}(?![0-9])|[0-9]{1,2}:[0-9]{1,2}(?::[0-9]{1,2})?|[A-Za-z]+|[0-9]+/g;
const IPV4 = /^[0-9]{1,3}(?:\.[0-9]{1,3}){3}$/;
// The largest expiry a cookie file may give.
const INT64_MAX = 2n ** 63n - 1n;
export function epochNow() {
return Math.floor(Date.now() / 1000);
}
// Space and tab off both ends, the blanks RFC 6265 strips.
// A control byte, which no cookie name or value may hold (curl 8.14.1).
function hasControl(text) {
for (let i = 0; i < text.length; i += 1) {
const code = text.charCodeAt(i);
if (code < 0x20 || code === 0x7f)
return true;
}
return false;
}
function trimWs(text) {
let start = 0;
let end = text.length;
while (start < end && (text[start] === ' ' || text[start] === '\t'))
start += 1;
while (end > start && (text[end - 1] === ' ' || text[end - 1] === '\t'))
end -= 1;
return text.slice(start, end);
}
/**
* The seconds since the epoch an Expires attribute names, null when it does
* not parse.
*
* The forms curl reads: a day of the month, a month name and a year in any
* order, a time (midnight without one), and a zone, RFC 822's names or a
* `+hhmm` offset of at most 1400, the first one found (UTC without one). A
* sign before a larger number only separates it (`01-Jan-1970`), and a
* number with no field left to fill fails the date. A two-digit year above
* 70 is in the 1900s, the rest in the 2000s, and a date before 1970 does not
* parse (curl 8.14.1). Zone names past RFC 822's are a deliberate gap: curl
* knows more of them.
*/
export function parseDate(text) {
let day = null;
let month = null;
let year = null;
let time = null;
let offset = 0;
let zoned = false;
for (const match of text.matchAll(DATE_TOKEN)) {
let token = match[0];
if (token.startsWith('+') || token.startsWith('-')) {
const hhmm = Number(token.slice(1));
if (hhmm <= 1400) {
if (!zoned) {
const sign = token.startsWith('-') ? -1 : 1;
offset = sign * (Math.floor(hhmm / 100) * 3600 + (hhmm % 100) * 60);
zoned = true;
}
continue;
}
token = token.slice(1);
}
if (token.includes(':')) {
if (time !== null)
continue;
const [h = 0, m = 0, s = 0] = token.split(':').map(Number);
if (h > 23 || m > 59 || s > 59)
return null;
time = [h, m, s];
}
else if (/^[A-Za-z]/.test(token)) {
const lower = token.toLowerCase();
const named = MONTHS.indexOf(lower.slice(0, 3));
if (named >= 0 && month === null && lower.length >= 3) {
month = named;
}
else if (lower in ZONES && !zoned) {
offset = (ZONES[lower] ?? 0) * 3600;
zoned = true;
}
}
else if (token.length === 4) {
if (year !== null)
return null;
year = Number(token);
}
else if (token.length <= 2) {
const n = Number(token);
if (day === null)
day = n;
else if (year === null)
year = n <= 70 ? 2000 + n : 1900 + n;
else
return null;
}
else {
return null;
}
}
if (day === null || month === null || year === null || day < 1 || day > 31)
return null;
if (year < 1970)
return null;
const [h, m, s] = time ?? [0, 0, 0];
const epoch = Date.UTC(year, month, day, h, m, s) / 1000 - offset;
return epoch < 0 ? null : epoch;
}
/** The request a URL makes, as cookie matching sees it. */
function target(url) {
let parsed;
try {
parsed = new URL(url);
}
catch {
return null;
}
const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
return {
host,
path: parsed.pathname === '' ? '/' : parsed.pathname,
secure: parsed.protocol === 'https:' || SECURE_HOSTS.has(host),
};
}
function isIp(host) {
return IPV4.test(host) || host.includes(':');
}
/**
* The path a cookie set without one takes: the request's up to and including
* its last slash. curl keeps that slash, which RFC 6265 drops, and the
* matching below ignores it.
*/
export function defaultPath(path) {
const cut = path.lastIndexOf('/');
return cut <= 0 ? '/' : path.slice(0, cut + 1);
}
// The path a cookie matches with: one that does not start with a slash
// covers everything, and a trailing slash is dropped (curl 8.14.1).
function pathKey(cookiePath) {
if (cookiePath?.startsWith('/') !== true)
return '/';
return cookiePath.length > 1 && cookiePath.endsWith('/') ? cookiePath.slice(0, -1) : cookiePath;
}
/**
* Whether a cookie's path covers a request's: RFC 6265's rule, the same path
* or one below it, over the path the cookie matches with.
*/
export function pathMatches(cookiePath, path) {
const own = pathKey(cookiePath);
if (path === own)
return true;
return path.startsWith(own) && (own.endsWith('/') || path[own.length] === '/');
}
function domainMatches(cookie, host) {
if (cookie.domain === null)
return true;
const domain = cookie.domain.toLowerCase();
return host === domain || (cookie.tailmatch && host.endsWith(`.${domain}`));
}
function sameCookie(a, b) {
return (a.name === b.name &&
(a.domain ?? '').toLowerCase() === (b.domain ?? '').toLowerCase() &&
pathKey(a.path) === pathKey(b.path));
}
/**
* Whether two URLs share an origin: scheme, host and port. A Cookie header
* the line typed goes only to the first request's origin (curl 8.14.1).
*/
export function sameOrigin(a, b) {
try {
return new URL(a).origin === new URL(b).origin;
}
catch {
return false;
}
}
/**
* The Cookie value a request carries: the jar's part, then the typed one.
* A deliberate divergence: curl sends the two as separate Cookie lines, and
* one line joined with `; ` reads the same to a server (node joins them so).
*/
export function cookieValue(part, typed) {
const pieces = [part, typed].filter((piece) => piece !== null && piece !== '');
return pieces.length === 0 ? null : pieces.join('; ');
}
/**
* curl's cookie engine for one invocation: what -b loads, what each response
* sets, what each request sends, and the file -c writes.
*
* Matching follows curl 8.14.1 where it parts from RFC 6265: a Secure cookie
* also rides plain http to localhost, 127.0.0.1 and ::1, and a request sends
* longer paths first, then longer names, then newer cookies. One deliberate
* gap: curl refuses a Domain that is a public suffix (co.uk) through the
* public suffix list, whose license mirage cannot ship, so only a suffix of
* one label (com) is refused here. A host-only cookie's domain is the host
* lowercase, as the URL parser hands it over, where curl keeps it as typed.
*/
export class CookieJar {
engine;
literal;
now;
// Oldest first.
cookies = [];
// The jar's part of each request's Cookie header, in the order sent;
// curl -v shows it per hop.
sent = [];
arrivals = 0;
constructor(
// Whether the jar keeps what responses set: on once the line names a
// cookie file or -c, off for literal -b data alone (curl 8.14.1).
engine,
// The literal -b data, sent to every host after the jar's cookies.
literal = null, now = epochNow) {
this.engine = engine;
this.literal = literal;
this.now = now;
}
keep(cookie, expired) {
const index = this.cookies.findIndex((other) => sameCookie(other, cookie));
if (expired) {
if (index >= 0)
this.cookies.splice(index, 1);
return;
}
if (index >= 0) {
const prior = this.cookies[index];
this.cookies[index] = { ...cookie, created: prior?.created ?? cookie.created };
return;
}
this.arrivals += 1;
this.cookies.push({ ...cookie, created: this.arrivals });
}
/**
* Add one Set-Cookie value. `url` is the request it answered, null for a
* line a file gave, which no host, path or scheme constrains.
*/
add(header, url) {
const request = url === null ? null : target(url);
if (url !== null && request === null)
return;
const [pair = '', ...attributes] = header.split(';');
const eq = pair.indexOf('=');
if (eq < 0)
return;
const name = trimWs(pair.slice(0, eq));
const value = trimWs(pair.slice(eq + 1));
if (hasControl(name) || hasControl(value))
return;
let expiresAt = null;
let maxAge = null;
let domainAttr = null;
let pathAttr = null;
let secure = false;
let httpOnly = false;
for (const attribute of attributes) {
const sep = attribute.indexOf('=');
const key = trimWs(sep < 0 ? attribute : attribute.slice(0, sep)).toLowerCase();
const raw = sep < 0 ? '' : trimWs(attribute.slice(sep + 1));
if (key === 'expires') {
expiresAt = parseDate(raw) ?? expiresAt;
}
else if (key === 'max-age') {
// curl drops a cookie whose Max-Age does not start with a digit or a
// minus, and reads the digits that lead one that does; past twelve
// significant ones only the sign matters.
const digits = /^(-?)([0-9]+)/.exec(raw);
if (digits === null)
return;
const [, minus = '', count = ''] = digits;
const significant = count.replace(/^0+(?=[0-9])/, '');
maxAge =
significant.length > 12
? minus === ''
? MAX_LIFETIME + 1
: -1
: Number(minus + significant);
}
else if (key === 'domain') {
if (raw !== '')
domainAttr = raw.replace(/^\./, '');
}
else if (key === 'path') {
pathAttr = raw;
}
else if (key === 'secure') {
secure = true;
}
else if (key === 'httponly') {
httpOnly = true;
}
}
const now = this.now();
let expires = 0;
let expired = false;
if (maxAge !== null) {
expired = maxAge <= 0;
expires = expired ? 0 : now + Math.min(maxAge, MAX_LIFETIME + 1);
}
else if (expiresAt !== null) {
expired = expiresAt <= now;
expires = expiresAt;
}
if (expires > now + MAX_LIFETIME)
expires = Math.ceil((now + MAX_LIFETIME) / 60) * 60;
let domain = domainAttr;
let tailmatch = domainAttr !== null;
let path = pathAttr;
if (request !== null) {
path = pathAttr ?? defaultPath(request.path);
if (domainAttr === null) {
domain = request.host;
}
else {
const wanted = domainAttr.toLowerCase();
if (isIp(request.host)) {
if (wanted !== request.host)
return;
domain = request.host;
tailmatch = false;
}
else if (wanted === request.host || request.host.endsWith(`.${wanted}`)) {
if (!wanted.includes('.') && wanted !== request.host)
return;
}
else {
return;
}
}
if (secure && !request.secure)
return;
if (name.startsWith('__Secure-') && !secure)
return;
if (name.startsWith('__Host-') && (!secure || tailmatch || path !== '/'))
return;
// Plain http may neither replace nor delete a Secure cookie of the
// same name and domain whose path covers this one's (RFC 6265bis,
// curl 8.14.1).
const key = (domain ?? '').toLowerCase();
const own = pathKey(path);
if (!request.secure &&
this.cookies.some((c) => c.secure &&
(c.expires === 0 || c.expires > now) &&
c.name === name &&
(c.domain ?? '').toLowerCase() === key &&
pathMatches(c.path, own))) {
return;
}
}
this.keep({ name, value, domain, tailmatch, path, secure, httpOnly, expires, created: 0 }, expired);
}
/** Keep what one response to `url` set. */
store(url, headers) {
if (!this.engine)
return;
for (const header of headers)
this.add(header, url);
}
/**
* Load a cookie file: curl's tab-separated lines, `#HttpOnly_` marking a
* domain, or `Set-Cookie:` header lines. A line that does not parse, one
* whose expiry passes 64 bits, and a cookie already expired, are skipped
* (curl 8.14.1). An expiry past 2^53 loses its low digits here, which
* Python keeps: no date is that far out.
*/
load(text) {
for (const raw of text.split('\n')) {
let line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
if (/^set-cookie:/i.test(line)) {
this.add(line.slice('set-cookie:'.length).trimStart(), null);
continue;
}
let httpOnly = false;
if (line.startsWith(HTTP_ONLY_PREFIX)) {
httpOnly = true;
line = line.slice(HTTP_ONLY_PREFIX.length);
}
else if (line === '' || line.startsWith('#')) {
continue;
}
const fields = line.split('\t');
if (fields.length === 6)
fields.push('');
if (fields.length !== 7)
continue;
const [domain = '', tail = '', path = '', secure = '', expiry = '', name = '', value = ''] = fields;
const digits = /^[0-9]+/.exec(expiry);
const count = digits?.[0].replace(/^0+(?=[0-9])/, '') ?? null;
if (count === null || count.length > 19 || BigInt(count) > INT64_MAX)
continue;
const expires = Number(count);
if (expires !== 0 && expires <= this.now())
continue;
this.keep({
name,
value,
domain: domain.replace(/^\./, ''),
tailmatch: tail.toUpperCase() === 'TRUE',
path,
secure: secure.toUpperCase() === 'TRUE',
httpOnly,
expires,
created: 0,
}, false);
}
}
/** The jar's part of the Cookie header a request to `url` sends. */
header(url) {
const request = target(url);
const now = this.now();
const pairs = !this.engine || request === null
? []
: this.cookies
.filter((c) => (c.expires === 0 || c.expires > now) &&
domainMatches(c, request.host) &&
pathMatches(c.path, request.path) &&
(!c.secure || request.secure))
.sort((a, b) => (b.path?.length ?? 0) - (a.path?.length ?? 0) ||
b.name.length - a.name.length ||
b.created - a.created)
.map((c) => `${c.name}=${c.value}`);
if (this.literal !== null)
pairs.push(this.literal);
const value = pairs.length === 0 ? null : pairs.join('; ');
this.sent.push(value);
return value;
}
/** The file -c writes: every live cookie with a domain, newest first. */
render() {
const now = this.now();
const lines = this.cookies
.filter((c) => c.domain !== null && (c.expires === 0 || c.expires > now))
.sort((a, b) => b.created - a.created)
.map((c) => [
`${c.httpOnly ? HTTP_ONLY_PREFIX : ''}${c.tailmatch ? '.' : ''}${c.domain ?? ''}`,
c.tailmatch ? 'TRUE' : 'FALSE',
c.path ?? '/',
c.secure ? 'TRUE' : 'FALSE',
String(c.expires),
c.name,
c.value,
].join('\t'));
return JAR_HEADER + lines.map((line) => `${line}\n`).join('');
}
}
//# sourceMappingURL=cookie.js.map