@b4run/workspace

Use this whenCopy link to section: Use this when

Use this package to implement or wrap filesystem, command, and execution-sandbox backends for a B4.run application. The root has the portable backend and sandbox contracts, middleware composition, and logging wrappers. Import /node for local filesystem/shell implementations and immutable source capture utilities.

Install and importCopy link to section: Install and import

bash
npm install @b4run/workspace
ts
import { compose, withExecLogging, type SandboxProvider } from "@b4run/workspace"
import { localExec, localFilesystem } from "@b4run/workspace/node"

Compatibility and audienceCopy link to section: Compatibility and audience

SurfaceRuntimePurityAudienceStability
@b4run/workspaceedge-safedependency-freeapplicationsupported
@b4run/workspace/nodenode-onlynot-claimedapplicationsupported

The root's emitted subpath graph has no runtime package or Node built-in dependencies. Its LocalExecOptions and LocalFilesystemOptions exports are types only. The /node implementations use Node child-process, filesystem, path, crypto, and utility APIs.

Public exportsCopy link to section: Public exports

@b4run/workspaceCopy link to section: @b4run/workspace

ExportResponsibility
composeCompose backend middleware right-to-left.
inspectWorkspaceInspect a bounded text inventory through author filesystem APIs, a sandbox handle, or a read-only workspace reader.
InspectWorkspaceOptionsSet byte and entry limits, cancellation, omitted directories, and required root links.
WorkspaceInspectionReturn relative-path text files, validated links, byte count, and entry count.
WorkspaceInspectionErrorA named inspection refusal: invalid_options, root_missing, refused, changed.
isWorkspaceInspectionErrorRecognize an inspection refusal by name and code, including one thrown by a second copy of the package.
WorkspaceInspectionErrorCodeName why an inspection refused.
WorkspaceInspectionErrorDetailCarry the refused root and whether it was absent or not a directory.
WorkspaceReadLimitErrorA bounded read found more bytes than its limit; thrown by B4.run's backends.
isWorkspaceReadLimitErrorRecognize a read-limit refusal by name, including one thrown by a second copy of the package.
isCanonicalWorkspaceRootCheck an inspection root: relative leaf names with no empty, ., .., backslash or control-character segment.
WorkspaceDefinitionDeclare initial source, immutable environment links, and an optional Git baseline.
CapturedWorkspaceDefinitionHold a verified source bundle and canonical creation options.
StagedWorkspaceReferenceName an uploaded source by digest, with the links and baseline a thread is created with (sandbox.stagedWorkspaces).
WorkspaceResolverDecide one thread's initial workspace from its id and client metadata, once, at first admission.
WorkspaceResolverInputWhat a resolver is told: the thread id, its stored client metadata with the reserved key stripped, the admitting run's abort signal, and staged, the verified workspace the thread was created with under sandbox.stagedWorkspaces.
ThreadSandboxResolverDecide one thread's workspace, image, policy and permissions from its id and client metadata, once, at first admission.
ThreadSandboxDeclare one thread's workspace, an optional provider-interpreted image, optional policy overrides, and optional permission lists.
ThreadSandboxPolicyName the network, environment and resource fields a thread may set for itself.
ThreadSandboxPermissionsDescribe one thread's own allow and deny lists.
ThreadSandboxRecordHold the image reference, policy and permission lists B4.run recorded for a thread at its first admission.
WorkspaceEnvironmentBind an immutable environment identity to provider, account, and scope.
WorkspaceCreateIntentPersist an operation identity and complete creation digest before provider mutation.
WorkspaceReferenceIdentify provider resources without credentials or executable configuration.
WorkspaceProvenanceRecord trusted source, environment, baseline, and retention information.
ReadyWorkspacePair a durable provider reference with verified provenance.
WorkspaceSessionReferenceName one compute incarnation of a durable workspace.
WorkspaceSessionExpose filesystem and command backends for one admitted incarnation.
CreationStatusDistinguish absent, pending, ready, failed, and unknown creation outcomes.
WorkspaceDeletionTargetClean up a persisted creation operation, including one with no ready reference.
ManagedWorkspaceProviderOwn physical workspace creation, inspection, reconnection, release, destruction, optional reads, and optional per-thread images.
WorkspaceLifecycleErrorCodeClassify lost, expired, conflicting, unsupported, retryable, and uncertain operations.
WorkspaceLifecycleErrorReport a typed workspace lifecycle failure.
SourceBundleDescribe immutable canonical source content and its digest.
SourceFileInputSupply exact file bytes and executable metadata.
WorkspaceSourceDefinitionDeclare the source directory, exact inventory, exclusions, and additional files.
LocalExecOptionsConfigure the local command backend without importing its runtime.
LocalFilesystemOptionsConfigure the local filesystem backend without importing its runtime.
SandboxConfigConfigure provider selection and lifecycle policy, including workspaceRead: "http", which serves a thread's workspace over the Agent Protocol, and stagedWorkspaces, which accepts one at thread creation.
SandboxHandleDescribe one acquired sandbox.
SandboxPolicyDescribe per-thread network, environment, resource, and security policy.
SandboxProviderDefine sandbox acquisition, release, destruction, and optional workspace reads.
SandboxSecurityPolicyDescribe provider-agnostic hardening intent.
OpenWorkspaceReaderInputName the thread to read, its cancellation signal, and the identity the reader runs as.
OpenManagedWorkspaceReaderInputName the published managed workspace to read, its cancellation signal, and the identity the reader runs as.
SandboxWorkspaceReaderRead one thread's workspace storage, with no write and no command surface.
ReadOnlyFilesystemBackendDefine the read half of a filesystem, with inspection metadata required.
WorkspaceReadSourceDescribe the minimum a workspace inspector consumes.
withWorkspaceReaderOpen a thread's workspace reader, hand it to an operation, and always close it.
scopedWorkspaceReaderRun an operation over any opened workspace reader and always close it.
BackendContextCarry cancellation and the active workspace root.
ExecBackendDefine shell-command execution.
ExecMiddlewareWrap an execution backend.
FilesystemBackendDefine text, binary, directory, canonicalization, and optional file operations.
WalkedEntryDescribe one entry of a batched tree walk.
FilesystemMiddlewareWrap a filesystem backend.
LoggingOptionsConfigure backend log delivery.
withExecLoggingLog command calls around an execution backend.
withFilesystemLoggingLog public filesystem calls while preserving optional capabilities.

