CLAUDE.md
Rama main · réplica de lectura del repositorio original.
# mso (product name: **MSO**)
Mobile-first web cockpit for a headless VPS, from any browser. Desktop-style UI
metaphor over a vertical-slice stack; value is utility (terminal/files/monitor/
browser), not an OS. Repo/service/domain keep the `mso` slug; "MSO" is the
UI brand. **Self-contained**: a single Next.js app, no database, no external
agent — it runs AS a host process and controls its own machine.
- Stack: Next 16 (App Router) · React 19 · Tailwind 4 · shadcn/ui · TypeScript.
**No `middleware.ts` — `proxy.ts`** (Next 16 rename).
- Auth: password + device approval → HMAC signed-cookie session plus live
Viewer/Operator/Owner role (`lib/auth/`). No Convex, no Clerk; roles are device-scoped
over one Unix service account, not named-user SSO.
- Host access: `lib/host/` does fs/exec/sys directly (Node `fs` + `child_process`),
bounded by `OS_FS_READ_ROOTS` / `OS_FS_WRITE_ROOTS`.
- Layout: `app/` + `frontend/slices/<slug>/`; barrel-only cross-slice imports
(`@/features/<slug>`).
## Read first
- `docs/PROGRESS.md` — source of truth for historical **WHY**, newest entry first.
Current code and the current-reference documents classified by `docs/README.md` define what
exists now; dated audits/plans may intentionally preserve obsolete details.
- `README.md` — what it is, features, security model, quickstart.
- `.env.example` — every var you can actually set. Reconciled against `process.env`
in code on 2026-08-03 (Camoufox, memory/threads paths, `NEXT_DEPLOYMENT_ID`,
`NEXT_PUBLIC_COMMIT_SHA` were all missing and were added). What is still deliberately
absent is what you never set by hand: framework vars (`NEXT_RUNTIME`,
`NEXT_PUBLIC_BUILD_ID` — injected by `next.config`), systemd's (`NOTIFY_SOCKET`,
`WATCHDOG_USEC`), the OS's (`PATH`, `SHELL`), test-only ones (`E2E_BASE_URL`,
`OPENCLAW_HOME`), and `OS_BROWSER_*`, which belong to the retired `os-browser/`
sidecar and not to this app. Still grep `process.env` before adding a new one.
- **The code wins over any doc.** `docs/README.md` classifies the doc set. Current
reference docs (including `docs/ARCHITECTURE.md`) must track `main`; dated audits/plans
are explicitly historical and may preserve obsolete implementation details only inside
that historical context.
- [`docs/FRONTEND-CONTRACT.md`](./docs/FRONTEND-CONTRACT.md) — required for every frontend,
state and responsive change, including native DOM surfaces; shell → feature → UI-kit ownership,
action semantics, accessibility, state lifetimes and per-feature evidence.
- `frontend/slices/assistant/CONTRACT.md` — current, and authoritative for what an
Agent / Tool / Skill / Playbook is and what reaches the model.
- `docs/MCP-FEATURE-IMPLEMENTATION.md` — stepwise observe/map/bound/reverse-engineer/
verify playbook for MCP tools, trusted skills and project capabilities.
**The doc set, and the one rule.** `docs/README.md` is the map. `docs/PROGRESS.md` is
the SSOT for historical WHY; current reference docs describe today's contracts; dated
plans/audits are historical. Append to PROGRESS when you ship — do not start a second
HAND-WRITTEN log. (`docs/CHANGELOG.md` was exactly that once and was merged
back in; the root `progress.md` is gitignored local scratch and claims authority it
does not have.) `docs/CHANGELOG.md` exists again as of 2026-08-11 but is **generated
from git subjects** by `scripts/gen-changelog.mjs` and gated stale-by-`gates.sh`, so
there is nothing to keep in sync and no way for it to disagree with history. PROGRESS
is the WHY; CHANGELOG is the WHAT, and it is what Settings → Account → About renders as
"What's new" so a shipped change is visible in the running app.
**Comparison is generated policy, not hand-written marketing.** Edit only
`docs/comparison-data.json`, then run `node scripts/gen-comparison.mjs`. The generator owns the
short README reference and full `docs/COMPARISON.md`, requires an existing repo evidence path for each MSO cell,
restricts competitor sources to reviewed official hosts, requires the SEO-readable words `Strong`,
`Partial`, and `Not offered` rather than icon-only status legends, and expires review after 90 days.
Do not raise a rating because a feature is planned; implementation evidence must land first.
**Service Center never becomes a shell-shaped button collection.** Inventory is Viewer, bounded
journal is Operator, and lifecycle is Operator plus an exact `OS_SERVICE_CONTROL_UNITS` match. Unit
names/actions are fixed argv and wildcards are rejected. Package visibility is cache-only and has no
apply action. New network/storage/user/firewall modules need their own typed policy and tests.
**New workflow skills use `bun run skill:new`, then `bun run skill:check`.** The source template is `templates/mso-skill-flow/SKILL.md.template`; do not hand-copy an untrusted skill into the official root.
**Systematic implementation is the default.** Resolve the canonical source and running target;
reproduce before editing; map the end-to-end pattern and every relevant limit; compare a working
analogue; run one discriminating experiment at a time; make the smallest reversible change; then
verify targeted contract, build, runtime and browser/client state. Do not repeatedly retry the same
failed operation, edit temporary/generated copies, or claim completion from a zero exit code. For
MCP features use `docs/MCP-FEATURE-IMPLEMENTATION.md` and the
`mso-mcp-feature-engineering` skill; tool name/schema/scope/audit/parity/toolset/client refresh and
skill trust/routing are one release contract.
**Shipping is `bun run ship "<conventional commit>"`, not `git push`.** Prod is
systemd with NO webhook, so a pushed commit changes nothing the owner can see until
someone rebuilds. The script does changelog → commit+push (gates run) → build →
restart → and then verifies the served CSS chunk actually resolves. When invoked
through MSO/MCP, it automatically hands build/restart/verification to the owner
`mso-self-update.service` transient user unit after the gated push: replacing
`mso.service` kills every process in its cgroup, including `nohup` children and the
MCP call itself. Poll `systemctl --user is-active mso-self-update.service` and
`~/.mso/self-update.log`; do not treat the handoff call returning as deployment
completion. An SSH invocation remains synchronous. Order is load-bearing: the
changelog is derived, so regenerating it AFTER the commit leaves it one commit behind
forever; and build-then-restart, never the reverse.
Deleted 2026-07-28 as dead: `SHELL-INTEGRATION-PLAN.md` and `SYNC-PLAN.md` (both target
sibling repos that do not exist on this machine), `browser-agent-plan.md` (the retired
Playwright sidecar), `SIXFIX-PLAN.md` (a finished dated fix list). Nothing linked to any
of them. Deleted 2026-07-30 in the same spirit: `PLAN.md` (the "master plan" — every
section contradicted by shipped code, and its one unique asset, a legacy-dashboard-vs-MSO
table, is descriptive rather than decision-carrying) and `MULTISHELL-PLAN.md` (its sibling
repo is gone, all six phases are checked off, and PROGRESS.md:574 reverses its one unique
decision). Both recoverable with `git show bccd0b1:docs/<name>`. Deleted 2026-08-10
(`git show 421ab7f:docs/<name>`): `IOS-PARITY-REFACTOR-PLAN.md` (every phase ✅, and the
`mock-os/` tree it refactored toward no longer exists), `DESIGN-RECONCILE.md` (self-labelled
ARCHIVE, same missing tree), `MOBILE-RESPONSIVE-PLAN.md` (phases 1–4 shipped; phase 5 is the
Playwright verification chore already described below), `SHELL-LAYOUT-KIT.md` (documented
`AppHeader`/`AppInspector`, both deleted the same day), and `AUDIT-2026-06-14.md` +
`SCORECARD-2026-06-14.md` (every row shipped or gone stale — one cites an `/api/v1/browser`
route that does not exist and a coverage install that already happened). **`AUDIT-2026-06-11.md`
STAYS** — five source comments cite its findings by number. `SHELL-FIDELITY-PLAN.md` and
`DRAWER-MENU-BYOK-PLAN.md` also stay: both look finished and are not, each carrying an
unstarted tail (phases D–F, and providers D2–D4). PROGRESS.md's pre-2026-06-15 tail (34
entries) was trimmed the same day; it is one `git show` away.
## Architecture
```
browser ──https──> mso (Next.js :4005) ──── lib/host → Node fs/child_process (host)
signed-cookie auth (lib/auth)
```
The Browser app is **Camoufox** — a real anti-fingerprinting Firefox on a headless
X display on this host, streamed over noVNC from the reserved split-origin host
`camoufox.<managed-app-domain>`. `proxy.ts` requires a live Operator/Owner device role,
strips cockpit credentials before loopback noVNC, and permanently 404s the retired
same-origin `/camoufox-vnc/*` path. It replaced BOTH the old Playwright sidecar
(`os-browser`, :4002, retired) and the sandboxed-iframe browser that briefly followed it—an
iframe cannot render most of the web because X-Frame-Options refuses framing on many sites.
`os-browser/` stays in-repo only as dev tooling (scripts/e2e use its Playwright
install). See the Browser/camoufox note further down for the systemd user unit.
- `/api/v1/*` = the host API (fs/exec/sys/term/apps/camoufox/managed-apps), every
route resolves the live device role first. Only explicitly listed reads are Viewer; every unclassified route
fails up to Owner. Operator exceptions must be explicit and bounded. There is no `/api/v1/browser`.
Client picks mock (default) vs live in Settings → Server.
- `/api/auth/*` = login/logout/me/devices. `/api/config` = BYOK AI key.
- Persistence is local: window layout + app registry in localStorage; device
allowlist + config in `~/.mso/*.json`.
## AppShell framework (the shell is generic + rr-liftable)
The shell is NOT one slice. It is split so the whole desktop+mobile shell can lift
to `resources/` (rr) and drive any project from one manifest:
- `frontend/slices/appshell/` — the **generic, brand-free** framework: window runtime
+ desktop/mobile surfaces, app/feature/brand registries, `<Slot region>`,
`ResponsiveProvider`/`useResponsive` + the 4 DRY primitives, the pub/sub buses
(toast/activity/inspector), and `<AppShell manifest>` (the one entry point). It
imports NO brand/feature and NO mso `@/lib/*` — only the universal `@/lib/utils`
(`cn`). Everything project-specific arrives via `manifest.capabilities`.
- `appshell/features/` currently contains 10 directories: `clipboard`,
`control-center`, `desktop-icons`, `force-quit`, `inspector`, `lock-screen`,
`notifications`, `search`, `shortcut-help`, `widgets`. `desktop-icons` and
`force-quit` are shell infrastructure rather than normal slot features. Keep
`docs/SLICE-CATALOG.md` and the machine-checked count in sync with the directory tree.
(`shell-settings` stays a flat UI-primitives slice — not an AppShell feature directory.)
- `os-shell` — the thin mso **consumer**: `shell.manifest.ts` (MSO brand + app
list + slugs + features) + `capabilities.ts` (adapts `@/lib/appearance`+`os-api`+
`ai/stream` to `ShellCapabilities`) + a re-export barrel (`@/features/os-shell`
re-exports appshell verbatim, so all app slices stay unedited).
- **Windowing** (`appshell/lib/store.ts`): `openWindow(app,title,size,payload,{multi})`.
Default = single instance per app (reuse/focus); `AppDescriptor.multi` (e.g. Files)
spawns a fresh window each open. `focusApp(id)` reveals the front-most existing
window without spawning — used by `UrlSync` so deep-links/back-forward don't
duplicate a multi app. **Window coords (`win.x/y`) are relative to the desktop
`<section top-[30px]>`, NOT the viewport** — snap/maximize geometry must use
`workArea()` (section-relative: `top=GAP`, `bottom=vh-TOPBAR-DOCK_RESERVE`), the
drag snap preview must be `position:absolute` (shares the surface), and drag
commits must use `offsetLeft/offsetTop`, never viewport `getBoundingClientRect`.
- **`window-content.tsx` loads app bundles with `useState`/`useEffect`, NOT
`React.lazy`+`Suspense`.** Window opens come from the synchronous external store
(`useSyncExternalStore`); a Suspense boundary suspending in that path misses its
retry ping — the chunk resolves but the spinner only clears on the next render
(a click). A `setState` on import-resolve always re-renders. Don't reintroduce
Suspense here. Dock hover warms the chunk (`app.load()`), so it stays instant.
- **Dock = macOS behaviour**: clicking a running app focuses its front window
(`focusApp`, never spawns); hovering shows its open windows to switch + a "New
Window" entry for `multi` apps. Opening surfaces (Launchpad/Spotlight) spawn.
- **`ShellCapabilities`** is the injection seam: `useAppearance`, `useCpuPercent`,
`useSearch`→`SearchHit[]`, `useSystemStats`, `useChat`, `useServerToggle`. Defaults
merged in `CapabilitiesProvider` so optional caps degrade (accessors stay
unconditional). Add an app = manifest edit; add a shell feature = new
`appshell/features/<feat>/` + `defineFeature` + add to `DEFAULT_FEATURES`. No surface
edits (open/closed).
## Routing — the OS is addressable (keep windowing!)
- ONE catch-all route `app/[[...slug]]/page.tsx` (no per-app pages). Windowing is
untouched; only the **focused** app + its launch path is mirrored to the URL
(`/files/home/user`, `/code`). `appshell` `UrlSync` does it.
- **URL writes use the History API, NOT `router.push`.** Opening a window is pure
client state — `router.push` triggers a full RSC transition + remount (slow, flashy
+ breaks the sync). Use `window.history.push/replaceState`; Next 16 syncs
`usePathname`. Deep links / ⌘-middle-click `<Link>` / back-forward still navigate.
- App URL slugs are assigned centrally in `shell.manifest.ts` (`AppDescriptor.slug`,
falls back to `id`); app slices stay URL-agnostic. Dock + Launchpad use `<Link href>`
with **`prefetch={false}`** — MANDATORY: left-click is intercepted (never navigates),
so default prefetch would fire one RSC render of the dynamic catch-all per link
(24 on load) and peg the VPS. The href is only for middle/⌘-click. Never drop it.
- The catch-all **must `notFound()` reserved paths** (`slug[0]==="_next"`): otherwise a
missing `/_next/static/*` chunk falls through and returns the app HTML with 200 →
wrong-MIME refusal, no recovery. 404 lets the client router hard-reload onto the new build.
- `next/Image` ONLY where the optimizer helps (browser favicons via the fixed Google s2
host in `next.config` `images.remotePatterns`). Host-fs images + the live Playwright
screenshot stream stay raw `<img>` on purpose (dynamic/auth'd bytes).
## Deploy / ops (instance-derived, not source-hardcoded)
- `mso.service` normally serves the current installed checkout on :4005. Resolve its actual
`WorkingDirectory` from the service unit and its browser origin from `OS_PUBLIC_ORIGIN`; never
copy a maintainer home path, hostname, or sibling app domain into portable source.
- Optional demo/review runtimes are installation-owned and must remain mock-only, loopback-bound
unless explicitly published, and outside the canonical checkout. Discover their actual service
unit/worktree instead of assuming a user or path.
- **Deploy prod:** use `bun run ship "<conventional commit>"`. It regenerates the derived
changelog, runs committed pre-push gates/out-of-tree build, pushes the exact SHA, then finalizes
the supported service replacement/chunk verification. Through MSO/MCP, finalization runs in
`mso-self-update.service`; completion is proven only when the log ends `UPDATE OK`. Operator
update/recovery is Settings → Update MSO or `mso update [--rebuild]`. Do not use a bare in-place
build merely to verify code.
- **Service worker** is served from `app/api/sw/route.ts` with a `beforeFiles` rewrite
`/sw.js`→`/api/sw`. It caches only icons+manifest, never chunks/HTML, and bakes `BUILD_ID` into
its cache name so browser clients detect a new deployment.
- **New routes need a clean build.** On a live production checkout use `mso deploy`; for
verification use `mso build`. Never remove/replace a live `.next`.
- **`bun run build` is fail-closed on a live checkout.** Use `mso build` for the out-of-tree
compile proof and `mso deploy` for a production rebuild. Raw `next build` is an internal deploy
primitive only after the supported lifecycle has quiesced the runtime.
- **The Browser app powers a systemd USER unit** whose `ExecStart` points at
`scripts/camoufox-vnc-service` in this repo. The script discovers the current user-local
Camoufox executable unless `CAMOUFOX_BROWSER` explicitly overrides it.
- For browser verification, discover an available authenticated/local or mock-only endpoint from
the current installation; do not encode an operator hostname or home path into tests.
## Pull request readiness
Follow the cross-agent hard gate in [`AGENTS.md`](./AGENTS.md#pull-request-readiness-mandatory-for-all-coding-agents).
Use `bun run pr:local` before pushing a committed task branch and
`bun run pr:security` for pinned CI scanner parity where Docker is available.
If either is missing/failed, keep the PR in **Draft** and explicitly record the
blocker. CodeQL and review-thread clearance are GitHub-side gates after PR
creation; a local green run cannot supersede them. Never bypass branch rules.
## Rules in force
- **One canonical checkout; parallel work only through isolated hidden worktrees.**
The canonical `main` checkout is a **release/integration surface, not an agent scratchpad**. Automated source-changing work must happen in a task-owned hidden worktree; if canonical is dirty with another session's work, stop and preserve it rather than stashing, resetting, committing, merging or overwriting on that session's behalf. Only a release/integration step may advance canonical after the candidate and concurrent work are independently preserved and verified.
the current `git rev-parse --show-toplevel` checkout on `main` is the canonical release SSOT.
Never create task-specific `mso-*` siblings under `~/projects` and never let two
sessions share one worktree, `HEAD`, or index. If parallel work is necessary, use one
Git worktree per task under `~/.cache/mso-worktrees/mso-<task>`. Never put a development
checkout under `~/.mso`: host-file security deliberately treats that entire tree as private state. The older one-checkout rule
existed because shared-index sessions repeatedly wiped/bundled each other's work;
isolated worktrees solve that collision without creating another source of truth.
A worktree is development-only: preserve its tracked/untracked WIP, reconcile every
user-deliverable commit into `main`, verify there, and ship through `bun run ship`.
Do not call a feature shipped while it exists only in a worktree/feature branch; the
installer and updater intentionally deliver only `origin/main`. Never prune a dirty
worktree until its intended work is committed/archived and accounted for.
- Max 200 lines/file, single responsibility, shadcn primitives only, theme tokens
not hex, mobile-first. Barrel-only cross-slice imports.
- `/api/v1` host ops go through `lib/host` (bounds + realpath checks) — never call
`fs`/`child_process` straight from a route.
- Autonomous coding agents must use isolated task branches and PR review, never push
directly to `main`. Human-owned release integration still uses the verified
`bun run ship` path after review. Conventional commits + Claude co-author.
- **The gates live in `scripts/gates.sh`, which IS committed.** `.git/hooks/pre-push`
is a one-line shim that execs it; reinstall with `bash scripts/gates.sh --install`.
They used to live in the untracked hook itself, which meant a fresh clone had NO
gates and an sc-git hook reinstall silently dropped the audit + build guards while
re-adding a `check-slices.mjs` line for a script deleted on 2026-08-03. Four guards
run the repository-owned `bun run verify`, cycle/docs/changelog checks, the local
strict high/critical audit, and `scripts/verify-build.sh`. There is no external sc-git runner
and no Convex auto-deploy tail. Hook installation resolves Git's hook path, including
linked worktrees. Build verification stays out-of-tree; never bypass gates.
- **`bun run audit` ≠ `bun audit`.** The script is `scripts/audit.mjs`, which wraps
`bun audit --json` because raw `bun audit` fails CLOSED — offline it exits 1, the
same code as a real advisory, which would turn every network blip into a fake
security failure. It skips when the registry is unreachable, applies a high/critical
floor, and keeps an `IGNORE` map (keyed by GHSA, with a reason and a date) for
advisories with no upstream fix. `--json` ignores `--audit-level`/`--ignore`, so the
filtering is done in the script. `bun run audit:strict` fails closed on unavailable,
malformed or incomplete evidence and is mandatory inside `security:ultimate`. CI also
runs the raw fail-closed command: an incomplete audit is never a security pass.
- **`bun run test:features` runs test areas separately.** PASS requires executed tests;
PARTIAL/SKIPPED evidence exits 2 and is listed explicitly. This is unit/integration
coverage, not proof that every UI workflow or third-party integration works.
## MCP server (`/mcp`) — optional, OFF by default
An MCP endpoint so ChatGPT / Claude.ai / Cursor can drive the host. `lib/mcp/*`
includes OAuth/PKCE/store/scope, read/discovery/learning/power/write tool catalogs,
toolset metadata, workflow activity and dispatch; routes live under `/mcp`, `/oauth/*`
and `/.well-known/oauth-*`. **`OS_MCP_ENABLED=1` or every one of those routes 404s** —
that is the kill switch, and demo mode forces it off. Read `docs/MCP.md`; for ChatGPT
setup/diagrams use `docs/CHATGPT-PLUGIN.md`.
Implementation changes must also follow `docs/MCP-FEATURE-IMPLEMENTATION.md`; inspect
the real runtime and working analogue before changing a tool or skill contract.
- **`/mcp` is deliberately NOT under `/api`.** `proxy.ts` blocks mutating `/api` that
cannot prove same-origin and an MCP client is cross-origin by definition; the bearer
is the control, not the CSRF gate. `proxy.ts` exempts `/mcp`, `/oauth/token`,
`/oauth/register` and `/.well-known/oauth-*` from BOTH that gate and the document
CSP. `/oauth/authorize` is NOT exempt — it is a real HTML page and needs its nonce.
- **Tool execution is transport-neutral.** `lib/capabilities/*` owns scope, required-argument validation, workflow correlation, rate limits, audit/activity and execution semantics. MCP/A2A/agent surfaces are adapters over that kernel. Host operations flow through narrow `lib/host/*-api.ts` facades, which preserve `OS_FS_*_ROOTS`, the credential denylist (including `~/.mso`) and destructive-command guards. Do not reimplement a guarded host operation inside an adapter.
- Scope ladder `read < write < exec`, picked per token on the consent page, capped by
`OS_MCP_MAX_SCOPE`. The default ceiling is `exec`, and consent preselects the highest
permitted tier; set the env to `read` or `write` to opt down. `tools/list` filters by
it AND `tools/call` re-checks it — a client can call a name it was never shown.
- **Tool names are an MSO public API contract.** Keep global names MSO-owned and generic.
Project-owned MCP names are data behind `project_mcp_tools` / `project_mcp_call`; never copy them into the global catalog.
The exact full MSO catalog **and** compact ChatGPT profile are source-generated in [`docs/generated/MCP-CATALOG.md`](./docs/generated/MCP-CATALOG.md); `GET /mcp` remains the live version/hash authority. Hidden ChatGPT-profile names are rejected at call time, not merely omitted from `tools/list`.
- Store is `~/.mso/mcp.json`, **sha256 only**, same atomic-write + fail-loud-on-corrupt
rule as `lib/auth/device-store.ts`. Codes are single-use, 60 s, deleted BEFORE the
token is minted. `browser_status` must never return the VNC password — that profile
holds a live Google session.
- **The capability kernel writes the audit trail, because model-facing tools bypass route-layer audit.** `/api/v1` routes audit at the route layer; capability tools may call guarded host APIs directly, so `lib/capabilities/execute.ts` enforces scope/rate/workflow telemetry and records declared `CapabilityTool.audit` actions once for MCP/A2A/agent adapters. MCP uses `actor: mcp:<id>` and `meta.via = "mcp"`; scope refusals log `mcp.denied`. Reads stay unlogged, same rule the routes follow. `GET /api/v1/sys/audit`
reads it (session-gated); there is deliberately **no MCP tool** for it, or a
compromised token could check whether it had been noticed.
## Install/update requests from an AI agent
When a user points an agent at this repository and asks to install or update MSO, the agent must use
`scripts/install.sh` / `mso update`, not hand-roll a competing setup. The installer is also the backward-
compatible bridge for old installations that predate `mso update` and Settings → Update MSO: it detects the
active service checkout, preserves `.env.local` + `~/.mso`, and updates in place. Never create a second
install beside an owned service or reset dirty/diverged source. End with `mso doctor` and health proof.
Credential prompts remain hidden/STDIN; tell the user where to create/store a required key, never put it
on argv or in an agent transcript.
## CLI (`bin/mso`) — the web UI is only one frontend
`bin/mso` reaches the same `/api` surface from a shell — every route has a named verb
(enforced by a test in `bin/mso.test.ts`), plus `doctor`, `completion` and `--base`.
`scripts/install.sh` verifies and launches `scripts/install-core.sh`; the core symlinks
it to `~/.local/bin/mso` and symlinks `claude-skills/*` into `~/.claude/skills/`
(every committed directory under `claude-skills/`, including `/mso-skill-authoring`).
`mso -h` lists every verb; `mso api <METHOD> <path> [json]` is
the escape hatch for anything without a named verb.
- **Every CLI caller must send `Origin: <base>`.** `proxy.ts` blocks mutating `/api`
that cannot prove same-origin; a browser proves it with `Sec-Fetch-Site`, and the
documented fallback is an `Origin` whose host matches `Host`. Without it login
itself 403s `cross_origin_blocked` — before any device check, so the error looks
like an approval problem and is not.
- One shared CLI device id lives in `~/.mso/cli.device.id` (auto-created, approve
once with `mso approve $(cat ~/.mso/cli.device.id)`). `audit.js` and
`image-editor.sh` read the same file — do not reintroduce per-script hardcoded ids.
- Local verbs (`-h`, `approve`, `devices`, `device role`, `service`, `build`) can use the
private device store/systemd directly where documented, so recovery still works while the service is down.
## Local dev
```bash
bun install
cp .env.example .env.local # set OS_LOGIN_PASSWORD + OS_SESSION_SECRET
bun run typecheck
bun run dev # OS desktop at :3000 (mock data by default)
node scripts/approve-device.js <deviceId> "my device" --role owner # bootstrap login
```
## Package manager: bun installs, Node runs (migrated from pnpm 2026-08-03)
`bun.lock` is committed; `pnpm-lock.yaml` is gone. **The runtime did NOT migrate** —
`.nvmrc`/`engines.node` still pin Node 22 and prod's `ExecStart` is still
`/usr/bin/npm run start`. `next`/`tsc`/`eslint`/`vitest` carry `#!/usr/bin/env node`
shebangs, which `bun run` honours, so every tool still executes under Node.
- **`bun run test`, NEVER `bun test`.** The builtin runner shadows the script, ignores
`vitest.config.mts`, and exits 0 having run nothing — `verify` goes green testing zero
files. Same trap in `.github/workflows/ci.yml`.
- **`node-pty` must stay in `trustedDependencies`.** No Linux prebuild → it compiles at
install; bun skips lifecycle scripts for untrusted packages. It loads eagerly through
`lib/host/pty.ts` → `lib/host/index.ts`, which every `/api/v1` route imports, so a
skipped build breaks the whole host API, not just Terminal. After ANY dependency
change: `node -e "require('node-pty')"` before building.
- **Never `bunx`/`bun x` in a deploy or CI script** — unlike `pnpm exec` it downloads a
missing package and runs it. Call `node_modules/.bin/<tool>` directly.
- `unrs-resolver`/`protobufjs` postinstalls stay blocked (both work from prebuilt
binaries). Don't "fix" the `bun pm untrusted` warning. `sharp` used to be a third
entry here; 0.35 dropped its install script, so it no longer appears at all.
- `lib/host/cleanup.ts`'s pnpm-store card stays — other repos on this box still use it.
<!-- antislop:start -->
## antislop
UI, copy, people, mobile layout, and code-comment work is filtered by antislop before it ships.
- Core filter, read first: `antislop` skill
- UI and visual: `antislop-ui`
- Copy and text: `antislop-copywriting`
- People, contrast, keyboard: `antislop-human`
- Mobile and responsive: `antislop-layoutmobile`
- Code comments: `antislop-code`
Hard gates that already apply to this repo, so a review will reject a change that breaks them:
- No em dash in user-visible strings. Comments and docs are out of scope.
- No fabricated numbers, testimonials, or security claims.
- Every interactive element has real behaviour; no dead controls.
- Text meets WCAG AA contrast; the repo gate is `node scripts/check-contrast.mjs`.
- Both shipped themes must work.
Design direction lives in `DESIGN.md`. Read it before any UI work. If it is absent, say so and treat the result as a draft without direction.
If a rule and a task conflict, name the rule and ask. Do not silently override either one.
<!-- antislop:end -->