PiROS — usage & developer guide
How to drive the system, share files, and build/sign/install your own programs. For the design and the "why", see ARCHITECTURE.md.
1. Build & boot
./build.sh # compile tools, sign, pack /bin, build the kernel, boot in QEMU
./build.sh --build # build the bootable image only (no boot)
The build creates two disk images in dist/:
dist/pros-bios.img— the bootable OS (kernel + baked/binramdisk).dist/data.img— persistent storage:/dataand the writable/binoverlay.
Created once and left alone (your files survive rebuilds). Delete it to wipe all persistent state (rm dist/data.img; the next build recreates a blank 16 MiB one).
Shared read-only mnt/. Anything in the host mnt/ folder is packed at ./build.sh and mounted read-only inside PiROS at mnt/, readable by every user (ls mnt, cat mnt/<file>; writes are refused). The SSH server reads mnt/authorized_keys, so the easy way to install a login key is: cp ~/.ssh/id_ed25519.pub mnt/authorized_keys && ./build.sh.
To boot manually (both disks, headless serial console):
qemu-system-x86_64 \
-drive format=raw,file=dist/pros-bios.img,if=ide,index=0 \
-drive format=raw,file=dist/data.img,if=ide,index=1 \
-serial stdio -display none \
-device isa-debug-exit,iobase=0xf4,iosize=0x04 -no-reboot -m 128M
Under emulation the kernel takes a few seconds to reach the login: prompt.
2. Logging in
Boot lands at login:. Enter <user> <password>:
| User | Password | Gets |
|---|---|---|
root | rootpw | everything (incl. publishing publicly) |
bob | bobpw | core + text_tools + iamgood |
mary | marypw | core only |
login: bob bobpw
welcome, bob.
bob@pros>
Session commands: whoami, su <user> <pw> (switch), logout (back to login), quit (power off). Note: the login line is not stored in history (it holds your password).
3. Command reference
Shell builtins (part of the kernel — always available, no grant)
| Command | What it does | ||||
|---|---|---|---|---|---|
help | command help + the live /bin program list | ||||
admin … (root) | live admin: admin user create <name> <pw>, `admin grant <user> <cap> tool\ | cluster, admin service create\ | start\ | stop\ | list <name>` |
cd [path] | change directory (cd alone → files/) | ||||
pwd | print the working directory | ||||
whoami | print the current user | ||||
su <user> <pw> | switch user | ||||
logout | return to the login prompt | ||||
free | RAM usage (usable / reserved) | ||||
ps | scheduler tasks (console / kernel service / ring-3 process) + resources | ||||
syslog (root) | in-kernel event log: spawns, service starts, faults, resource snapshots | ||||
ports / netstat | active TCP sockets: local port, state (LISTEN/ESTAB/SYN-RCVD/CLOSE-WAIT/CLOSING/LAST-ACK/TIME-WAIT), peer, owning task, and the retransmit timer RTO(ms) | ||||
netlog | packet log + firewall choke point. Every TCP segment in/out with a monotonic timestamp, direction, endpoints, flags (SAPFR) and seq/ack/len. netlog dumps; netlog follow [secs] (alias -f) live-tails; netlog port <N> filters (default 80, 0 = all); `netlog on\ | off\ | clear`. Ground truth for "is it the service or the network?" (see §11) | ||
df | /data backing (persistent disk vs RAM) | ||||
disk | data-disk region map (our custom format; like fdisk -l) | ||||
net | NIC status + ARP-probe the gateway (link check) | ||||
ping [ip] | ICMP echo (defaults to the gateway) | ||||
host <name> | resolve a hostname to an IPv4 address (DNS) | ||||
| `ntp [ip\ | host]` | set the wall clock from a network time server (SNTP over UDP :123; no arg → pool.ntp.org). Also auto-synced best-effort at boot | |||
date / time | print the current wall clock (UTC), once set by NTP (no RTC on this box) | ||||
netfw (root) | iptables-style firewall on new inbound connections: netfw lists; `netfw allow\ | deny <src> <port> [label] adds (src: a.b.c.d[/n] or any; port or any); netfw insert <n> … orders; netfw del <n>; netfw policy allow\ | deny; netfw clear`. First match wins; live sessions are never cut (see §11) | ||
ssh <host> | SSH-2 client transport handshake (KEX + host-key verify) | ||||
admin service start sshd (root) | SSH server on :2222 (password or authorized_keys pubkey → remote shell). Several users can be logged in at once, each with their own privileges (~3–5 simultaneous sessions; a full box refuses a new connection with a one-line hint). Line commands, ssh <box> '<cmd>' (one command non-interactively, e.g. ssh bob@box 'free'), and the full-screen prano editor all work over it. admin service stop sshd to stop | ||||
http <host> | fetch http://<host>/ over TCP and print the response | ||||
history | recent commands (also ↑/↓ at the prompt) | ||||
run <file> | execute a file of commands (a shell script) | ||||
quit / exit | power off |
Programs (in /bin — require a grant; run unprivileged in ring 3)
| Program | Usage | What it does | |
|---|---|---|---|
echo <text> | echo hi > files/note | write text to the console or a redirect | |
cat <path> | cat files/note | print a file from your /data | |
ls [-v] [path] | ls -v files/proj | list a directory (-v adds size + owner) | |
mkdir <path> | mkdir files/proj | make a directory (parent must exist) | |
mv <src> <dst> | mv note.txt docs/ | move/rename a file or directory subtree | |
rmdir <path> | rmdir docs/old | remove an empty directory | |
du [path] | du proj | recursive size of a directory tree | |
grep <path> <pattern> | grep files/note meet | print matching lines (with line numbers) | |
cp <src> <dst> | cp files/a files/b | copy a file within your /data | |
rm <path> | rm files/old | delete a file | |
prano <path> | prano files/note | full-screen editor (↑↓←→, ^O save, ^X exit, ^F/^N find, syntax colors); works on the local console and over SSH (see §5) | |
publish <path> <who> | publish files/memo bob | share a file (see §6) | |
argo <owner>:<name> [dest] | argo mary:memo | fetch a published file (see §6) | |
curl <url> | curl example.com | fetch a URL over HTTP and print the response | |
wget <url> [dest] | wget example.com/a.txt | download a URL over HTTP into files/ (body only; curl prints) | |
| `asterix <path\ | url>` | asterix http://example.com | text-mode HTML viewer (local file or live URL) |
psh <script> | psh files/hello.psh | run a PHP/bash-style script (see §7) |
iambad and hello are demo tools (access-control and install/heap demos respectively). list_build / list_get / list_save are in the admin namespace (root only) — see §7.5.
4. Files, /data, and redirects
Each user owns /data/$user/{files,logs,tmp}:
files/— your content. You write here via a>redirect or a tool.logs/— program-authored audit records. Readable by you, not user-writable.tmp/— scratch.
Paths are relative to your /data — there is no path that reaches another user's files. Redirect output with > (truncate) or >> (append):
bob@pros> echo one > files/memo
bob@pros> echo two >> files/memo # append
bob@pros> cat files/memo
one
two
All of /data is persistent (survives reboot) and encrypted at rest (file contents are ciphertext on disk). Files are ≤ 4 KB each.
Directories. files/ and tmp/ are real trees — mkdir files/proj, then echo x > files/proj/main.rs, cat files/proj/main.rs, ls files/proj. Nesting is arbitrary; ls marks subdirectories with a trailing /, and ls -v shows <dir> for them. logs/ stays flat (kernel-written audit). The tree structure is plaintext metadata; file contents are still encrypted.
Working directory. The prompt shows where you are (bob:files/proj>). cd <path> moves (relative or absolute, with ..); cd alone returns to files/; pwd prints the path. Once you cd in, paths are relative — cat main.rs, ls, echo x > out all resolve against the current directory. An absolute path (files/…, tmp/…) ignores the cwd; a bare name is relative to it.
bob:files> mkdir proj
bob:files> cd proj
bob:files/proj> echo hi > note # writes files/proj/note
bob:files/proj> ls # lists the current dir
note
bob:files/proj> cd .. # back to files/
mv moves or renames (if dst is a directory, src moves into it; otherwise src is renamed to dst) — and moving a directory carries its whole subtree. rmdir removes an empty directory. You can move across trees too (mv report.txt tmp/).
Redirect tokens: a>is only a redirect when it's a real token (start of a word or after a space), soecho <h1>x</h1> > fnow writes the HTML correctly andfgets<h1>x</h1>. Still no quoting for spaces, so for anything fiddly useprano.
5. Editing with prano
prano files/note
Arrows move the cursor, typing inserts, backspace deletes, ^O saves, ^X exits. If the file exists it's loaded first. Saved files persist on the encrypted disk.
prano runs on the local console and over SSH — an interactive ssh login requests a pty and wires your keystrokes straight to the editor. (The initial-render freeze over SSH that earlier builds had is fixed: it was a scheduler yield_now() in the TCP send loop that let a concurrent poll() steal the render's ACKs; the send path no longer yields for the SSH channel — see net/tcp.rs.)
6. Publishing & fetching (cross-user sharing)
Share one of your files with another user; the kernel mediates the read (decrypting with your key) and enforces the audience — no user gets raw access to another's store.
publish <path> <audience> # a file OR a directory; audience = username, or * (root)
argo --list <owner> # list what <owner> shares with you (dirs marked /)
argo --list <owner>:<dir> # browse the contents of a shared directory
argo <owner>:<name> [dest] # fetch into your files/ (saved to the basename)
Worked example — Mary shares a memo with Bob:
login: mary marypw
mary@pros> echo lunch at 1pm > files/memo
mary@pros> publish files/memo bob → published memo to bob
mary@pros> logout
login: bob bobpw
bob@pros> argo mary:memo → consumed -> files/memo
bob@pros> cat files/memo
lunch at 1pm
A user not in the audience is denied (argo: not found or access denied). Only root may publish <file> (public to everyone). argo --list mary shows Bob just the resources Mary shared with him* — a discovery step before fetching each.
Sharing a directory. publish a directory and the whole subtree becomes reachable:
mary:files> publish docs bob → published docs to bob
bob:files> argo --list mary → docs/ memo
bob:files> argo --list mary:docs → plan.txt sub/
bob:files> argo mary:docs/sub/deep.txt → consumed -> files/deep.txt (saved by basename)
The audience can browse and fetch anything under a shared directory, but nothing outside it — argo mary:private.txt (not shared) is still denied.
7. Shell scripting & history
A script is a file of commands, one per line (# starts a comment). Author it with prano, then run it:
bob@pros> prano files/setup.sh
# (type, e.g.:)
# provision my files
echo hello world > files/greeting
echo notes here > files/notes
ls files
(^O to save, ^X to exit)
bob@pros> run files/setup.sh
history lists recent commands; ↑/↓ at the prompt recall and edit them; Tab completes the current word (commands for the first word, your file names after).
Scripting language (psh). Beyond command scripts, psh is a small PHP/bash-style language (runs as a heap-using tool). Write a .psh file with prano, then psh <file>:
# fibonacci
$a = 0; $b = 1; $i = 0;
while ($i < 8) {
echo "$a "; # double-quoted strings interpolate $vars
$t = $a + $b; $a = $b; $b = $t;
$i = $i + 1;
}
echo "\n";
write("files/out", "hello from psh"); # file I/O builtins: read/write/strlen/int/str
echo read("files/out");
Supports $vars (int/string), + - * / %, string concat ., comparisons, && ||, if/else, while, $var interpolation in "…", and builtins strlen, int, str, read("path"), write("path", val).
Functions and includes let you build bigger programs (and exceed the 4 KB file cap by composing multiple files):
function add($a, $b) { return $a + $b; } # user-defined functions with return
echo add(3, 4); # -> 7
include "files/lib.psh"; # run another script in the same scope
echo sq(5); # a function defined in lib.psh
include reads one of your files (no escalation) and shares variables/functions with the caller. (A future exec() for running other tools would be grant-checked against the current user — being able to read a script never confers the right to run what it calls.)
7.5 Admin: interactive list management
The admin namespace (granted to root only) holds a select/multi-select toolkit, split into a pure UI component and a separate data layer — so the data layer can later become a kernel service without touching the UI:
| Tool | Role |
|---|---|
list_build <id> <items,…> [sel,…] | interactive checkbox UI; writes the result to tmp/sel.<id> |
list_get <name> | prints files/list.<name> (composes with > redirect) |
list_save <name> [id] | promotes tmp/sel.<id> → files/list.<name> (removes the tmp file) |
list_build draws a checkbox list; ↑/↓ (or k/j) move, Space toggles, Enter → Save? [Y/N], X cancels (exit 1 vs 0, so scripts can tell). It is pure UI: everything comes in as args, and its only side effect is the one tmp/sel.<id> file — it never reads files/ and doesn't know lists persist as list.<name>.
root@pros> admin list_build alice apples,pears,grapes # (toggle apples+pears, Enter, Y)
saved tmp/sel.alice
root@pros> admin list_save alice # tmp/sel.alice -> files/list.alice
saved files/list.alice
root@pros> admin list_get alice
apples,pears
Re-running with the saved selection pre-checks it: admin list_build alice apples,pears,grapes apples,pears. Because /data is per-user, each user's lists are isolated automatically. Item names can't contain commas (CSV is the interchange format).
Note on scripting. These are shell commands. A batch of them runs via `run <file>(withlist_build` pausing for interactive input mid-script). The variable- driven form ($sel = list_build($list, ...)) would need them wired aspshbuiltins — a clean future step; the tools themselves wouldn't change.
8. Writing, signing & installing a program
This is how you add your own program (e.g. a browser). Every program is a small freestanding crate that talks only to the kernel ABI — so it stays userland, and the kernel never changes to accommodate it.
8.1 Program structure
programs/<name>/
Cargo.toml # package name = "<name>", dependency = pros-rt
src/main.rs # your code
<asset files> # optional, e.g. embed with include_bytes!
Cargo.toml:
[package]
name = "myprog"
version = "0.1.0"
edition = "2021"
[dependencies]
pros-rt = { path = "../../crates/rt" }
src/main.rs (the required shape — no_std, no main, entry via tool!):
#![no_std]
#![no_main]
use pros_rt::{tool, Sys};
fn main(sys: &Sys, args: &[u8]) -> i32 {
sys.write_str("hello from myprog\n");
0
}
tool!(main);
Constraints: no_std, no heap (fixed buffers only, for now), output to the serial console, and no network — a program reads/writes local files. std-based code (or crates needing std/alloc/networking) will not compile yet.
8.2 The Sys API (what a program can call)
| Call | Returns | Notes |
|---|---|---|
sys.write(&[u8]) / sys.write_str(&str) | to console, or the shell redirect | |
sys.read_file(dir, name, &mut out) | Option<usize> | read from your /data |
sys.write_file(dir, name, data) | bool | write to your /data (files/tmp) |
sys.delete(dir, name) | bool | delete a file |
sys.list_dir(dir, &mut out) | usize | newline-joined names |
sys.user(&mut out) | usize | the owning user's name |
sys.getc() | u8 | one console byte (for interactive) |
sys.publish(dir, name, audience) | bool | share a file |
sys.fetch(b"owner:name", &mut out) | Option<usize> | fetch a published file |
dir is DIR_FILES / DIR_LOGS / DIR_TMP. Helpers in pros_rt: trim, split_first, parse_path, contains. See programs/asterix/ for a fuller example.
To develop programs with an AI assistant that can't see this repo (e.g. claude.ai), paste docs/pros-program-brief.md — a self-contained contract of the ABI, constraints, and idioms.
8.3 Make it runnable: grant it a place in a namespace
A program only runs if the current user is granted it. The simplest home is the core namespace (bare-invocable for all users). Grants are baked into the OS, so this is a one-time step:
- Add the program to the
corecluster inbuild.sh(the--memberslist), e.g.
--members echo,cat,ls,grep,cp,rm,prano,publish,wget,asterix,hello,myprog.
- Add it to
programs/Cargo.tomlmembers. ./build.sh --buildonce to bake the grant into the OS image.
After this, you can update the program's binary freely (below) without rebuilding.
8.4 Two ways to ship the binary
(a) Bake it into the OS — add the program to TOOLS=(…) in build.sh and run ./build.sh. It's packed into the immutable ramdisk. Simple, but changing it means a full OS rebuild + reboot. Important: a baked tool shadows the overlay (the ramdisk wins at lookup), so a program you install while it's also baked will be ignored. For a program you're actively developing, use (b) and do not put it in TOOLS. (asterix and hello are set up this way — granted in core, not baked.)
(b) Install it persistently (no OS rebuild) — compile, sign, and install into the writable /bin overlay on data.img. It persists across reboots and the OS image never changes. This is the recommended loop while developing.
# 1. compile (all tools; cargo is incremental)
(cd programs && cargo build --release)
# -> programs/target/x86_64-unknown-none/release/<name>
# 2. sign it with the authority key
cargo run --manifest-path tools/manifest/Cargo.toml -- \
sign --key keys/manifest_private.bin --name <name> --version 1 \
--image programs/target/x86_64-unknown-none/release/<name> \
--out dist/certs/<name>.cert
# 3. install into the persistent /bin overlay
cargo run --manifest-path tools/install/Cargo.toml -- \
dist/data.img <name> dist/certs/<name>.cert \
programs/target/x86_64-unknown-none/release/<name>
Boot, and <name> is in /bin for good. The kernel verifies its signature at load, so a tampered or unsigned binary is refused — a writable /bin is safe. Re-run steps 1–3 to update it; no OS rebuild needed.
Common install pitfalls (both show as[DENIED] …when you run the program): - Signing the release build but installing the debug build (or rebuilding between the two). Sign and install the same file — use the…/release/…path in both. →image does not match signed hash. - Signing with a different key than the OS was built from (e.g. you regeneratedkeys/). Use the samekeys/manifest_private.bin. →signature does not match manifest. - The program is also inTOOLS(baked) — the ramdisk shadows your install. Remove it fromTOOLS.
8.5 Concrete example: asterix
asterix is granted in core but not baked into the ramdisk (so you can iterate on it via install). Sign and install its binary and it runs — before that, asterix reports unknown tool:
(cd programs && cargo build --release)
cargo run --manifest-path tools/manifest/Cargo.toml -- \
sign --key keys/manifest_private.bin --name asterix --version 1 \
--image programs/target/x86_64-unknown-none/release/asterix --out dist/certs/asterix.cert
cargo run --manifest-path tools/install/Cargo.toml -- \
dist/data.img asterix dist/certs/asterix.cert \
programs/target/x86_64-unknown-none/release/asterix
Then in the OS: asterix (renders the embedded page) or asterix files/page.html (renders a page you authored with prano).
9. Namespaces & grants
core— the ambient namespace: its tools run bare (cat files/x). Per-user.- Services (e.g.
text_tools,iamgood,admin) — namespaces you invoke explicitly
(text_tools cat files/x). Holding a service grant does not make its tools bare-invocable — a service is a namespace, not an alias.
- Deny-by-default — no grant, no run. A program in no granted namespace is
denied (iambad bare) but reachable through a service that lists it (iamgood iambad).
Grants and services are defined in build.sh (signed by the authority) and baked into the OS image. Editing them is a rebuild; installing/updating a program binary is not.
10. Reset & troubleshooting
- Wipe all persistent state (
/dataand installed programs):rm dist/data.img,
then rebuild.
- Program says
unknown tool— it's granted but its binary isn't present: install
it (§8.4b) or bake it (§8.4a).
- Program says
[DENIED] … no grant— add it to a granted namespace (§8.3). - Program says
[DENIED] … image does not match signed hash— the binary doesn't
match its certificate; re-sign after rebuilding.
- QEMU won't exit — type
quitat the prompt (the harness maps it to power-off).
11. Network diagnostics: netlog & the firewall
The network stack has a single filter choke point (kernel/src/net/filter.rs) that every TCP segment passes through — inbound from poll(), outbound from tx_seq(). Today its policy is accept everything and hand it to the stack by default; it is where the firewall (allow_inbound) lives. It also logs, which turns "the browser gets nothing" from a guess into something you can read off the wire.
Firewall — netfw (root). iptables-style, first-match rules evaluated on each new inbound connection (packets on an already-established connection are never filtered, so a rule can't drop your live SSH session):
netfw # list rules + default policy
netfw allow any 80 public-web # allow anyone to :80 (src: a.b.c.d[/n] or `any`; port or `any`)
netfw allow 203.18.30.0/24 2222 ssh # allow an admin subnet to SSH
netfw policy deny # default-deny everything else (allow :2222 first, or lock yourself out of NEW SSH)
netfw insert 0 deny 10.0.0.0/8 any # ordering: a deny at the top wins over later allows
netfw del <n> · netfw clear
A denied SYN is silently dropped (no SYN-ACK) — it still shows in netlog with no reply. Outbound connections you initiate (downloads, DNS, NTP) are never filtered.
netlog # dump the ring (oldest first)
netlog follow 30 # live tail for 30s (alias: netlog -f); stops on a console key or the timeout
netlog port 2222 # only log segments touching a port (default 80; `netlog port 0` = every port)
netlog on | off # pause / resume capture
netlog clear
Each line is [secs.usec] IN/OUT src:port > dst:port FLAGS seq=… ack=… len=…, timestamped with the monotonic RDTSC clock — but once you run ntp (or after the boot auto-sync) each line switches to a real HH:MM:SS.mmm wall-clock time. (date shows the current wall clock; there is no RTC.) Flags are SYN ACK PSH FIN RST. A healthy HTTP request reads like:
IN ..:53656 > :80 S---- client SYN
OUT :80 > ..:53656 SA--- our SYN-ACK
IN ..:53656 > :80 -A--- handshake done
IN ..:53656 > :80 -AP-- len=78 the GET
OUT :80 > ..:53656 -AP-- len=115 response headers
OUT :80 > ..:53656 -AP-- len=303 response body
IN ..:53656 > :80 -A--- ack=… client ACKs the reply ← delivered
IN ..:53656 > :80 -A-F- / OUT -A-F- clean four-way close
Diagnose by where a failing request diverges: no OUT …SA = we aren't answering the SYN; reply bytes go OUT but never get an IN …A acking them = the network is eating replies (firewall/NAT), not the service; an …R = something is resetting it.
Connections close with a proper four-way teardown (CLOSING/LAST-ACK/TIME-WAIT, visible in netstat); TIME-WAIT lingers ~2 s then the reaper frees the slot (short, to protect the 32-socket table). A stray segment for a socket that's already been freed is answered with a RST so the peer closes cleanly instead of retransmitting a FIN into the void.
netlog followruns on the SSH:2222channel, which the default port-80 filter excludes — so it never logs its own output. If younetlog port 0while following over SSH you'll see (harmless) feedback.