kuro is the Kotoba terminal model for kobo
— the interactive terminal / console layer of the Kotoba workbench
(ADR-2606301000). If you are looking for a terminal, a console, a shell
session, a TTY surface, or command execution with an audit trail in this
workspace, this is the repository. The name carries none of that, which is why
this paragraph does.
kuro.terminal is portable Clojure/ClojureScript (.cljc) and contains no
direct shell, PTY, filesystem, network, thread, or clock access. A host
provides those effects; kuro defines the data contract that makes terminal
sessions auditable.
kuro = terminal session + command intent + effective grant + receipt
Status: R2 — the model is stable and a real backing runs commands with streaming output, stdin and cancellation. Not a PTY (see Not a PTY below).
Terminal modes are explicit:
| mode | purpose |
|---|---|
:terminal-repo |
default grant: repo read, tmp write, no secrets |
:terminal-build |
build grant: repo read/write, cache, bounded net |
:terminal-agent |
durable agent tick terminal |
:terminal-host |
signed opt-in escape hatch |
The host may implement the backing with a local sandbox, container, microVM, or
aiueos surface provider. kuro.terminal only records the intent and verifies
the portable shape.
A receipt is uniformly :kuro/*. Host-supplied result keys are namespaced on
the way in and keys outside the declared set are dropped, so one host cannot
widen the shape another host will not produce.
(require '[kuro.terminal :as t])
(def session
(t/session "s1" "repo-cid" :terminal-repo
{:kuro/cwd "."
:kuro/grant {:capabilities #{"repo/read" "tmp/write"}}}))
(def cmd (t/command ["kbb" "-M:test"]))
(t/receipt session cmd {:exit-code 0 :stdout "ok\n" :stderr ""})kuro.host.node is the Node (nbb) host provider for one-shot commands. It is ClojureScript, not .cljc, because the effect belongs to
the host and the model stays portable without it.
(require '[kuro.host.node :as host])
(host/run session (t/command ["git" "status" "--short"]) {:repo-root "."})
;; => {:kuro/type :kuro/receipt :kuro/exit-code 0
;; :kuro/stdout "…" :kuro/stdout-cid "bafkrei…" :kuro/duration-ms 41 …}| guarantee | mechanism |
|---|---|
| denial before execution | missing capability ⇒ denial map, no spawn |
| no shell interpolation | argv vector, :shell false |
| cwd confinement | resolved path must stay under the repo root |
| no ambient environment | the child env is the declared manifest; process.env is never passed |
| bounded time | :timeout-ms, default 120 s ⇒ exit 124, :kuro/timed-out? |
| bounded output | :max-output-bytes, default 1 MiB ⇒ exit 125, :kuro/truncated? |
| content-addressed output | stdout/stderr as CIDv1-raw/sha2-256 (bafkrei…) |
The default mode is :terminal-repo, not terminal-safe. It was renamed on
2026-08-04 because nothing here confines the filesystem or the network, and
"safe" was the one word in the vocabulary claiming otherwise — read by an
operator deciding what to run.
Every receipt carries :kuro/isolation, defaulting to :none. A host that
really confines (container, microVM, aiueos surface provider) puts its own
value there. A receipt that omits it would be read as though it had been
isolated, so it is never omitted.
A capability set is an intent record, not a kernel. This provider does not
confine filesystem writes, does not block network access, and does not isolate
the process namespace. A command granted only repo/read can still write to
disk; nothing stops it but the receipt saying it should not have.
That is the documented split — the backing may be a sandbox, container, microVM,
or aiueos surface provider. This is the smallest honest backing: enough to
produce real receipts from real commands, not enough to run untrusted code.
:terminal-host has no backing here at all and is refused.
One measured caveat: on macOS, CoreFoundation injects
__CF_USER_TEXT_ENCODING into every child below the spawn API. The manifest is
everything this provider passes, not literally everything the child sees.
run is synchronous: it returns only when the command is over, which makes a
ten-minute build and an infinite loop look identical to the caller. The
streaming provider fixes that.
(require '[kuro.host.stream-node :as sh])
(def h (sh/start session (t/command ["npm" "test"])
{:repo-root "."
:on-chunk (fn [st chunk] (print (:text chunk)))
:on-exit (fn [receipt] ...)}))
((:write h) "y\n") ; stdin
((:close-stdin h))
((:kill h)) ; SIGTERM
@(:stream h) ; the live kuro.stream value, readable at any momentsh/run-async wraps it in a Promise resolving to the receipt.
The same guarantees apply — capability check before spawn, argv with no shell,
cwd confinement, declared environment, deadline, output cap. The cap is
enforced across both streams together by kuro.stream, which also records
how many bytes it dropped: a silently-cut receipt is indistinguishable from a
short success.
dropped-bytes counts bytes the host actually read from the pipe and then
discarded. When the cap fires, the provider stops reading and kills the
child, so bytes still sitting unread in the OS pipe are not counted: the sum
stdout-bytes + stderr-bytes + dropped-bytes is a lower bound on what the
child wrote, not an exact total. This is deliberate — kuro is a pipe, not a
PTY, and the host cannot count what it never read. kuro.stream drops
chunk-wise (a chunk that does not fit whole is dropped whole), so what is kept
is always an exact prefix of the emitted output.
A kuro.stream value lives only in memory: if the host dies, both what was
running and how far it got die with it. kobo.agent's durable loop cannot
sit on that.
(require '[kuro.checkpoint :as cp])
(cp/->edn @(:stream h)) ; a plain EDN value — write it anywhere
(cp/restore saved) ; back, as a streamPure .cljc: this namespace converts, it does not persist. Writing is the
host's job, so the choice of file / kotobase / DataLad does not reach here.
Two things it refuses to pretend:
- A restored run is never
:running. Its process did not survive the host, so it comes back:orphaned(with:kuro/restored-fromkeeping what it was). Returning:runningwould invite the caller to send stdin to a corpse. cp/abandoncloses an orphan into a receipt — exit 129 and a stated reason by default, or whatever the host actually knows. A run that cannot be closed leaves an open hole in the ledger.
:max-chunk-bytes truncates the saved body and records
:kuro.checkpoint/dropped-bytes; the byte counters keep the pre-truncation
truth. Off by default — capping is the storage's business, not this layer's.
Command output is not plain text. kuro.ansi turns it into styled lines.
(ansi/lines "\u001b[32mok\u001b[0m\nnext")
;; => [[{:text "ok" :style {:fg "green"}}] [{:text "next" :style {}}]]Handled: SGR (bold/dim/italic/underline/inverse/strike, 8 + bright + 256 +
truecolor, resets), \r in-line overwrite, \b, tab expansion, and CSI K
erase — which is what actually collapses a progress bar (a bare \r leaves
the tail, on a real tty too). OSC 8 hyperlinks drop the URI and the sequence
but keep the linked words — a hyperlink wraps visible text, and losing the
words would make the log lie. Everything else — cursor addressing, scroll
regions, alternate screen, window-title OSC — is discarded, never printed.
The child is connected to pipes, not a pseudo-terminal. isatty is false,
so many programs disable colour and switch to line buffering; full-screen
programs (vim, top) do not work; there is no terminal size and no
SIGWINCH. The declared environment sets TERM=dumb rather than lying about
it.
kuro.ansi is a line-oriented reader, not a screen emulator — it has no
cursor grid and no scrollback. A real PTY needs a native addon (node-pty or
equivalent), which is a dependency decision this repo has not taken.
In a browser there is no process to spawn and no disk — so the host seam is filled differently, and the receipts say so.
kuro.fs is portable .cljc (the same source runs on the JVM, under nbb,
and in a browser via the shadow-cljs build). Paths map onto a content-
addressed DAG: a directory is an IPLD map block, a file is a unixfs file
DAG (tech-ipfs-specs-unixfs — not re-implemented here). The host injects
two effects:
put-block : bytes -> cid
get-block : cid -> bytes | nil
The production wiring is an OPFS-backed cache in front of the kotobase
gateway (kuro.host.opfs); tests use an in-memory atom. Writes are
immutable — every write produces a new root, so undo is remembering a
previous root. Denied work (path traversal, missing paths, non-empty-dir
removal) produces recorded receipts, not exceptions — an empty receipt list
must not mean both refused and never asked.
A dedicated Worker holds OPFS sync access handles (a Worker-only API) and
speaks a closed op vocabulary (put / get / delete / publish /
stats / drop-cache). CIDs are minted with WebCrypto sha-256 inside the
Worker; the math is parity-locked with kuro.host.cid (node:crypto) and the
independent Python mint at scripts/cid_mint.py (pinned by
test:kuro.host.opfs/python-mint-parity and gated in CI — a mint nobody
runs would be a claim nobody checks). publish verifies each block's bytes hash to its
claimed CID before counting it. delete is idempotent: removing a cid
that is not cached succeeds with {removed: false} (never an error), while a
real failure (open handle, permission, quota) surfaces as a :kuro.opfs/error
reply — a bare ok: false that cannot distinguish "not cached" from "failed"
is not the contract. drop-cache reports {removed: n} — a partial failure
mid-removal is visible in the reply, not collapsed into a generic
worker-error with no count. OPFS is origin-scoped cache — durable
custody is kotobase (PUT/GET kotobase.net/ipld/:cid), and publish is an
explicit op, never automatic.
The stream-node shape (start / write / kill / on-chunk / on-exit) with a
guest-component backing instead of a spawned process. Commands are guest
invocation specs (guest CID + export + args); argv vectors are denied with
:argv-not-a-process — a browser has no processes, and a receipt that
pretended otherwise would lie about what ran.
Every browser-host receipt carries :kuro/isolation :browser-origin, which
records: ran in a Worker, no DOM, no ambient authority, effects only through
capability-gated host imports. It does not claim kernel-level
sandboxing — a capability set is an intent record, not a kernel (same rule
as above). The gate is real where it can be: the host refuses to satisfy a
guest's fs_read import unless the grant covers it, and fs/write implies
nothing about fs/read (separate leaves).
- real OPFS round-trip with WebCrypto-minted CIDs (byte-identical across node:crypto, WebCrypto-in-Worker, and an independent Python mint)
- a real WASM guest (assembled with wasm-tools) streaming its output into
kuro.streamand finishing with an isolated receipt kuro.fsitself running in the browser (shadow-cljs build) writing, listing, and reading back byte-identical through the OPFS store- a guest reading a
kuro.fs-written file through a gatedfs_readimport, with the un-granted capability refused - a real WASM guest running inside a Worker (not the page): the page only
postMessages a start request, and the guest's memory and i32 exit code cross
the worker boundary as a
kuro.streamstdout chunk and an exit message
See test/browser/verify_*.cljs (one file per entry above) and ADR-2609041240.
kbb -M:test # portable model + ansi + stream (JVM)
npm install
npm run test:parity # the SAME .cljc suites under ClojureScript
npm run test:host # both Node host providers (nbb)All three run in CI.
test:parity exists because "portable .cljc" was a claim nobody checked. For
one day kuro.ansi was green on the JVM and silently produced no output at
all under ClojureScript — (int c) is NaN there, so the CSI scanner never
found a final byte and discarded everything. The consumer that broke was
kobo's server, which runs on nbb. A .cljc namespace tested on one runtime
can be entirely dead on the other.