CLAUDE.md@packages/host · git:20261014.0012f9a · 2026-10-14 · sha256 3f6bdeb77b2086cc

CLAUDE.md@packages/host git:20261014.0012f9aA

Immutable. This exact content never changes and is served at /api/v1/blob/3f6bdeb77b2086cc.

# host

## Purpose

Shared operating-system primitives for the daemon and terminal launcher.
Shared endpoint policy stays in Gleam; this package owns its typed private
record and the filesystem, lock, process, clock, and cryptographic operations
beneath it. The shared WebSocket transport owns socket startup and lifetime;
callers own launch timing, authentication policy, and application messages.

## Key Types

- `host/skill.Catalogue` captures canonical skill documents and discovery
  warnings. Each `Skill` keeps its name, description, argument hint, invocation
  modes, source path, body and complete document together. `discover` suppresses
  directory/file aliases and reports distinct name collisions; `expand` discloses
  the complete captured document only at activation, with bounded substitution.

- `host/bootstrap.LaunchLock` holds the original lock-helper port.
  `try_launch_lock` acquires it, `lock_monitor` observes its death, and
  `release_launch_lock` closes it. The calling process owns its lifetime.
- `host/bootstrap.ServerProcess` retains a paused wrapper's port.
  `spawn_server` returns it with the OS PID; `release_server_process` lets
  the wrapper exec the server after its identity has been published.
  `spawn_server` waits for one byte the wrapper's shell writes before it
  parks, so the PID it returns already leads its own process group: OTP
  reports a forked child's PID before the child calls `setsid`.
- `host/bootstrap.ProcessIdentity` distinguishes `ProcessPresent(birth)`
  from confirmed `ProcessAbsent`. Observation errors remain errors. The Erlang
  side returns exactly this shape, so nothing translates between the
  observation and the caller.
  On Linux, only `ENOENT` and `ESRCH` from the target `/proc/<pid>/stat`
  read establish absence; boot-id, self-stat and other read errors stay errors.
- `host/endpoint.{Paths, Fence, Endpoint}` defines fixed state-root paths and
  `Starting`/`Ready` discovery. `availability` permits replacement only for
  fresh state or an observed departed native identity. `claim` adopts only
  the matching paused-wrapper reservation; `publish_ready` replaces that exact
  reservation with the actual listener port and daemon epoch.
- Private-file operations validate ownership and permissions, bound reads,
  and atomically replace a file after flushing its complete contents.
- `host/claim` is the shape of a claim token (protocol-change/053) shared by
  the daemon that mints claims, `loomd access` that prints them and `loom
  claim` that redeems them: `prefix` (`loomclaim_`), `validate_token` (the
  prefix and 64 lowercase hex, nothing else), `is_claim_shaped`,
  `mint_token(entropy)`, `random_credential` (32 bytes of
  `crypto:strong_rand_bytes`), and `digest` (lowercase SHA-256 hex). It also
  owns `remote_address`, the rule that a bearer or a claim crosses cleartext
  `ws` only to a literal loopback address, and `endpoint`, which turns a
  `/v2/control` address into its `/v2/claim` twin.
- `host/login` is the browser login's token (protocol-change/065): a
  macaroon-style `loomb1:<id>:<caveats>:<sig>` whose signature is an
  HMAC-SHA256 chain from a root key (`issue`, `sign`, `append`), and its
  verification (`parse`, `verify`, `intersect`, `check`, or `open` for the
  four in order) with `crypto.secure_compare` for every comparison. `parse`
  refuses over 384 bytes before reading a field and uppercase hex anywhere;
  `intersect` narrows repeats and refuses an unknown caveat name; `row_digest`
  is the one place a login's public identifier becomes a credential digest, to
  be looked up only as a `Browser` credential. `load_root` reads or draws
  `<state-dir>/browser.key` through the owner-and-`0600` rule `owner.token`
  uses (`Kept`, or `Drawn` when the file was missing, which tells the daemon to
  revoke every login row); an unreadable or wrong-sized file is an error and
  is never regenerated. Pure apart from that file; the clock and entropy are
  arguments.
