@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
209 lines • 9.27 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. =========
import { DirListing, FileStat, FileType } from "../../types.js";
import { GoogleApiError } from "../google/client.js";
import { FOLDER_MIME, MIME_TO_EXT } from "../google/drive.js";
import { driveFingerprint } from "./fingerprint.js";
import { rootContext } from "./resolve.js";
import { contentTypeForPath } from "../../utils/filetype.js";
import { compareCodePoints } from "../../utils/sort.js";
import { enotdir, enoent } from "../../errors/fs.js";
import { stripSlash } from "../../utils/slash.js";
const RESOURCE_TYPES = {
'application/vnd.google-apps.folder': 'gdrive/folder',
'application/vnd.google-apps.document': 'gdrive/gdoc',
'application/vnd.google-apps.spreadsheet': 'gdrive/gsheet',
'application/vnd.google-apps.presentation': 'gdrive/gslide',
};
export function resourceTypeFor(mime) {
return RESOURCE_TYPES[mime] ?? 'gdrive/file';
}
export function uniqueSharedDriveName(name, existingNames) {
if (!existingNames.has(name))
return name;
let filename = `${name} [Shared Drive]`;
let suffix = 2;
while (existingNames.has(filename)) {
filename = `${name} [Shared Drive ${String(suffix)}]`;
suffix += 1;
}
return filename;
}
/**
* The stat of one Drive file resource, as a listing, `files.get` or an
* upload's reply returns it.
*
* Pure, so an upload's reply parses the same as a stat. A missing `mimeType`
* reads as a plain file: natives are refused before any upload, so a reply
* without one names bytes. `extra` carries what the functions handed the stat
* act on: the file id, its shared drive, its MIME type, and the md5 and head
* revision a read checks. Mirrors Python's `stat_from_item`.
*
* @param item a Drive file resource with `ITEM_FIELDS`
*/
export function statFromItem(item) {
const mime = item.mimeType ?? '';
const modified = item.modifiedTime ?? '';
const extra = { file_id: item.id, mime_type: mime };
if (item.driveId !== undefined && item.driveId !== '')
extra.drive_id = item.driveId;
if (item.parents !== undefined && item.parents.length > 0)
extra.parents = [...item.parents];
if (mime === FOLDER_MIME) {
return new FileStat({ name: item.name, type: FileType.DIRECTORY, modified, extra });
}
const resourceType = resourceTypeFor(mime);
const ext = MIME_TO_EXT[mime];
const vfsName = ext !== undefined ? `${item.name}${ext}` : item.name;
if (typeof item.md5Checksum === 'string' && item.md5Checksum !== '') {
extra.md5_checksum = item.md5Checksum;
}
if (typeof item.headRevisionId === 'string' && item.headRevisionId !== '') {
extra.head_revision_id = item.headRevisionId;
}
// Binary files download raw, so Drive's size is the rendered byte length.
// Google-apps files render to JSON, so Drive's source size must not become
// FileStat.size (see the CLAUDE.md FUSE rules); it lives in extra instead.
const sourceSize = Number.parseInt(item.size ?? item.quotaBytesUsed ?? '0', 10);
if (ext !== undefined && Number.isFinite(sourceSize) && sourceSize > 0) {
extra.source_size = sourceSize;
}
const size = ext === undefined && item.size !== undefined ? parseInt(item.size, 10) : null;
return new FileStat({
name: vfsName,
size,
type: FileType.FILE,
content: contentTypeForPath(vfsName),
modified,
fingerprint: driveFingerprint(resourceType, item.md5Checksum, item.headRevisionId, modified),
extra: { ...extra, resource_type: resourceType },
});
}
/**
* What is at `path` now, for a change about to be made to it.
*
* A listed row names its item by id, and another client may have moved,
* renamed or trashed the item since: a change made by that id would land on a
* file at another path. One `files.get` confirms the item still has the name
* and the folders the listing gave it; otherwise that folder is listed again,
* and what it names at the path answers, null for nothing. A row that names
* no folder (a Shared Drive) is taken as is. Mirrors Python's `live_row`.
*/
export async function liveRow(accessor, path, entry) {
if (entry === null || entry === undefined)
return null;
const folders = entry.extra.parents;
const folder = folders?.[0];
if (folders === undefined || folder === undefined)
return entry;
const drive = accessor.drive;
try {
const item = await drive.getFile(entry.extra.file_id);
const row = statFromItem(item);
const filed = row.extra.parents;
if (item.trashed !== true &&
row.name === entry.name &&
JSON.stringify(filed ?? []) === JSON.stringify(folders)) {
return row;
}
}
catch (err) {
if (!(err instanceof GoogleApiError) || err.status !== 404)
throw err;
console.debug(`listed id for ${path.virtual} is gone: ${String(err)}`);
}
const files = await drive.listFiles({
folderId: folder,
driveId: entry.extra.drive_id ?? null,
});
return files.map(statFromItem).find((row) => row.name === entry.name) ?? null;
}
/**
* A folder's children, each as `stat` answers it. The mount root lists the
* scoped folder, or My Drive and the Shared Drives the account sees; any
* other folder is listed by the id its parent's listing named.
*/
export async function readdir(accessor, path, entry) {
const target = path.pattern !== null ? path.dir : path;
const atRoot = stripSlash(target.vfsPath) === '';
let folderId;
let driveId = null;
if (atRoot) {
;
[folderId, driveId] = await rootContext(accessor);
}
else if (entry === null || entry === undefined) {
throw enoent(path.virtual);
}
else if (entry.type !== FileType.DIRECTORY) {
// Listing a file's id answers with an empty child set rather than an
// error, so the listing that named it decides: opendir(2) says ENOTDIR
// for `ls /data/a.txt/x`.
throw enotdir(path.virtual);
}
else {
folderId = entry.extra.file_id;
const entryDriveId = entry.extra.drive_id;
driveId = typeof entryDriveId === 'string' ? entryDriveId : null;
}
const drive = accessor.drive;
const files = await drive.listFiles({ folderId, driveId });
const rows = files.map((f) => statFromItem(f));
let complete = true;
if (atRoot && folderId === 'root') {
// Shared Drive enumeration is best-effort: if the account can't list
// them (missing scope, API error, network), still return My Drive
// contents. A folder-scoped mount lists only the folder's children, so
// shared drives never surface there.
let sharedDrives = [];
try {
sharedDrives = await drive.listSharedDrives();
}
catch (err) {
// fetch rejects with a TypeError when the connection fails and a
// DOMException when its timeout aborts it
const failed = err instanceof GoogleApiError || err instanceof TypeError || err instanceof DOMException;
if (!failed)
throw err;
console.debug(`Unable to list Google Shared Drives: ${String(err)}`);
sharedDrives = [];
complete = false;
}
const existingNames = new Set(rows.map((row) => row.name));
for (const d of sharedDrives) {
const filename = uniqueSharedDriveName(d.name, existingNames);
existingNames.add(filename);
rows.push(new FileStat({
name: filename,
type: FileType.DIRECTORY,
extra: {
file_id: d.id,
mime_type: FOLDER_MIME,
drive_id: d.id,
resource_type: 'gdrive/shared_drive',
},
}));
}
}
// Deterministic vfs order: glob expansion and walkers follow readdir
// order, so sort by rendered filename (Drive returns modifiedTime desc).
// Codepoint compare, not localeCompare: python sorts by codepoint and the
// two runners must list identically.
rows.sort((a, b) => compareCodePoints(a.name, b.name));
// A root whose Shared Drives could not be listed is short: kept as rows,
// it would pin a My-Drive-only root as the whole listing until it
// expired, hiding Shared Drives after the cause clears.
return complete ? rows : new DirListing({ entries: rows, partial: true });
}
//# sourceMappingURL=readdir.js.map