UNPKG

@openai/agents-core

Version:

The OpenAI Agents SDK is a lightweight yet powerful framework for building multi-agent workflows.

747 lines • 29.7 kB
import { isDir, isGitRepo, isMount, } from "./entries/index.mjs"; import { normalizePathGrant, } from "./pathGrants.mjs"; import { normalizePermissions } from "./permissions.mjs"; import { normalizeEntryGroup, normalizeGroup, normalizeUser, } from "./users.mjs"; import { DEFAULT_REMOTE_MOUNT_COMMAND_ALLOWLIST } from "./shared/remoteMountCommandAllowlist.mjs"; import { hasBackslashPathSeparator, hasParentPathSegment, normalizePosixPath, relativePosixPathWithinRoot, } from "./shared/posixPath.mjs"; import { isRecord } from "./shared/typeGuards.mjs"; import { SandboxGitSubpathError } from "./errors.mjs"; import { typedMountProviderConfig } from "./shared/typedMountConfig.mjs"; export class Environment { value; resolver; ephemeral; description; constructor(entry) { if (entry instanceof EnvValueReference) { return entry; } if (isSerializedEnvValueReference(entry)) { return parseEnvValueReference(entry); } if (entry instanceof Environment) { this.value = entry.value; this.resolver = entry.resolver; this.ephemeral = entry.ephemeral; this.description = entry.description; return; } if (typeof entry === 'string') { this.value = entry; this.resolver = undefined; this.ephemeral = false; this.description = undefined; return; } if (typeof entry === 'function') { this.value = ''; this.resolver = entry; this.ephemeral = false; this.description = undefined; return; } this.value = entry.value; this.resolver = entry.resolve; this.ephemeral = entry.ephemeral ?? false; this.description = entry.description; } async resolve() { const value = this.resolver ? await this.resolver() : this.value; if (typeof value !== 'string') { throw new Error('Environment resolvers must return a string.'); } return value; } init() { return { ...this.normalized(), ...(this.resolver ? { resolve: this.resolver } : {}), }; } normalized() { return { value: this.value, ...(this.ephemeral ? { ephemeral: true } : {}), ...(this.description ? { description: this.description } : {}), }; } } export class Manifest { version; root; entries; environment; users; groups; extraPathGrants; remoteMountCommandAllowlist; constructor(init = {}) { if (MOUNT_CREDENTIAL_EXPOSURE_POLICY_KEYS.some((key) => key in init)) { throw new TypeError('In-container mount credential exposure must be configured on a trusted Manifest instance, not in a manifest init object.'); } rejectKnownSnakeCaseKeys(init, ['extra_path_grants', 'remote_mount_command_allowlist'], 'sandbox manifest config'); this.version = init.version ?? 1; this.root = normalizeRoot(init.root ?? '/workspace'); this.entries = normalizeEntries(init.entries ?? {}); this.environment = Object.fromEntries(Object.entries(init.environment ?? {}).map(([key, value]) => [ key, new Environment(value), ])); this.users = (init.users ?? []).map((user) => normalizeUser(user)); this.groups = (init.groups ?? []).map((group) => normalizeGroup(group)); this.extraPathGrants = (init.extraPathGrants ?? []).map((grant) => normalizePathGrant(grant)); this.remoteMountCommandAllowlist = [ ...(init.remoteMountCommandAllowlist ?? DEFAULT_REMOTE_MOUNT_COMMAND_ALLOWLIST), ]; } /** * Returns a trusted manifest that acknowledges exposure of mount-scoped * credentials for exact in-container mount paths. * * This application-side policy is runtime-only. It is not accepted from * manifest init objects and is never serialized into sandbox session state. */ withInContainerMountCredentialExposureAcknowledged(...mountPaths) { return withInContainerMountCredentialExposureAcknowledgement(this, 'mount_scoped', mountPaths); } /** * Returns a trusted manifest that acknowledges exposure of broad credential * authority for exact in-container mount paths. * * Broad authority includes ambient credentials, workload or managed * identity, and external credential files. This application-side policy is * runtime-only. It is not accepted from manifest init objects and is never * serialized into sandbox session state. */ withInContainerMountBroadCredentialExposureAcknowledged(...mountPaths) { return withInContainerMountCredentialExposureAcknowledgement(this, 'broad', mountPaths); } validatedEntries() { for (const _entry of this.iterEntries()) { continue; } return { ...this.entries }; } *iterEntries() { const queue = Object.entries(this.entries) .sort(([left], [right]) => left.localeCompare(right)) .map(([path, entry]) => { const normalizedPath = normalizeRelativePath(path); return { logicalPath: normalizedPath, absolutePath: joinAbsolutePath(this.root, normalizedPath), entry, depth: 1, }; }); while (queue.length > 0) { const current = queue.shift(); yield current; if (!isDir(current.entry) || !current.entry.children) { continue; } const children = Object.entries(current.entry.children) .sort(([left], [right]) => left.localeCompare(right)) .map(([childPath, childEntry]) => { const normalizedChildPath = normalizeRelativePath(childPath); const logicalPath = current.logicalPath ? `${current.logicalPath}/${normalizedChildPath}` : normalizedChildPath; return { logicalPath, absolutePath: joinAbsolutePath(this.root, logicalPath), entry: childEntry, depth: current.depth + 1, }; }); queue.unshift(...children); } } ephemeralEntryPaths() { const paths = new Set(); for (const { logicalPath, entry } of this.iterEntries()) { if (entry.ephemeral) { paths.add(logicalPath); } } return paths; } /** * Returns mount targets in most-specific-first order for path resolution. */ mountTargets() { return collectMountTargets(this).sort(compareMountTargetsForResolution); } /** * Returns mount targets in parent-first order for filesystem materializers. */ mountTargetsForMaterialization() { return collectMountTargets(this).sort(compareMountTargetsForMaterialization); } ephemeralMountTargets() { return this.mountTargets().filter(({ entry }) => entry.ephemeral); } ephemeralPersistencePaths() { const paths = this.ephemeralEntryPaths(); // Ephemeral mounts are keyed by their effective mount path because mountPath can // differ from the manifest entry's logical path. for (const { mountPath } of this.ephemeralMountTargets()) { const relativePath = relativePathWithinRoot(this.root, mountPath); if (relativePath !== null) { paths.add(relativePath); } } return paths; } async resolveEnvironment(overrides = {}) { return { ...Object.fromEntries(await Promise.all(Object.entries(this.environment).map(async ([key, value]) => [ key, await value.resolve(), ]))), ...overrides, }; } describe(depth = 1) { return renderManifestDescription(this, { depth }).text; } } const envValueReferenceRegistry = new Map(); /** * A JSON-serializable reference to a runtime sandbox environment value. * * Subclasses must serialize only non-secret lookup metadata. Register each * subclass in every process that reconstructs manifests before parsing JSON. */ export class EnvValueReference extends Environment { static type = ''; constructor(options = {}) { super({ value: '', ...(options.ephemeral ? { ephemeral: true } : {}), ...(options.description ? { description: options.description } : {}), }); } get type() { return this.constructor.type; } init() { return parseEnvValueReference(serializeEnvValueReference(this)); } normalized() { throw new TypeError('EnvValueReference cannot be normalized to an EnvValue. Use serializeEnvValueReference() instead.'); } toJSON() { return serializeEnvValueReference(this); } } /** * Registers one environment reference subtype for JSON reconstruction. * * The parser receives persisted data and must validate it before constructing * a reference, including allowlisting any secret-store lookup keys. * * Returns an unregister callback for test isolation and controlled module * lifecycle cleanup. Conflicting registrations fail instead of replacing the * active parser. */ export function registerEnvValueReference(referenceClass, parse) { if (typeof referenceClass !== 'function') { throw new TypeError('EnvValueReference registration requires a class.'); } if (!Object.prototype.hasOwnProperty.call(referenceClass, 'type')) { throw new TypeError(`${envValueReferenceClassName(referenceClass)} must declare its own static type.`); } const type = referenceClass.type; if (typeof type !== 'string' || type.trim() === '') { throw new TypeError('EnvValueReference type must be a non-empty string.'); } const existing = envValueReferenceRegistry.get(type); if (existing) { throw new TypeError(`Environment value reference type "${type}" is already registered by ${envValueReferenceClassName(existing.referenceClass)}.`); } const registration = { referenceClass, parse, }; envValueReferenceRegistry.set(type, registration); return () => { if (envValueReferenceRegistry.get(type) === registration) { envValueReferenceRegistry.delete(type); } }; } export function serializeEnvValueReference(reference) { const registration = envValueReferenceRegistry.get(reference.type); if (!registration || reference.constructor !== registration.referenceClass) { throw new TypeError(`${reference.constructor.name || 'EnvValueReference subclass'} must be registered with its own static type before serialization.`); } const serialized = reference.serialize(); if (!isRecord(serialized)) { throw new TypeError(`${reference.constructor.name || 'EnvValueReference subclass'}.serialize() must return a record.`); } if ('value' in serialized) { throw new TypeError(`${reference.constructor.name || 'EnvValueReference subclass'}.serialize() must not return the reserved "value" field.`); } return { ...serialized, type: reference.type, }; } export function isEnvValueReference(value) { return value instanceof EnvValueReference; } export function isSerializedEnvValueReference(value) { return (isRecord(value) && typeof value.type === 'string' && !('value' in value)); } function parseEnvValueReference(serialized) { const registration = envValueReferenceRegistry.get(serialized.type); if (!registration) { const knownTypes = [...envValueReferenceRegistry.keys()].sort().join(', '); throw new TypeError(`Unknown environment value reference type "${serialized.type}". Registered types: ${knownTypes || '<none>'}.`); } const { type: _type, ...payload } = serialized; const reference = registration.parse(payload); if (!(reference instanceof EnvValueReference) || reference.constructor !== registration.referenceClass || reference.type !== serialized.type) { throw new TypeError(`Parser for environment value reference type "${serialized.type}" must return an instance of ${envValueReferenceClassName(registration.referenceClass)}.`); } return reference; } function envValueReferenceClassName(referenceClass) { const name = referenceClass.name; return typeof name === 'string' && name ? name : 'EnvValueReference subclass'; } export const MOUNT_CREDENTIAL_EXPOSURE_POLICY_KEYS = [ 'in_container_mount_credential_exposure_allowed_paths', '_in_container_mount_credential_exposure_allowed_paths', 'inContainerMountCredentialExposureAllowedPaths', '_inContainerMountCredentialExposureAllowedPaths', 'in_container_mount_credential_exposure_acknowledged_paths', '_in_container_mount_credential_exposure_acknowledged_paths', 'inContainerMountCredentialExposureAcknowledgedPaths', '_inContainerMountCredentialExposureAcknowledgedPaths', 'in_container_mount_broad_credential_exposure_acknowledged_paths', '_in_container_mount_broad_credential_exposure_acknowledged_paths', 'inContainerMountBroadCredentialExposureAcknowledgedPaths', '_inContainerMountBroadCredentialExposureAcknowledgedPaths', ]; const inContainerMountCredentialExposurePolicy = new WeakMap(); export function manifestAcknowledgesInContainerMountCredentialExposure(manifest, mountPath, authority) { const acknowledged = inContainerMountCredentialExposurePolicy.get(manifest)?.[authority]; if (!acknowledged) { return false; } const normalized = normalizeRoot(mountPath); const relative = relativePathWithinRoot(manifest.root, normalized); return ((relative !== null && acknowledged.has(`relative:${relative}`)) || acknowledged.has(`absolute:${normalized}`)); } export function copyManifestMountCredentialExposurePolicy(target, ...sources) { const policy = { mount_scoped: new Set(), broad: new Set(), }; for (const source of sources) { const sourcePolicy = inContainerMountCredentialExposurePolicy.get(source); for (const authority of ['mount_scoped', 'broad']) { for (const path of sourcePolicy?.[authority] ?? []) { policy[authority].add(path); } } } if (policy.mount_scoped.size > 0 || policy.broad.size > 0) { inContainerMountCredentialExposurePolicy.set(target, policy); } } export function replaceManifestMountCredentialExposurePolicy(target, source) { const policy = inContainerMountCredentialExposurePolicy.get(source); if (!policy || (policy.mount_scoped.size === 0 && policy.broad.size === 0)) { inContainerMountCredentialExposurePolicy.delete(target); return; } inContainerMountCredentialExposurePolicy.set(target, { mount_scoped: new Set(policy.mount_scoped), broad: new Set(policy.broad), }); } export function cloneManifest(manifest) { const cloned = new Manifest({ version: manifest.version, root: manifest.root, entries: structuredClone(manifest.entries ?? {}), environment: Object.fromEntries(Object.entries(manifest.environment ?? {}).map(([key, value]) => [ key, value instanceof Environment ? value.init() : value, ])), users: structuredClone(manifest.users ?? []), groups: structuredClone(manifest.groups ?? []), extraPathGrants: structuredClone(manifest.extraPathGrants ?? []), remoteMountCommandAllowlist: structuredClone(manifest.remoteMountCommandAllowlist ?? DEFAULT_REMOTE_MOUNT_COMMAND_ALLOWLIST), }); if (manifest instanceof Manifest) { copyManifestMountCredentialExposurePolicy(cloned, manifest); } return cloned; } function normalizeManifestMountPolicyPath(root, path) { const trimmed = path.trim(); if (!trimmed.startsWith('/')) { const relative = normalizeRelativePath(trimmed); if (!relative) { throw new Error('Mount credential exposure path must identify a non-root path.'); } return `relative:${relative}`; } const normalized = normalizeRoot(trimmed); if (normalized === '/' || normalized === normalizeRoot(root)) { throw new Error('Mount credential exposure path must identify a non-root path.'); } const relative = relativePathWithinRoot(root, normalized); return relative === null ? `absolute:${normalized}` : `relative:${relative}`; } function withInContainerMountCredentialExposureAcknowledgement(manifest, authority, mountPaths) { if (mountPaths.length === 0) { throw new TypeError('At least one in-container mount path is required.'); } const trusted = cloneManifest(manifest); const current = inContainerMountCredentialExposurePolicy.get(manifest); const policy = { mount_scoped: new Set(current?.mount_scoped ?? []), broad: new Set(current?.broad ?? []), }; for (const mountPath of mountPaths) { policy[authority].add(normalizeManifestMountPolicyPath(manifest.root, mountPath)); } inContainerMountCredentialExposurePolicy.set(trusted, policy); return trusted; } export function normalizeRelativePath(path) { const trimmed = path.trim(); if (!trimmed || trimmed === '.') { return ''; } if (hasBackslashPathSeparator(path)) { throw new Error(`Manifest path "${path}" must use "/" separators.`); } if (trimmed.startsWith('/')) { throw new Error(`Manifest path "${path}" must be relative.`); } if (hasParentPathSegment(path)) { throw new Error(`Manifest path "${path}" must not contain parent segments.`); } const normalized = normalizePosixPath(path); return normalized === '.' ? '' : normalized; } function normalizeGitRepoSubpath(repo, subpath) { const trimmed = subpath.trim(); let reason; if (subpath === '' || normalizePosixPath(trimmed) === '.') { return ''; } if (!trimmed) { reason = 'empty'; } else if (hasBackslashPathSeparator(subpath) || /^[A-Za-z]:($|\/)/.test(trimmed)) { reason = 'windows_path'; } else if (trimmed.startsWith('/')) { reason = 'absolute'; } else if (hasParentPathSegment(subpath)) { reason = 'parent_traversal'; } if (reason) { throw new SandboxGitSubpathError(`git_repo subpath must be a relative path inside the repository: ${subpath}`, { repo, subpath, reason }); } return normalizePosixPath(trimmed); } export function renderManifestDescription(manifest, options = {}) { const maxDepth = options.depth ?? null; const maxLines = options.maxLines ?? 120; const maxChars = options.maxChars ?? 12_000; const lines = [manifest.root]; let renderedChars = manifest.root.length; const entries = [...manifest.iterEntries()]; let renderedPaths = 0; for (const { logicalPath, absolutePath, entry, depth } of entries) { if (maxDepth !== null && depth > maxDepth) { continue; } if (lines.length >= maxLines) { break; } const indent = depth <= 1 ? '' : ' '.repeat(depth - 1); const name = logicalPath.split('/').pop() ?? logicalPath; const label = entry.type === 'dir' ? `${name}/` : name; const description = entry.description ? ` - ${entry.description}` : ''; const line = `${indent}${label} # ${absolutePath}${description}`; if (!lineFits(lines, line, maxChars, renderedChars)) { break; } lines.push(line); renderedChars += lineCharCost(lines, line); renderedPaths += 1; } const totalPaths = entries.filter(({ depth }) => maxDepth === null || depth <= maxDepth).length; if (renderedPaths < totalPaths) { renderedChars = appendLineIfFits(lines, `... (truncated ${totalPaths - renderedPaths} additional paths)`, maxChars, renderedChars); appendLineIfFits(lines, `Hint: increase depth, maxLines, or maxChars to see more of the manifest layout.`, maxChars, renderedChars); } return { text: lines.join('\n'), renderedPaths, totalPaths, }; } function appendLineIfFits(lines, line, maxChars, currentChars) { if (!lineFits(lines, line, maxChars, currentChars)) { return currentChars; } lines.push(line); return currentChars + lineCharCost(lines, line); } function lineFits(lines, line, maxChars, currentChars) { return currentChars + lineCharCost(lines, line) <= maxChars; } function lineCharCost(lines, line) { return (lines.length > 0 ? 1 : 0) + line.length; } function normalizeRoot(root) { const trimmed = root.trim(); if (hasBackslashPathSeparator(root)) { throw new Error(`Manifest root "${root}" must use "/" separators.`); } if (!trimmed.startsWith('/')) { throw new Error(`Manifest root "${root}" must be absolute.`); } if (hasParentPathSegment(root)) { throw new Error(`Manifest root "${root}" must not contain parent segments.`); } return normalizePosixPath(root); } function joinAbsolutePath(root, relativePath) { const normalizedRoot = normalizeRoot(root); const normalizedRelativePath = normalizeRelativePath(relativePath); if (!normalizedRelativePath) { return normalizedRoot; } if (normalizedRoot === '/') { return `/${normalizedRelativePath}`; } return `${normalizedRoot}/${normalizedRelativePath}`; } function resolveMountPathForRoot(root, absolutePath, mountPath) { if (mountPath === undefined) { return absolutePath; } if (mountPath.startsWith('/')) { return normalizeRoot(mountPath); } return joinAbsolutePath(root, mountPath); } function relativePathWithinRoot(root, path) { const normalizedRoot = normalizeRoot(root); const normalizedPath = normalizeRoot(path); return relativePosixPathWithinRoot(normalizedRoot, normalizedPath); } function collectMountTargets(manifest) { const targets = []; for (const iterEntry of manifest.iterEntries()) { if (!isMount(iterEntry.entry)) { continue; } targets.push({ ...iterEntry, entry: iterEntry.entry, mountPath: resolveMountPathForRoot(manifest.root, iterEntry.absolutePath, iterEntry.entry.mountPath), }); } return targets; } function compareMountTargetsForResolution(left, right) { // Path resolution needs the most specific nested mount before its parent. const depthDelta = mountPathDepth(right.mountPath) - mountPathDepth(left.mountPath); return depthDelta || left.mountPath.localeCompare(right.mountPath); } function compareMountTargetsForMaterialization(left, right) { // Mount creation needs parents first so broader targets do not replace children. const depthDelta = mountPathDepth(left.mountPath) - mountPathDepth(right.mountPath); return depthDelta || left.mountPath.localeCompare(right.mountPath); } function mountPathDepth(path) { return path.split('/').filter(Boolean).length; } function normalizeEntries(entries, parentPath = '', seenPaths = new Set()) { const normalizedEntries = {}; for (const [path, entry] of Object.entries(entries)) { const normalizedPath = normalizeRelativePath(path); const logicalPath = parentPath ? `${parentPath}/${normalizedPath}` : normalizedPath; if (seenPaths.has(logicalPath)) { throw new Error(`Manifest path "${path}" duplicates normalized path "${logicalPath}".`); } seenPaths.add(logicalPath); if (Object.prototype.hasOwnProperty.call(normalizedEntries, normalizedPath)) { throw new Error(`Manifest path "${path}" duplicates normalized path "${normalizedPath}".`); } normalizedEntries[normalizedPath] = normalizeEntry(entry, logicalPath, seenPaths); } return normalizedEntries; } function normalizeEntry(entry, logicalPath, seenPaths) { const normalized = structuredClone(entry); if (normalized.permissions !== undefined) { normalized.permissions = normalizePermissions(normalized.permissions); } if (normalized.group !== undefined) { normalized.group = normalizeEntryGroup(normalized.group); } if (isDir(normalized) && normalized.children) { normalized.children = normalizeEntries(normalized.children, logicalPath, seenPaths); } if (isGitRepo(normalized)) { const repo = normalized.repo; if (!repo || typeof repo !== 'string' || !repo.trim()) { throw new Error('git_repo entries must include a non-empty repo.'); } normalized.repo = repo.trim(); // URL-like values pass through to host Git without an HTTPS prefix. if (normalized.repo.startsWith('-') && normalized.repo.includes('://')) { throw new Error('git_repo repository URL must not start with "-".'); } normalized.host = normalizeGitHost(normalized.host); if (normalized.subpath !== undefined) { normalized.subpath = normalizeGitRepoSubpath(normalized.repo, normalized.subpath); } } if (isMount(normalized)) { normalized.ephemeral = normalized.ephemeral ?? true; rejectKnownSnakeCaseMountKeys(normalized); if (normalized.mountPath !== undefined) { normalized.mountPath = normalizeMountPath(normalized.mountPath); } normalized.readOnly = normalized.readOnly ?? true; if (normalized.mountStrategy !== undefined) { normalized.mountStrategy = normalizeMountStrategy(normalized.mountStrategy); } normalizeTypedMountProvider(normalized); } return normalized; } function normalizeGitHost(host) { const normalizedHost = (host ?? 'github.com').trim(); if (!normalizedHost) { throw new Error('git_repo host must be non-empty.'); } return normalizedHost; } function normalizeMountPath(path) { const trimmed = path.trim(); if (!trimmed) { throw new Error('Mount path must not be empty.'); } if (hasBackslashPathSeparator(path)) { throw new Error('Mount path must use "/" separators.'); } if (!trimmed.startsWith('/')) { return normalizeRelativePath(trimmed); } return normalizeRoot(trimmed); } function normalizeMountStrategy(strategy) { if (!isRecord(strategy)) { throw new Error('Mount strategy must be an object.'); } if (typeof strategy.type !== 'string' || !strategy.type.trim()) { throw new Error('Mount strategy must include a non-empty type.'); } const normalized = { ...strategy, type: strategy.type.trim(), }; rejectKnownSnakeCaseKeys(normalized, ['driver_options'], 'mount strategy'); if (isRecord(normalized.pattern)) { rejectKnownSnakeCaseKeys(normalized.pattern, [ 'remote_name', 'extra_args', 'config_file_path', 'nfs_addr', 'nfs_mount_options', 'allow_other', 'log_type', 'log_level', 'cache_type', 'cache_path', 'cache_size_mb', 'block_cache_block_size_mb', 'block_cache_disk_timeout_sec', 'file_cache_timeout_sec', 'file_cache_max_size_mb', 'attr_cache_timeout_sec', 'entry_cache_timeout_sec', 'negative_entry_cache_timeout_sec', ], 'mount strategy pattern'); if (isRecord(normalized.pattern.options)) { rejectKnownSnakeCaseKeys(normalized.pattern.options, ['endpoint_url', 'mount_target_ip', 'access_point', 'extra_options'], 'mount strategy pattern options'); } } return normalized; } function normalizeTypedMountProvider(entry) { if (!isMount(entry) || entry.type === 'mount') { return; } const { provider, config } = typedMountProviderConfig(entry); entry.provider = entry.provider ?? provider; entry.config = { ...(entry.config ?? {}), ...config, }; } function rejectKnownSnakeCaseMountKeys(entry) { rejectKnownSnakeCaseKeys(entry, [ 'mount_path', 'read_only', 'mount_strategy', 'client_id', 'client_secret', 'access_id', 'access_key_id', 'access_token', 'secret_access_key', 'session_token', 'service_account_credentials', 'service_account_file', 'endpoint_url', 'account_id', 'account_name', 'account_key', 'custom_domain', 'file_system_id', 'mount_target_ip', 'access_point', 'extra_options', 's3_provider', 'identity_client_id', 'box_config_file', 'config_credentials', 'box_sub_type', 'root_folder_id', 'owned_by', ], 'sandbox manifest entries'); } function rejectKnownSnakeCaseKeys(value, keys, source) { const found = keys.find((key) => key in value); if (!found) { return; } throw new Error(`Use camelCase config keys in ${source}; snake_case key "${found}" is not supported.`); } //# sourceMappingURL=manifest.mjs.map