Skip to content

Multi-user architecture: hub router, runtime policy, and containerized solo mode (keep core single-user) #45

Description

@ashwin-pc

Multi-user & safe-deployment architecture: hub, runtime policy, and containerized solo mode

Goal

Enable multiple users in an org to use pi-web through one standard, org-blessed deployment (no hosted service, no per-team custom setups, no forks) — without losing the single-user simplicity of npx pi-web on a personal machine, and without server.ts growing multi-tenant complexity.

Guiding principle

Multi-user is a deployment topology, not a feature. The core server stays single-user forever; multi-user is achieved by running more copies of the single-user server behind a small router. Single-user is not a "mode" — it is the core, and the multi-user layer composes it.

The agent executes arbitrary shell, so isolation must come from OS/container boundaries, not in-process checks. A chat agent with bash cannot be safely multi-tenanted inside one Node process.

Architecture: three independent layers

flowchart TB
    A[alice] & B[bob] --> HUB

    subgraph L1["Layer 1: identity — hub (new, separate package)"]
        HUB["hub<br/>token → identity → instance"]
    end

    HUB --> IA & IB

    subgraph L2["Layer 2: the app — stock single-user pi-web, one per user"]
        IA["alice's instance :9001"]
        IB["bob's instance :9002"]
    end

    subgraph L3["Layer 3: execution — runtimes (#43)"]
        RA["alice's docker runner"]
        RB["bob's docker runner"]
    end

    IA --> RA
    IB --> RB