@b4run/workspace/nodeCopy link to section: @b4run/workspace/node

The /node entry owns the exports below. Its types are also exported from the dependency-free root. Its runtime functions are only in /node.

ExportResponsibility
LocalExecOptionsConfigure timeout and command allowlisting.
localExecCreate the Node shell-command backend.
LocalFilesystemOptionsConfigure the default file-size limit.
localFilesystemCreate the Node filesystem backend.
SourceBundleDescribe immutable canonical source content and its digest.
SourceFileInputSupply exact file bytes and executable metadata.
WorkspaceSourceDefinitionDeclare source files to capture from an application tree.
createSourceBundleConstruct a canonical bundle from explicit binary file inputs.
verifySourceBundleValidate bundle shape, encoding, paths, limits, and digest.
readSourceFileRead initial bytes into independently owned memory.
captureWorkspaceSourceCapture the declared application source into a verified bundle.
captureWorkspaceDefinitionCapture declared source and validate creation options.
verifyCapturedWorkspaceDefinitionValidate and freeze a captured definition.
verifyStagedWorkspaceReferenceValidate and normalize a staged workspace reference: a bare digest, checked and sorted links, and only a "git" baseline.
stagedWorkspaceDefinitionBuild the captured definition a staged reference names over the held source, verifying it once and refusing a source that is not the one named.
stagedWorkspaceFitsCheck a staged reference's links and baseline against a source's recorded file paths, without its bytes.
createWorkspaceIntentCreate a canonical immutable intent bound to operation and environment.
verifyWorkspaceIntentValidate the persisted intent and digest.
verifyReadyWorkspaceVerify provider readiness against its persisted intent.
verifyCreationStatusValidate every provider inspection outcome before acting on it.
verifyImageReferenceValidate an image reference a thread names; flag-like references are refused.
verifyThreadSandboxValidate what a thread-sandbox resolver returned, refusing unknown keys by name.
verifyThreadSandboxPolicyValidate and normalize a thread's policy overrides.
verifyThreadSandboxPermissionsValidate and normalize a thread's permission lists; empty patterns are refused.
verifyThreadSandboxRecordValidate and normalize a stored thread-sandbox record.
MAX_THREAD_SANDBOX_RECORD_BYTESBound a thread-sandbox record's stored size; a larger record is refused at first admission.
threadSandboxRecordBytesMeasure a verified thread-sandbox record as it is stored.

