Skip to content

Fileservers

Any TypeScript object implementing the 10-method protocol. The universal interface — everything in the system is a fileserver or is accessed through one.

The Protocol

interface Fileserver {
// Core I/O
open(path: string, flags: OpenFlags): Promise<unknown>
read(fd: unknown, offset: number, count: number): Promise<Uint8Array>
write(fd: unknown, offset: number, data: Uint8Array): Promise<number>
close(fd: unknown): Promise<void>
// Metadata
stat(path: string): Promise<Stat>
readdir(path: string): Promise<DirEntry[]>
// Mutation
mkdir(path: string): Promise<void>
remove(path: string): Promise<void>
rename(oldPath: string, newPath: string): Promise<void>
wstat(path: string, changes: Partial<StatChanges>): Promise<void>
}

The original 6-method protocol (open/read/write/close/stat/readdir) covered I/O and metadata but had no way to mutate the filesystem tree. mkdir, remove, and rename added tree mutation. wstat added metadata mutation — without it, chmod, chown, and explicit touch -t can’t be implemented.

Fileservers that don’t support mutation (e.g., ProcFS, DevFS) throw EPERM for these methods. remove on a non-empty directory throws ENOTEMPTY.

Fd Ownership

Decision: fileservers allocate and own their fds. When the kernel calls server.open(), the fileserver returns an opaque token (its “fd”). The kernel never inspects it — it stores it in the process fd table and passes it back on read/write/close. This means a fileserver can use whatever internal handle it wants: an integer, a cursor object, a stream reference. The kernel maps process-local fd numbers (integers, lowest-available) to these opaque tokens. Two-level scheme, clean separation.

The unknown type for fd parameters reflects this — the kernel treats them as opaque pass-throughs.

Open Flags

Decision: flags as a structured object, not a bitfield or mode string. Unix-style O_RDONLY | O_CREAT | O_TRUNC is terse but hostile to TypeScript. A string like "r+" is ambiguous (does it create?). An object is self-documenting and extensible.

interface OpenFlags {
read?: boolean // default true
write?: boolean // default false
create?: boolean // create if missing, default false
truncate?: boolean // truncate to zero on open, default false
append?: boolean // writes go to end regardless of offset, default false
}

Convenience shorthands (not part of the protocol, built on top):

const O = {
READ: { read: true },
WRITE: { write: true, create: true, truncate: true },
APPEND: { write: true, create: true, append: true },
READWRITE: { read: true, write: true },
}

Fileservers validate flags and throw if they don’t support a combination (e.g., a read-only fileserver rejects write: true).

Stat

interface Stat {
name: string // basename (not full path)
size: number // bytes, 0 if unknown/stream
type: NodeType // 'file' | 'dir' | 'device'
mtime: number // ms since epoch, 0 if not tracked
mode?: number // permission bits (e.g., 0o644, 0o755)
uid?: number // owner user id
gid?: number // owner group id
ctime?: number // metadata change time
}

All new fields are optional. Consumers apply defaults when absent:

  • mode defaults to 0o644 for files, 0o755 for dirs
  • uid and gid default to 0
  • ctime defaults to mtime

This preserves backward compatibility. Existing fileservers (ProcFS, DevFS, TtyFS) continue returning four-field Stat objects. Consumers that don’t care about permissions work unchanged. See the inodes spec for the full INode data model and the rationale for these fields.

Decision: no 'symlink' type. There is no symlink creation method in the fileserver protocol, so no fileserver can produce a symlink. If symlinks are added later (via a symlink(target, path) method), the type union can be extended. For now, three types.

Decision: new Stat fields are optional, not required. Mandatory fields would force every fileserver to synthesize permissions and ownership, even when meaningless (what’s the uid of /dev/null?). Optional fields let each fileserver provide what it naturally has. The consumer decides what absence means.

Readdir

interface DirEntry {
name: string
type: 'file' | 'dir' | 'device'
}

Returns entries for the immediate children. Does not include . or .. — those are navigation concepts handled by path resolution, not by the fileserver.

Decision: no . or .. in readdir. Fileservers shouldn’t need to synthesize parent references. Path normalization (resolving ..) happens in the kernel before the path reaches a fileserver. A fileserver only sees paths within its mount subtree, already cleaned.

wstat

Metadata mutation — chmod, chown, and explicit timestamp changes — goes through wstat:

interface StatChanges {
mode: number
uid: number
gid: number
mtime: number
}

wstat(path, changes) applies partial metadata updates. Only the fields present in changes are modified. Fileservers that don’t support metadata mutation throw EPERM. See the inodes spec for the full design rationale, including why wstat replaces separate chmod/chown methods.

Error Contract

