Skip to content

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 namespace

Each 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/sh
agent:1:/home/agent:/bin/sh
guest:2:/home/guest:/bin/sh

Session

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 deliverysignal(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 up

This 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 sid

init

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: setuid is 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 single if (proc.uid !== 0) throw ProcError('EPERM') check in the kernel.

MVP vs True North

ConceptMVPTrue North
uidAlways 0, ignoredPer-user, enforced by fileserver middleware
sidAlways = pidCreated by setsid(), inherited by children
pgidAlways = pidProcess groups for job control
initNone — sys.boot() spawns shell directlyPID 1 supervisor, respawns gettys
gettyNonePer-tty listener
loginNoneAuthenticates, sets identity, execs shell
/etc/passwdNoneUser store (fileserver or flat file)
~/.profileNoneSourced by login on session start
SIGHUP cascadeNoneSession leader death → SIGHUP to all members
setuidNot implementedKernel call, root-only
Controlling ttyImplicit (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=1

But 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 agent

Each 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/N for multi-tty, with /dev/tty as a symlink to the process’s controlling terminal. This matches Unix: /dev/tty always refers to “my terminal,” while /dev/tty/0, /dev/tty/1 are 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 exec to 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, replace process.bin and process.argv, start the new bin with the same ProcContext. The calling bin’s code after exec() 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:

  1. Three fields on the process table (uid, sid, pgid)
  2. A few kernel calls (setuid, setsid, waitAny)
  3. Three bins (init, getty, login)
  4. A user store (/etc/passwd or a UserFS fileserver)
  5. exec on ProcContext

Everything else — authentication, session lifecycle, terminal management, profile scripts — is policy implemented in userspace. The kernel stays small.