Key contractsCopy link to section: Key contracts

Bounded text inspectionCopy link to section: Bounded text inspection

inspectWorkspace(source, options?) accepts a WorkspaceFs or SandboxHandle. It returns { files, symlinks, totalBytes, entries }. File keys are relative paths. Metadata and binary reads are required. Unsupported entries, executable files, NUL bytes, invalid UTF-8, and exceeded limits fail inspection. UTF-8 BOMs remain part of the returned text.

Defaults are 10,000 entries, 2 MiB per file, and 16 MiB total. Override these with maxEntries, maxFileBytes, and maxTotalBytes. excludeRootDirectories omits only directory subtrees. A file or link at an excluded name fails. Every entry in expectedRootSymlinks must exist with its exact raw link target. Other symlinks are rejected. signal cancels inspection.

root (a sandbox handle or workspace reader only) starts the inspection at a relative directory: nothing outside it is read, keys are relative to it, and every segment must be a real directory, so a missing root or one that passes through a file or a symlink is a root_missing refusal naming it. Refusals are WorkspaceInspectionErrors with a code; a backend's own failure is rethrown as it was thrown.

ts
import { inspectWorkspace } from "@b4run/workspace"
 
const inspection = await inspectWorkspace(ctx.fs, {
  signal: ctx.signal,
  maxTotalBytes: 2 * 1024 * 1024,
  excludeRootDirectories: [".git"],
})

When a backend implements both walkTree and readBinaryFiles, inspection walks the tree in one call and reads the files in as few calls as the backend allows. The Docker and Kubernetes backends implement both, so inspecting a container workspace costs a few execs instead of one per entry. The checks and limits are the same either way. A batched inspection also refuses a file that grew between the walk and the read.

Inspection uses separate metadata and read operations, so it is not an atomic snapshot. Quiesce writers or revalidate before acting on an inventory. Expected links establish link identity, not the contents of their targets. Custom backends must honor byte limits during reads to bound allocation. The built-in local, Docker, and Kubernetes readers cap content before collecting it.

ts
export interface BackendContext {
  readonly signal: AbortSignal
  readonly workspaceRoot: string
}

Fields: @b4run/workspace#.:BackendContext

FieldTypeRequiredDescription
readonly signalAbortSignalyesAbort work when the parent run is cancelled.
readonly workspaceRootstringyesName the active route workspace's absolute root.
ts
export interface FilesystemBackend {
  lstat?(path: string, ctx: BackendContext): Promise<{ readonly kind: "file" | "directory" | "symlink" | "other"; readonly size: number; readonly executable: boolean; readonly target?: string }>
  readFile(
    path: string,
    ctx: BackendContext,
    opts?: { readonly maxBytes?: number },
  ): Promise<string>
  readBinaryFile?(
    path: string,
    ctx: BackendContext,
    opts?: { readonly maxBytes?: number },
  ): Promise<Uint8Array>
  writeFile(
    path: string,
    content: string,
    ctx: BackendContext,
  ): Promise<{ readonly bytesWritten: number }>
  listDir(path: string, ctx: BackendContext): Promise<readonly string[]>
  realPath(path: string, ctx: BackendContext): Promise<string>
  statFile?(
    path: string,
    ctx: BackendContext,
  ): Promise<{ readonly size: number; readonly mtimeMs: number }>
  removeFile?(path: string, ctx: BackendContext): Promise<void>
  touchFile?(path: string, ctx: BackendContext): Promise<void>
  mkdir?(path: string, ctx: BackendContext): Promise<void>
  walkTree?(
    path: string,
    ctx: BackendContext,
    opts: { readonly maxEntries: number; readonly prune?: readonly string[] },
  ): Promise<readonly WalkedEntry[]>
  readBinaryFiles?(
    requests: readonly { readonly path: string; readonly maxBytes: number }[],
    ctx: BackendContext,
  ): Promise<readonly Uint8Array[]>
}

walkTree returns every entry below path with the metadata lstat reports, without following symlinks. Names in prune are reported but not descended, and a walk over maxEntries throws instead of returning a partial tree. readBinaryFiles returns results in request order, with readBinaryFile's maxBytes meaning for each request. Both are optional.

ts
export interface WalkedEntry {
  readonly path: string
  readonly kind: "file" | "directory" | "symlink" | "other"
  readonly size: number
  readonly executable: boolean
  readonly target?: string
}

Fields: @b4run/workspace#.:WalkedEntry

FieldTypeRequiredDescription
readonly pathstringyesName the entry by its /-separated path relative to the walked directory.
readonly kind"file" | "directory" | "symlink" | "other"yesReport the entry's type without following a symlink.
readonly sizenumberyesReport the size in bytes, as lstat would.
readonly executablebooleanyesReport whether any execute bit is set.
readonly targetstringnoCarry a symlink's raw link text.
ts
export interface ExecBackend {
  runCommand(
    args: {
      readonly command: string
      readonly cwd?: string
      readonly env?: Readonly<Record<string, string>>
    },
    ctx: BackendContext,
  ): Promise<{
    readonly stdout: string
    readonly stderr: string
    readonly exitCode: number
  }>
}
ts
export declare function compose<T>(
  ...middlewares: ReadonlyArray<(next: T) => T>
): (base: T) => T

Behavior contract workspace.compose.order

Backend middleware composes right-to-left, with the first listed middleware outermost.

Sandbox contracts

ts
export interface SandboxPolicy {
  readonly network:
    | { readonly mode: "allow"; readonly denylist?: readonly string[] }
    | { readonly mode: "deny"; readonly allowlist?: readonly string[] }
  readonly env?: Readonly<Record<string, string>>
  readonly resources?: {
    readonly memoryMb?: number
    readonly cpus?: number
    readonly timeoutMs?: number
    readonly diskGb?: number
  }
  readonly security?: SandboxSecurityPolicy
}

Fields: @b4run/workspace#.:SandboxPolicy

FieldTypeRequiredDescription
readonly network| { readonly mode: "allow"; readonly denylist?: readonly string[] } | { readonly mode: "deny"; readonly allowlist?: readonly string[] }yesSet the provider's network policy intent. The reference Docker and Kubernetes providers do not enforce an allow-mode denylist.
readonly envReadonly<Record<string, string>>noSupply the sandbox environment explicitly.
readonly resources{ readonly memoryMb?: number; readonly cpus?: number; readonly timeoutMs?: number; readonly diskGb?: number }noRequest provider resource limits.
readonly securitySandboxSecurityPolicynoOverride provider hardening intent.
ts
export interface SandboxSecurityPolicy {
  readonly dropAllCapabilities?: boolean
  readonly noNewPrivileges?: boolean
  readonly readOnlyRootFilesystem?: boolean
  readonly runAsNonRoot?: boolean | { readonly uid: number; readonly gid: number }
  readonly pidsLimit?: number
}

Fields: @b4run/workspace#.:SandboxSecurityPolicy

