UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

399 lines • 16.3 kB
// ========= 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. ========= import { statOf } from "./io.js"; import { PathSpec } from "../../../../types.js"; import git, { Errors } from 'isomorphic-git'; import { HttpConnectError } from "../../../builtin/errors.js"; import { httpRequest } from "../../../builtin/utils/http.js"; import { materialize } from "../../../../io/types.js"; import { GITLINK_MODE } from "./constants.js"; import { discover } from "./discover.js"; import { GitError, MissingRepositoryError } from "./errors.js"; import { requireVfs } from "./util.js"; import { loadRefs, readHead } from "./refs.js"; import { objectType, openRepo, repoArgs } from "./repo.js"; import { compareCodePoints } from "../../../../utils/sort.js"; import { concat } from "../../../../utils/bytes.js"; const ENC = new TextEncoder(); const DEC = new TextDecoder(); const FLUSH = '0000'; const SERVICE = 'git-upload-pack'; // What mirage asks an upload-pack for: no progress chatter, packs carried on // the side band so an error can interrupt them, offset deltas, and the // annotated tags that point into what is sent. Never thin-pack: a thin pack // names bases outside itself, which a stored pack cannot hold. const CAPABILITIES = 'side-band-64k ofs-delta include-tag no-progress agent=git/mirage'; const USER_AGENT = 'git/mirage'; const PACK_BAND = 1; const ERROR_BAND = 3; const REMOTE_HELPER = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//; const SCP_LIKE = /^[^/:]+:/; // The places git tries for a local repository path, in the order it prefers // them. const REPO_SUFFIXES = ['/.git', '', '.git/.git', '.git']; const SYMREF_HEAD = 'symref=HEAD:'; const PEELED = '^{}'; /** One pkt-line: four hex digits of length, then the payload. */ export function pktLine(data) { const bytes = typeof data === 'string' ? ENC.encode(data) : data; const out = new Uint8Array(bytes.length + 4); out.set(ENC.encode((bytes.length + 4).toString(16).padStart(4, '0'))); out.set(bytes, 4); return out; } /** Split a pkt-line stream, yielding null for each flush. */ export function* pktLines(data) { let at = 0; while (at < data.length) { const head = DEC.decode(data.subarray(at, at + 4)); if (!/^[0-9a-fA-F]{4}$/.test(head)) throw new GitError(`protocol error: bad line length character: ${head}`); const size = parseInt(head, 16); if (size === 0) { yield null; at += 4; continue; } yield data.subarray(at + 4, at + size); at += size; } } /** Read a protocol v0 ref advertisement. */ export function parseAdvertisement(lines) { const refs = new Map(); const peeled = new Map(); let head = null; for (const line of lines) { if (line === null) continue; let text = DEC.decode(line).replace(/\n$/, ''); const nul = text.indexOf('\0'); if (nul >= 0) { for (const cap of text.slice(nul + 1).split(' ')) if (cap.startsWith(SYMREF_HEAD)) head = cap.slice(SYMREF_HEAD.length); text = text.slice(0, nul); } const space = text.indexOf(' '); const oid = text.slice(0, space); const name = text.slice(space + 1); if (name === 'capabilities^{}') continue; if (name.endsWith(PEELED)) peeled.set(name.slice(0, -PEELED.length), oid); else refs.set(name, oid); } return { refs, peeled, head }; } /** A remote's URL as git prints it: no credentials, no `.git`. */ export function displayUrl(url) { let shown = url; const scheme = REMOTE_HELPER.exec(url); if (scheme) { const rest = url.slice(scheme[0].length); const slash = rest.indexOf('/'); const host = slash < 0 ? rest : rest.slice(0, slash); if (host.includes('@')) shown = scheme[0] + host.slice(host.lastIndexOf('@') + 1) + (slash < 0 ? '' : rest.slice(slash)); } shown = shown.replace(/\/+$/, ''); return shown.endsWith('.git') && shown.length > 4 ? shown.slice(0, -4) : shown; } /** * Every object reachable from `wants` that the other side lacks. * * Commits the other side has stop the walk; the trees of those boundary commits * are what it already holds, so their contents are left out as upload-pack's * edge does. Gitlinks name another repository and are never followed. */ export async function missingObjects(repo, wants, has) { const sending = []; const boundary = []; const trees = []; const seen = new Set(); const stack = [...wants]; for (let oid = stack.pop(); oid !== undefined; oid = stack.pop()) { if (seen.has(oid)) continue; seen.add(oid); if (await has(oid)) { boundary.push(oid); continue; } const type = await objectType(repo, oid); sending.push(oid); if (type === 'tag') stack.push((await git.readTag({ ...repoArgs(repo), oid })).tag.object); else if (type === 'commit') { const { commit } = await git.readCommit({ ...repoArgs(repo), oid }); trees.push(commit.tree); for (const item of commit.parent) stack.push(item); } } const held = new Set(); for (const oid of boundary) { if ((await objectType(repo, oid)) !== 'commit') continue; const { commit } = await git.readCommit({ ...repoArgs(repo), oid }); await walkTree(repo, commit.tree, held, null); } for (const tree of trees) await walkTree(repo, tree, held, sending); return sending; } /** Visit a tree once, collecting what is new into `sending`. */ async function walkTree(repo, tree, held, sending) { const stack = [tree]; for (let oid = stack.pop(); oid !== undefined; oid = stack.pop()) { if (held.has(oid)) continue; held.add(oid); sending?.push(oid); const { tree: entries } = await git.readTree({ ...repoArgs(repo), oid }); for (const entry of entries) { if (entry.mode === GITLINK_MODE || held.has(entry.oid)) continue; if (entry.type === 'tree') stack.push(entry.oid); else { held.add(entry.oid); sending?.push(entry.oid); } } } } /** A repository inside the workspace, read through the dispatcher. */ export class LocalTransport { repo; head; constructor(repo, head) { this.repo = repo; this.head = head; } /** The source's refs, as upload-pack would advertise them. */ async advertise() { const loaded = await loadRefs(this.repo.vfs, this.repo.location.gitdir, this.repo.location.commondir); const refs = new Map(); const peeled = new Map(); const names = [ 'HEAD', ...[...loaded.keys()].filter((name) => name.startsWith('refs/')).sort(compareCodePoints), ]; for (const name of names) { let oid; try { oid = await git.resolveRef({ ...repoArgs(this.repo), ref: name }); } catch (err) { // A symref to an unborn branch names nothing yet. if (err instanceof Errors.NotFoundError) continue; throw err; } refs.set(name, oid); let target = oid; while ((await objectType(this.repo, target)) === 'tag') target = (await git.readTag({ ...repoArgs(this.repo), oid: target })).tag.object; if (target !== oid) peeled.set(name, target); } return { refs, peeled, head: this.head }; } /** A pack of everything reachable from `wants` the receiver lacks. */ async fetchPack(wants, _haves, has) { const oids = await missingObjects(this.repo, wants, has); if (!oids.length) return new Uint8Array(); const { packfile } = await git.packObjects({ ...repoArgs(this.repo), oids }); return packfile ?? new Uint8Array(); } } /** * A remote reached over git's smart HTTP protocol, version 0. * * The credentials a URL carried belong to its origin: when the first request * is redirected to another one they are dropped, as git reads credentials again * from the URL it was sent to. Configured headers go with every request, which * is what git does with `http.extraHeader`. */ export class HttpTransport { url; headers; credentials; configured; /** * @param url the repository URL, credentials stripped * @param headers extra headers for every request * @param credentials the Authorization header the URL's userinfo spelled, * empty for none */ constructor(url, headers, credentials) { this.url = url.replace(/\/+$/, ''); this.headers = { 'User-Agent': USER_AGENT, ...headers }; this.credentials = credentials; this.configured = 'Authorization' in headers; } async request(url, method, headers, body) { let resp; try { resp = await httpRequest(url, { method, headers: { ...this.headers, ...this.credentials, ...headers }, ...(body === undefined ? {} : { body }), timeoutMs: null, followRedirects: true, }); } catch (err) { if (err instanceof HttpConnectError) throw new GitError(`unable to access '${this.url}/': ${err.message}`); throw err; } if (resp.status === 401 || resp.status === 403) { if (this.configured || Object.keys(this.credentials).length) throw new GitError(`Authentication failed for '${this.url}/'`); const host = new URL(this.url).origin; throw new GitError(`could not read Username for '${host}': terminal prompts disabled`); } if (resp.status === 404) throw new GitError(`repository '${this.url}/' not found`); if (resp.status >= 400) throw new GitError(`unable to access '${this.url}/': The requested URL returned error: ${String(resp.status)}`); if (method === 'GET') { const moved = resp.url.split('/info/refs')[0] ?? this.url; if (new URL(moved).origin !== new URL(this.url).origin) this.credentials = {}; this.url = moved; } return await materialize(resp.body); } /** GET `info/refs` for upload-pack and read what it lists. */ async advertise() { const body = await this.request(`${this.url}/info/refs?service=${SERVICE}`, 'GET', {}, undefined); const lines = pktLines(body); const first = lines.next(); if (first.done === true || first.value === null || DEC.decode(first.value).replace(/\n$/, '') !== `# service=${SERVICE}`) throw new GitError(`repository '${this.url}/' is not a smart HTTP git server`); return parseAdvertisement(lines); } /** POST the wants and haves to upload-pack; return the pack. */ async fetchPack(wants, haves) { const [first, ...rest] = wants; if (first === undefined) return new Uint8Array(); const parts = [ pktLine(`want ${first} ${CAPABILITIES}\n`), ...rest.map((want) => pktLine(`want ${want}\n`)), ENC.encode(FLUSH), ...haves.map((have) => pktLine(`have ${have}\n`)), pktLine('done\n'), ]; const reply = await this.request(`${this.url}/${SERVICE}`, 'POST', { 'Content-Type': `application/x-${SERVICE}-request`, Accept: `application/x-${SERVICE}-result`, }, concat(parts)); const pack = []; for (const line of pktLines(reply)) { if (line === null) continue; const text = DEC.decode(line.subarray(0, 4)); if (text.startsWith('NAK') || text === 'ACK ') continue; if (line[0] === PACK_BAND) pack.push(line.subarray(1)); else if (line[0] === ERROR_BAND) throw new GitError(`remote error: ${DEC.decode(line.subarray(1)).trim()}`); } return concat(pack); } } /** Split userinfo out of a URL into a basic Authorization header. */ function credentials(url) { const scheme = REMOTE_HELPER.exec(url); if (!scheme) return [url, {}]; const rest = url.slice(scheme[0].length); const slash = rest.indexOf('/'); const host = slash < 0 ? rest : rest.slice(0, slash); const at = host.lastIndexOf('@'); if (at < 0) return [url, {}]; const userinfo = decodeURIComponent(host.slice(0, at)); const bare = scheme[0] + host.slice(at + 1) + (slash < 0 ? '' : rest.slice(slash)); const token = btoa(String.fromCharCode(...ENC.encode(userinfo))); return [bare, { Authorization: `Basic ${token}` }]; } /** `http.extraHeader` values as a header table, later ones winning. */ export function extraHeaders(values) { const headers = {}; for (const value of values) { const colon = value.indexOf(':'); const name = (colon < 0 ? value : value.slice(0, colon)).trim(); const text = colon < 0 ? '' : value.slice(colon + 1).trim(); if (name && text) headers[name] = text; } return headers; } /** * The transport a remote URL or workspace path names. * * `https://` and `http://` speak smart HTTP; a path, or a `file://` URL, is a * repository inside the workspace reached through the dispatcher. Anything * else names a remote helper mirage does not have, which git words the same * way. `scoped` is an Authorization for the URL's origin when it carries no * userinfo, dropped with it on a redirect elsewhere. */ export async function openTransport(ctx, url, start, headers, scoped = {}) { const scheme = REMOTE_HELPER.exec(url); const helper = scheme?.[1] ?? null; if (helper === 'http' || helper === 'https') { const [bare, auth] = credentials(url); return new HttpTransport(bare, headers, Object.keys(auth).length > 0 ? auth : { ...scoped }); } if (helper !== null && helper !== 'file') throw new GitError(`Unable to find remote helper for '${helper}'`); if (helper === null && SCP_LIKE.test(url)) throw new GitError("Unable to find remote helper for 'ssh'"); const vfs = requireVfs(ctx); const raw = helper === null ? url : decodeURIComponent(new URL(url).pathname); const path = PathSpec.fromStrPath(raw, undefined, start); for (const suffix of REPO_SUFFIXES) { const candidate = PathSpec.fromStrPath((path.dotted ?? path.virtual).replace(/\/+$/, '') + suffix, undefined, '/'); if ((await statOf(vfs, candidate)) === null) continue; let location; try { location = await discover(vfs, candidate.parent, candidate); } catch (err) { if (err instanceof GitError) continue; throw err; } const head = await readHead(vfs, location.gitdir); return new LocalTransport(await openRepo(vfs, location), head.ref); } throw new MissingRepositoryError(url); } /** Whether a remote names a path rather than a URL. */ export function isLocal(url) { return !REMOTE_HELPER.test(url) && !SCP_LIKE.test(url); } //# sourceMappingURL=transport.js.map