Fileservers throw typed errors. The kernel catches and propagates them to the calling process.

class FsError extends Error {
constructor(
public code: string, // e.g., 'ENOENT', 'EPERM'
message: string,
) {
super(message)
}
}

Standard error codes:

CodeMeaning
ENOENTNo such file or directory
EEXISTFile already exists (create when already present)
EISDIRIs a directory (e.g., read() on a dir)
ENOTDIRNot a directory (e.g., readdir() on a file)
ENOSPCNo space left (if fileserver enforces limits)
EBADFBad file descriptor (unknown fd token)
EINVALInvalid argument

Fileservers are not required to use all codes. A minimal fileserver might only throw ENOENT. The error contract is a vocabulary, not a mandate.

Decision: single FsError class with string codes, not subclasses per error. One catch (e) { if (e.code === 'ENOENT') } pattern is simpler than a class hierarchy. Matches Node.js convention. The code string is stable for programmatic use, the message is human-readable context.


Built-in Fileservers

MemoryFS

Mount: /, /tmp Backing: In-memory tree of INodes

The default general-purpose fileserver. A tree of directories and files stored as INode objects. MemoryFS provides full metadata support (mode, uid, gid, ctime) and implements ExecCapable for native executable resolution. See the inodes spec for the INode data model and the executables spec for how exec nodes work.

  • Open returns a cursor object { node, offset: 0 }
  • Read/write operate on node.content, a Uint8Array that grows as needed
  • wstat updates metadata fields in place and sets ctime to Date.now()
  • No size limits by default (it’s in-memory, bounded by heap)
  • /tmp is a separate MemoryFS instance so it can be wiped independently

Decision: content stored as Uint8Array, grown by doubling. When a write exceeds current capacity, allocate a new buffer at 2x size and copy. This amortizes allocation cost. Shrinking is not supported — files don’t auto-compact. This is fine for an in-memory fs that lives for a session.

BinFS (Removed)

BinFS has been replaced by exec nodes on MemoryFS. Native function references are stored as the exec field on INode file nodes in /bin. The kernel accesses them through the ExecCapable interface during PATH resolution. See the executables spec for the full exec model and the inodes spec for how exec fields live on INodes.

ProcFS

Mount: /proc Backing: Kernel process table (read-only view)

Exposes the process table as a synthetic filesystem. Each process is a directory, each property is a file.

/proc/
1/
status → "running"
argv → '["sh"]'
env → '{"PATH":"/bin","HOME":"/home"}'
fds → '{"0":"/dev/tty","1":"/dev/tty","2":"/dev/tty"}'
cwd → "/home"
2/
status → "running"
argv → '["grep","pattern"]'
...
  • All files are read-only. open() with write flag → EPERM
  • Content is generated on read(), not stored — always reflects current state
  • readdir("/proc") → lists pids as directory entries
  • readdir("/proc/1") → lists ["status", "argv", "env", "fds", "cwd"]
  • Content is JSON-encoded for structured data, plain strings for simple values

Decision: ProcFS is read-only, content generated on read. No caching, no staleness. Every read hits the live process table. Performance is not a concern — the process table is small and in-memory. Writing to procfs is disallowed; process manipulation goes through kernel syscalls (signal, etc.), not file writes.

ConsFS

Mount: /dev/cons (directory) Backing: External I/O channel (LLM API, terminal, WebSocket)

The unified terminal fileserver, modeled after Plan 9’s #c console device. Replaces the former TtyFS + LineDiscipline + TtyCtlFS trio with a single fileserver. The bridge between the Unix environment and the outside world.

/dev/cons/
data — read/write terminal data (cooked or raw mode)
ctl — write control commands ("rawon", "rawoff")
size — read terminal dimensions as "cols rows\n"

Factory: createConsFS(tty: TtyConfig, opts: ConsOpts) → ConsResult

  • data: reads pull from the terminal input stream, writes push to the terminal output stream
  • ctl: accepts mode-switch commands; line discipline is internal to the fileserver
  • size: returns current terminal dimensions
  • Mounted via mountConsTerminal() helper during boot

Decision: consFS collapses three fileservers into one. Plan 9’s #c device exposes /dev/cons (data) and /dev/consctl (mode). We unify under a single directory fileserver at /dev/cons with data, ctl, and size inner paths. This eliminates the coordination complexity between separate ttyFS, ldisc, and ttyCtlFS objects.

DevFS

Mount: /dev Backing: Synthetic devices

Provides standard Unix device files.

PathBehavior
/dev/nullReads return EOF. Writes succeed silently, data discarded.
/dev/zeroReads return zero-filled bytes. Writes discarded.
/dev/randomReads return random bytes via crypto.getRandomValues().