FieldTypeRequiredDescription
readonly dropAllCapabilitiesbooleannoRequest dropping every Linux capability.
readonly noNewPrivilegesbooleannoRequest blocking setuid/setgid escalation.
readonly readOnlyRootFilesystembooleannoRequest an immutable root filesystem.
readonly runAsNonRootboolean | { readonly uid: number; readonly gid: number }noRequest non-root execution or an explicit identity.
readonly pidsLimitnumbernoRequest a process-count limit.
ts
export interface SandboxHandle {
  readonly threadId: string
  readonly filesystem: FilesystemBackend
  readonly exec: ExecBackend
  readonly workspaceRoot: string
}

Fields: @b4run/workspace#.:SandboxHandle

FieldTypeRequiredDescription
readonly threadIdstringyesIdentify the owning conversation thread.
readonly filesystemFilesystemBackendyesRoute file operations into the sandbox.
readonly execExecBackendyesRoute command execution into the sandbox.
readonly workspaceRootstringyesName the absolute root inside the sandbox.
ts
export interface SandboxProvider {
  readonly workspaces?: ManagedWorkspaceProvider
  readonly name: string
  acquire(input: {
    readonly threadId: string
    readonly policy: SandboxPolicy
    readonly signal: AbortSignal
  }): Promise<SandboxHandle>
  release(threadId: string): Promise<void>
  destroy(threadId: string): Promise<void>
  preflight?(): Promise<{
    readonly ok: boolean
    readonly detail?: string
    readonly warnings?: readonly string[]
  }>
  openWorkspaceReader?(input: OpenWorkspaceReaderInput): Promise<SandboxWorkspaceReader>
}

Fields: @b4run/workspace#.:SandboxProvider

FieldTypeRequiredDescription
readonly workspacesManagedWorkspaceProvidernoSupply provider-owned durable workspace operations.
readonly namestringyesIdentify the provider.

openWorkspaceReader is an optional capability. Presence is the probe: a provider whose storage cannot be attached a second time omits it rather than implementing it unreliably. An implementation must leave the thread's sandbox undisturbed, make writes impossible rather than merely undocumented, and reject rather than return an empty view when the thread has no workspace storage.

It is not an authorization boundary. The caller already holds the provider. Naming a thread id is not a claim of ownership.

ts
export interface SandboxWorkspaceReader extends WorkspaceReadSource {
  readonly threadId: string
  readonly filesystem: ReadOnlyFilesystemBackend
  close(): Promise<void>
}

Fields: @b4run/workspace#.:SandboxWorkspaceReader

FieldTypeRequiredDescription
readonly threadIdstringyesIdentify the thread whose workspace is being read.
readonly filesystemReadOnlyFilesystemBackendyesRead files, directories, and leaf metadata.

close() releases reader-side resources and is idempotent. It never touches the thread's sandbox. workspaceRoot comes from WorkspaceReadSource and names the absolute root inside the sandbox.

ts
export interface OpenWorkspaceReaderInput {
  readonly threadId: string
  readonly signal: AbortSignal
  readonly runAsNonRoot?: SandboxSecurityPolicy["runAsNonRoot"]
}

Fields: @b4run/workspace#.:OpenWorkspaceReaderInput

FieldTypeRequiredDescription
readonly threadIdstringyesName the thread whose workspace storage to open.
readonly signalAbortSignalyesCancel the open.
readonly runAsNonRootSandboxSecurityPolicy["runAsNonRoot"]noMatch the identity that owns the produced files; defaults to the provider's secure default.
ts
export declare function withWorkspaceReader<T>(
  provider: SandboxProvider,
  input: OpenWorkspaceReaderInput,
  operation: (reader: SandboxWorkspaceReader) => Promise<T>,
): Promise<T>

withWorkspaceReader closes the reader on both paths. A close failure surfaces on its own, and alongside a body failure as an AggregateError rather than replacing it. A provider without the capability throws instead of returning nothing.

ts
export declare function scopedWorkspaceReader<T>(
  open: () => Promise<SandboxWorkspaceReader>,
  operation: (reader: SandboxWorkspaceReader) => Promise<T>,
): Promise<T>

