Users & Sessions
The path from boot to prompt, user identity, session lifecycle, and how these concepts grow from MVP to full userspace OS.
True North
A realistic boot sequence looks like this:
kernel → init (pid 1) — spawns services, manages session lifecycle → getty (per-tty) — listens for connections on a tty → login — authenticates credentials against user store → shell (user session) — runs with user identity, scoped namespaceEach layer is a regular bin — no kernel privileges. Init, getty, and login are userspace programs that compose through the same spawn/pipe/fd mechanics as grep and cat. The kernel provides the primitives (process table, namespaces, signals); userspace builds the policy.
Concepts
User
A user is an identity with associated configuration.
interface User { uid: number name: string home: string // home directory path shell: string // default shell bin env?: Record<string, string> // default env vars}Users are stored in a fileserver — the equivalent of /etc/passwd. No special kernel data structure. The kernel knows about uid (it’s on the process table entry) but doesn’t interpret it. It’s a label, not an access control mechanism — until fileserver middleware uses it for permission checks.
/etc/passwd (or a UserFS fileserver):
root:0:/:/bin/shagent:1:/home/agent:/bin/shguest:2:/home/guest:/bin/shSession
A session is a process group rooted at a session leader (typically a shell), associated with a controlling terminal.
interface Session { sid: number // session id = pid of session leader uid: number // user who owns this session tty: string // controlling terminal path (e.g., '/dev/tty/0') leader: number // pid of session leader}The kernel tracks session id (sid) on each process entry. Children inherit their parent’s sid. This gives us:
- Process grouping — “all processes in this session” is a query on the process table
- Signal delivery —
signal(sid, 'SIGHUP')kills the whole session (terminal disconnect) - Cleanup — when a session leader exits, all session members get SIGHUP
- Accounting — who spawned what, for how long
Controlling Terminal
Each session has at most one controlling terminal. When the tty disconnects (LLM stops responding, WebSocket closes, browser tab closes), the kernel sends SIGHUP to the session leader, which cascades to all session members.
tty disconnect → kernel detects EOF on tty → SIGHUP to session leader (shell) → shell's default SIGHUP handler: SIGHUP to all children → children die → session cleaned upThis is how real Unix handles terminal hangups. The signal cascade is already in our kernel spec — we just need sid on the process table and SIGHUP in the signal set.
Process Table Extension
The kernel process table entry gains three fields:
interface Process { // ... existing fields from kernel spec ... pid: number ppid: number state: ProcessState namespace: Namespace fds: Map<number, Fd> env: Record<string, string> cwd: string argv: string[] exitCode: number | null bin: BinFunction promise: Promise<number> waiters: Array<(code: number) => void>
// New: identity and session uid: number // user identity (inherited from parent, set by login) sid: number // session id (pid of session leader) pgid: number // process group id (for future job control)}Decision: add uid, sid, pgid to process table now, even if MVP doesn’t enforce them. These are cheap (three numbers per process) and expensive to retrofit. Every process carries its identity and session from day one. MVP sets
uid=0(root),sid=pid(every process is its own session leader),pgid=pid. The fields exist; the policy grows later.
Boot Sequence: True North
Unix(config) → sys.boot() │ 1. Kernel starts 2. Fileservers mounted 3. Bins registered │ 4. init spawns as PID 1 (uid=0, sid=1) │ └─ init reads /etc/inittab (or equivalent config) │ └─ for each configured tty: │ └─ spawn getty │ 5. getty (per-tty) │ └─ opens tty device │ └─ writes "login: " prompt │ └─ reads username │ └─ spawns login with username │ 6. login │ └─ reads password (if auth configured) │ └─ validates against /etc/passwd (or auth fileserver) │ └─ on success: │ ├─ kernel.setuid(pid, user.uid) │ ├─ kernel.setsid(pid) — create new session │ ├─ set env: USER, HOME, SHELL, LOGNAME │ ├─ chdir(user.home) │ ├─ source user's profile (~/.profile) │ └─ exec(user.shell) │ └─ on failure: │ └─ stderr "Login incorrect", respawn getty │ 7. shell (user session) └─ prompt, read, execute loop └─ all children inherit uid and sidinit
Init is a bin. Its job:
async function init(proc: ProcContext): Promise<number> { // Read tty configuration const ttys = await loadTtyConfig(proc)
// Spawn getty for each tty for (const tty of ttys) { spawnGetty(proc, tty) }
// Wait forever, respawning gettys that exit while (true) { const { pid, code } = await proc.waitAny() const tty = ttyForPid(pid) if (tty) { // getty/login/shell exited — respawn getty for this tty spawnGetty(proc, tty) } }}Init never exits (unless the system is shutting down). It’s the process supervisor.
getty
Getty is a bin. One per terminal:
async function getty(proc: ProcContext): Promise<number> { const ttyPath = proc.argv[1] // e.g., '/dev/tty/0'
// Open the tty const fd = await proc.fs.open(ttyPath, { read: true, write: true })
// Prompt for username await write(fd, 'login: ') const username = await readLine(fd)
// Exec login (replace this process) return proc.exec('/bin/login', [username], { stdin: fd, stdout: fd, stderr: fd, })}login
Login is a bin. Authenticates and sets up the session:
async function login(proc: ProcContext): Promise<number> { const username = proc.argv[1]
// Look up user const user = await lookupUser(proc, username) if (!user) { await proc.stderr.write('Login incorrect\n') return 1 }
// Authenticate (if auth is configured) if (await needsAuth(proc)) { await proc.stdout.write('Password: ') const password = await readLine(proc.stdin) // TODO: no-echo mode if (!await authenticate(proc, username, password)) { await proc.stderr.write('Login incorrect\n') return 1 } }
// Set up session proc.setuid(user.uid) // kernel call: change process identity proc.setsid() // kernel call: create new session proc.env.USER = user.name proc.env.HOME = user.home proc.env.LOGNAME = user.name proc.env.SHELL = user.shell
// Enter home directory proc.chdir(user.home)
// Exec user's shell with profile as startup script // The shell handles sourcing ~/.profile via --rcfile or equivalent proc.exec(user.shell, ['--rcfile', `${user.home}/.profile`])}Kernel Calls for Identity and Sessions
The kernel gains a few new operations (used by login/init, not by regular bins):
interface Kernel { // ... existing ...
// Identity (privileged — only uid=0 can call) setuid(pid: number, uid: number): void
// Sessions setsid(pid: number): number // create new session, return sid getsid(pid: number): number // get session id
// Process groups (future: job control) setpgid(pid: number, pgid: number): void getpgid(pid: number): number
// Wait for any child (used by init) waitAny(ppid: number): Promise<{ pid: number, code: number }>}Decision:
setuidis a kernel call restricted to uid=0. Only root (uid=0) can change a process’s identity. Login runs as root (inherited from init), sets uid to the authenticated user, then execs the shell. After that, the shell and all its children run as that user. This is exactly how real Unix login works. The restriction isn’t enforced by a complex capability system — it’s a singleif (proc.uid !== 0) throw ProcError('EPERM')check in the kernel.
MVP vs True North
| Concept | MVP | True North |
|---|---|---|
| uid | Always 0, ignored | Per-user, enforced by fileserver middleware |
| sid | Always = pid | Created by setsid(), inherited by children |
| pgid | Always = pid | Process groups for job control |
| init | None — sys.boot() spawns shell directly | PID 1 supervisor, respawns gettys |
| getty | None | Per-tty listener |
| login | None | Authenticates, sets identity, execs shell |
| /etc/passwd | None | User store (fileserver or flat file) |
| ~/.profile | None | Sourced by login on session start |
| SIGHUP cascade | None | Session leader death → SIGHUP to all members |
| setuid | Not implemented | Kernel call, root-only |
| Controlling tty | Implicit (one tty, one shell) | Explicit per-session association |
MVP Implementation
MVP stays exactly as specified in the bootstrap doc:
const sys = Unix({ ... })const exitCode = await sys.boot() // spawns shell as pid 1, uid=0, sid=1But the process table carries uid/sid/pgid from day one (all defaulting). When we add init/getty/login, the boot path changes but the kernel primitives are already there.
Growth Path
Phase 1 (MVP): sys.boot() → shell (pid 1, uid=0)
Phase 2 (multi-session): sys.boot() → init (pid 1) → getty per tty → login → shell (uid=0, no real auth)
Phase 3 (multi-user): Same as phase 2, but login authenticates against /etc/passwd Fileserver middleware checks uid on open/write
Phase 4 (networked): Remote agents connect via WebRTC/WebSocket Each connection gets a getty Auth via fileserver attach() credentials Scoped namespace per agentEach phase adds userspace code (bins and fileserver middleware). The kernel changes are minimal: setuid, setsid, waitAny, and SIGHUP delivery. The architecture doesn’t change — it’s the same three layers, the same 9-method protocol, the same spawn/pipe/fd model.
Multiple TTYs
True north supports multiple simultaneous terminals:
/dev/tty/ 0 ← first connection (e.g., LLM agent) 1 ← second connection (e.g., human operator) 2 ← third connection (e.g., monitoring agent)TtyFS becomes a directory of tty devices, not a single device. Each tty is its own I/O channel. Getty spawns per-tty. Each authenticated session gets its own namespace view.
Decision: TtyFS path scheme is
/dev/tty/Nfor multi-tty, with/dev/ttyas a symlink to the process’s controlling terminal. This matches Unix:/dev/ttyalways refers to “my terminal,” while/dev/tty/0,/dev/tty/1are specific devices. MVP mounts a single tty at/dev/tty. Multi-tty extends to the directory scheme without changing the abstraction.
exec vs spawn
The login sequence uses exec — replace the current process image rather than spawning a child:
proc.exec(bin, argv) 1. Resolve bin 2. Replace this process's bin and argv (same pid, same fds, same namespace) 3. Start executing the new bin 4. Does not return (old bin is gone)This is why login → shell doesn’t create an extra process. Login becomes the shell. The pid, fds, and session stay the same.
Decision: add
execto ProcContext. It’s needed for the login → shell transition and for any bin that wants to “become” another bin. Implementation: cancel the current bin’s promise, replaceprocess.binandprocess.argv, start the new bin with the same ProcContext. The calling bin’s code afterexec()never runs (same as real Unix). MVP can implement this as spawn + exit if true exec is complex, but the interface should exist.
Summary
Users, sessions, and login are all userspace concerns built on kernel primitives we already have (processes, namespaces, signals, fds). The architecture doesn’t need a new layer — it needs:
- Three fields on the process table (uid, sid, pgid)
- A few kernel calls (setuid, setsid, waitAny)
- Three bins (init, getty, login)
- A user store (/etc/passwd or a UserFS fileserver)
execon ProcContext
Everything else — authentication, session lifecycle, terminal management, profile scripts — is policy implemented in userspace. The kernel stays small.