Workspace
vm.workspace reads and writes the files under /workspace inside the VM. Guest programs see the same files: what you write with writeText("notes.txt", ...) is /workspace/notes.txt to a shell in the VM, and what a guest program writes there you can read back. The workspace has the same methods and types in Userspace and System.
- Every path addresses
/workspace.notes.txtand/notes.txtname the same file,/workspace/notes.txtin the guest;/names the workspace root. - Paths in returned entries are workspace paths with a leading
/, for example/src/main.ts. - A path may not contain NUL,
\, or.and..segments, and stays within Linux limits: 4,095 bytes in total and 255 bytes per segment. Other paths fail withINVALID_ARGUMENT. - Symbolic links cannot lead outside
/workspace.
Process cwd and command paths are guest paths, validated separately (see VM).
Members
Section titled “Members”| Member | Returns | Meaning |
|---|---|---|
id |
string |
The workspace ID: your workspaceId, or a generated ID for an ephemeral workspace. |
persistence |
WorkspacePersistence |
How the workspace is stored. See Persistence. |
readFile(path, signal?) |
Promise<Uint8Array> |
Reads a file’s bytes. |
readText(path, signal?) |
Promise<string> |
Reads a file as UTF-8 text. |
writeFile(path, data, signal?) |
Promise<void> |
Creates or replaces a file with data (Uint8Array). |
writeText(path, text, signal?) |
Promise<void> |
Creates or replaces a file with UTF-8 text. |
list(path?, signal?) |
Promise<readonly WorkspaceEntry[]> |
Lists a directory, / by default. |
remove(path, options?) |
Promise<void> |
Removes a file, a link or an empty directory. options: recursive?: boolean removes a directory and everything in it; signal?. |
entries(path?, signal?) |
Promise<WorkspaceFileEntry[]> |
Lists a directory with sizes, link targets and versions, / by default. |
stat(path, signal?) |
Promise<WorkspaceFileEntry> |
Describes one entry. |
read(path, options?) |
Promise<WorkspaceFile> |
Reads a file together with its entry. options: maxBytes?, signal?. |
write(path, data, options?) |
Promise<WorkspaceFileEntry> |
Writes a file, optionally only over a known version, and returns its new entry. options: ifVersion?, signal?. |
createDirectory(path, options?) |
Promise<void> |
Creates a directory and any missing parents. An existing directory is not an error. options: signal?. |
rename(from, to, options?) |
Promise<void> |
Moves an entry. It never replaces an existing entry at to. options: signal?. |
watch(onChange, options) |
WorkspaceWatch |
Reports changes in the directories you choose. See Watching. |
- Every method except
watchreturns a promise. An abortedsignalcancels the request withABORTED. - Writes copy your bytes before they return, so you can reuse the buffer, and resolve after the change is committed.
- Text helpers use UTF-8;
readFile,writeFile,readandwritework with bytes. - After
vm.dispose(), every method fails withPROTOCOL_ERROR.
The option types are exported as WorkspaceReadOptions ({ maxBytes?: number; signal?: AbortSignal }), WorkspaceWriteOptions ({ ifVersion?: string | null; signal?: AbortSignal }), WorkspaceOperationOptions ({ signal?: AbortSignal }) and WorkspaceWatchOptions ({ signal: AbortSignal }).
Entry types
Section titled “Entry types”list returns WorkspaceEntry values:
| Field | Type | Meaning |
|---|---|---|
kind |
"file" | "directory" | "symlink" |
What the entry is. |
name |
string |
The last path segment. |
path |
string |
The workspace path, with a leading /. |
entries and stat return WorkspaceFileEntry values, which add size, link and version:
| Field | Type | Meaning |
|---|---|---|
kind |
"file" | "directory" | "symlink" |
The entry itself; a symbolic link is reported as "symlink". |
link |
{ kind: "file" | "directory" | "missing"; path: string | null } | null |
For a symbolic link, what its target is and the target’s workspace path (null when the target is missing). null for every other entry. |
name |
string |
The last path segment. |
path |
string |
The workspace path, with a leading /. |
size |
number |
Size in bytes; 0 for a directory. |
version |
string |
A 48-hex-digit content version. Pass it to write as ifVersion. |
entries lists names in code-point order. read returns a WorkspaceFile: { entry: WorkspaceFileEntry; data: Uint8Array }.
Versioned writes
Section titled “Versioned writes”write takes an optional ifVersion so that an editor never overwrites changes it has not seen.
ifVersion |
write succeeds when |
Otherwise |
|---|---|---|
| omitted | always; it creates or replaces the file | |
| a version string | the file exists and its version is still that version | STORAGE_ERROR, detail.reason: "version-mismatch" |
null |
nothing exists at the path yet | STORAGE_ERROR, detail.reason: "exists" |
- The parent directory must already exist; create it with
createDirectoryfirst. - A directory at the path fails with
STORAGE_ERRORanddetail.reason: "not-a-file". - An
ifVersionthat is not 48 lowercase hex digits fails withINVALID_ARGUMENT.
import { VMError, type VM } from "@oxidevm/sdk";
/** Saves only if nobody changed the file since `version` was read. */export async function save(vm: VM, path: string, text: string, version: string): Promise<string | "conflict"> { try { const entry = await vm.workspace.write(path, new TextEncoder().encode(text), { ifVersion: version }); return entry.version; } catch (error: unknown) { if (error instanceof VMError && error.code === "STORAGE_ERROR" && error.detail?.["reason"] === "version-mismatch") { return "conflict"; } throw error; }}import { VMError, type VM } from "@oxidevm/sdk";
/** Creates a starter file on the first visit and keeps the user's edits afterwards. */export async function seed(vm: VM, path: string, text: string): Promise<void> { try { await vm.workspace.write(path, new TextEncoder().encode(text), { ifVersion: null }); } catch (error: unknown) { if (error instanceof VMError && error.code === "STORAGE_ERROR" && error.detail?.["reason"] === "exists") return; throw error; }}Size limits
Section titled “Size limits”| Limit | Value | When exceeded |
|---|---|---|
read |
maxBytes, which defaults to and may not exceed 16 MiB (16,777,216 bytes) |
STORAGE_ERROR, detail.reason: "too-large", with the file’s size in detail.size |
maxBytes |
an integer from 0 to 16 MiB | INVALID_ARGUMENT |
write |
16 MiB of data | STORAGE_ERROR, detail.reason: "too-large" |
Userspace writeFile and writeText |
16 MiB of data, as for write |
INVALID_ARGUMENT |
| Path | 4,095 bytes in total, 255 bytes per segment | INVALID_ARGUMENT |
| Watched directories | 256 distinct directories per VM, across all its watches | INVALID_ARGUMENT |
Errors
Section titled “Errors”| Code | detail.reason |
When |
|---|---|---|
INVALID_ARGUMENT |
none | An invalid path, maxBytes or ifVersion, or too many watched directories. |
STORAGE_NOT_FOUND |
none | The path, or a parent directory on it, does not exist. |
PERMISSION_DENIED |
none | The entry is not accessible. |
STORAGE_ERROR |
"too-large" |
A file or a write is above the size limits. |
STORAGE_ERROR |
"version-mismatch" |
write with an ifVersion string found another version, or no file. |
STORAGE_ERROR |
"exists" |
write with ifVersion: null found an entry, or rename found an entry at to. |
STORAGE_ERROR |
"not-a-file" |
read or write met a directory or another non-file. |
STORAGE_ERROR |
"not-a-directory" |
A path component is not a directory, for example createDirectory("a/b") when a is a file, or a watched path is not a directory. |
STORAGE_ERROR |
"busy" |
The entry stayed busy in the guest after the SDK’s own retries. |
STORAGE_ERROR |
none | Another storage failure. In Userspace, detail.errno then holds the Linux error number, for example after remove of a non-empty directory without recursive. |
FEATURE_NOT_AVAILABLE |
"image-update-required" |
System: the image’s guest agent has no workspace file operations. This affects entries, stat, read, write, createDirectory, rename, watch and list. |
FEATURE_NOT_AVAILABLE |
"no-root-filesystem" |
Userspace: the VM has no root filesystem. |
ABORTED |
none | The request’s signal aborted, or the VM was disposed while a watch ran. |
PROTOCOL_ERROR |
none | The VM was disposed. |
Branch on code and detail.reason, never on message. See Errors.
Watching
Section titled “Watching”watch( onChange: (changes: readonly WorkspaceChange[]) => void, options: { signal: AbortSignal },): WorkspaceWatch;
interface WorkspaceWatch { setDirectories(paths: readonly string[]): Promise<void>; readonly done: Promise<void>;}
type WorkspaceChange = | { kind: "changed"; directory: string; name: string } | { kind: "rescan" };watchreturns at once. A new watch watches nothing untilsetDirectoriesresolves.setDirectories(paths)replaces the watch’s directories. Paths follow the path rules and are reported back in normalized form (srcbecomes/src); duplicates collapse. Each path must be a directory or a link to one. A VM watches at most 256 distinct directories across all its watches. If the call fails, the watch keeps its previous directories.onChangereceives batches of changes:{ kind: "changed", directory, name }: the entrynamein the watcheddirectorychanged. Callstatorentriesto see its new state.{ kind: "rescan" }: read every watched directory again. It is sent, for example, when more changes arrived than a batch reports, when a watched directory was moved or removed, and after a restore.
- Each watch receives the changes of its own directories and every
rescan. doneresolves whenoptions.signalaborts and rejects when the watch fails. When the VM is disposed,donerejects withABORTED.
Read the directories after setDirectories resolves, then apply the changes that follow:
import type { VM } from "@oxidevm/sdk";
export async function watchSource(vm: VM, signal: AbortSignal, refresh: (directory: string) => void): Promise<void> { const watch = vm.workspace.watch((changes) => { for (const change of changes) refresh(change.kind === "rescan" ? "/src" : change.directory); }, { signal }); await watch.setDirectories(["src"]); refresh("/src"); await watch.done;}Persistence
Section titled “Persistence”type WorkspacePersistence = | { kind: "ephemeral" } | { kind: "persistent"; backend: "opfs" | "indexeddb"; browserRetention: "best-effort" | "granted" };kind |
Meaning |
|---|---|
"ephemeral" |
Created without workspaceId. The files belong to this VM alone and are not kept for a later visit. |
"persistent" |
Created with workspaceId. The files are stored in this origin’s browser storage, in OPFS ("opfs") or IndexedDB ("indexeddb"), and the same ID opens them again on a later visit. |
browserRetentionis"granted"when the browser reported persistent storage for the origin (navigator.storage.persisted()) as the VM was created, otherwise"best-effort". Even"granted"storage is not safe from quota limits, users clearing site data, or hardware failure.- A workspace created with
workspaceIdis never reported as durable while being kept in memory: if durable storage is refused, the operation fails instead. - Files that guest programs write under
/workspacebelong to the same workspace. For a durable workspace,vm.dispose()saves them before it resolves. - Files survive a normal close and reopen; running processes do not. To bring processes back, use restore points.
- Only one VM at a time can hold a durable workspace. See
workspaceId. - When a durable Userspace workspace opens on a different image than the one it was saved on, for example with
restorePoint: "update-image", your/workspacechanges are carried onto the new image’s files, andvm.start.workspaceDetailsreports the outcome.