scopedWorkspaceReader is the lifetime rule behind withWorkspaceReader for any way of opening a reader: open lazily, run, always close, and never let a close failure hide a body failure. withWorkspaceReader and @b4run/cli/workspace's withManagedWorkspaceReader both delegate to it.

Managed workspace reads

A workspace created through sandbox.workspace is a managed workspace. Its storage is named by the creating installation and operation, not by the thread id, so SandboxProvider.openWorkspaceReader cannot address it. ManagedWorkspaceProvider.openWorkspaceReader, optional under the same contract, is addressed by the published ReadyWorkspace instead.

ts
export interface OpenManagedWorkspaceReaderInput {
  readonly workspace: ReadyWorkspace
  readonly signal: AbortSignal
  readonly runAsNonRoot?: SandboxSecurityPolicy["runAsNonRoot"]
}

Fields: @b4run/workspace#.:OpenManagedWorkspaceReaderInput

FieldTypeRequiredDescription
readonly workspaceReadyWorkspaceyesName the published workspace whose storage to open; the provider verifies it against its own record.
readonly signalAbortSignalyesCancel the open.
readonly runAsNonRootSandboxSecurityPolicy["runAsNonRoot"]noMatch the identity that owns the produced files; defaults to the provider's secure default.

The Docker provider verifies the record, binding and volume exactly as reconnect does, then binds the managed volume's backing directory read-only in a separate networkless container. It never lists, starts, stops or replaces a session container. A lost record or volume is a lost error, never a recreated volume. A co-located host process obtains the ReadyWorkspace through @b4run/sqlite-storage's openWorkspaceInstallationReader. @b4run/cli/workspace's withManagedWorkspaceReader joins the two by thread id.

ts
export interface SandboxConfig {
  readonly workspace?: WorkspaceDefinition | WorkspaceResolver
  readonly thread?: ThreadSandboxResolver
  readonly workspaceRead?: "http"
  readonly workspaceReadTimeoutMs?: number
  readonly stagedWorkspaces?:
    | boolean
    | {
        readonly maxUploadBytes?: number
        readonly retentionMs?: number
        readonly maxStagedBytes?: number
        readonly uploadTimeoutMs?: number
      }
  readonly provider: SandboxProvider
  readonly network?: SandboxPolicy["network"]
  readonly env?: SandboxPolicy["env"]
  readonly resources?: SandboxPolicy["resources"]
  readonly security?: SandboxSecurityPolicy
  readonly idleTimeoutMs?: number
}

Fields: @b4run/workspace#.:SandboxConfig

FieldTypeRequiredDescription
readonly workspaceWorkspaceDefinition | WorkspaceResolvernoDeclare the initial managed workspace: one definition for every thread, or a resolver that decides per thread at first admission.
readonly threadThreadSandboxResolvernoDecide each thread's workspace, image, policy and permissions once, at first admission; exclusive with workspace.
readonly workspaceRead"http"noServe POST /threads/:thread_id/workspace/inspect, a read-only inventory of a thread's managed workspace behind the thread-access policy; refused without a policy.
readonly workspaceReadTimeoutMsnumbernoBound one workspace read, open to close, before it is abandoned with 504 workspace_read_timeout; 1,000 to 1,800,000 ms, default 120,000.
readonly stagedWorkspaces| boolean | { readonly maxUploadBytes?: number; readonly retentionMs?: number; readonly maxStagedBytes?: number; readonly uploadTimeoutMs?: number }noAccept a thread's workspace at creation (PUT /workspace/sources/:digest, then workspace on POST /threads), handed to the resolver as thread.staged; needs a resolver and a thread-access policy.
readonly providerSandboxProvideryesSupply the sandbox provider.
readonly networkSandboxPolicy["network"]noSet the default network policy.
readonly envSandboxPolicy["env"]noSet the default explicit environment.
readonly resourcesSandboxPolicy["resources"]noSet default resource requests.
readonly securitySandboxSecurityPolicynoSet default hardening intent.
readonly idleTimeoutMsnumbernoSet the manager idle-reap window; default 600,000 ms.
ts
export interface LocalExecOptions {
  readonly timeout?: number
  readonly allowedCommands?: readonly RegExp[]
}
ts
export declare function localExec(opts?: LocalExecOptions): ExecBackend