Decision: DevFS for MVP includes only null, zero, and random. These cover the essential use cases: discard output (> /dev/null), generate test data (/dev/zero), randomness (/dev/random). Other devices (urandom, stdin/stdout aliases) can be added trivially since they follow the same pattern.

Implementation is minimal — each device is a few lines:

const devices: Record<string, { read, write }> = {
null: {
read: () => new Uint8Array(0), // always EOF
write: (_, __, data) => data.length, // discard, report success
},
zero: {
read: (_, __, count) => new Uint8Array(count), // zero-filled
write: (_, __, data) => data.length,
},
random: {
read: (_, __, count) => {
const buf = new Uint8Array(count)
crypto.getRandomValues(buf)
return buf
},
write: (_, __, data) => data.length,
},
}

NetFS (Future)

Mount: /dev/net Backing: fetch, WebSocket, or real sockets

Not specified for MVP. The idea: opening /dev/net/tcp/example.com/80 returns an fd you can read/write like a file. HTTP could be a convenience layer on top. Deferred until networking is actually needed.

PersistentFS (Future)

Mount: /home Backing: LevelDB, localStorage, IndexedDB, or real fs

Not specified for MVP. MemoryFS at / handles all storage needs for single-session use. Persistence matters when state needs to survive across sessions. The interface is identical to MemoryFS — only the backing store changes.


Writing a Custom Fileserver

A fileserver is any object satisfying the Fileserver interface. Minimal example:

const counterFS: Fileserver = {
value: 0,
async open(path: string, flags: OpenFlags) {
return {} // opaque fd token
},
async read(fd: unknown, offset: number, count: number) {
return encoder.encode(String(this.value))
},
async write(fd: unknown, offset: number, data: Uint8Array) {
this.value = parseInt(decoder.decode(data), 10)
return data.length
},
async close(fd: unknown) {},
async stat(path: string) {
return { name: 'counter', size: 0, type: 'file' as const, mtime: 0 }
},
async readdir(path: string) {
throw new FsError('ENOTDIR', 'counter is not a directory')
},
async mkdir() { throw new FsError('EPERM', 'counter: not supported') },
async remove() { throw new FsError('EPERM', 'counter: not supported') },
async rename() { throw new FsError('EPERM', 'counter: not supported') },
async wstat() { throw new FsError('EPERM', 'counter: not supported') },
}
// Mount it
proc.mount(counterFS, '/srv/counter')
// Use it
// echo 42 > /srv/counter
// cat /srv/counter → "42"

This is the extension point. LLMs can create fileservers at runtime that wrap APIs, databases, sensors — anything that can be modeled as read/write on paths.


SrvFS

Mount: /srv Backing: Shared in-memory registry of posted Fileserver references

The Plan 9 /srv analog. Server processes post Fileserver objects here for other processes to discover and mount into their own namespace. Acts as a rendezvous point — not on the data path after mounting. See srv.md for the full design.

  • readdir('/') → lists posted server names
  • stat('/<name>') → confirms a posted entry exists
  • open/read on /<name> → returns description/status string
  • All writes → EPERM (posting is programmatic via ProcContext)
  • Implements SrvCapable for object-level access (getServer, postServer, removeServer)

See also: namespaces.md for mount/umount/bind commands, roadmap.md for the incremental implementation plan.


Future: Permission Middleware

Permissions are fileserver wrappers, not kernel concerns. A middleware fileserver delegates to an inner fileserver with access checks:

function readonlyView(inner: Fileserver): Fileserver {
const deny = () => { throw new FsError('EPERM', 'read-only') }
return {
open: (path, flags) => {
if (flags.write) deny()
return inner.open(path, { read: true })
},
read: inner.read.bind(inner),
write: deny,
close: inner.close.bind(inner),
stat: inner.stat.bind(inner),
readdir: inner.readdir.bind(inner),
mkdir: deny,
remove: deny,
rename: deny,
wstat: deny,
}
}

These compose: mount(rateLimit(readonlyView(authFilter(realFs, creds))))

Not implemented for MVP. Documented here because it validates the protocol design — if permissions couldn’t be modeled as middleware, the protocol would be wrong.

Future: 9P Wire Bridging

The protocol mirrors 9P2000 semantics. To bridge to a real Plan 9 fileserver:

  • Add attach(aname, uname) → rootFd for session setup
  • Add walk(fd, names[]) → newFd for path traversal
  • Wire format translation (binary ↔ JS objects) over TCP/WebSocket

Deferred. The current open(path) collapses attach+walk into one call, which is correct for in-process use. Walk becomes necessary only for network-mounted fileservers where per-component traversal matters for latency.