@tanstack/ai-sandbox
Version:
Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.
712 lines (676 loc) • 23.3 kB
text/typescript
import type { SandboxHandle, SandboxFsStat } from './contracts'
import type {
SandboxSnapshotArtifact,
SandboxSnapshotEntry,
} from './checkpoint-store'
import type { MemoryArtifactRecord as ArtifactRecord } from './memory-snapshot-types'
type SnapshotBlobStore = {
get: (key: string) => Promise<{
arrayBuffer: () => Promise<ArrayBuffer>
} | null>
head: (key: string) => Promise<unknown>
put: (key: string, body: Uint8Array) => Promise<unknown>
}
export interface SandboxSnapshotPolicy {
/** Exact workspace projection hash, when known. */
workspaceHash?: string
include?: (path: string, kind: 'file' | 'dir') => boolean
exclude?: (path: string, kind: 'file' | 'dir') => boolean
redact?: (input: {
path: string
bytes: Uint8Array
resolvedSecrets: Readonly<Record<string, string>>
}) => Uint8Array
}
export interface SandboxSnapshotBundle {
blobs: SnapshotBlobStore
/** Internal resolved workspace root. */
workspaceRoot?: string
/** Internal persistence stores used to capture immutable artifact bytes. */
artifacts?: {
listForThread: (threadId: string) => Promise<ReadonlyArray<ArtifactRecord>>
}
resolveArtifactBlobKey?: (record: ArtifactRecord) => string
}
export type SandboxSnapshotErrorCode =
| 'SANDBOX_SNAPSHOT_INVALID_TOOL_INPUT'
| 'SANDBOX_SNAPSHOT_MISSING_SANDBOX'
| 'SANDBOX_SNAPSHOT_MISSING_INSTANCES'
| 'SANDBOX_SNAPSHOT_MISSING_PERSISTENCE_STORES'
| 'SANDBOX_SNAPSHOT_MISSING_REUSABLE_SANDBOX'
| 'SANDBOX_SNAPSHOT_REUSE_NONE'
| 'SANDBOX_SNAPSHOT_MISSING_CHECKPOINT'
| 'SANDBOX_SNAPSHOT_MISSING_CHECKPOINT_ARTIFACT'
| 'SANDBOX_SNAPSHOT_FOREIGN_CHECKPOINT_ARTIFACT'
| 'SANDBOX_SNAPSHOT_INVALID_ARTIFACT_BYTES'
| 'SANDBOX_SNAPSHOT_FORK_UNAVAILABLE'
| 'SANDBOX_SNAPSHOT_INVALID_PATH'
| 'SANDBOX_SNAPSHOT_INVALID_WORKSPACE'
| 'SANDBOX_SNAPSHOT_LSTAT_REQUIRED'
| 'SANDBOX_SNAPSHOT_UNSUPPORTED_ENTRY'
| 'SANDBOX_SNAPSHOT_MISSING_BLOB'
| 'SANDBOX_SNAPSHOT_INVALID_BLOB'
| 'SANDBOX_SNAPSHOT_ARTIFACT_SUPPORT_REQUIRED'
| 'SANDBOX_SNAPSHOT_MISSING_ARTIFACT_BLOB'
export class SandboxSnapshotError extends Error {
readonly code: SandboxSnapshotErrorCode
constructor(code: SandboxSnapshotErrorCode, message: string) {
super(message)
this.name = 'SandboxSnapshotError'
this.code = code
}
}
const DEFAULT_ROOT = '/workspace'
const PROJECTED_SKILL_ROOTS = new Set(['.claude', '.codex', '.grok'])
function isFrameworkGeneratedSymlinkPath(path: string): boolean {
if (path === 'CLAUDE.md' || path === 'GEMINI.md') return true
const segments = path.split('/')
return (
segments.length === 3 &&
PROJECTED_SKILL_ROOTS.has(segments[0] ?? '') &&
segments[1] === 'skills'
)
}
function defaultExcluded(path: string, workspaceHash?: string): boolean {
const segments = path.split('/')
return (
isFrameworkGeneratedSymlinkPath(path) ||
segments.some(
(segment) =>
segment === '.git' ||
segment === 'node_modules' ||
segment.startsWith('.env'),
) ||
(workspaceHash !== undefined &&
segments[0] === `.tanstack-projected-${workspaceHash}`)
)
}
function isProtectedPath(path: string, workspaceHash?: string): boolean {
return (
workspaceHash !== undefined &&
path.split('/')[0] === `.tanstack-projected-${workspaceHash}`
)
}
const FILE_BLOB_KEY = /^sandbox-files\/sha256\/[0-9a-f]{64}$/
export function defaultSandboxSnapshotPolicy(
workspaceHash?: string,
): SandboxSnapshotPolicy {
return {
workspaceHash,
exclude: (path) => defaultExcluded(path, workspaceHash),
}
}
/**
* Keep default exclusions unless the caller passed `exclude`.
* `include` or `redact` alone must not capture `.env`, `.git`, or
* `node_modules`.
*/
export function resolveSandboxSnapshotPolicy(
supplied: SandboxSnapshotPolicy | undefined,
workspaceHash?: string,
): SandboxSnapshotPolicy {
const defaults = defaultSandboxSnapshotPolicy(
workspaceHash ?? supplied?.workspaceHash,
)
if (supplied === undefined) return defaults
const include = supplied.include
const exclude = supplied.exclude
const redact = supplied.redact
const suppliedWorkspaceHash = supplied.workspaceHash
return {
...(suppliedWorkspaceHash === undefined
? {}
: { workspaceHash: suppliedWorkspaceHash }),
...(workspaceHash === undefined ? {} : { workspaceHash }),
...(include === undefined ? {} : { include }),
exclude: exclude ?? defaults.exclude,
...(redact === undefined ? {} : { redact }),
}
}
function normalize(path: string): string {
if (path.includes('\\'))
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_PATH',
`Unsafe snapshot path '${path}'`,
)
const value = path
if (
!value ||
value.includes('\0') ||
value.startsWith('/') ||
/^[A-Za-z]:/.test(value) ||
value.endsWith('/')
)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_PATH',
`Unsafe snapshot path '${path}'`,
)
const parts = value.split('/')
if (
parts.some((part) => !part || part === '.' || part === '..') ||
parts.join('/') !== value
)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_PATH',
`Unsafe snapshot path '${path}'`,
)
return value
}
function childPath(
parent: string,
child: { name: string; path: string },
): { absolute: string; relative: string } {
if (
!child.name ||
child.name.includes('/') ||
child.name.includes('\\') ||
child.name.includes('\0') ||
child.name === '.' ||
child.name === '..'
)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_WORKSPACE',
`Invalid workspace entry '${child.name}'`,
)
const absolute = `${parent}/${child.name}`
if (child.path !== absolute)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_WORKSPACE',
`Invalid workspace entry path '${child.path}'`,
)
return { absolute, relative: child.name }
}
function lstat(
handle: SandboxHandle,
path: string,
): Promise<SandboxFsStat | undefined> {
if (!handle.fs.lstat)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_LSTAT_REQUIRED',
'Snapshot operations require fs.lstat',
)
return handle.fs.lstat(path)
}
function assertSupported(stat: SandboxFsStat, path: string): void {
if (
stat.type === 'symlink' ||
stat.type === 'other' ||
(stat.type === 'file' && (stat.mode & 0o111) !== 0)
)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_UNSUPPORTED_ENTRY',
`Unsupported entry '${path}'`,
)
}
function included(
path: string,
kind: 'file' | 'dir',
policy: SandboxSnapshotPolicy,
): boolean {
if (policy.exclude?.(path, kind)) return false
return kind === 'dir' ? true : (policy.include?.(path, kind) ?? true)
}
async function hash(bytes: Uint8Array): Promise<string> {
// TypeScript requires an ArrayBuffer-backed view; Uint8Array can also use SharedArrayBuffer.
const digest = await crypto.subtle.digest('SHA-256', new Uint8Array(bytes))
return Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('')
}
async function putIfAbsent(
blobs: SnapshotBlobStore,
bytes: Uint8Array,
keys: Map<string, string>,
): Promise<{ key: string; size: number }> {
const key = `sandbox-files/sha256/${await hash(bytes)}`
if (!keys.has(key)) {
if (!(await blobs.head(key))) await blobs.put(key, bytes)
keys.set(key, key)
}
return { key, size: bytes.byteLength }
}
async function redactBytes(
bytes: Uint8Array,
resolvedSecrets: Readonly<Record<string, string>>,
): Promise<Uint8Array> {
const output = bytes.slice()
const redacted = new Uint8Array(bytes.length)
const secrets = Object.values(resolvedSecrets)
.filter(Boolean)
.map((secret) => new TextEncoder().encode(secret))
.sort((a, b) => b.length - a.length || compareBytes(a, b))
for (const needle of secrets) {
if (!needle.length || needle.length > bytes.length) continue
for (let start = 0; start <= bytes.length - needle.length; start++) {
let match = true
for (let index = 0; index < needle.length; index++)
if (bytes[start + index] !== needle[index]) {
match = false
break
}
if (!match) continue
if (match) redacted.fill(1, start, start + needle.length)
}
}
for (let index = 0; index < output.length; index++)
if (redacted[index]) output[index] = 0
return output
}
export async function captureSandboxFiles(
handle: SandboxHandle,
bundle: SandboxSnapshotBundle,
suppliedPolicy: SandboxSnapshotPolicy = defaultSandboxSnapshotPolicy(),
resolvedSecrets: Readonly<Record<string, string>> = {},
): Promise<{ files: Array<SandboxSnapshotEntry> }> {
const policy = resolveSandboxSnapshotPolicy(
suppliedPolicy,
suppliedPolicy.workspaceHash,
)
if (!handle.fs.lstat)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_LSTAT_REQUIRED',
'Snapshot capture requires fs.lstat',
)
const rootPath = bundle.workspaceRoot ?? DEFAULT_ROOT
const root = await lstat(handle, rootPath)
if (!root || root.type !== 'dir')
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_WORKSPACE',
'Snapshot workspace is missing or is not a directory',
)
assertSupported(root, rootPath)
const files: Array<SandboxSnapshotEntry> = []
const destinationKeys = new Map<string, string>()
const walk = async (absolute: string, relative: string): Promise<boolean> => {
const stat = await lstat(handle, absolute)
if (!stat)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_WORKSPACE',
`Snapshot entry disappeared '${relative}'`,
)
assertSupported(stat, relative)
if (stat.type !== 'file' && stat.type !== 'dir')
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_UNSUPPORTED_ENTRY',
`Unsupported entry '${relative}'`,
)
if (
relative &&
(isProtectedPath(relative, policy.workspaceHash) ||
policy.exclude?.(relative, stat.type))
)
return false
if (stat.type === 'file') {
const path = normalize(relative)
if (policy.include && !policy.include(path, 'file')) return false
let bytes = await handle.fs.readBytes(absolute)
if (policy.redact) bytes = policy.redact({ path, bytes, resolvedSecrets })
bytes = await redactBytes(bytes, resolvedSecrets)
const blob = await putIfAbsent(bundle.blobs, bytes, destinationKeys)
files.push({ path, kind: 'file', blobKey: blob.key, size: blob.size })
return true
}
const children = await handle.fs.list(absolute)
let hasCapturedChild = false
for (const child of children) {
const childEntry = childPath(absolute, child)
const childRelative = relative
? `${relative}/${childEntry.relative}`
: childEntry.relative
if (
isProtectedPath(childRelative, policy.workspaceHash) ||
policy.exclude?.(childRelative, child.type)
)
continue
hasCapturedChild =
(await walk(childEntry.absolute, childRelative)) || hasCapturedChild
}
if (
relative &&
!hasCapturedChild &&
(!policy.include || policy.include(relative, 'dir'))
)
files.push({ path: normalize(relative), kind: 'dir' })
return (
hasCapturedChild ||
(relative !== '' && (!policy.include || policy.include(relative, 'dir')))
)
}
await walk(rootPath, '')
files.sort((a, b) => comparePath(a.path, b.path))
return { files }
}
type PlannedEntry = SandboxSnapshotEntry & { path: string }
type PlannedFile = Extract<PlannedEntry, { kind: 'file' }>
function validateManifest(
snapshot: {
files: ReadonlyArray<SandboxSnapshotEntry>
},
policy: SandboxSnapshotPolicy,
): Array<PlannedEntry> {
const paths = new Map<string, PlannedEntry>()
for (const entry of snapshot.files) {
const path = normalize(entry.path)
for (const ancestor of parents(path)) {
if (
isProtectedPath(ancestor, policy.workspaceHash) ||
policy.exclude?.(ancestor, 'dir')
)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_PATH',
`Excluded snapshot ancestor '${ancestor}'`,
)
}
if (isProtectedPath(path, policy.workspaceHash))
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_PATH',
`Protected snapshot path '${path}'`,
)
if (
!included(path, entry.kind, policy) ||
(entry.kind === 'dir' && policy.include?.(path, 'dir') === false)
)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_PATH',
`Excluded snapshot path '${path}'`,
)
if (paths.has(path))
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_PATH',
`Duplicate path '${path}'`,
)
if (entry.kind === 'file') {
const { blobKey, size } = entry
if (
typeof blobKey !== 'string' ||
!FILE_BLOB_KEY.test(blobKey) ||
typeof size !== 'number' ||
!Number.isSafeInteger(size) ||
size < 0
)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_PATH',
`Invalid file entry '${path}'`,
)
paths.set(path, { path, kind: 'file', blobKey, size })
} else if (entry.kind === 'dir') paths.set(path, { path, kind: 'dir' })
else
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_PATH',
`Unknown snapshot entry '${path}'`,
)
}
for (const [path] of paths)
for (
let index = path.indexOf('/');
index !== -1;
index = path.indexOf('/', index + 1)
) {
const parent = paths.get(path.slice(0, index))
if (parent?.kind === 'file')
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_PATH',
`File ancestor '${parent.path}'`,
)
}
return [...paths.values()]
}
async function loadBlobs(
entries: ReadonlyArray<PlannedEntry>,
bundle: SandboxSnapshotBundle,
): Promise<Map<string, Uint8Array>> {
const blobs = new Map<string, Uint8Array>()
for (const entry of entries)
if (entry.kind === 'file' && !blobs.has(entry.blobKey)) {
const object = await bundle.blobs.get(entry.blobKey)
if (!object)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_MISSING_BLOB',
`Missing snapshot blob '${entry.blobKey}'`,
)
const bytes = new Uint8Array(await object.arrayBuffer())
const expectedKey = `sandbox-files/sha256/${await hash(bytes)}`
if (entry.blobKey !== expectedKey)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_BLOB',
`Invalid content for snapshot blob '${entry.blobKey}'`,
)
blobs.set(entry.blobKey, bytes)
}
for (const entry of entries)
if (entry.kind === 'file') {
const bytes = blobs.get(entry.blobKey)
if (!bytes || bytes.byteLength !== entry.size)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_BLOB',
`Wrong size for snapshot blob '${entry.blobKey}'`,
)
}
return blobs
}
type CurrentEntry = { path: string; kind: 'file' | 'dir'; protected?: boolean }
async function scanCurrent(
handle: SandboxHandle,
absolute: string,
relative: string,
policy: SandboxSnapshotPolicy,
): Promise<Array<CurrentEntry>> {
const stat = await lstat(handle, absolute)
if (!stat) return []
assertSupported(stat, relative)
if (stat.type === 'file') return [{ path: relative, kind: 'file' }]
const paths: Array<CurrentEntry> = []
for (const child of await handle.fs.list(absolute)) {
const childEntry = childPath(absolute, child)
const childRelative = relative
? `${relative}/${childEntry.relative}`
: childEntry.relative
if (isProtectedPath(childRelative, policy.workspaceHash)) {
// Preserve the marker and its parent directories without inspecting the
// protected tree. The restore planner uses this marker to avoid removing
// an ancestor directory that contains it.
paths.push({ path: childRelative, kind: 'dir', protected: true })
continue
}
if (!included(childRelative, child.type, policy)) {
// Excluded entries are outside the portable snapshot. Keep a protected
// marker so removing a parent directory cannot remove them either.
paths.push({ path: childRelative, kind: child.type, protected: true })
continue
}
paths.push(
...(await scanCurrent(
handle,
childEntry.absolute,
childRelative,
policy,
)),
)
}
return relative ? [{ path: relative, kind: 'dir' }, ...paths] : paths
}
async function scanDestination(
handle: SandboxHandle,
policy: SandboxSnapshotPolicy,
rootPath: string,
): Promise<Array<CurrentEntry>> {
const root = await lstat(handle, rootPath)
if (!root || root.type !== 'dir')
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_WORKSPACE',
'Snapshot workspace is missing or is not a directory',
)
assertSupported(root, rootPath)
return scanCurrent(handle, rootPath, '', policy)
}
function parents(path: string): Array<string> {
const values: Array<string> = []
const parts = path.split('/')
for (let length = 1; length < parts.length; length++)
values.push(parts.slice(0, length).join('/'))
return values
}
function comparePath(a: string, b: string): number {
return compareBytes(new TextEncoder().encode(a), new TextEncoder().encode(b))
}
function compareBytes(a: Uint8Array, b: Uint8Array): number {
for (let i = 0; i < Math.min(a.length, b.length); i++) {
const left = a[i]
const right = b[i]
if (left !== right) return (left ?? 0) - (right ?? 0)
}
return a.length - b.length
}
function getRequiredBlob(
blobs: ReadonlyMap<string, Uint8Array>,
key: string,
): Uint8Array {
const bytes = blobs.get(key)
if (!bytes) {
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_MISSING_BLOB',
`Missing snapshot blob '${key}'`,
)
}
return bytes
}
function depth(path: string): number {
return path.split('/').length
}
function buildRestorePlan(
entries: ReadonlyArray<PlannedEntry>,
current: ReadonlyArray<CurrentEntry>,
): {
removes: Array<string>
mkdirs: Array<string>
writes: Array<PlannedFile>
} {
const desired = new Map<string, 'file' | 'dir'>()
for (const entry of entries) {
desired.set(entry.path, entry.kind)
for (const parent of parents(entry.path)) desired.set(parent, 'dir')
}
const currentKinds = new Map(current.map((entry) => [entry.path, entry.kind]))
const candidates = current
.filter((entry) => desired.get(entry.path) !== entry.kind)
.map((entry) => entry.path)
.sort((a, b) => depth(a) - depth(b) || comparePath(a, b))
const removes: Array<string> = []
for (const path of candidates) {
const containsProtectedEntry = current.some(
(entry) =>
entry.protected &&
(entry.path === path || entry.path.startsWith(`${path}/`)),
)
if (containsProtectedEntry) continue
if (removes.some((ancestor) => path.startsWith(`${ancestor}/`))) continue
removes.push(path)
}
const mkdirs = [...desired]
.filter(
([path, kind]) => kind === 'dir' && currentKinds.get(path) !== 'dir',
)
.map(([path]) => path)
.sort((a, b) => depth(a) - depth(b) || comparePath(a, b))
const writes = entries
.filter((entry): entry is PlannedFile => entry.kind === 'file')
.sort((a, b) => comparePath(a.path, b.path))
return { removes, mkdirs, writes }
}
export async function restoreSandboxFiles(
handle: SandboxHandle,
bundle: SandboxSnapshotBundle,
snapshot: { files: ReadonlyArray<SandboxSnapshotEntry> },
suppliedPolicy: SandboxSnapshotPolicy = defaultSandboxSnapshotPolicy(),
): Promise<void> {
const policy = resolveSandboxSnapshotPolicy(
suppliedPolicy,
suppliedPolicy.workspaceHash,
)
if (!handle.fs.lstat)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_LSTAT_REQUIRED',
'Snapshot restore requires fs.lstat',
)
const entries = validateManifest(snapshot, policy)
const rootPath = bundle.workspaceRoot ?? DEFAULT_ROOT
const current = await scanDestination(handle, policy, rootPath)
for (const entry of entries) {
if (
entry.kind === 'file' &&
current.some(
(currentEntry) =>
currentEntry.protected &&
currentEntry.path.startsWith(`${entry.path}/`),
)
)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_INVALID_PATH',
`Protected current descendant conflicts with '${entry.path}'`,
)
}
const blobs = await loadBlobs(entries, bundle)
const plan = buildRestorePlan(entries, current)
// Restore is called only while a new sandbox is private to setup. It is not
// safe for a caller that allows another process to change the workspace.
for (const path of plan.removes) await handle.fs.remove(`${rootPath}/${path}`)
for (const path of plan.mkdirs) await handle.fs.mkdir(`${rootPath}/${path}`)
for (const entry of plan.writes)
await handle.fs.write(
`${rootPath}/${entry.path}`,
getRequiredBlob(blobs, entry.blobKey),
)
}
export async function captureSandboxArtifacts(
bundle: SandboxSnapshotBundle,
threadId: string,
resolvedSecrets: Readonly<Record<string, string>> = {},
): Promise<ReadonlyArray<SandboxSnapshotArtifact>> {
if (!bundle.artifacts)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_ARTIFACT_SUPPORT_REQUIRED',
'Snapshot artifact capture requires an artifact store',
)
const records = await bundle.artifacts.listForThread(threadId)
const loaded = new Map<string, Uint8Array>()
const destinationKeys = new Map<string, string>()
const resolveBlobKey =
bundle.resolveArtifactBlobKey ??
((record: ArtifactRecord) =>
record.blobKey ?? `artifacts/${record.runId}/${record.artifactId}`)
for (const record of records) {
const sourceKey = resolveBlobKey(record)
if (loaded.has(sourceKey)) continue
const source = await bundle.blobs.get(sourceKey)
if (!source)
throw new SandboxSnapshotError(
'SANDBOX_SNAPSHOT_MISSING_ARTIFACT_BLOB',
`Missing artifact source blob '${sourceKey}'`,
)
loaded.set(sourceKey, new Uint8Array(await source.arrayBuffer()))
}
const output = []
for (const record of records) {
const sourceKey = resolveBlobKey(record)
let bytes = getRequiredBlob(loaded, sourceKey)
bytes = await redactBytes(bytes, resolvedSecrets)
const key = `sandbox-artifacts/sha256/${await hash(bytes)}`
if (!destinationKeys.has(key)) {
if (!(await bundle.blobs.head(key))) await bundle.blobs.put(key, bytes)
destinationKeys.set(key, key)
}
output.push({
artifactId: record.artifactId,
name: record.name,
mimeType: record.mimeType,
size: bytes.byteLength,
blobKey: key,
createdAt: record.createdAt,
})
}
output.sort(
(a, b) =>
a.createdAt - b.createdAt || comparePath(a.artifactId, b.artifactId),
)
return Object.freeze(output.map((artifact) => Object.freeze(artifact)))
}