- `host/access` is the owner's access command (protocol-change/053), one
  implementation behind `loomd access` and `loom access`. `parse(arguments,
  program)` turns the positional grammar (`list`, `show`, `members`, `invite`,
  `set-role`, `revoke`, `rotate`, `revoke-credentials`, `isolate`) into an
  opaque `Request` whose target is `Local` (the state directory's endpoint
  record and `owner.token`) or `Remote` (`--addr` with `--token-file`, which
  only `Loom` accepts). `run(arguments, program, check)` prints through a
  `Console` and answers `Succeeded` or `Failed`; the binary turns that into
  its exit status, and `check` lets `loomd` add the daemon's own decoder as a
  further refusal. `principal_lines`, `membership_lines` and `member_lines` (a
  session's members, `loom access members SESSION [--after PRINCIPAL]`) are the
  listing checks on their own, and `invite_line` and `rotate_line` render the `loom
  access` lines the terminal's `/access` overlay shows in place of granting
  (phase 3). Replies are re-encoded from checked fields: a listing row
  keeps a 16-hex fingerprint and never a longer value, a claim is printed only
  for an invitation or rotation that asked for one, and any other field the
  daemon sent is dropped. `raw_request`, `discover` and `exchange` are the
  seam the peer commands share.
- `host/websocket.Connection` retains the original socket subject.
  `connect_mapped` maps every lifecycle event in the existing socket owner, so
  terminal adapters need no forwarding process. `Connected` is minted in the
  Stratus initialiser, which runs after the upgrade and before the actor's
  first loop pass, so it cannot arrive behind an `Incoming` or `Closed` the
  socket delivered while startup was still returning.

## Relationships

- **Depends on**: `glaml`/`yamerl` for YAML skill frontmatter and `gleam_regexp`
  for Unicode skill names; `core/json` for the bounded total endpoint codec;
  `gleam_stdlib` for typed results; `gleam_erlang` for the whole of
  `gleam/erlang/process` that `host/websocket` runs on — monitors, selectors,
  links, trapped exits — and for the lock monitor type; `gleam_http` and
  `stratus` for the existing WebSocket transport; `weft` for guarded startup,
  socket custody and the monotonic clock; and `gleam_crypto`, `gleam_time`,
  `envoy`, `simplifile` and `filepath` for the facts that used to be
  hand-written Erlang. These transport dependencies moved from the TUI; no
  second WebSocket implementation was added.
- **Depended on by**: `client` for daemon state-root ownership, the claim
  token shape and `loomd access`; `tui` for local server bootstrap, its own
  bounded file reads, the remote-address rule, `loom claim` and `loom access`. Terminal logger suppression,
  stdout forwarding, and VM exit remain in `tui`.
- **FFI**: `host/internal/ffi_process` exposes only the inherited-environment
  Darwin birth observation needed to compare legacy endpoints. New observations
  use `host/bootstrap.process_identity`, with a UTC/C-locale subprocess and a
  `darwin:utc:` marker. `host/internal/ffi_zlib` exposes the shared bounded
  gzip inflater
  in `host_zlib_ffi.erl`. Extension installation retains its original client
  adapter, while release installation calls the shared primitive directly.
  OTP zlib produces bounded chunks; the shim rejects excess output before
  retaining another chunk. `host/bootstrap` and `host_bootstrap_ffi.erl` hold
  the remaining operations no Gleam package reaches: cross-process advisory locking through a helper port,
  `open_port` process launch and release, a positioned bounded read
  (`read_prefix`/`read_bounded`), an exclusive-create atomic rename,
  `realpath`, `os:find_executable`, `os:getpid`, `id -u`, procfs and Darwin
  `ps` birth identity, and the loopback-port reservation the legacy v1
  launcher still uses. Clocks, digests, the environment, `stat`, directory
  listing and permission bits are Gleam over the packages above. OTP's port
  monitor is called directly from `host/bootstrap` because `gleam_erlang`
  exposes `process.PortDown` but no public port-monitor constructor.

## Traffic

- **Actor messages**: `websocket.Message` delivers `Connected`, `Incoming`,
  `Closed`, and `NetworkFault` to the original reader-owned inbox.
  `Outbound.SendText` and `Stop` reach the existing Stratus socket actor.
  Its guardian monitors the reader and startup attempt; Erlang ports separately
  deliver data and exit status, and `lock_monitor` delivers `process.PortDown`.
- **Commits and registers**: none. Conversation databases are outside this package.
- **OS boundary**: lock helpers use `lockf` on Darwin and `flock` on Linux.
  A paused server wrapper waits for one release line before exec, and its
  environment is this VM's plus `LOOM_LOG` rather than a replacement for it,
  so the daemon inherits provider credentials and locale. Birth identity uses
  Linux procfs or Darwin `ps`; canonical paths use `realpath`, and
  `ensure_private_directory`/`read_private_bounded` use `/usr/bin/id` for the
  one fact `stat` cannot give them. `host/endpoint` therefore needs a platform
  `realpath` and `/usr/bin/id` on the startup path.

## Invariants

- Skill discovery reads at most 256 direct entries per configured location and
  64 KiB per document. Metadata and body share one capture; model context gets
  only selection metadata until invocation. No skill frontmatter or shell
  snippet executes in the host, and expansion grants no tool permissions.

- The lock owner must outlive readiness. Port loss can release the kernel
  lock even if a pathname still exists; the pathname alone proves nothing.
- Darwin uses `lockf -k` to preserve one inode across releases. Competing
  owners must never lock different inodes through the same pathname.
- Paths cross the Erlang boundary as Unicode characters, never UTF-8 bytes
  expanded into separate codepoints.
- A wrapper starts paused. Its caller publishes PID and birth identity before
  release. Closing its port after exec does not prove that the server stopped.
- Failure to observe a process is not confirmed absence. Startup policy must
  not infer safe replacement from an observation error.
- Birth identity is not equally strong on both platforms. Linux pairs the
  kernel boot id with `/proc/<pid>/stat`'s `starttime`, which is jiffy-grained;
  Darwin's `ps -o lstart=` has one-second resolution, so two processes born
  within the same second under one recycled pid are indistinguishable there.
  Nothing in the design closes that gap; it bounds how much a Darwin birth
  marker proves. New Darwin markers use UTC and the C locale independently of
  the caller's environment. Legacy `darwin:` markers remain usable when their
  inherited-environment observation matches. A mismatch on a live PID is an
  error, because the old record cannot distinguish PID reuse from a timezone or
  locale change; only confirmed process absence permits its replacement.
  UTC records use schema three without build identity and four with it; older
  decoders reject both before their unsafe native comparison can authorize
  replacement. New decoders retain schemas one/two for legacy/Linux markers
  and reject any mismatch between the schema pair and the birth format.
- A daemon log is arbitrary child output, so `current_log_tail` returns bytes
  and `host/bootstrap` decodes them. A byte offset can land inside a codepoint,
  and a `String` minted from such a slice breaks the type's invariant for every
  later `string.*` call on the one path that reports why startup failed.
- A failed probe, root death, or released lifetime lock never replaces a
  still-live VM. Missing discovery beside existing catalogue state and malformed
  records fail closed. Endpoint removal is not part of normal shutdown.
- The launcher releases `launch.lock` before waiting for a child which must
  reacquire it. `daemon.lock` independently bounds the root's live resources.
- Guarded WebSocket startup retains its links until the guardian acknowledges
  ownership. Abnormal attempt loss or reader death closes the original socket;
  normal startup-worker exit does not close a successfully returned handle.
  A guardian that fails to start is the one exit that would otherwise leave a
  socket with no owner — the worker exits normally and a normal exit over a
  link is ignored — so that path kills the socket explicitly.
- The transport does not implement application credits or bound an arbitrary
  reader's inbox. Callers enforce their protocol's frame and outstanding-work
  limits; moving the transport does not establish a new memory bound.
- A login token is never compared with `==`, never stored and never logged:
  `verify` recomputes the chain and compares in constant time, and the daemon
  keeps the root key and one catalogue row per login. The token's identifier is
  public, so nothing may authenticate with it or with `row_digest` alone; the
  catalogue lookup that follows names the `Browser` kind.
- A claim token and a bearer are told apart by shape alone: a claim always
  carries the prefix and a bearer never does. Every place that accepts one
  refuses the other, and no function here logs, formats or echoes either;
  `validate_token`'s refusal is a fixed sentence that does not repeat its
  input.

## Deep Docs

- [Skills](../../docs/skills.md) describes discovery and progressive disclosure.

- [Client architecture](../../docs/architecture/client.md) explains local startup.
- [Root CLAUDE.md](../../CLAUDE.md) contains repository rules and the doc graph.