Behavior contract workspace.exec.timeout

The local exec backend enforces its configured timeout.

ts
export interface LocalFilesystemOptions {
  readonly maxFileBytes?: number
}
ts
export declare function localFilesystem(opts?: LocalFilesystemOptions): FilesystemBackend

localFilesystem.realPath resolves an escaping symlink to its outside real path; Core owns any path-jail enforcement.

Immutable source bundlesCopy link to section: Immutable source bundles

ts
export interface SourceFileInput {
  readonly path: string
  readonly bytes: Uint8Array
  readonly executable: boolean
}
ts
export interface SourceBundle {
  readonly version: 1
  readonly digest: string
  readonly files: readonly {
    readonly path: string
    readonly base64: string
    readonly executable: boolean
  }[]
}
ts
export interface WorkspaceSourceDefinition {
  readonly directory: string
  readonly include: readonly string[]
  readonly excludeDirectories?: readonly string[]
  readonly files?: readonly (
    | { readonly path: string; readonly file: string }
    | { readonly path: string; readonly text: string; readonly executable?: boolean }
  )[]
}
ts
export declare function createSourceBundle(files: readonly SourceFileInput[]): SourceBundle
ts
export declare function verifySourceBundle(value: unknown): SourceBundle
ts
export declare function readSourceFile(bundle: SourceBundle, path: string): Uint8Array
ts
export declare function captureWorkspaceSource(
  appRoot: string,
  definition: WorkspaceSourceDefinition,
  options?: { readonly signal?: AbortSignal },
): Promise<SourceBundle>

createSourceBundle copies explicit bytes into sorted, frozen file entries and computes a versioned SHA256 identity covering paths, content, and executable bits. verifySourceBundle rejects malformed or noncanonical entries and digest mismatch; it returns a new frozen bundle. readSourceFile verifies the bundle, rejects a missing path, and returns an independently owned byte array.

captureWorkspaceSource resolves directory and referenced file paths relative to appRoot. directory: "." selects that root. include is an exact inventory of regular files relative to the source directory. Missing or extra files fail. excludeDirectories names existing directory subtrees to skip, without following symlinks. Additional files entries declare destination paths with either a file reference relative to appRoot, or inline text and an optional executable flag. Text preserves BOMs and rejects malformed Unicode. Referenced files preserve their executable flags. Duplicate destinations, conflicting ancestors, and inconsistent directory casing are rejected.

Bundles allow at most 10,000 files, 16 MiB per file, and 64 MiB total content. Capture also limits distinct inspected filesystem paths to 10,000, including the app root, ancestor directories, exclusions, and extra references. Paths use an ASCII portability subset, at most 1,024 bytes per path and 255 bytes per segment. Traversal, absolute paths, backslashes, device names, leading/trailing spaces, consecutive spaces, trailing dots, and case-insensitive collisions fail. Leading dotfiles such as .gitignore are supported.

Capture rejects symlinks and special files, checks size before allocating file buffers, and detects changes through file metadata and inventory rechecks. It reads a trusted application tree: these checks are not an atomic snapshot or a defense against a hostile host process swapping ancestor directories. Cancellation closes open handles and returns no completed bundle. These utilities do not create sandboxes or activate managed workspace recovery in runtime configuration.

Lifecycle, failures, and trustCopy link to section: Lifecycle, failures, and trust

Backend methods receive absolute paths and a cancellation signal from their caller. Core owns the path jail and canonical-root enforcement. localFilesystem canonicalizes existing ancestors, including escaping symlinks, and does not enforce that boundary. Its reads default to a 256 KiB cap, allow a per-call override, and reject missing or oversized files. Writes create parent directories.

localExec defaults to a 30-second timeout. A non-empty allowedCommands list rejects a command unless a regular expression matches. When args.env is omitted, localExec inherits process.env. When supplied, it replaces that environment. By contrast, SandboxPolicy.env is the explicit environment injected into a sandbox and does not inherit the host environment.

Sandbox release() and destroy() have distinct lifecycle roles. Each provider defines the exact persistence and cleanup behavior. Security fields express provider-agnostic intent. An unset field does not by itself prove a particular provider's enforcement.

Logging middleware defaults to console.error. A custom destination receives { method, args }. Filesystem logging does not serialize binary content and passes through realPath, statFile, removeFile, touchFile, and mkdir. Treat logged paths, command text, working directories, and text write contents as potentially sensitive.

Managed workspace lifecycleCopy link to section: Managed workspace lifecycle

Declare sandbox.workspace with an exact source inventory. b4 build captures source bytes for the Node target. Startup verifies the artifact and retains it in the installation store. Existing threads retain their original source and provenance across deployment changes. Providers without workspaces support reject the configuration. The first qualified provider is Docker. Managed workspace builds require build: { targets: ["node"] } and the existing app-root workspace/ capability marker.

sandbox.workspace may instead be a WorkspaceResolver: host code called once per thread, at the thread's first admission, with the thread id and its stored client metadata. B4.run captures what it returns (or verifies it, if already captured) and records it by digest in that thread's creation intent. It's never re-resolved. Later turns, restarts and readers all use the record.

b4 build can't capture a resolver, so the artifact only records that one is configured, and startup refuses an artifact whose form disagrees with the loaded config. Deployed host code decides the result, never the client or the model. A resolver treats the metadata it is given as input to validate, not as authority.

The resolver sees the thread's stored metadata with B4.run's reserved key stripped. On the server run endpoints (runs/stream, runs/wait, AG-UI), route is present and server-authoritative, because the runtime stamps it before admission and the store's merge overwrites any client value. Under b4 run it is absent. Every other key is client-writable, so never use metadata as an authorization input.

POST /threads always assigns the id, so to attach metadata before the first resolution, create the thread and use the id it returns. b4 run creates a fresh thread per invocation, so the resolver runs on every run with empty metadata.

Provider creation receives an immutable SourceBundle, bounded to 64 MiB of original content, and a persisted WorkspaceCreateIntent. Readiness must match that intent before tools receive a session. An unknown outcome cannot authorize a new operation. Release targets a compute incarnation. Destroy targets the original operation and may run before readiness exists. Missing established storage is an explicit failure, never an instruction to reseed.

Environment links point to directories in the pinned immutable environment. They cannot overlap source files or generated .git metadata. Docker requires a Node-capable preparation image, keeps those targets read-only, and supports UID/GID 1000 for managed non-root execution. Git baselines use fixed identity and time with ambient configuration and hooks disabled. The stored baseline commit is provenance. Agent-writable .git contents are not verification truth.

sandbox.thread goes one step further: a ThreadSandboxResolver returns the thread's workspace, an optional image and optional policy overrides, once, at first admission. Every thread admitted this way has a record, and a thread-mode admission of a thread without one is refused as a conflict. The provider's resolveImageEnvironment turns the image into the identity recorded in the creation intent, and B4.run writes the image reference and the policy to a per-thread record in the same transaction as the association. Every reconnect runs the thread's recorded policy over the app's; nothing is re-resolved. A provider without resolveImageEnvironment refuses a thread that names an image. When the resolver returns permissions, they go in the same record, and the thread's permission gates use them in place of the app's allow-list; its "Always" grants are kept beside the record and deleted with it (see Per-thread permissions).

ts
import { compose, withExecLogging, withFilesystemLogging } from "@b4run/workspace"
import { localExec, localFilesystem } from "@b4run/workspace/node"
 
const filesystem = compose(withFilesystemLogging())(
  localFilesystem({ maxFileBytes: 512 * 1024 }),
)
const exec = compose(withExecLogging())(
  localExec({ timeout: 10_000, allowedCommands: [/^pnpm test\b/] }),
)

Continue with Workspace Filesystem, Execution Sandbox, and Permissions API.