Loading
Layer Question it answers Boundary Code
Hub Who is making this request? OS account / process per user new pi-web-hub package
Core pi-web The app itself — (unchanged, single-user) server.ts + server/*
Runtimes (#43) Where does agent code execute? Container/remote sandbox SessionHost + runners

The layers meet only at stock server.ts; neither the hub nor the runtimes know about each other. Every combination works without special-casing.

Deployment shapes (all wirings of the same parts)

  1. Solo, plainnpx pi-web. Today's experience, unchanged forever. No hub code loaded.
  2. Solo + sandboxed sessions — plain + per-session docker/ssh runtimes (Add runtime-bound sessions and runtime UI #43).
  3. Solo, containerized ("safe box") — official Docker image wrapping the whole app; one writable workspace mount; the container boundary replaces all safeguards at once. Recommended for non-technical users who want the agent caged and safeguards they can't accidentally unset (weakening it requires editing the docker run invocation; recovery is docker rm + re-run). Never mount the docker socket.
  4. Org multi-user — hub in front of N copies of shape 1/2/3, spawned per user with per-user HOME, workspace, port, and random PI_WEB_TOKEN.

The hub

A small router (~300 lines): authenticate (start with a static token→identity map, interface shaped for OIDC later), spawn the user's instance on demand (idle-reap), proxy HTTP + WebSocket upgrades with the instance token injected as Authorization.

Its only contract with pi-web:

  1. spawn: CLI + env vars (PORT, HOST, PI_WEB_TOKEN, PI_WEB_CWD, HOME)
  2. speak: public HTTP/WS API with a bearer token — no privileged backdoor
  3. health: readiness check

Because of that contract it can (and should eventually) live as a separate package (pi-web-hub), with pi-web as a runtime dependency. Prototype it in-repo first (hub/ directory, own entrypoint, zero imports from server/) so extraction later is a git mv. instances.ts spawning "container per user" instead of "process per user" is a one-strategy change, converging the org recipe and the solo container image.

Codebase plan

server.ts            → shrinks toward routing-only (SessionHost + module extraction per the #43 review)
server/…             → core single-user modules

proxy/               → NEW: plumbing extracted from supervisor.ts, no behavior change
  httpProxy.ts       # proxyHttp + WS upgrade tunnel, generalized to a target port
  child.ts           # spawn/stop/restart-with-backoff for one pi-web child

supervisor.ts        → stays the dev-restart tool (~80 lines), now a thin consumer of proxy/

hub/                 → NEW entrypoint (later: separate package)
  hub.ts             # server + upgrade handler (~150 lines)
  auth.ts            # Authenticator: token map now, OIDC later
  instances.ts       # Map<identity, Instance>: spawn on demand, idle-reap
  config.ts          # hub.json: users, workspace template, runtime policy, extension pack

Sequencing

  1. Land Add runtime-bound sessions and runtime UI #43 with the blocking items (especially SessionHost — the seam everything hangs off) and the server.ts decomposition. Don't build on a moving core.
  2. Extraction PR (pure refactor): pull proxy/child code out of supervisor.ts into proxy/. Dev workflow byte-identical.
  3. Runtime policy consolidation (see below) — useful for Add runtime-bound sessions and runtime UI #43 on its own.
  4. Official Dockerfile + compose file for containerized solo mode (~50 lines).
  5. Hub PR: static-token auth, per-user spawn, idle-reap, WS routing. Black-box test: two fake users, assert isolation (A's token can't reach B's instance, separate session lists).
  6. Later, independently: OIDC authenticator, org extension pack wiring, systemd/socket-activation docs, hub extraction to its own package.

Runtime policy (single-user feature; the hub is just one author of it)

Consolidate the scattered runtime flags (PI_WEB_LOCAL_RUNNER, PI_WEB_ALLOW_CUSTOM_RUNTIMES, docker provider settings) into one policy config:

{
  "defaultRuntime": "docker",
  "allowedProviders": ["docker"],
  "docker": { "image": "org/agent:1.4", "network": "none", "memory": "4g" },
  "allowCustomRuntimes": false
}

Rules:

  • Read-only to the application in every mode: loaded at startup/reload, no write API, not part of the mutable settings store. UI may display it, never edit it. Warn loudly at startup if the file is writable by the service user.
  • Solo dev owns the file → policy is a seatbelt (defaults, accident protection), not a security boundary against themselves.
  • Hub mode: file written by the hub, owned by a different account, read-only to the user; combined with OS permissions (no docker socket, no access to others' homes) it is actually enforced. Core enforces config; deployment decides who may write config.
  • Users still choose per session within the allowed envelope; the hub never micromanages sessions or speaks the runtime protocol.

Org standardization via extensibility (no forks)

Org-wide behavior ships as a versioned extension pack the hub provisions into every instance — audit hooks (tool_call/user_bash → log sink), advisory guardrails, org defaults/context, identity footer. Extensions are not the multi-user mechanism (they run inside the process, post-auth, with full trust) — they are how org policy lands without patching core.

Simplicity guardrails (enforceable)

  • server/ never imports from hub/; hub/ never imports from server/.
  • server.ts never contains the concepts "user", "identity", "owner".
  • The hub talks to instances only through the public API (bearer token), never a backdoor.
  • Any core change the hub needs must also make sense as a single-user feature (runtime policy ✅; ownership checks ❌).
  • Single-user quickstart in the README stays one command, no mention of the hub.

Hardening items (worth doing regardless)

  • Constant-time token comparison (crypto.timingSafeEqual) in isAuthorized.
  • Move the token out of the URL query string (WS: one-time ticket or subprotocol).
  • Origin/host checks on WebSocket upgrades.
  • Rate-limit auth failures.
  • In multi-user deployments: PI_WEB_ALLOW_CUSTOM_RUNTIMES stays off (authenticated host RCE), host sessions disabled by policy, per-user artifact dirs / knownCwds.

Explicit non-goals

  • No in-process multi-tenancy (per-route owner checks, per-user creds in server.ts).
  • No hosted/SaaS mode.
  • No pi-web lockdown system installer — the containerized solo image covers the "safe box" persona with less machinery.
  • No hub awareness in the frontend.

Relationship to #43

The runtime branch is Layer 3 — finish it as-is (it's the isolation half and valuable to solo users on its own). It is not multi-user by itself: identity, ownership, per-user creds/settings are untouched by it and are handled entirely by the hub layer above.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions