From dd0c55715103bce8cf9715defd0fe581ff54c888 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 05:33:17 +0000 Subject: [PATCH 01/61] =?UTF-8?q?docs:=20ratify=20UI=20client=20split=20(O?= =?UTF-8?q?ption=20C)=20=E2=80=94=20revise=20runtime-modes=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ui-client-split-assessment.md: decision record ratifying the host/client split — packages/ui stays the host control plane; chat/connections extract to a thin static packages/client (+ shared packages/ui-kit); PWA ships with two install origins (localhost via harness/CLI, official hosted URL with central TLS). Revise ui-runtime-modes-plan.md accordingly: - Phases 1-4 unchanged; new Phase 1.5 (openpalm admin / host-ui mode) - Phases 5-6 re-scoped to the client extraction; new Phase 6.5 (guardian TLS + CORS workstream, gates the phone story) - [as-landed] corrections: skeleton resolution is a five-strategy chain retaining Electron extraResources; tools use the baked package.json + bun update pattern (supersedes tools.json design); entrypoint install/start_ui already scaffolded, with the OPENCODE_API_URL vs OP_OPENCODE_URL wiring gap recorded - Simplicity guardrails: TLS never a manual user task on default install paths; two UI packages max; pairing is paste-or-scan Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 --- docs/technical/ui-client-split-assessment.md | 177 ++++++ docs/technical/ui-runtime-modes-plan.md | 636 +++++++++++-------- 2 files changed, 546 insertions(+), 267 deletions(-) create mode 100644 docs/technical/ui-client-split-assessment.md diff --git a/docs/technical/ui-client-split-assessment.md b/docs/technical/ui-client-split-assessment.md new file mode 100644 index 000000000..b43865c08 --- /dev/null +++ b/docs/technical/ui-client-split-assessment.md @@ -0,0 +1,177 @@ +# UI Client Split — Decision Record + +**Date:** 2026-07-06 +**Status:** RATIFIED +**Repo:** `itlackey/openpalm` +**Revises:** `docs/technical/ui-runtime-modes-plan.md` (Phases 5–6 scope) +**Related issues:** #486 (remote-only install), #509 (RuntimeContext), #510 (assistant-container), #511 (PWA), #506 (styling), #435 (guardian auth) + +--- + +## 1. Decision + +**Split the client, not the admin.** + +- `packages/ui` (`@openpalm/ui`) remains what it already is: the **host control plane** — an + `adapter-node` SvelteKit app (setup wizard, `/host` admin, secrets, Docker lifecycle, + connection CRUD) served loopback-only by the harness (Electron or CLI). It is not split + into a new package; it *sheds* the chat surface instead. +- A new **`packages/client`** (`@openpalm/client`) carries the end-user surface: chat, + connection switching, assistant settings views. It is a static, PWA-installable SvelteKit + app (`adapter-static` + `@vite-pwa/sveltekit`) with **one uniform transport**: connect to a + guardian/OpenCode base URL with credentials — same code whether that URL is + `127.0.0.1`, a LAN host, or a remote instance. +- Shared look and feel lives in **`packages/ui-kit`** (components, icons, theme tokens), + consumed as a raw-source workspace package (no publish step; inlined at build time by + both apps). +- The PWA ships with **two primary install origins**: + 1. **Localhost** — the harness/CLI serves the static client on a stable loopback port; + `http://127.0.0.1:` is a secure context, so it installs with zero TLS setup. + 2. **Official hosted URL** — e.g. `https://app.openpalm.dev`: a canonical, centrally + TLS-terminated origin serving the same static client, giving phones/tablets a single + install that connects to any of the user's instances. +- Phases 1–4 of `ui-runtime-modes-plan.md` (RuntimeContext/capabilities, `/connections`, + landing resolver, `/host` + control-plane API split) proceed **unchanged** — they are + prerequisites for the split and for every alternative considered. +- `openpalm admin` (the plan's `host-ui` mode) ships **early**: the CLI already serves the + UI loopback-only with password auth; enabling the admin capability there is a small, + proven pattern (Syncthing, code-server, Prisma Studio all default to a loopback-only + admin web UI). +- **Guardian edge TLS + CORS** becomes its own tracked workstream. It is a hard + prerequisite for the hosted-origin install path connecting to LAN instances (mixed + content), and independent of any packaging choice. + +## 2. Context (verified 2026-07-06, v0.12.52) + +- The UI package is one `adapter-node` app of ~44k LOC source. Admin/setup routes plus + admin components are ~22.5k LOC; chat is ~6.6k. There are 59 `/admin/*` server endpoints + and 19 `/api/setup/*` endpoints vs. one chat proxy. "Splitting out the admin" would mean + moving four-fifths of the app; extracting the client moves one-fifth. +- Admin is already effectively harness-only: `features.admin` requires `OP_INSIDE_ELECTRON=1` + or `OP_ENABLE_ADMIN=1` (`packages/ui/src/lib/server/features.ts`), and the CLI sets + neither — a CLI-served UI today is chat + setup wizard only. +- The Electron app is a genuinely thin harness (~2,400 LOC; #495, shipped 0.12.0): it spawns + the same SvelteKit build the CLI runs, on `127.0.0.1:3880`, and self-updates the control + plane from npm. Nothing on the Electron side needs restructuring. +- Instance switching exists (`endpoints.json`, `EndpointSwitcher`, per-request resolution in + `/proxy/assistant/[...path]`) but is **server-side per host process** — the chat client + assumes same-origin + cookie auth. A phone PWA needs client-held connections and direct + cross-origin calls; that transport does not transfer. +- `containers/assistant/entrypoint.sh` already installs `@openpalm/ui` + `@openpalm/skeleton` + at startup and launches a UI co-process (unexposed; see plan §6.9 as-landed notes). +- No PWA assets exist (no manifest, no service worker, no `@vite-pwa/sveltekit`). +- Plan status: Phase 0 (`@openpalm/skeleton`, #508) landed (with deltas — see plan + "As-landed corrections"); Phases 1–8 not started. `/admin`, `/splash`, `FeatureFlags` + are still current. + +## 3. Options considered + +### A — One app, capability-gated everywhere (plan as originally written) + +One `adapter-node` artifact for all four host modes; capabilities hide features; PWA is the +same build plus manifest + service worker. + +- Cheapest incremental path; single release artifact; Open WebUI precedent. +- **Rejected as the Phase 5–6 shape** because: (a) the transport still forks internally — + host modes chat through a same-origin server proxy with cookies while PWA mode needs + direct cross-origin guardian calls with Basic/Bearer, so the "one code path" claim breaks + exactly where it matters; (b) every deployment (including the assistant container) ships + all host-admin server endpoints and the inlined control-plane lib, relying on runtime + 403s (Phase 7 negative tests become permanently load-bearing); (c) an official hosted + install origin is impossible — you cannot host a machine-bound `adapter-node` control + plane at `app.openpalm.dev`. + +### B — Split admin/setup into a new package now + +- **Rejected.** Admin+setup is ~80% of the app with the deepest server coupling + (`hooks.server.ts`, sessions, Docker, secrets, `@openpalm/lib`). The destination would + look exactly like what `packages/ui` already is: an adapter-node control-plane app served + loopback-only by the harness. Weeks of churn to arrive where we already are, and the + entanglements that make it expensive (Navbar↔chat imports, `endpoints-state`↔`chat-state`, + the `$lib/api.js` barrel) are removed by Phases 1–4 anyway. + +### C — Phases 1–4 unchanged + `openpalm admin` now; extract a thin client at the Phase 5/6 fork — **RATIFIED** + +- The split happens *after* Phases 2–4 have moved connections out of chat state and split + the API namespaces — at that point it is mostly moving files, not untangling them. +- Each artifact ends with one transport model, one audience, one reason to change: + - host app: server-proxied, cookie-auth, loopback, privileged; + - client app: direct API consumer with client-held connections, static, unprivileged. +- Enables both PWA install origins (§1) — the hosted origin *requires* a static client. +- Structural exclusion of host-admin code from containers and from the PWA bundle + (defense in depth beyond runtime 403s), smaller container npm pull, no `@openpalm/lib` + in the client at all. +- Honest costs: a second build/release unit; a shared `ui-kit` package to keep healthy; + assistant-container mode needs a small server for `/api/assistant/*` writes (static + client cannot write persona files) — a minimal sidecar rather than the full control + plane; a version-skew handshake between hosted client and instance APIs (`/api/runtime` + contract version, already in the plan). + +### D — Minimal: `openpalm admin` + TLS docs only, defer PWA/container modes + +- Fine as a stopgap; abandons #510/#511 and leaves mobile as an un-installed browser tab. + Only right if 0.13.0 must shrink. Not chosen, but note that its two cheap items + (`openpalm admin`, TLS docs) are included in C anyway. + +## 4. Evidence + +External findings that decided the shape (researched 2026-07-06): + +1. **PWA install requires a secure context** — HTTPS or `localhost`/`127.0.0.1`. A UI served + over plain HTTP from a LAN IP is not installable, period. + (MDN: Making PWAs installable — developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable) +2. **Mixed content:** an HTTPS-served page cannot `fetch()` plain-HTTP LAN endpoints. So the + hosted-origin install path requires TLS on any guardian it connects to (Tailscale + `ts.net` certs and Caddy are the proven homelab paths). A `http://127.0.0.1` origin is + *not* subject to mixed-content blocking and — being the most-trusted tier under Chrome's + Private Network Access model — may call plain-HTTP LAN guardians. This is why the + localhost install path works with zero TLS setup and the hosted path needs the TLS + workstream. +3. **PWA identity is per-origin** (one installed app = one origin; `start_url` must be + same-origin). Multi-instance switching must be an in-app connection switcher against + multiple backend APIs — which `/connections` + IndexedDB already design correctly — and + a single canonical hosted origin means one install serves all instances. + (web.dev/articles/multi-origin-pwas) +4. **SvelteKit has no supported per-build route exclusion** (`config.kit.routes` was removed; + kit#6031). Route groups gate access but ship the code. Two thin apps sharing a workspace + component package is the grain-of-the-tool pattern (official Turborepo Svelte example; + `svelte-package` or raw-source workspace exports). +5. **Electron security guidance** — privileged windows load local/loopback content, never + remote; OpenPalm already complies and nothing about the harness changes under this + decision. (electronjs.org/docs/latest/tutorial/security) +6. **Prior art:** Open WebUI keeps one capability-gated app, but its "admin" is same-process + settings, not host Docker control. Platforms whose admin holds host-level power (Umbrel, + CasaOS, Rancher Desktop) separate the platform UI from app UIs. Loopback-only + CLI-launched admin web UIs are a proven pattern (Syncthing, code-server, Prisma Studio, + Drizzle Studio). Expo's sunset web UI is the cautionary tale: keep the second surface + thin — which the client (not the admin) is. +7. **`@vite-pwa/sveltekit`** is the mature tooling path for the client app (SvelteKit 2, + Workbox precache, adapter-static + SPA fallback). + +## 5. Consequences + +1. `ui-runtime-modes-plan.md` Phases 5–6 are re-scoped (see the plan's revision note): + assistant-container pulls `@openpalm/client` (+ a minimal assistant-settings API sidecar) + instead of the full `@openpalm/ui`; PWA work lands in `packages/client`. +2. New release units: `@openpalm/client` (npm, exact-pin delivery like the UI) and the + hosted static deploy (CI publish of the client build to the official URL). `ui-kit` is + workspace-internal and not published. +3. Guardian grows a CORS allowlist (the official origin + configurable extras) and the TLS + workstream lands at its edge. Coordinates with #435 (auth) — credentials over HTTPS only + for remote connections. +4. The `/api/runtime` public endpoint doubles as the version-skew handshake for the hosted + client (client checks the instance's advertised contract version before enabling + features). +5. `packages/ui` keeps a chat surface only until the client app reaches parity in Electron + (the harness serves the client build alongside the host app; Electron users keep one + window). After that, chat code is deleted from `packages/ui`. + +## 6. What this does **not** change + +- Electron thin-harness architecture (#495), the harness contract, and the artifact + delivery pattern (exact-pin npm for platform components) all stay as-is; `@openpalm/client` + simply becomes one more exact-pinned artifact. +- Host admin remains loopback-only with the `op_session` cookie. The client app never + holds host credentials. +- `resolveCapabilities()` remains the single capability-resolution point *within each app*; + the split reduces how much it must express (the client never resolves `host:*`). diff --git a/docs/technical/ui-runtime-modes-plan.md b/docs/technical/ui-runtime-modes-plan.md index 6c81c382a..735f42f91 100644 --- a/docs/technical/ui-runtime-modes-plan.md +++ b/docs/technical/ui-runtime-modes-plan.md @@ -1,16 +1,23 @@ # UI Host/Client Runtime Refactor Plan -**Date:** 2026-06-19 -**Status:** DRAFT — decisions recorded, ready for implementation +**Date:** 2026-06-19 (revised 2026-07-06) +**Status:** RATIFIED — Phases 0 landed; Phases 1–4 as designed; Phases 5–6 re-scoped per `docs/technical/ui-client-split-assessment.md` **Repo:** `itlackey/openpalm`, branch `main` -**Related issues:** #486 (remote-only install), #435 (guardian authn), #433 (guardian state), #488 (mDNS), #506 (styling) +**Related issues:** #486 (remote-only install), #435 (guardian authn), #433 (guardian state), #488 (mDNS), #506 (styling), #509 (RuntimeContext), #510 (assistant-container), #511 (PWA) + +> **Revision note (2026-07-06).** The original draft shipped one `adapter-node` build to +> every runtime mode. That scope is revised: the product is still **one UI**, but it ships +> as **two artifacts** — the host control plane (`@openpalm/ui`, unchanged) and a thin +> static client (`@openpalm/client`) for chat/connections/PWA. Rationale, options +> considered, and evidence: `docs/technical/ui-client-split-assessment.md`. Phases 1–4 +> are unchanged. Sections marked **[as-landed]** correct implementation details that +> drifted between the 2026-06-19 draft and the code that actually shipped in Phase 0 / +> the entrypoint scaffolding. --- ## 1. TL;DR -The right design is **one UI product with multiple runtime modes and a consistent artifact delivery pattern** across every component of the stack. - OpenPalm splits the UI into three capability-scoped surfaces: 1. **Host Control Plane** — Electron/host SvelteKit process. Docker, lifecycle, secrets, OP_HOME. @@ -19,33 +26,59 @@ OpenPalm splits the UI into three capability-scoped surfaces: The current code collapses all of this into `features.admin`, `/admin/*`, `/splash`. That cannot express "chat + assistant settings but no host management" or "PWA connection manager." +**Two artifacts, one product:** + +- **`@openpalm/ui`** (existing, `adapter-node`) — the **host app**: setup wizard, `/host` + admin, connection CRUD, Docker lifecycle. Privileged, loopback-only, served by the + harness (Electron or `openpalm admin`). Bundles `@openpalm/lib`. +- **`@openpalm/client`** (new, `adapter-static` + `@vite-pwa/sveltekit`) — the **client + app**: chat, connection switching, assistant settings views. Unprivileged, no + `@openpalm/lib`, one transport: talk to a guardian/OpenCode base URL with credentials. + Installable as a PWA from two origins (localhost, official hosted URL — §6.10). +- **`packages/ui-kit`** — shared components/icons/theme, raw-source workspace package, + inlined at build time by both apps. Not published. + +### Simplicity guardrails (non-negotiable UX constraints) + +- **Default flows never make TLS a user task.** Desktop installs from + `http://127.0.0.1:` (secure context, no certificates). Phones install from the + official hosted URL, which is TLS-terminated centrally. +- **"Install → open → chat."** Adding a connection is paste-a-URL-and-code (or scan a QR + from the host app); no config files, no cert stores, no port math. +- **Two apps, not N.** No further UI packages beyond `client` and `ui-kit`. The + assistant-settings write API in container mode is a minimal shim, not a third app — + and it is an optional follow-up slice, not a launch requirement. + ### Standard artifact delivery pattern Every updatable component follows the same pattern. This is the design standard for the whole stack: | Component | Package | Version strategy | Runtime install | Containers | |---|---|---|---|---| -| UI | `@openpalm/ui` | Exact pin: `OP_UI_VERSION` → `PLATFORM_VERSION` → **error** | `npm install` | assistant | +| Host UI | `@openpalm/ui` | Exact pin: `OP_UI_VERSION` → `PLATFORM_VERSION` → **error** | `npm install` | — (host process) | +| Client UI | `@openpalm/client` | Exact pin: `OP_CLIENT_VERSION` → `PLATFORM_VERSION` → **error** | `npm install` | assistant | | Skeleton / OP_HOME seed | `@openpalm/skeleton` | Exact pin: `OP_SKELETON_VERSION` → `PLATFORM_VERSION` → **error** | `npm install` | assistant, guardian | | Guardian | `@openpalm/guardian` | Exact pin: `OP_GUARDIAN_VERSION` → `PLATFORM_VERSION` → **error** | `npm install` | guardian | | Portal (discord) | `@openpalm/portal-discord` | Exact pin: `OP_PORTAL_DISCORD_VERSION` → `PLATFORM_VERSION` → **error** | `npm install` | portal | | Portal (slack) | `@openpalm/portal-slack` | Exact pin: `OP_PORTAL_SLACK_VERSION` → `PLATFORM_VERSION` → **error** | `npm install` | portal | -| opencode | `opencode` (npm) | Range: `^minor`, `OP_TOOL_OPENCODE_VERSION` override, from `tools.json` | `bun add -g` | assistant, guardian | -| akm-cli | `akm-cli` | Range: `^minor`, `OP_TOOL_AKM_VERSION` override, from `tools.json` | `bun add -g` | guardian | -| claude-code, codex, … | per-tool npm packages | Range: `^minor`, `OP_TOOL_*_VERSION` override, from `tools.json` | `bun add -g` | assistant | +| opencode, akm-cli, claude-code, … | per-tool npm packages | Range (`^minor`) via the baked tools `package.json`, per-tool env override | `bun update` | assistant, guardian | **Two version strategies, one delivery mechanism:** -- **Exact pin** — components where a specific version must be matched at the API or data level: UI (RuntimeContext contract version), skeleton (`SHIPPED_DEFAULT_HASHES` hash values), guardian (security boundary, operator must explicitly choose a version). Fail loudly if version cannot be resolved. -- **Range (`^minor`)** — all tools: opencode, akm-cli, claude-code, codex, and any future additions. No correctness coupling; auto-updating within minor is desirable and aligns with npm semver expectations. Both containers reading the same `tools.json` from the same skeleton version naturally resolve opencode to the same version — no special lockstep handling needed. Per-tool `OP_TOOL_*_VERSION` env var overrides when an operator needs to pin or skip a specific tool. - -**Deviation from range requires written justification in the issue.** If a tool ever needs exact-pinning, document why the `^minor` contract is insufficient before adding the exception. +- **Exact pin** — components where a specific version must be matched at the API or data level: UI/client (RuntimeContext contract version), skeleton (`SHIPPED_DEFAULT_HASHES` hash values), guardian (security boundary, operator must explicitly choose a version). Fail loudly if version cannot be resolved. +- **Range (`^minor`)** — all tools. **[as-landed]** Tool ranges are declared in a + `package.json` baked into the image at `/opt/openpalm/tools/package.json` (source: + `containers/assistant/tools/package.json`), bind-mounted from + `OP_HOME/data/assistant/tools` at runtime, and advanced with `bun update --production`. + This supersedes the draft's `tools.json`-in-skeleton + `bun add -g` design — the + landed pattern gets lockfile semantics and per-tool pinning by editing one file, with + no magic env-key normalization. **Key principles:** -- One UI. The PWA is the same SvelteKit app (adapter-node + service worker), features hidden by resolved capabilities. -- Assistant image is a thin host — pulls `@openpalm/ui` and `@openpalm/skeleton` from npm at startup. +- One UI *product*, two *artifacts*. The host app is the privileged control plane; the client app is a pure API consumer. Neither ships the other's code. +- Assistant image is a thin host — pulls `@openpalm/client` and `@openpalm/skeleton` from npm at startup. - Containers carry no bundled content; content is versioned and delivered separately. -- Capabilities are resolved from server context + client context by one function. That function is the only extension point. +- Capabilities are resolved from server context + client context by one function per app. The client app can never resolve `host:*` capabilities — structurally, not by branching. --- @@ -53,19 +86,22 @@ Every updatable component follows the same pattern. This is the design standard | Question | Decision | |---|---| -| Skeleton delivery | `@openpalm/skeleton` npm package, no-code, no-build, published alongside platform release | +| Skeleton delivery | `@openpalm/skeleton` npm package — **landed** (#508); see §6.7 for the as-landed resolution chain | | Skeleton version coupling | Hard: skeleton version === platform version. No `latest` in production. `isUnmodifiedDefault` hashes depend on this. | -| Skeleton 0.13.0 scope | Full unification in 0.13.0: create package, replace GitHub raw fallback, drop Electron extraResources skeleton, add manifest.json | -| `assistant-container` UI deployment | `@openpalm/ui` pulled from npm at container startup | -| `assistant-container` skeleton deployment | `@openpalm/skeleton` pulled from npm at container startup (same startup script, same version chain) | -| `assistant-container` process model | One image, two co-processes: UI server + OpenCode | -| PWA build approach | One adapter-node build, service worker + manifest in `static/`. Full UI, capabilities hide features. No new pages. | -| PWA/remote auth | Guardian Basic auth matching OpenCode implementation. Coordinate with #435. No parallel credential mechanism. | +| Host/client split | **Ratified 2026-07-06** — extract `packages/client` + `packages/ui-kit`; `packages/ui` remains the host control plane. See `ui-client-split-assessment.md`. | +| `assistant-container` UI deployment | `@openpalm/client` pulled from npm at container startup (was: `@openpalm/ui`) | +| `assistant-container` skeleton deployment | `@openpalm/skeleton` pulled from npm at container startup — **landed** in `entrypoint.sh` | +| `assistant-container` process model | One image, co-processes: static client (+ optional settings shim) + OpenCode | +| PWA build approach | `packages/client`: `adapter-static` + `@vite-pwa/sveltekit`. Full client UI; host features are absent from the artifact, not hidden. | +| PWA install origins | **Two primary paths:** (1) localhost — harness/CLI serves the client on a stable loopback port (secure context, zero TLS setup); (2) official hosted URL (e.g. `app.openpalm.dev`) — canonical TLS-terminated origin, one install for phones/tablets. | +| Guardian TLS + CORS | Own workstream. Hard prerequisite for hosted-origin → LAN connections (mixed content) and for any phone → guardian connection. Never a manual user task on default paths. | +| PWA/remote auth | Guardian Basic auth matching the OpenCode implementation. Coordinate with #435. No parallel credential mechanism. | +| `host-ui` mode / `openpalm admin` | Ship **early** (Phase 1.5): CLI serves the host app with admin enabled, loopback-only, existing password auth. | | `clientMode` detection | Client-side only: `matchMedia('(display-mode: standalone)')` + `navigator.userAgent`. Not server-computed. | -| Capability resolution | `effectiveCapabilities = resolve(serverCaps, clientCtx)`. One function, one file. | +| Capability resolution | `effectiveCapabilities = resolve(serverCaps, clientCtx)`. One function, one file (per app). | | Legacy `/admin` route lifetime | No alias. `/admin/*` → 404 when `/host/*` ships. <10 installs, no audience to protect. | | `endpoints.json` rename | Do not rename. Add `kind` field. Internal model uses "connection" language. | -| Milestones | 0.13.0: Phases 0–4 + skeleton package. 0.14.0: Phases 5–8 (assistant-container + PWA). | +| Milestones | 0.13.0: Phases 0–4 + 1.5. 0.14.0 candidates: Phases 5–6 (client extraction, assistant-container, PWA) + TLS workstream — re-split if 0.13.0 gets heavy. | --- @@ -89,48 +125,41 @@ function resolveArtifactVersion( } ``` -### Source resolution (skeleton / all file-seeding packages) +### Source resolution (skeleton / all file-seeding packages) — **[as-landed]** -Three steps. No more. +The landed chain (`packages/lib/src/control-plane/ui-assets.ts`, `resolveLocalOpenpalmDir()`) +has five strategies, not the draft's three: ``` -1. OPENPALM_REPO_ROOT env var → dev/source mode (keep forever) -2. require.resolve('@openpalm/skeleton/package.json') → resolved package dir (CLI bundled dep, Electron) -3. npm install --prefix @openpalm/skeleton@ → cold start / upgrade path -``` - -Steps that are gone: `import.meta.url` relative chains, GitHub `raw.githubusercontent.com` download. - -### Install script shape (entrypoint.sh) - -```sh -# ── Exact-pinned components ─────────────────────────────────────────────────── -install_artifact() { - local pkg="$1" version="$2" prefix="$3" - echo "Installing ${pkg}@${version}..." - npm install --prefix "$prefix" "${pkg}@${version}" --omit=dev --prefer-offline -} - -install_artifact "@openpalm/ui" "$OP_UI_VERSION" /opt/openpalm/ui -install_artifact "@openpalm/skeleton" "$OP_SKELETON_VERSION" /opt/openpalm/skeleton - -# ── Range-versioned tools (opencode, akm-cli, claude-code, codex, …) ───────── -# tools.json in the skeleton defines defaults; per-tool env vars override. -# BUN_INSTALL is on the cache volume — warm restarts with unchanged ranges are instant. -export BUN_INSTALL=/opt/openpalm/tools -export PATH="$BUN_INSTALL/bin:$PATH" - -TOOL_SECTION="${CONTAINER_ROLE:-global}" # 'global' for assistant, 'guardian' for guardian -TOOL_PKGS=$(bun -e " - const tools = require('/opt/openpalm/skeleton/node_modules/@openpalm/skeleton/tools.json')['${TOOL_SECTION}'] || []; - const pkgs = tools.map(t => t.package + '@' + (process.env[t.envKey] || t.default)); - console.log(pkgs.join(' ')); -") - -[ -n "\$TOOL_PKGS" ] && bun add -g \$TOOL_PKGS || echo "WARN: some tool installs failed; continuing" +1. OPENPALM_REPO_ROOT env var → ${root}/packages/skeleton/ (dev mode — keep forever) +2. OPENPALM_SKELETON_DIR env var → Electron extraResources dir (set by Electron main) +3. require.resolve('@openpalm/skeleton/package.json') → package dir (CLI bundled dep) +4. import.meta.url source-relative → repo tree (bun run / bun test) +5. null → applyHomeSeed downloads @openpalm/skeleton from the npm registry ``` -Hard error if any exact-pinned artifact install fails. Tool install failures log a warning and do not block the container — one unavailable tool should not prevent the assistant or guardian from starting. +The GitHub `raw.githubusercontent.com` fallback is **gone** (replaced by the npm registry +download in step 5). The draft's "remove Electron extraResources skeleton" item was **not** +adopted — the extraResources bundle was kept as step 2 so a fresh Electron install works +offline before any npm fetch. This is deliberate; do not "clean it up." + +### Install script shape (entrypoint.sh) — **[as-landed]** + +`containers/assistant/entrypoint.sh` implements `install_runtime_artifacts()` with these +properties (which supersede the draft sketch): + +- Exact-pinned artifacts (`@openpalm/ui` today → `@openpalm/client` after Phase 5; + `@openpalm/skeleton`) resolve `OP_*_VERSION` → `PLATFORM_VERSION` → **hard error**. +- An install **failure after version resolution** logs an ERROR and continues with the + existing on-disk artifact if present (warm restart resilience) — the draft's + "hard error on any install failure" was softened deliberately: a registry blip must not + brick a previously-working container. Cold start with no artifact still fails visibly + (the co-process is skipped with a loud log line). +- npm cache lives under the bind-mounted assistant HOME + (`/home/opencode/.cache/openpalm-npm`), so `--prefer-offline` hits a persistent cache. + The draft's named `openpalm-artifact-cache` volume was not needed. +- Tools: `bun update --cwd /opt/openpalm/tools --production` against the baked/bind-mounted + `package.json` (see §1). Tool failures warn and never block container start. --- @@ -138,12 +167,12 @@ Hard error if any exact-pinned artifact install fails. Tool install failures log ### 4.1 Server host modes -| `hostMode` | What it is | What the server provides | -|---|---|---| -| `electron-host` | Electron launches/supervises the SvelteKit Node process on the local machine | Host + Assistant + Connections | -| `host-ui` | CLI/host process serves SvelteKit without Electron chrome | Host + Assistant + Connections (no Electron IPC) | -| `assistant-container` | SvelteKit UI pulled from npm at startup, co-runs with OpenCode | Assistant settings + single locked local connection | -| `pwa-static` | The same SvelteKit build served to browser/PWA clients connecting remotely | Connections management only | +| `hostMode` | Artifact | What it is | What it provides | +|---|---|---|---| +| `electron-host` | `@openpalm/ui` | Electron launches/supervises the SvelteKit Node process on the local machine | Host + Assistant + Connections | +| `host-ui` | `@openpalm/ui` | `openpalm admin` serves the same process without Electron chrome | Host + Assistant + Connections (no Electron IPC) | +| `assistant-container` | `@openpalm/client` | Static client served from the assistant image, co-running with OpenCode | Chat + assistant settings (single locked local connection) | +| `pwa-static` | `@openpalm/client` | Static client served from localhost (harness) or the official hosted URL | Connections management + chat | ### 4.2 Client display modes @@ -162,7 +191,14 @@ Hard error if any exact-pinned artifact install fails. Tool install failures log | `assistant-container` | `browser` / `standalone-pwa` | `chat`, `assistant-settings:read/write` | | `pwa-static` | `standalone-pwa` / `browser` | `connections:manage`, `connections:switch`, `chat` (if connected), `pwa:install` | -**Future extension point (not 0.13.0):** `pwa-static` + remote connection with `grantedCapabilities` → add `assistant-settings:read/write` for that connection. +**Future extension point:** `pwa-static` + remote connection with `grantedCapabilities` → add `assistant-settings:read/write` for that connection. `grantedCapabilities` must be verified server-side by the connected instance — the client cannot self-grant. + +**Note on the client app:** the client has no privileged server of its own. Its "server +capabilities" come from the *connected instance's* `/api/runtime` response (assistant- +container mode) or are the static `pwa-static` baseline. `resolveCapabilities()` in the +client therefore operates on (baseline ∪ connection-granted) × display mode — same +function shape, smaller input space, and `host:*` capabilities do not exist in its +type-space at all. --- @@ -180,7 +216,14 @@ Hard error if any exact-pinned artifact install fails. Tool install failures log **F — assistant settings mix host and assistant concerns.** Persona (assistant-scoped) and `OP_PROJECT_NAME` / bind address (host-scoped) are in the same tab. -**G — skeleton delivery is fragile.** Five-step fallback ladder in `ui-assets.ts` ending in a GitHub raw URL tied to an exact git tag. Does not resolve `latest`. Breaks in air-gapped environments. `import.meta.url`-relative paths break when lib is bundled. This is the only remaining component not following the standard artifact delivery pattern. +**G — skeleton delivery is fragile.** ~~Five-step fallback ladder ending in a GitHub raw URL.~~ **RESOLVED** by #508 (Phase 0): `@openpalm/skeleton` npm package + the §3 resolution chain. + +**H — chat transport assumes a privileged same-origin server.** The chat client calls +`/proxy/assistant/*` on its own origin with the `op_session` cookie; the server resolves +the active endpoint per request. A PWA on a phone has no such server: it needs client-held +connections and direct cross-origin calls to a guardian with Basic/Bearer auth. This is +the structural reason the client is a separate artifact — one app would carry both +transports behind mode branches. --- @@ -256,6 +299,9 @@ export type RuntimeContext = ServerRuntimeContext & { }; ``` +In the **client app**, the `host:*` members are absent from its local `Capability` union — +the narrowing is enforced by the type system, not by runtime filtering. + ### 6.2 Capability resolution — one function ```ts @@ -319,13 +365,16 @@ Initialized in `+layout.svelte`. Never server-computed. ### 6.4 API namespace split -| Namespace | Purpose | Auth | -|---|---|---| -| `/api/runtime` | Server runtime context | Public — no auth | -| `/api/connections/*` | Connection list, active connection, validation | `connections:manage` capability | -| `/api/assistant/*` | Assistant-owned settings | `assistant-settings:write` capability | -| `/api/host/*` | Host stack lifecycle, privileged ops | `host:stack:write` + loopback-only | -| `/proxy/assistant/*` | Same-origin assistant broker | Same-origin cookie | +| Namespace | Purpose | Served by | Auth | +|---|---|---|---| +| `/api/runtime` | Server runtime context (+ contract-version handshake for remote clients) | host app; assistant-container shim | Public — no auth | +| `/api/connections/*` | Connection list, active connection, validation | host app | `connections:manage` capability | +| `/api/assistant/*` | Assistant-owned settings | host app; assistant-container shim (optional slice) | `assistant-settings:write` capability | +| `/api/host/*` | Host stack lifecycle, privileged ops | host app only | `host:stack:write` + loopback-only | +| `/proxy/assistant/*` | Same-origin assistant broker | host app only | Same-origin cookie | + +The client app calls guardian/OpenCode APIs **directly** (Basic/Bearer per connection); +it has no privileged namespaces of its own. **No `/admin/*` alias.** With <10 installs the alias-for-one-release hedge is unnecessary overhead. `/admin/*` becomes 404 when `/host/*` ships. @@ -367,58 +416,37 @@ export type ConnectionEntry = { ``` `endpoints.json` is NOT renamed. `kind` field added. Internal model uses "connection" language. +In the client app, connections live in IndexedDB (offline-readable); in the host app they +stay in `endpoints.json` under `OP_HOME/config/`. -### 6.7 `@openpalm/skeleton` package - -``` -packages/skeleton/ - package.json # name: "@openpalm/skeleton", no main, no exports, no build, no deps - manifest.json # [{relPath, category: 'managed'|'guardian-managed'|'seeded'}] - tools.json # global CLI tool defaults; per-tool env key + default version range - config/ - stack/ # core.compose.yml, services.compose.yml, portals.compose.yml, custom.compose.yml - assistant/ # opencode.jsonc (seeded), persona.md (seeded), instructions/, themes/ - guardian/ # opencode.jsonc (guardian-managed), instructions/moderation.md (guardian-managed) - knowledge/ - skills/ # bundled skills - tasks/ # automation task YAML files -``` +**Pairing UX (simplicity guardrail):** the host app's `/connections` page can mint a +one-time pairing payload (guardian URL + short-lived code, rendered as QR + copyable +string); the client's `/connections/new` accepts paste-or-scan. No manual credential +assembly for non-technical users. (Design detail lands with Phase 6; guardian side +coordinates with #435.) -**`tools.json` schema:** - -```json -{ - "global": [ - { "package": "opencode", "envKey": "OP_TOOL_OPENCODE_VERSION", "default": "^1.2.3" }, - { "package": "@anthropic-ai/claude-code", "envKey": "OP_TOOL_CLAUDE_CODE_VERSION", "default": "^1.5.0" }, - { "package": "@openai/codex", "envKey": "OP_TOOL_CODEX_VERSION", "default": "^0.1.0" } - ] -} -``` - -The `envKey` field is explicit — no magic normalization of `@scope/package-name`. Adding a new tool is one line + a skeleton publish. Removing a tool is one line + a skeleton publish. No image rebuild. The default range (`^minor`) keeps tools on the latest patch/minor automatically; operators pin exact versions via env vars when needed. - -`.openpalm/` in the repo root becomes a README-only stub: "skeleton assets moved to `packages/skeleton/`." - -**`manifest.json` schema:** -```json -[ - { "relPath": "config/stack/core.compose.yml", "category": "managed" }, - { "relPath": "config/guardian/instructions/moderation.md", "category": "guardian-managed" }, - { "relPath": "config/assistant/opencode.jsonc", "category": "seeded" } -] -``` +### 6.7 `@openpalm/skeleton` package — **[as-landed]** -`core-assets.ts` reads from `manifest.json` instead of hard-coded `MANAGED_ASSETS`, `GUARDIAN_MANAGED_ASSETS`, `SEEDED_ASSETS` arrays. Write policy logic (`SHIPPED_DEFAULT_HASHES`, `isUnmodifiedDefault`) preserved byte-for-byte. +Landed in #508. What shipped (differences from the draft noted): -**Source resolution (replaces 5-step ladder in `ui-assets.ts`):** ``` -1. OPENPALM_REPO_ROOT env var → ${root}/packages/skeleton/ (dev mode) -2. require.resolve('@openpalm/skeleton/package.json') → package dir (CLI bundled dep, Electron) -3. npm install --prefix @openpalm/skeleton@ → upgrade / cold start +packages/skeleton/ + package.json # name: "@openpalm/skeleton", no main, no build, no deps + manifest.json # [{relPath, category: 'managed'|'guardian-managed'|'seeded'}] + config/ knowledge/ system/ data/ workspace/ # full OP_HOME seed trees + openpalm.sh openpalm.ps1 ``` -GitHub raw fallback deleted. Electron `extraResources` skeleton bundle removed (Electron resolves via step 2). +- `manifest.json` drives asset categories in `core-assets.ts`; `SHIPPED_DEFAULT_HASHES` + and `isUnmodifiedDefault()` preserved byte-for-byte. ✅ (as designed) +- Source resolution: the five-strategy chain in §3 (**not** the draft's three-step chain; + Electron extraResources retained deliberately). +- `tools.json` was **not** added to the skeleton — tool ranges live in the baked + `containers/assistant/tools/package.json` instead (§1, §3). +- `.openpalm/` in the repo root is a README-only stub. ✅ +- `@openpalm/skeleton` is a pinned dep of `packages/cli`. **Follow-up:** the pin + (`0.12.18` at time of writing) must be advanced by the release process in lockstep with + `PLATFORM_VERSION` — verify `platform-release.yml` does this before each release. ### 6.8 Authentication model @@ -426,90 +454,123 @@ GitHub raw fallback deleted. Electron `extraResources` skeleton bundle removed ( |---|---| | Host Control Plane | Existing `op_session` cookie, `HttpOnly`, `SameSite=Strict`, loopback-only. Unchanged. | | Assistant Control Plane | Guardian Basic auth (matching OpenCode's auth model) | -| Connection Control Plane | Host admin session for writes; capability-gated reads | -| PWA / remote clients | Guardian Basic auth aligned with #435 Bearer seam | - -### 6.9 `assistant-container` mode - -One image, two co-processes. UI and skeleton pulled from npm at startup: - -```sh -# entrypoint.sh — version resolution -resolve_version() { - local override="$1" platform="$2" name="$3" - if [ -n "$override" ]; then echo "$override"; return; fi - if [ -n "$platform" ]; then echo "$platform"; return; fi - echo "ERROR: Cannot resolve version for $name. Set OP_${name}_VERSION." >&2; exit 1 -} - -# ── Exact-pinned artifacts ──────────────────────────────────────────────────── -UI_VERSION=$(resolve_version "$OP_UI_VERSION" "$PLATFORM_VERSION" "UI") -SKELETON_VERSION=$(resolve_version "$OP_SKELETON_VERSION" "$PLATFORM_VERSION" "SKELETON") - -npm install --prefer-offline --omit=dev --prefix /opt/openpalm/ui "@openpalm/ui@${UI_VERSION}" -npm install --prefer-offline --omit=dev --prefix /opt/openpalm/skeleton "@openpalm/skeleton@${SKELETON_VERSION}" - -# Seed OP_HOME from skeleton (migrations + backups applied by lib) -node -e "require('@openpalm/lib').seedFromSkeleton('/opt/openpalm/skeleton', process.env.OP_HOME)" - -# ── Range-versioned CLI tools (from tools.json) ─────────────────────────────── -export BUN_INSTALL=/opt/openpalm/tools -export PATH="$BUN_INSTALL/bin:$PATH" - -TOOL_PKGS=$(bun -e " - const tools = require('/opt/openpalm/skeleton/node_modules/@openpalm/skeleton/tools.json').global; - const pkgs = tools.map(t => t.package + '@' + (process.env[t.envKey] || t.default)); - console.log(pkgs.join(' ')); -") -bun add -g $TOOL_PKGS || echo "WARN: some tool installs failed; continuing" - -# ── Start UI co-process ─────────────────────────────────────────────────────── -OP_UI_HOST_MODE=assistant-container \ -OP_UI_SINGLE_CONNECTION=1 \ -OP_UI_DEFAULT_ASSISTANT_URL=http://127.0.0.1:4096 \ -node /opt/openpalm/ui/node_modules/@openpalm/ui/build/index.js & - -exec opencode ... -``` - -**Mounts in assistant-container mode:** +| Connection Control Plane | Host app: host admin session for writes. Client app: per-connection credentials in IndexedDB, capability-gated reads | +| PWA / remote clients | Guardian Basic auth aligned with #435 Bearer seam; HTTPS required for non-loopback targets from the hosted origin | + +### 6.9 `assistant-container` mode — **[as-landed + re-scoped]** + +**Already landed in `containers/assistant/entrypoint.sh`:** `install_runtime_artifacts()` +(exact-pin resolution, npm install of UI + skeleton, tools via `bun update`) and +`start_ui()` (co-process launch, `OP_UI_PORT` default 3000). Boot order: +`install_runtime_artifacts → … → start_ui → start_opencode`. + +**Not yet landed / known gaps (tracked in #510):** + +1. The co-process port is **not published** in any compose file — the UI runs but is + unreachable from outside the container. +2. `start_ui` exports `OPENCODE_API_URL`, but the UI reads `OP_OPENCODE_URL` / + `OP_ASSISTANT_URL` — the co-process therefore points at the default + `http://127.0.0.1:3800` instead of the in-container OpenCode at `:4096`. Wiring bug; + fix regardless of the re-scope. +3. No mode env (`OP_UI_HOST_MODE`, `OP_UI_SINGLE_CONNECTION`) is set, and no + skeleton-seed call runs for assistant-scoped config (`seedFromSkeleton()` from the + draft does not exist in lib). + +**Re-scope (ratified):** after Phase 5, the co-process serves **`@openpalm/client`** +(static files) instead of the full host app: + +- Slice A (launch): chat with a single locked connection to the local OpenCode. Serving + the static bundle needs only a minimal static file server (bun is present in the image). + `/api/host/*` does not exist in the artifact — nothing to 403. +- Slice B (optional follow-up): a **settings shim** — a small co-process endpoint set + (`/api/runtime`, `/api/assistant/*`) that performs assistant-scoped file writes + (persona, AKM runtime config). Only this slice needs anything beyond static serving. + Ship it only when assistant-settings-from-browser is actually wanted. + +**Mounts in assistant-container mode (unchanged):** - `config/assistant/` — read/write - `config/akm/` — read/write - No Docker socket - No broad `OP_HOME` access -### 6.10 PWA mode +### 6.10 PWA mode — **[re-scoped]** + +The PWA is `packages/client` (`adapter-static` + `@vite-pwa/sveltekit`: manifest, icons, +Workbox precache of the app shell). Host features are **absent from the artifact**, not +hidden by capability checks. + +**Two primary install origins:** + +1. **Localhost (desktop, zero-setup).** The harness serves the built client on a stable + loopback port (stable because PWA identity = origin incl. port). `http://127.0.0.1` is + a secure context → installable with no certificates. A loopback origin is also exempt + from mixed-content blocking and sits in the most-trusted Private Network Access tier, + so it may call plain-HTTP LAN guardians. Entry points: Electron menu / host app button + ("Install OpenPalm app") and `openpalm app` on the CLI. +2. **Official hosted URL** (e.g. `https://app.openpalm.dev`). CI publishes the same static + build to a canonical TLS-terminated origin. One install works on any phone/tablet and + connects to any of the user's instances. Constraints that follow from the platform, + not from our code: + - an HTTPS origin cannot call plain-HTTP guardians (mixed content) → **guardian TLS + workstream is a hard prerequisite for this path** (Tailscale `ts.net` certs are the + recommended default; Caddy with a user domain as the alternative); + - guardian must send CORS headers for the official origin (allowlist, configurable); + - version skew is handled by the `/api/runtime` contract-version handshake — the + hosted client degrades gracefully against older instances. + +Offline behavior: app shell + connection records (IndexedDB) available offline; chat +requires connectivity. Since the client is static and unauthenticated at the origin level, +the service worker never caches credentialed responses. + +**Phone reality check:** a phone can only reach a guardian over TLS (both install paths — +the hosted origin because of mixed content; and any non-loopback origin is not installable +over plain HTTP anyway). The desktop/localhost path is the only truly TLS-free path, which +is why it is the default for the machine that runs the stack, and the hosted path + TLS +workstream is the mobile story. + +### 6.11 `packages/client` and `packages/ui-kit` -Same adapter-node build. `static/manifest.webmanifest` + `src/service-worker.ts`. When resolved capabilities exclude `host:*`, host tabs/nav absent via `hasCapability()`. No new pages. No duplicate UI. IndexedDB connection store for offline-capable connection records. +``` +packages/client/ # @openpalm/client — published, exact-pin delivery + svelte.config.js # adapter-static, SPA fallback + vite.config.ts # @vite-pwa/sveltekit + src/routes/ + chat/ # moved from packages/ui (after Phase 3/4 decoupling) + connections/ # client-side connection manager (IndexedDB) + assistant/settings/ # visible only when the connection grants it + src/lib/ + transport/ # ONE transport: guardian/OpenCode base URL + credentials + +packages/ui-kit/ # raw-source workspace package — NOT published + src/lib/components/ # common/, icons/, chrome primitives shared by ui + client + src/lib/theme/ # tokens, app.css design vocabulary (coordinates with #506) +``` + +Rules: +- `client` has **no dependency on `@openpalm/lib`** and no `src/lib/server/`. +- `ui-kit` contains no stores with server assumptions — presentational components, + icons, and theme only. The `endpoints-state` ↔ `chat-state` coupling gets untangled in + Phases 2–3 *before* extraction, so the move is file relocation, not surgery. +- `packages/ui` keeps its chat surface only until the client reaches parity inside + Electron (the harness serves the client build alongside the host app), then deletes it. --- ## 7. Implementation phases -All phases ship in **0.13.0**. The prior 0.13.0/0.14.0 split was a staged-rollout hedge for large user bases. With fewer than 10 installs, mostly owner-operated, the constraint doesn't apply. The phases below are implementation ordering (code dependencies), not release milestones. - -### Phase 0 — Baseline + skeleton package +Phases are implementation ordering (code dependencies), not release milestones. 0.13.0 +carries Phases 1–4 + 1.5; the client extraction (5–6) and TLS workstream follow — pull +them into 0.13.0 only if it stays light. -Two tracks merged into one phase. Both are non-behavior-changing foundation work. +### Phase 0 — Baseline + skeleton package — ✅ DONE (#508) -**Track A: Baseline tests (partially complete)** -- Already done: unit tests for `computeFeatureFlags()` and `hooks.server.ts` route decisions -- Still needed: Playwright smoke tests (host mode, admin redirect, chat route), `docs/technical/ui-route-map.md` +Landed with the deltas recorded in §3/§6.7 (five-strategy resolution, extraResources +retained, tools via baked `package.json`, npm-registry cold-start download replacing the +GitHub raw fallback). Outstanding from the original Track A: Playwright smoke tests and +`docs/technical/ui-route-map.md` were **not** produced — fold them into Phase 3, where the +route structure they document actually changes. -**Track B: `@openpalm/skeleton` package** -1. Create `packages/skeleton/package.json` — no main, no exports, no build, no deps. `files` field restricts to `config/`, `knowledge/`, `manifest.json`. -2. Move `.openpalm/` contents into `packages/skeleton/`. Leave `.openpalm/README.md` stub. -3. Write `packages/skeleton/manifest.json` listing all files with their category. -4. Update `core-assets.ts`: read `MANAGED_ASSETS`, `GUARDIAN_MANAGED_ASSETS`, `SEEDED_ASSETS` from `manifest.json` instead of hard-coded arrays. `bundledAssetPath()` → `require.resolve('@openpalm/skeleton/package.json')`. `SHIPPED_DEFAULT_HASHES` preserved exactly. -5. Update `ui-assets.ts`: three-step resolution chain (§6.7). Delete GitHub raw fallback. Delete Electron extraResources skeleton path. -6. Add `@openpalm/skeleton` as pinned exact-version dep in `packages/cli/package.json`. -7. Remove Electron extraResources skeleton bundle from `packages/electron/electron-builder.yml` / `forge.config.ts`. Electron now resolves via step 2 (bundled npm dep). -8. Add `@openpalm/skeleton` as first entry in `platform-release.yml` publish DAG (must publish before `@openpalm/lib` since lib will import/resolve it). -9. Update dev-setup.sh / `OPENPALM_REPO_ROOT` override to point to `packages/skeleton/` instead of `.openpalm/`. - -Acceptance: skeleton resolves from npm dep in all non-dev paths; dev still works via `OPENPALM_REPO_ROOT`; `SHIPPED_DEFAULT_HASHES` hashes still match the same file content; existing installs upgrade correctly; GitHub raw fallback is gone. - -### Phase 1 — RuntimeContext v2, no UI change +### Phase 1 — RuntimeContext v2, no UI change (#509) **Files:** `lib/server/features.ts`, `lib/types.ts`, `routes/+layout.server.ts`, `lib/runtime-context.svelte.ts`, `lib/client-context.ts`, `routes/api/runtime/+server.ts` @@ -518,29 +579,47 @@ Acceptance: skeleton resolves from npm dep in all non-dev paths; dev still works 3. Return `serverRuntimeContext` from `+layout.server.ts`. 4. Initialize `clientContext` in `+layout.svelte` (client-only; detect display mode; wire active connection). 5. Derive `effectiveCapabilities` reactively. -6. Add `/api/runtime` endpoint (public, no auth). +6. Add `/api/runtime` endpoint (public, no auth) — include the contract version used by the future hosted-client handshake. Acceptance: existing routes unchanged; `features.admin` alias works; capability matrix correct for all current combinations. -### Phase 2 — Connection management out of `/admin` +### Phase 1.5 — `openpalm admin` (host-ui mode) — NEW, small + +1. CLI: `openpalm admin` (and/or a flag on the default serve path) launches the existing + UI server with the admin capability enabled (today: `OP_ENABLE_ADMIN=1`; after Phase 1: + `hostMode: 'host-ui'`), loopback-only, existing `op_session` password auth. Opens the + browser. +2. No new UI. No new auth. Refuse `OP_ALLOW_REMOTE_SETUP`-style non-loopback binds for + this mode. + +Acceptance: full host management from a browser on the host machine without Electron; +remains loopback-only; `openpalm admin` on a machine without the stack installed lands on +`/setup`. -**Closes #486.** +### Phase 2 — Connection management out of `/admin` (#486) 1. Rename internal model "endpoint" → "connection" in UI layer. `endpoints.json` unchanged. 2. Add `kind` to `ConnectionEntry`; default existing records. 3. New routes: `/connections`, `/api/connections/*`. Guard with `connections:manage`. 4. Update chat page link: `/admin/endpoints` → `/connections`. 5. `/admin/endpoints` → redirect to `/connections` (0.13.0 alias). +6. **Untangle for extraction:** break the `endpoints-state` ↔ `chat-state` bidirectional + import (connection activation emits an event; chat subscribes). This is a hard + prerequisite for Phase 5. -Acceptance: PWA-mode clients can manage connections without `/admin`; host mode unchanged; no data migration. +Acceptance: connection management reachable without `/admin`; host mode unchanged; no data migration; `endpoints-state` no longer imports `chat-state`. ### Phase 3 — Capability-driven landing and navigation 1. Add `resolveLanding(ctx, launchState)`. 2. Split `/splash` into `/attention`, `/setup`, `/host`, `/chat`, `/connections`. 3. Nav reads `runtimeContext.routes` + `hasCapability()`. No `if (features.admin)` checks. +4. **Untangle for extraction:** give the chat surface its own minimal chrome — `Navbar` + must stop importing chat components/stores into the admin surface. Chat stops importing + the `$lib/api.js` barrel (direct domain-client imports only). +5. Produce `docs/technical/ui-route-map.md` + Playwright smoke tests (carried from Phase 0). -Acceptance: Electron healthy → chat; assistant-container → chat; PWA no-connections → `/connections/new`. +Acceptance: Electron healthy → chat; capability-driven nav; chat chunk free of admin API clients (verify bundle); route map doc exists. ### Phase 4 — Split Host and Assistant Control Planes @@ -551,48 +630,74 @@ Acceptance: Electron healthy → chat; assistant-container → chat; PWA no-conn Acceptance: assistant-container can edit persona/AKM but not project name or bind address; host mode unchanged; `/admin/*` returns 404. -### Phase 5 — `assistant-container` mode +### Phase 5 — Extract `packages/client` + `packages/ui-kit`; assistant-container serves it -*Depends on Phases 0, 1, 4.* +*Depends on Phases 2–4 (the untangling steps).* -1. Add UI + skeleton install to `containers/assistant/entrypoint.sh` (see §6.9). -2. `npm install --prefer-offline` for both packages; fail fast if version unresolvable. -3. `seedFromSkeleton()` call to seed assistant-scoped `OP_HOME` paths. -4. Start SvelteKit UI co-process. -5. Add `PLATFORM_VERSION` build arg to Dockerfile; wire `OP_SKELETON_VERSION` and `OP_UI_VERSION` defaults. -6. Add locked default connection to local OpenCode (port 4096). -7. Add named Docker volume `openpalm-artifact-cache` mounted at `/opt/openpalm` (covers UI, skeleton, and tools cache in one volume). -8. Add `tools.json` to `@openpalm/skeleton` with initial tool list and `^minor` defaults. -9. Wire `BUN_INSTALL=/opt/openpalm/tools` in entrypoint before tool install step. +1. Create `packages/ui-kit` (raw-source workspace package); move `components/common/`, + `icons/`, theme tokens. Both apps consume it. +2. Create `packages/client` per §6.11; move chat + connections views; implement the single + transport (direct guardian/OpenCode calls, IndexedDB connections). +3. Harness serves the client build on a stable loopback port; Electron window offers it; + `packages/ui` chat stays until parity, then dies. +4. Assistant container: `install_runtime_artifacts` pulls `@openpalm/client` + (`OP_CLIENT_VERSION` → `PLATFORM_VERSION` → error); co-process serves the static bundle + (Slice A). Fix the `OP_OPENCODE_URL` wiring bug (§6.9). Publish the port in compose + behind the existing bind-address policy. +5. Release: `@openpalm/client` joins the publish DAG and the exact-pin table. +6. (Optional Slice B, separate issue) settings shim for `/api/assistant/*` writes. -Acceptance: `docker restart` with new `OP_UI_VERSION` or `OP_SKELETON_VERSION` picks up new artifact; `OP_TOOL_CLAUDE_CODE_VERSION=^2.0.0` overrides that tool's range; tool install failure logs a warning and does not block container start; tools available on PATH; `/api/host/*` returns 403; assistant settings editable; skeleton correctly seeds assistant config on first start. +Acceptance: chat in Electron runs from the client build; assistant-container URL serves +chat against local OpenCode with zero host-admin code in the artifact; `docker restart` +with a new `OP_CLIENT_VERSION` picks up the new client. -### Phase 6 — PWA mode +### Phase 6 — PWA (two install origins) (#511) -*Depends on Phases 1, 2, 3.* +*Depends on Phase 5.* -1. `static/manifest.webmanifest` + `src/service-worker.ts`. -2. When resolved capabilities exclude `host:*`, host routes absent — no new pages. -3. IndexedDB connection store for offline operation. -4. Remote auth: Guardian Basic auth aligned with #435. +1. `@vite-pwa/sveltekit` in `packages/client`: manifest, icons (192/512 + maskable), + Workbox app-shell precache. +2. Localhost install path: stable port, install affordances in Electron/host app/CLI + (`openpalm app`). +3. Hosted install path: CI deploy of the static build to the official URL; `/api/runtime` + contract-version handshake; connection pairing UX (§6.6). +4. Guardian CORS allowlist for the official origin. +5. IndexedDB connection store; offline shell. + +Acceptance: install prompt on desktop from localhost with zero TLS setup; install on a +phone from the official URL; add/switch connections via paste-or-scan pairing; offline +launch shows the shell + saved connections, not a blank page. + +### Phase 6.5 — Guardian edge TLS workstream — NEW (own issue) + +*Parallel to Phases 5–6; hard prerequisite for hosted-origin → LAN connections.* + +1. Recommended default: Tailscale integration docs + `ts.net` cert flow (guided, no cert + management for the user). +2. Alternative: Caddy front for guardian with a user-owned domain (DNS challenge). +3. Explicit non-goal: asking non-technical users to install a private CA on iOS/Android. +4. `requiresHttpsForRemoteConnections` enforced: the hosted client refuses plain-HTTP + non-loopback targets with a clear, actionable message (deep-link to the TLS guide). ### Phase 7 — Security hardening *Depends on Phases 1–6.* 1. Host admin loopback-only checks unchanged. -2. Context-aware origin checks per mode. +2. Context-aware origin checks per mode; guardian CORS tests (allowlist enforced). 3. Route guard tests for every `/api/host/*` endpoint (must 403 in non-host modes). -4. Negative tests: assistant-container and PWA cannot reach host APIs. -5. CSP review. +4. Negative tests: assistant-container artifact contains no host-admin code (build + assertion, not just runtime 403); hosted client cannot reach host APIs. +5. CSP review for both apps; service worker never caches credentialed responses. ### Phase 8 — Release integration *Depends on all prior phases.* -1. Add `OP_UI_VERSION`, `OP_SKELETON_VERSION` to compose env-file docs and release notes. -2. New docs: `docs/technical/ui-runtime-modes.md` (replaces this plan), `docs/technical/artifact-delivery-pattern.md`. -3. Release checklist: smoke-test electron-host, assistant-container (with version overrides), pwa-static before publishing. +1. Add `OP_CLIENT_VERSION`, `OP_SKELETON_VERSION` to compose env-file docs and release notes. +2. Verify the release process advances the CLI's pinned `@openpalm/skeleton` dep (§6.7 follow-up). +3. New docs: `docs/technical/ui-runtime-modes.md` (replaces this plan), `docs/technical/artifact-delivery-pattern.md`. +4. Release checklist: smoke-test electron-host, host-ui (`openpalm admin`), assistant-container (with version overrides), localhost PWA install, hosted PWA install. --- @@ -602,11 +707,13 @@ Acceptance: `docker restart` with new `OP_UI_VERSION` or `OP_SKELETON_VERSION` p 2. **`isUnmodifiedDefault` and `SHIPPED_DEFAULT_HASHES` are preserved byte-for-byte.** Skeleton version must equal platform version in production or these hashes diverge and the guardian-managed write policy silently misfires. 3. **Host admin remains loopback-only.** Never weakened. 4. **Assistant container gets no Docker socket and no broad OP_HOME write access.** -5. **APIs enforce capabilities server-side.** `hasCapability()` is UX, not the security boundary. -6. **`resolveCapabilities()` is the only place capability logic lives.** No scattered `if (features.admin)`. +5. **APIs enforce capabilities server-side.** `hasCapability()` is UX, not the security boundary. In the client app the stronger form applies: host capabilities are absent from the artifact. +6. **`resolveCapabilities()` is the only place capability logic lives** (one per app). No scattered `if (features.admin)`. 7. **`OPENPALM_REPO_ROOT` dev override is preserved forever.** Essential for local dev loop. 8. **Backup-before-overwrite for managed assets is preserved.** User recovery path for bad skeleton releases. 9. **`grantedCapabilities` on connections must be server-verified at connection-add time.** The client cannot self-grant capabilities. +10. **The client app never bundles `@openpalm/lib` and never holds host credentials.** +11. **TLS is never a manual task on default install paths.** Desktop = localhost (none needed); phone = hosted origin + guided guardian TLS (Tailscale default). --- @@ -614,58 +721,51 @@ Acceptance: `docker restart` with new `OP_UI_VERSION` or `OP_SKELETON_VERSION` p | Area | Current | Change | |---|---|---| -| Skeleton source | `.openpalm/` (repo root) | Move to `packages/skeleton/`; leave README stub | -| Skeleton path resolution | `ui-assets.ts` 5-step ladder + GitHub raw | Three-step chain; delete GitHub raw; delete Electron extraResources path | -| Skeleton asset arrays | Hard-coded in `core-assets.ts` | Read from `packages/skeleton/manifest.json` | -| Skeleton bundled path | `import.meta.url` relative | `require.resolve('@openpalm/skeleton/package.json')` | -| CLI deps | (skeleton not listed) | Add `@openpalm/skeleton` pinned exact dep | -| Electron extraResources | Bundles skeleton dir | Remove; Electron resolves via npm dep | -| Feature flags | `lib/server/features.ts`, `lib/types.ts` | Replace with `ServerRuntimeContext` + `resolveCapabilities()` | -| Client context | (none) | New `lib/client-context.ts` | -| Layout data | `routes/+layout.server.ts`, `+layout.svelte` | Pass server context; initialize client context + resolved caps | -| Landing | `routes/+page.ts`, `routes/splash/*`, `hooks.server.ts` | `resolveLanding()` | -| Host admin | `routes/admin/*` | Move/alias to `/host`; `host:*` guards | -| Connections | `routes/admin/endpoints/*`, `endpoints-state.svelte.ts` | Move to `/connections`; rename internal model | -| Assistant settings | `AssistantTab.svelte` | Split host stack vs. assistant-owned | -| AKM | `AkmTab.svelte` | Split assistant-scoped vs. host-only | -| Assistant image | `containers/assistant/entrypoint.sh`, `Dockerfile` | Runtime npm pull for UI + skeleton; co-process start | -| Electron main | `packages/electron/src/main.ts` | Pass `OP_UI_HOST_MODE=electron-host` | -| Release DAG | `platform-release.yml` | `@openpalm/skeleton` first in publish order | +| Skeleton package | ✅ landed (#508) | Follow-up only: release-time advance of the CLI's pinned dep | +| Feature flags | `lib/server/features.ts`, `lib/types.ts` | Replace with `ServerRuntimeContext` + `resolveCapabilities()` (Phase 1) | +| Client context | (none) | New `lib/client-context.ts` (Phase 1) | +| Layout data | `routes/+layout.server.ts`, `+layout.svelte` | Pass server context; initialize client context + resolved caps (Phase 1) | +| CLI admin | (admin unreachable via CLI) | `openpalm admin` — host-ui mode (Phase 1.5) | +| Landing | `routes/+page.ts`, `routes/splash/*`, `hooks.server.ts` | `resolveLanding()` (Phase 3) | +| Host admin | `routes/admin/*` | Move to `/host`; `host:*` guards (Phase 4) | +| Connections | `routes/admin/endpoints/*`, `endpoints-state.svelte.ts` | Move to `/connections`; rename internal model; break chat-state coupling (Phase 2) | +| Chat chrome | `Navbar.svelte` imports chat stores | Chat gets its own chrome; barrel imports removed (Phase 3) | +| Assistant settings | `AssistantTab.svelte` | Split host stack vs. assistant-owned (Phase 4) | +| AKM | `AkmTab.svelte` | Split assistant-scoped vs. host-only (Phase 4) | +| Shared components | `packages/ui/src/lib/components/{common,icons}` | Move to `packages/ui-kit` (Phase 5) | +| Chat + connections views | `packages/ui/src/routes/{chat,advanced}`, `/connections` | Move to `packages/client` (Phase 5) | +| Assistant image | `containers/assistant/entrypoint.sh`, `Dockerfile` | Pull `@openpalm/client`; fix `OP_OPENCODE_URL` wiring; publish port (Phase 5) | +| PWA | (none) | `@vite-pwa/sveltekit` in `packages/client`; hosted deploy pipeline (Phase 6) | +| Guardian edge | plain HTTP, no CORS | CORS allowlist + TLS workstream (Phases 6, 6.5) | +| Electron main | `packages/electron/src/main.ts` | Pass `OP_UI_HOST_MODE=electron-host`; serve/point at client build (Phases 1, 5) | +| Release DAG | `platform-release.yml` | Add `@openpalm/client`; skeleton pin advance check (Phase 8) | --- ## 10. Milestone summary -**0.13.0 — All phases** - -Implementation order (phases are code-dependency ordering, not release gates): - ``` -Phase 0 (skeleton package) ──────────────────────────────► prerequisite for all -Phase 1 (RuntimeContext) ──────────────────────────────► prerequisite for 2–8 -Phase 2 (connections) ──┐ -Phase 3 (landing) ──┼─► parallelizable ──────────► prerequisite for 5–8 -Phase 4 (control plane split) ┘ -Phase 5 (assistant-container) ─ needs 0, 1, 4 ──────────► prerequisite for 7–8 -Phase 6 (PWA) ─── needs 1, 2, 3 ────────────► prerequisite for 7–8 -Phase 7 (security tests) ─── needs 1–6 ───────────────► prerequisite for 8 -Phase 8 (release cleanup) ─── needs all ───────────────► ships in 0.13.0 +Phase 0 (skeleton package) ✅ DONE (#508) +Phase 1 (RuntimeContext) ─────────────► prerequisite for everything below +Phase 1.5 (openpalm admin) ─ tiny, ships with Phase 1 +Phase 2 (connections + untangle) ──┐ +Phase 3 (landing + chrome untangle)─┼─► parallelizable ─► prerequisite for 5 +Phase 4 (control plane split) ──┘ +Phase 5 (client + ui-kit extraction, assistant-container) ─► prerequisite for 6 +Phase 6 (PWA, two install origins) ─ needs 5 +Phase 6.5 (guardian TLS + CORS) ─ parallel to 5/6; gates the phone story +Phase 7 (security tests) ─ needs 1–6.5 +Phase 8 (release integration) ─ needs all ``` -No 0.14.0 split. The split was a staged-rollout hedge for large installs; it does not apply here. +0.13.0 target: Phases 1–4 + 1.5. Phases 5–6.5 land in 0.13.0 only if it stays light; +otherwise they open 0.14.0. (The original "all phases in 0.13.0" call predates the +client-split re-scope; re-evaluated at Phase 4 completion.) --- ## 11. Acceptance criteria -**Skeleton delivery:** -- `@openpalm/skeleton` resolves from bundled npm dep in CLI and Electron (no network call needed for CLI path) -- `OPENPALM_REPO_ROOT` still works for local dev -- GitHub raw fallback is gone; Electron extraResources skeleton is gone -- `SHIPPED_DEFAULT_HASHES` values are unchanged; `isUnmodifiedDefault` still works correctly -- `manifest.json` drives asset category logic in `core-assets.ts` -- `@openpalm/skeleton` publishes before `@openpalm/lib` in the release DAG - **Capability system:** - `resolveCapabilities()` is the only capability logic; all components call `hasCapability()` only - Adding a new capability rule requires editing one function @@ -673,14 +773,16 @@ No 0.14.0 split. The split was a staged-rollout hedge for large installs; it doe **Host modes:** - Electron app: full host management, all features -- Browser to host UI: full host management when authenticated -- Browser to assistant-container URL: chat + assistant settings only; `/api/host/*` returns 403 -- PWA: install prompt works; add/switch remote connections; no host controls visible or callable +- `openpalm admin`: full host management from a browser on the host, loopback-only, no Electron +- Browser to assistant-container URL: chat (+ assistant settings when Slice B ships); no host-admin code in the artifact +- PWA (localhost): installable on the host machine with zero TLS setup +- PWA (hosted): installable on a phone from the official URL; connects only to HTTPS guardians; pairing is paste-or-scan **Versioning:** -- `OP_UI_VERSION` and `OP_SKELETON_VERSION` override the version used by the assistant container +- `OP_CLIENT_VERSION` / `OP_SKELETON_VERSION` override the versions used by the assistant container - Neither ever silently falls back to `latest` - `docker restart` with a new version env var picks up the new artifact +- Hosted client ↔ instance skew is handled by the `/api/runtime` contract handshake **Release:** -- All three host modes smoke-tested before publishing each release +- electron-host, host-ui, assistant-container, and both PWA install paths smoke-tested before publishing each release From 763eb116c7a497daad39a2d3245e65b47e175da4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 05:36:51 +0000 Subject: [PATCH 02/61] docs: link new issues #555-#557 into the runtime-modes plan phases Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 --- docs/technical/ui-runtime-modes-plan.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/technical/ui-runtime-modes-plan.md b/docs/technical/ui-runtime-modes-plan.md index 735f42f91..781e60c4b 100644 --- a/docs/technical/ui-runtime-modes-plan.md +++ b/docs/technical/ui-runtime-modes-plan.md @@ -3,7 +3,7 @@ **Date:** 2026-06-19 (revised 2026-07-06) **Status:** RATIFIED — Phases 0 landed; Phases 1–4 as designed; Phases 5–6 re-scoped per `docs/technical/ui-client-split-assessment.md` **Repo:** `itlackey/openpalm`, branch `main` -**Related issues:** #486 (remote-only install), #435 (guardian authn), #433 (guardian state), #488 (mDNS), #506 (styling), #509 (RuntimeContext), #510 (assistant-container), #511 (PWA) +**Related issues:** #486 (remote-only install), #435 (guardian authn), #433 (guardian state), #488 (mDNS), #506 (styling), #509 (RuntimeContext), #510 (assistant-container), #511 (PWA), #555 (client extraction), #556 (openpalm admin), #557 (guardian TLS + CORS) > **Revision note (2026-07-06).** The original draft shipped one `adapter-node` build to > every runtime mode. That scope is revised: the product is still **one UI**, but it ships @@ -583,7 +583,7 @@ route structure they document actually changes. Acceptance: existing routes unchanged; `features.admin` alias works; capability matrix correct for all current combinations. -### Phase 1.5 — `openpalm admin` (host-ui mode) — NEW, small +### Phase 1.5 — `openpalm admin` (host-ui mode) (#556) — NEW, small 1. CLI: `openpalm admin` (and/or a flag on the default serve path) launches the existing UI server with the admin capability enabled (today: `OP_ENABLE_ADMIN=1`; after Phase 1: @@ -630,7 +630,7 @@ Acceptance: Electron healthy → chat; capability-driven nav; chat chunk free of Acceptance: assistant-container can edit persona/AKM but not project name or bind address; host mode unchanged; `/admin/*` returns 404. -### Phase 5 — Extract `packages/client` + `packages/ui-kit`; assistant-container serves it +### Phase 5 — Extract `packages/client` + `packages/ui-kit` (#555); assistant-container serves it (#510) *Depends on Phases 2–4 (the untangling steps).* @@ -668,7 +668,7 @@ Acceptance: install prompt on desktop from localhost with zero TLS setup; instal phone from the official URL; add/switch connections via paste-or-scan pairing; offline launch shows the shell + saved connections, not a blank page. -### Phase 6.5 — Guardian edge TLS workstream — NEW (own issue) +### Phase 6.5 — Guardian edge TLS + CORS workstream (#557) — NEW *Parallel to Phases 5–6; hard prerequisite for hosted-origin → LAN connections.* From 483b4bfc9a6ba05a167b2f4b2228af07bf0fb823 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 06:38:24 +0000 Subject: [PATCH 03/61] test(phase-1): red tests for #509 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD red suite for RuntimeContext v2 (plan §6.1-§6.4, §4.3): - lib/server/features.vitest.ts: computeServerRuntimeContext env→hostMode mapping (OP_UI_HOST_MODE > OP_INSIDE_ELECTRON > OP_ENABLE_ADMIN > pwa-static baseline), ServerRuntimeContext contract shape (version 2, security, activeConnectionMode), and per-hostMode serverCapabilities. RED: 15 tests (computeServerRuntimeContext is not exported yet). CHARACTERIZATION (passes pre-change, must stay green): 3 tests pinning computeFeatureFlags().admin under OP_INSIDE_ELECTRON=1 / OP_ENABLE_ADMIN=1. - lib/runtime-context.vitest.ts: resolveCapabilities full matrix per §6.2 (electron=ALL; host:stack:read+browser=all minus electron-only; connections:single→chat+assistant-settings; pwa baseline→connections+ chat+pwa:install; grantedCapabilities union+dedupe; standalone-pwa restriction per §4.2), hasCapability via the runtimeContext store, and §4.3 matrix rows end-to-end. RED: whole file (module does not exist). - routes/api/runtime/server.vitest.ts: GET /api/runtime is public (200 with no/garbage session cookie), returns JSON ServerRuntimeContext with the contract-version handshake field. RED: whole file (+server.ts does not exist). - routes/layout.server.vitest.ts: layout load returns serverRuntimeContext (RED: 3 tests) and keeps the features.admin derived alias (CHARACTERIZATION: 3 tests, green pre-change). Verified red for the right reasons: 21 failed tests / 2 failed suites, all "Cannot find module" / "not a function" / missing-key assertions; server project otherwise at baseline (940 passed incl. the 6 characterization tests). ui:check reports 9 errors, all in these test files, all missing Phase 1 exports — resolves when the implementation lands. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 --- packages/ui/src/lib/runtime-context.vitest.ts | 307 ++++++++++++++++++ packages/ui/src/lib/server/features.vitest.ts | 240 ++++++++++++++ .../src/routes/api/runtime/server.vitest.ts | 101 ++++++ .../ui/src/routes/layout.server.vitest.ts | 97 ++++++ 4 files changed, 745 insertions(+) create mode 100644 packages/ui/src/lib/runtime-context.vitest.ts create mode 100644 packages/ui/src/lib/server/features.vitest.ts create mode 100644 packages/ui/src/routes/api/runtime/server.vitest.ts create mode 100644 packages/ui/src/routes/layout.server.vitest.ts diff --git a/packages/ui/src/lib/runtime-context.vitest.ts b/packages/ui/src/lib/runtime-context.vitest.ts new file mode 100644 index 000000000..c7651971a --- /dev/null +++ b/packages/ui/src/lib/runtime-context.vitest.ts @@ -0,0 +1,307 @@ +/** + * Tests for lib/runtime-context.svelte.ts — Phase 1 RuntimeContext v2 (issue #509). + * + * ALL RED until the implementation lands: the module under test does not exist + * yet. Per plan §6.2 it must be the ONLY place capability logic lives, exporting + * `resolveCapabilities(serverCaps, clientCtx)`, `hasCapability(cap)` and the + * reactive `runtimeContext` store. + * + * Pins the full effective-capability matrix from plan §4.3: + * electron-host + electron → ALL server capabilities + * host-ui + browser → ALL minus Electron-only (none reserved yet) + * connections:single (+ any display) → chat + assistant-settings:* + * pwa baseline (+ any display) → connections:* + chat + pwa:install + * activeConnection.grantedCapabilities → unioned in (deduped) + * + * Runs in the node ("server") vitest project; the `.svelte.ts` module is + * compiled by the Svelte plugin, so its runes work here (same pattern as + * lib/setup/setup-state.vitest.ts). + */ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import type { RequestEvent } from '@sveltejs/kit'; +import type { Capability, ClientContext } from '$lib/types.js'; +import { hasCapability, resolveCapabilities, runtimeContext } from './runtime-context.svelte.js'; +import { computeServerRuntimeContext } from '$lib/server/features.js'; + +// ── fixtures ───────────────────────────────────────────────────────────────── + +/** Order-insensitive comparison helper — the plan fixes membership, not order. */ +function sorted(caps: Capability[]): Capability[] { + return [...caps].sort(); +} + +const electron: ClientContext = { displayMode: 'electron' }; +const browser: ClientContext = { displayMode: 'browser' }; +const standalonePwa: ClientContext = { displayMode: 'standalone-pwa' }; + +const HOST_CAPS: Capability[] = [ + 'host:setup', + 'host:stack:read', + 'host:stack:write', + 'host:containers', + 'host:addons', + 'host:updates', + 'host:logs', + 'host:secrets', + 'host:recovery', + 'host:akm-sharing', +]; + +/** Server capabilities of a host-capable process (electron-host / host-ui). */ +const HOST_SERVER_CAPS: Capability[] = [ + 'chat', + 'connections:read', + 'connections:manage', + 'connections:switch', + 'assistant-settings:read', + 'assistant-settings:write', + ...HOST_CAPS, +]; + +/** Server capabilities in assistant-container mode (single locked connection). */ +const ASSISTANT_CONTAINER_CAPS: Capability[] = [ + 'connections:single', + 'chat', + 'assistant-settings:read', + 'assistant-settings:write', +]; + +/** The pwa-static baseline. */ +const PWA_BASELINE_CAPS: Capability[] = [ + 'chat', + 'connections:read', + 'connections:manage', + 'connections:switch', + 'pwa:install', +]; + +// ── resolveCapabilities — plan §6.2, exactly ───────────────────────────────── + +describe('resolveCapabilities — electron display mode', () => { + test('returns the server capabilities unchanged (ALL)', () => { + const result = resolveCapabilities(HOST_SERVER_CAPS, electron); + expect(sorted(result)).toEqual(sorted(HOST_SERVER_CAPS)); + }); + + test('ignores connection-granted capabilities — electron already has everything', () => { + const result = resolveCapabilities(HOST_SERVER_CAPS, { + displayMode: 'electron', + activeConnection: { + kind: 'remote-opencode', + id: 'r1', + grantedCapabilities: ['pwa:install'], + }, + }); + expect(sorted(result)).toEqual(sorted(HOST_SERVER_CAPS)); + }); +}); + +describe('resolveCapabilities — host-capable server + browser (host-ui row)', () => { + test('host:stack:read + browser yields everything minus Electron-only caps (none reserved yet)', () => { + const result = resolveCapabilities(HOST_SERVER_CAPS, browser); + // isElectronOnlyCap() is reserved and returns false for every capability + // today, so "ALL minus Electron-only" is currently the full set. + expect(sorted(result)).toEqual(sorted(HOST_SERVER_CAPS)); + }); + + test('a host-capable browser session does not union connection-granted caps', () => { + const result = resolveCapabilities(HOST_SERVER_CAPS, { + displayMode: 'browser', + activeConnection: { + kind: 'remote-opencode', + id: 'r1', + grantedCapabilities: ['pwa:install'], + }, + }); + expect(sorted(result)).toEqual(sorted(HOST_SERVER_CAPS)); + }); + + test('host-capable server + standalone-pwa display falls back to connection management (plan §4.2)', () => { + const result = resolveCapabilities(HOST_SERVER_CAPS, standalonePwa); + expect(sorted(result)).toEqual( + sorted(['chat', 'connections:read', 'connections:manage', 'connections:switch']), + ); + }); +}); + +describe('resolveCapabilities — connections:single (assistant-container row)', () => { + test('browser display yields exactly chat + assistant-settings:*', () => { + const result = resolveCapabilities(ASSISTANT_CONTAINER_CAPS, browser); + expect(sorted(result)).toEqual( + sorted(['chat', 'assistant-settings:read', 'assistant-settings:write']), + ); + }); + + test('standalone-pwa display yields the same set', () => { + const result = resolveCapabilities(ASSISTANT_CONTAINER_CAPS, standalonePwa); + expect(sorted(result)).toEqual( + sorted(['chat', 'assistant-settings:read', 'assistant-settings:write']), + ); + }); + + test("the 'connections:single' marker itself is not an effective capability", () => { + const result = resolveCapabilities(ASSISTANT_CONTAINER_CAPS, browser); + expect(result).not.toContain('connections:single'); + }); +}); + +describe('resolveCapabilities — pwa-static baseline row', () => { + test('browser display keeps connections:* + chat + pwa:install', () => { + const result = resolveCapabilities(PWA_BASELINE_CAPS, browser); + expect(sorted(result)).toEqual(sorted(PWA_BASELINE_CAPS)); + }); + + test('standalone-pwa display keeps the same baseline', () => { + const result = resolveCapabilities(PWA_BASELINE_CAPS, standalonePwa); + expect(sorted(result)).toEqual(sorted(PWA_BASELINE_CAPS)); + }); + + test('non-baseline capabilities are filtered out unless connection-granted', () => { + const result = resolveCapabilities( + [...PWA_BASELINE_CAPS, 'assistant-settings:write'], + browser, + ); + expect(result).not.toContain('assistant-settings:write'); + }); + + test('activeConnection.grantedCapabilities are unioned in (plan §4.3 extension point)', () => { + const result = resolveCapabilities(PWA_BASELINE_CAPS, { + displayMode: 'standalone-pwa', + activeConnection: { + kind: 'remote-opencode', + id: 'r1', + grantedCapabilities: ['assistant-settings:read', 'assistant-settings:write'], + }, + }); + expect(result).toEqual( + expect.arrayContaining([ + ...PWA_BASELINE_CAPS, + 'assistant-settings:read', + 'assistant-settings:write', + ]), + ); + }); + + test('granted capabilities are deduplicated against the baseline', () => { + const result = resolveCapabilities(PWA_BASELINE_CAPS, { + displayMode: 'browser', + activeConnection: { + kind: 'remote-opencode', + id: 'r1', + grantedCapabilities: ['chat', 'assistant-settings:read'], + }, + }); + expect(new Set(result).size).toBe(result.length); + expect(sorted(result)).toEqual(sorted([...PWA_BASELINE_CAPS, 'assistant-settings:read'])); + }); +}); + +// ── hasCapability + runtimeContext store ───────────────────────────────────── + +describe('hasCapability — reads the reactive runtimeContext store', () => { + let before: Capability[]; + + beforeEach(() => { + before = runtimeContext.effectiveCapabilities; + }); + + afterEach(() => { + runtimeContext.effectiveCapabilities = before; + }); + + test('runtimeContext exposes an effectiveCapabilities array', () => { + expect(Array.isArray(runtimeContext.effectiveCapabilities)).toBe(true); + }); + + test('true for a capability present in effectiveCapabilities', () => { + runtimeContext.effectiveCapabilities = ['chat', 'connections:read']; + expect(hasCapability('chat')).toBe(true); + expect(hasCapability('connections:read')).toBe(true); + }); + + test('false for a capability absent from effectiveCapabilities', () => { + runtimeContext.effectiveCapabilities = ['chat']; + expect(hasCapability('host:setup')).toBe(false); + expect(hasCapability('connections:manage')).toBe(false); + }); +}); + +// ── integration: computeServerRuntimeContext × resolveCapabilities ─────────── +// Pins the four rows of the plan §4.3 matrix end-to-end through the real +// env → hostMode → serverCapabilities → effectiveCapabilities pipeline. + +describe('capability matrix — plan §4.3 rows end-to-end', () => { + const MODE_ENV_KEYS = ['OP_UI_HOST_MODE', 'OP_INSIDE_ELECTRON', 'OP_ENABLE_ADMIN'] as const; + let savedEnv: Record = {}; + + function makeEvent(url = 'http://127.0.0.1:3880/'): RequestEvent { + const u = new URL(url); + return { + url: u, + request: new Request(u, { headers: { host: u.host } }), + params: {}, + locals: {}, + route: { id: '/' }, + getClientAddress: () => '127.0.0.1', + isDataRequest: false, + isSubRequest: false, + } as unknown as RequestEvent; + } + + beforeEach(() => { + savedEnv = {}; + for (const key of MODE_ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of MODE_ENV_KEYS) { + const prev = savedEnv[key]; + if (prev === undefined) delete process.env[key]; + else process.env[key] = prev; + } + }); + + test('electron-host × electron → ALL', () => { + process.env.OP_INSIDE_ELECTRON = '1'; + const ctx = computeServerRuntimeContext(makeEvent()); + const effective = resolveCapabilities(ctx.serverCapabilities, electron); + expect(sorted(effective)).toEqual(sorted(ctx.serverCapabilities)); + expect(effective).toEqual( + expect.arrayContaining(['chat', 'connections:manage', 'host:setup', 'host:stack:write']), + ); + }); + + test('host-ui × browser → ALL minus Electron-only', () => { + process.env.OP_ENABLE_ADMIN = '1'; + const ctx = computeServerRuntimeContext(makeEvent()); + const effective = resolveCapabilities(ctx.serverCapabilities, browser); + expect(effective).toEqual( + expect.arrayContaining(['chat', 'connections:manage', 'host:stack:read', 'host:stack:write']), + ); + }); + + test('assistant-container × browser → chat + assistant-settings only', () => { + process.env.OP_UI_HOST_MODE = 'assistant-container'; + const ctx = computeServerRuntimeContext(makeEvent()); + const effective = resolveCapabilities(ctx.serverCapabilities, browser); + expect(sorted(effective)).toEqual( + sorted(['chat', 'assistant-settings:read', 'assistant-settings:write']), + ); + for (const cap of HOST_CAPS) expect(effective).not.toContain(cap); + expect(effective).not.toContain('connections:manage'); + }); + + test('pwa-static × standalone-pwa → connections + chat + pwa:install', () => { + process.env.OP_UI_HOST_MODE = 'pwa-static'; + const ctx = computeServerRuntimeContext(makeEvent()); + const effective = resolveCapabilities(ctx.serverCapabilities, standalonePwa); + expect(effective).toEqual( + expect.arrayContaining(['connections:manage', 'connections:switch', 'chat', 'pwa:install']), + ); + for (const cap of HOST_CAPS) expect(effective).not.toContain(cap); + expect(effective).not.toContain('assistant-settings:write'); + }); +}); diff --git a/packages/ui/src/lib/server/features.vitest.ts b/packages/ui/src/lib/server/features.vitest.ts new file mode 100644 index 000000000..34a7811fc --- /dev/null +++ b/packages/ui/src/lib/server/features.vitest.ts @@ -0,0 +1,240 @@ +/** + * Tests for lib/server/features.ts — Phase 1 RuntimeContext v2 (issue #509). + * + * TDD status: + * - `computeServerRuntimeContext(event)` describes the NOT-YET-IMPLEMENTED + * replacement for `FeatureFlags` (plan §6.1 / Phase 1). Those tests are RED + * until the implementation lands. + * - `computeFeatureFlags` tests are CHARACTERIZATION of the existing + * `features.admin` behavior. They pass pre-change and MUST keep passing: + * Phase 1 keeps `features.admin` as a derived alias so existing code and + * routes work unchanged. + * + * env → hostMode mapping under test (plan Phase 1): + * OP_UI_HOST_MODE (explicit) → that mode + * else OP_INSIDE_ELECTRON=1 → 'electron-host' + * else OP_ENABLE_ADMIN=1 → 'host-ui' + * else → 'pwa-static' baseline + */ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import type { RequestEvent } from '@sveltejs/kit'; +import type { Capability, UiHostMode } from '$lib/types.js'; +import { computeFeatureFlags, computeServerRuntimeContext } from './features.js'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +function makeEvent(url = 'http://127.0.0.1:3880/'): RequestEvent { + const u = new URL(url); + return { + url: u, + request: new Request(u, { headers: { host: u.host } }), + params: {}, + locals: {}, + route: { id: '/' }, + getClientAddress: () => '127.0.0.1', + isDataRequest: false, + isSubRequest: false, + } as unknown as RequestEvent; +} + +/** The full host:* capability set from plan §6.1. */ +const HOST_CAPS: Capability[] = [ + 'host:setup', + 'host:stack:read', + 'host:stack:write', + 'host:containers', + 'host:addons', + 'host:updates', + 'host:logs', + 'host:secrets', + 'host:recovery', + 'host:akm-sharing', +]; + +const MODE_ENV_KEYS = ['OP_UI_HOST_MODE', 'OP_INSIDE_ELECTRON', 'OP_ENABLE_ADMIN'] as const; +let savedEnv: Record = {}; + +beforeEach(() => { + savedEnv = {}; + for (const key of MODE_ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of MODE_ENV_KEYS) { + const prev = savedEnv[key]; + if (prev === undefined) delete process.env[key]; + else process.env[key] = prev; + } +}); + +// ── env → hostMode mapping (RED until #509 lands) ──────────────────────────── + +describe('computeServerRuntimeContext — env → hostMode mapping (plan §6.1)', () => { + const explicitModes: UiHostMode[] = [ + 'electron-host', + 'host-ui', + 'assistant-container', + 'pwa-static', + ]; + + for (const mode of explicitModes) { + test(`OP_UI_HOST_MODE=${mode} maps to hostMode '${mode}'`, () => { + process.env.OP_UI_HOST_MODE = mode; + const ctx = computeServerRuntimeContext(makeEvent()); + expect(ctx.hostMode).toBe(mode); + }); + } + + test("falls back to 'electron-host' when OP_INSIDE_ELECTRON=1 (legacy env)", () => { + process.env.OP_INSIDE_ELECTRON = '1'; + expect(computeServerRuntimeContext(makeEvent()).hostMode).toBe('electron-host'); + }); + + test("falls back to 'host-ui' when OP_ENABLE_ADMIN=1 (legacy env)", () => { + process.env.OP_ENABLE_ADMIN = '1'; + expect(computeServerRuntimeContext(makeEvent()).hostMode).toBe('host-ui'); + }); + + test('OP_INSIDE_ELECTRON wins over OP_ENABLE_ADMIN when both are set', () => { + process.env.OP_INSIDE_ELECTRON = '1'; + process.env.OP_ENABLE_ADMIN = '1'; + expect(computeServerRuntimeContext(makeEvent()).hostMode).toBe('electron-host'); + }); + + test('OP_UI_HOST_MODE takes precedence over the legacy env fallbacks', () => { + process.env.OP_UI_HOST_MODE = 'pwa-static'; + process.env.OP_INSIDE_ELECTRON = '1'; + process.env.OP_ENABLE_ADMIN = '1'; + expect(computeServerRuntimeContext(makeEvent()).hostMode).toBe('pwa-static'); + }); + + test("defaults to the 'pwa-static' baseline when no mode env is set", () => { + expect(computeServerRuntimeContext(makeEvent()).hostMode).toBe('pwa-static'); + }); +}); + +// ── contract shape (RED until #509 lands) ──────────────────────────────────── + +describe('computeServerRuntimeContext — ServerRuntimeContext shape (plan §6.1)', () => { + test('carries contract version 2', () => { + const ctx = computeServerRuntimeContext(makeEvent()); + expect(ctx.version).toBe(2); + }); + + test('exposes the full contract shape', () => { + const ctx = computeServerRuntimeContext(makeEvent()); + expect(Array.isArray(ctx.serverCapabilities)).toBe(true); + expect(typeof ctx.publicBaseUrl).toBe('string'); + expect(typeof ctx.uiVersion).toBe('string'); + expect(typeof ctx.skeletonVersion).toBe('string'); + expect(ctx.routes).toBeTypeOf('object'); + expect(typeof ctx.security.hostAdminLoopbackOnly).toBe('boolean'); + expect(typeof ctx.security.requiresHttpsForRemoteConnections).toBe('boolean'); + expect(['loopback-origin', 'same-site', 'bearer-token']).toContain(ctx.security.csrfMode); + }); + + test("activeConnectionMode is 'single' in assistant-container mode", () => { + process.env.OP_UI_HOST_MODE = 'assistant-container'; + expect(computeServerRuntimeContext(makeEvent()).activeConnectionMode).toBe('single'); + }); + + test("activeConnectionMode is 'multi' in electron-host mode", () => { + process.env.OP_INSIDE_ELECTRON = '1'; + expect(computeServerRuntimeContext(makeEvent()).activeConnectionMode).toBe('multi'); + }); + + test('host admin stays loopback-only in host-capable modes (plan §8.3)', () => { + process.env.OP_INSIDE_ELECTRON = '1'; + expect(computeServerRuntimeContext(makeEvent()).security.hostAdminLoopbackOnly).toBe(true); + delete process.env.OP_INSIDE_ELECTRON; + process.env.OP_ENABLE_ADMIN = '1'; + expect(computeServerRuntimeContext(makeEvent()).security.hostAdminLoopbackOnly).toBe(true); + }); +}); + +// ── serverCapabilities per hostMode (RED until #509 lands) ─────────────────── + +describe('computeServerRuntimeContext — serverCapabilities per hostMode (plan §4)', () => { + test('electron-host: all host, connection and assistant-settings capabilities', () => { + process.env.OP_INSIDE_ELECTRON = '1'; + const caps = computeServerRuntimeContext(makeEvent()).serverCapabilities; + expect(caps).toEqual( + expect.arrayContaining([ + 'chat', + 'connections:read', + 'connections:manage', + 'connections:switch', + 'assistant-settings:read', + 'assistant-settings:write', + ...HOST_CAPS, + ]), + ); + // 'connections:single' is the assistant-container marker (plan §6.2) — + // multi-connection host modes must not carry it. + expect(caps).not.toContain('connections:single'); + }); + + test("host-ui: same host capability set — 'host:stack:read' is load-bearing for resolveCapabilities' browser branch", () => { + process.env.OP_ENABLE_ADMIN = '1'; + const caps = computeServerRuntimeContext(makeEvent()).serverCapabilities; + expect(caps).toEqual( + expect.arrayContaining(['chat', 'host:stack:read', 'host:stack:write', ...HOST_CAPS]), + ); + expect(caps).not.toContain('connections:single'); + }); + + test('assistant-container: connections:single + chat + assistant settings, no host or connection management', () => { + process.env.OP_UI_HOST_MODE = 'assistant-container'; + const caps = computeServerRuntimeContext(makeEvent()).serverCapabilities; + expect(caps).toEqual( + expect.arrayContaining([ + 'connections:single', + 'chat', + 'assistant-settings:read', + 'assistant-settings:write', + ]), + ); + for (const hostCap of HOST_CAPS) expect(caps).not.toContain(hostCap); + expect(caps).not.toContain('connections:manage'); + }); + + test('pwa-static baseline: connections + chat + pwa:install, nothing privileged', () => { + const caps = computeServerRuntimeContext(makeEvent()).serverCapabilities; + expect(caps).toEqual( + expect.arrayContaining([ + 'chat', + 'connections:read', + 'connections:manage', + 'connections:switch', + 'pwa:install', + ]), + ); + for (const hostCap of HOST_CAPS) expect(caps).not.toContain(hostCap); + expect(caps).not.toContain('connections:single'); + expect(caps).not.toContain('assistant-settings:write'); + }); +}); + +// ── features.admin alias (CHARACTERIZATION — green pre-change) ─────────────── +// Phase 1 keeps `features.admin` as a derived alias; these pin the legacy +// env behavior that existing code and routes rely on. Do NOT delete when the +// implementation lands — they must stay green. + +describe('computeFeatureFlags — features.admin alias (characterization)', () => { + test('admin is true under OP_INSIDE_ELECTRON=1', () => { + process.env.OP_INSIDE_ELECTRON = '1'; + expect(computeFeatureFlags().admin).toBe(true); + }); + + test('admin is true under OP_ENABLE_ADMIN=1', () => { + process.env.OP_ENABLE_ADMIN = '1'; + expect(computeFeatureFlags().admin).toBe(true); + }); + + test('admin is false when neither legacy env is set', () => { + expect(computeFeatureFlags().admin).toBe(false); + }); +}); diff --git a/packages/ui/src/routes/api/runtime/server.vitest.ts b/packages/ui/src/routes/api/runtime/server.vitest.ts new file mode 100644 index 000000000..920fb33a5 --- /dev/null +++ b/packages/ui/src/routes/api/runtime/server.vitest.ts @@ -0,0 +1,101 @@ +/** + * Tests for GET /api/runtime — Phase 1 RuntimeContext v2 (issue #509). + * + * ALL RED until the implementation lands: routes/api/runtime/+server.ts does + * not exist yet. + * + * Per plan §6.4 this endpoint is PUBLIC (no auth) and returns the + * ServerRuntimeContext, including the contract version field that the future + * hosted client uses as a version-skew handshake before enabling features. + * + * Asserts: + * - 200 with no session cookie (public — unlike every /admin/* endpoint) + * - 200 with a garbage session cookie (auth is never consulted) + * - JSON body carrying version: 2 and the full ServerRuntimeContext shape + * - hostMode reflects the env mapping (OP_INSIDE_ELECTRON / baseline) + */ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { GET } from './+server.js'; + +function makeEvent(headers: Record = {}) { + const url = new URL('http://127.0.0.1:3880/api/runtime'); + return { + url, + request: new Request(url, { headers }), + params: {}, + locals: {}, + route: { id: '/api/runtime' }, + getClientAddress: () => '127.0.0.1', + isDataRequest: false, + isSubRequest: false, + } as unknown as Parameters[0]; +} + +const MODE_ENV_KEYS = ['OP_UI_HOST_MODE', 'OP_INSIDE_ELECTRON', 'OP_ENABLE_ADMIN'] as const; +let savedEnv: Record = {}; + +beforeEach(() => { + savedEnv = {}; + for (const key of MODE_ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of MODE_ENV_KEYS) { + const prev = savedEnv[key]; + if (prev === undefined) delete process.env[key]; + else process.env[key] = prev; + } +}); + +describe('GET /api/runtime — public runtime-context endpoint (plan §6.4)', () => { + test('returns 200 with no session cookie (no auth required)', async () => { + const res = await GET(makeEvent()); + expect(res.status).toBe(200); + }); + + test('returns 200 with a garbage session cookie (auth is never consulted)', async () => { + const res = await GET(makeEvent({ cookie: 'op_session=not-a-real-token' })); + expect(res.status).toBe(200); + }); + + test('responds with JSON', async () => { + const res = await GET(makeEvent()); + expect(res.headers.get('content-type') ?? '').toContain('application/json'); + }); + + test('body carries the contract version (2) for the hosted-client handshake', async () => { + const res = await GET(makeEvent()); + const body = (await res.json()) as Record; + expect(body.version).toBe(2); + }); + + test('body exposes the ServerRuntimeContext shape (plan §6.1)', async () => { + const res = await GET(makeEvent()); + const body = (await res.json()) as Record; + expect(Array.isArray(body.serverCapabilities)).toBe(true); + expect(body.serverCapabilities).toContain('chat'); + expect(typeof body.hostMode).toBe('string'); + expect(typeof body.publicBaseUrl).toBe('string'); + expect(typeof body.uiVersion).toBe('string'); + expect(typeof body.skeletonVersion).toBe('string'); + expect(['single', 'multi']).toContain(body.activeConnectionMode); + expect(body.routes).toBeTypeOf('object'); + expect(body.security).toBeTypeOf('object'); + }); + + test("hostMode reflects OP_INSIDE_ELECTRON=1 → 'electron-host'", async () => { + process.env.OP_INSIDE_ELECTRON = '1'; + const res = await GET(makeEvent()); + const body = (await res.json()) as Record; + expect(body.hostMode).toBe('electron-host'); + }); + + test("hostMode is the 'pwa-static' baseline when no mode env is set", async () => { + const res = await GET(makeEvent()); + const body = (await res.json()) as Record; + expect(body.hostMode).toBe('pwa-static'); + }); +}); diff --git a/packages/ui/src/routes/layout.server.vitest.ts b/packages/ui/src/routes/layout.server.vitest.ts new file mode 100644 index 000000000..6b0618376 --- /dev/null +++ b/packages/ui/src/routes/layout.server.vitest.ts @@ -0,0 +1,97 @@ +/** + * Tests for routes/+layout.server.ts — Phase 1 RuntimeContext v2 (issue #509). + * + * TDD status: + * - The `features.admin` tests are CHARACTERIZATION: they pass pre-change and + * MUST keep passing — Phase 1 keeps `features.admin` as a derived alias so + * all existing code and routes work unchanged (zero behavior change). + * - The `serverRuntimeContext` tests are RED until #509 lands: the layout + * load must additionally return the ServerRuntimeContext computed by + * computeServerRuntimeContext(event) (plan Phase 1 step 3). + */ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import type { ServerRuntimeContext } from '$lib/types.js'; +import { load } from './+layout.server.js'; + +function makeEvent(url = 'http://127.0.0.1:3880/') { + const u = new URL(url); + return { + url: u, + request: new Request(u, { headers: { host: u.host } }), + params: {}, + locals: {}, + route: { id: '/' }, + getClientAddress: () => '127.0.0.1', + isDataRequest: false, + isSubRequest: false, + } as unknown as Parameters[0]; +} + +async function runLoad(): Promise> { + return (await load(makeEvent())) as unknown as Record; +} + +const MODE_ENV_KEYS = ['OP_UI_HOST_MODE', 'OP_INSIDE_ELECTRON', 'OP_ENABLE_ADMIN'] as const; +let savedEnv: Record = {}; + +beforeEach(() => { + savedEnv = {}; + for (const key of MODE_ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of MODE_ENV_KEYS) { + const prev = savedEnv[key]; + if (prev === undefined) delete process.env[key]; + else process.env[key] = prev; + } +}); + +// ── features.admin alias (CHARACTERIZATION — green pre-change) ─────────────── + +describe('+layout.server load — features.admin alias (characterization)', () => { + test('features.admin is true under OP_INSIDE_ELECTRON=1', async () => { + process.env.OP_INSIDE_ELECTRON = '1'; + const data = await runLoad(); + expect((data.features as { admin: boolean }).admin).toBe(true); + }); + + test('features.admin is true under OP_ENABLE_ADMIN=1', async () => { + process.env.OP_ENABLE_ADMIN = '1'; + const data = await runLoad(); + expect((data.features as { admin: boolean }).admin).toBe(true); + }); + + test('features.admin is false when neither legacy env is set', async () => { + const data = await runLoad(); + expect((data.features as { admin: boolean }).admin).toBe(false); + }); +}); + +// ── serverRuntimeContext (RED until #509 lands) ────────────────────────────── + +describe('+layout.server load — serverRuntimeContext (plan Phase 1)', () => { + test('layout data includes serverRuntimeContext with contract version 2', async () => { + const data = await runLoad(); + const ctx = data.serverRuntimeContext as ServerRuntimeContext | undefined; + expect(ctx, 'load() must return serverRuntimeContext').toBeDefined(); + expect(ctx?.version).toBe(2); + }); + + test("serverRuntimeContext.hostMode maps OP_INSIDE_ELECTRON=1 → 'electron-host'", async () => { + process.env.OP_INSIDE_ELECTRON = '1'; + const data = await runLoad(); + const ctx = data.serverRuntimeContext as ServerRuntimeContext | undefined; + expect(ctx?.hostMode).toBe('electron-host'); + }); + + test("serverRuntimeContext.hostMode maps OP_ENABLE_ADMIN=1 → 'host-ui'", async () => { + process.env.OP_ENABLE_ADMIN = '1'; + const data = await runLoad(); + const ctx = data.serverRuntimeContext as ServerRuntimeContext | undefined; + expect(ctx?.hostMode).toBe('host-ui'); + }); +}); From 71f1686b646cac8ed76cdfeeb47d32a593048060 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 06:53:03 +0000 Subject: [PATCH 04/61] =?UTF-8?q?feat(ui):=20RuntimeContext=20v2=20?= =?UTF-8?q?=E2=80=94=20server=20context,=20capabilities,=20/api/runtime=20?= =?UTF-8?q?(#509)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the ui-runtime-modes plan (docs/technical/ui-runtime-modes-plan.md §6.1-§6.4): replace the single features.admin boolean with a capability-based ServerRuntimeContext while keeping zero behavior change for existing routes. - lib/types.ts: UiHostMode, Capability, ServerRuntimeContext (contract version 2), ClientDisplayMode, ConnectionKind, ActiveConnectionContext, ClientContext, RuntimeContext per plan §6.1. FeatureFlags kept as the documented derived alias. - lib/server/features.ts: computeServerRuntimeContext(event). hostMode resolution: OP_UI_HOST_MODE (explicit) > OP_INSIDE_ELECTRON=1 → electron-host > OP_ENABLE_ADMIN=1 → host-ui > pwa-static baseline. Per-mode serverCapabilities (host modes carry host:*, never connections:single; assistant-container is connections:single + chat + assistant-settings:*; pwa-static is connections:* + chat + pwa:install). computeFeatureFlags() is now derived from hostMode — identical results for every env combination in use today. - lib/runtime-context.svelte.ts: resolveCapabilities(serverCaps, clientCtx) verbatim per plan §6.2 — the ONLY place capability logic lives — plus the reactive runtimeContext store, hasCapability(cap) (UX only; APIs enforce capabilities server-side), and initializeRuntimeContext(). - lib/client-context.ts: detectClientDisplayMode() per plan §6.3, browser-side only. - routes/+layout.server.ts: returns serverRuntimeContext alongside the features alias. routes/+layout.svelte: initializes the client context in onMount (client-only — never server-computed, and never mutating the module-level store during SSR) and derives effectiveCapabilities. - routes/api/runtime/+server.ts: public GET (no auth) returning the ServerRuntimeContext — the contract-version handshake for future hosted clients. Turns the 21-test red suite from 483b4bf green without modifying it; the 6 characterization tests (features.admin alias) stay green. Verified against a live dev server: /api/runtime returns 200 with no/garbage session cookie in pwa-static and host-ui modes; / and /login routing unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 --- packages/ui/src/lib/client-context.ts | 14 ++ packages/ui/src/lib/runtime-context.svelte.ts | 98 ++++++++++++ packages/ui/src/lib/server/features.ts | 149 ++++++++++++++++-- packages/ui/src/lib/types.ts | 81 +++++++++- packages/ui/src/routes/+layout.server.ts | 7 +- packages/ui/src/routes/+layout.svelte | 12 ++ packages/ui/src/routes/api/runtime/+server.ts | 16 ++ 7 files changed, 364 insertions(+), 13 deletions(-) create mode 100644 packages/ui/src/lib/client-context.ts create mode 100644 packages/ui/src/lib/runtime-context.svelte.ts create mode 100644 packages/ui/src/routes/api/runtime/+server.ts diff --git a/packages/ui/src/lib/client-context.ts b/packages/ui/src/lib/client-context.ts new file mode 100644 index 000000000..31e760663 --- /dev/null +++ b/packages/ui/src/lib/client-context.ts @@ -0,0 +1,14 @@ +/** + * Client display-mode detection — plan ui-runtime-modes-plan.md §6.3 + * (issue #509). Browser-side ONLY: initialized in +layout.svelte, never + * server-computed. On the server (no `navigator`/`window`) it returns the + * 'browser' default. + */ +import type { ClientDisplayMode } from '$lib/types.js'; + +export function detectClientDisplayMode(): ClientDisplayMode { + if (typeof navigator !== 'undefined' && /Electron/.test(navigator.userAgent)) return 'electron'; + if (typeof window !== 'undefined' && window.matchMedia('(display-mode: standalone)').matches) + return 'standalone-pwa'; + return 'browser'; +} diff --git a/packages/ui/src/lib/runtime-context.svelte.ts b/packages/ui/src/lib/runtime-context.svelte.ts new file mode 100644 index 000000000..7365c7fdc --- /dev/null +++ b/packages/ui/src/lib/runtime-context.svelte.ts @@ -0,0 +1,98 @@ +/** + * Runtime context — RuntimeContext v2 (plan ui-runtime-modes-plan.md §6.2, + * issue #509). + * + * This module is the ONLY place capability logic lives. Components call + * `hasCapability(cap)` and nothing else — no scattered `if (features.admin)` + * checks (plan §8.6). `hasCapability()` is UX only; APIs enforce capabilities + * server-side (plan §8.5). + * + * The `runtimeContext` store is populated by +layout.svelte from the layout + * server data (ServerRuntimeContext) plus the browser-detected ClientContext, + * and `effectiveCapabilities` is re-derived on every change. + */ +import type { + Capability, + ClientContext, + RuntimeContext, + ServerRuntimeContext, +} from '$lib/types.js'; + +export function resolveCapabilities( + serverCaps: Capability[], + clientCtx: ClientContext, +): Capability[] { + const { displayMode, activeConnection } = clientCtx; + + if (displayMode === 'electron') return serverCaps; + + if (serverCaps.includes('host:stack:read') && displayMode === 'browser') { + return serverCaps.filter((c) => !isElectronOnlyCap(c)); + } + + if (serverCaps.includes('connections:single')) { + // assistant-container: chat + assistant settings + return serverCaps.filter((c) => c === 'chat' || c.startsWith('assistant-settings')); + } + + // pwa-static: connections + chat + let caps = serverCaps.filter( + (c) => c.startsWith('connections') || c === 'chat' || c === 'pwa:install', + ); + + // Extension point: active connection may grant additional capabilities + if (activeConnection?.grantedCapabilities) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient dedupe helper inside a pure function, never held as reactive state + caps = [...new Set([...caps, ...activeConnection.grantedCapabilities])]; + } + + return caps; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- signature fixed by plan §6.2; the parameter is consulted once Electron IPC-dependent capabilities exist +function isElectronOnlyCap(_c: Capability): boolean { + return false; // reserved for future Electron IPC-dependent features +} + +/** + * Reactive runtime context. Starts at the unprivileged pwa-static baseline + * with zero capabilities until +layout.svelte initializes it — capabilities + * are opted INTO from server data, never defaulted on. + */ +export const runtimeContext = $state({ + version: 2, + hostMode: 'pwa-static', + serverCapabilities: [], + publicBaseUrl: '', + uiVersion: '', + skeletonVersion: '', + activeConnectionMode: 'multi', + routes: {}, + security: { + hostAdminLoopbackOnly: true, + requiresHttpsForRemoteConnections: true, + csrfMode: 'loopback-origin', + }, + clientContext: { displayMode: 'browser' }, + effectiveCapabilities: [], +}); + +export function hasCapability(cap: Capability): boolean { + return runtimeContext.effectiveCapabilities.includes(cap); +} + +/** + * Populate the store from layout data + browser-detected client context and + * re-derive `effectiveCapabilities`. Called from +layout.svelte (client-only). + */ +export function initializeRuntimeContext( + serverCtx: ServerRuntimeContext, + clientCtx: ClientContext, +): void { + Object.assign(runtimeContext, serverCtx); + runtimeContext.clientContext = clientCtx; + runtimeContext.effectiveCapabilities = resolveCapabilities( + serverCtx.serverCapabilities, + clientCtx, + ); +} diff --git a/packages/ui/src/lib/server/features.ts b/packages/ui/src/lib/server/features.ts index 0614fc1a3..3861152a8 100644 --- a/packages/ui/src/lib/server/features.ts +++ b/packages/ui/src/lib/server/features.ts @@ -1,17 +1,148 @@ -import type { FeatureFlags } from '$lib/types.js'; +import type { RequestEvent } from '@sveltejs/kit'; +import { PLATFORM_VERSION } from '@openpalm/lib'; +import uiPkg from '../../../package.json'; +import type { Capability, FeatureFlags, ServerRuntimeContext, UiHostMode } from '$lib/types.js'; /** - * Compute feature flags from environment variables. - * Called server-side on every request via +layout.server.ts. + * Server runtime context — RuntimeContext v2 (plan ui-runtime-modes-plan.md + * §6.1, issue #509). Computed server-side on every request via + * +layout.server.ts and served publicly at GET /api/runtime (the + * contract-version handshake for remote/hosted clients). * - * admin: enabled when the UI is hosted inside the Electron harness - * (OP_INSIDE_ELECTRON=1, injected by packages/electron/src/main.ts) or when - * OP_ENABLE_ADMIN=1 is set explicitly (local dev / testing without Electron). + * hostMode resolution (Phase 1): + * OP_UI_HOST_MODE (explicit) → that mode + * else OP_INSIDE_ELECTRON=1 → 'electron-host' (legacy, injected by + * packages/electron/src/main.ts) + * else OP_ENABLE_ADMIN=1 → 'host-ui' (legacy, local dev / openpalm admin) + * else → 'pwa-static' baseline + */ + +const HOST_MODES: readonly UiHostMode[] = [ + 'electron-host', + 'host-ui', + 'assistant-container', + 'pwa-static', +]; + +function isUiHostMode(value: string): value is UiHostMode { + return (HOST_MODES as readonly string[]).includes(value); +} + +function resolveHostMode(): UiHostMode { + const explicit = process.env.OP_UI_HOST_MODE?.trim() ?? ''; + if (isUiHostMode(explicit)) return explicit; + if (process.env.OP_INSIDE_ELECTRON === '1') return 'electron-host'; + if (process.env.OP_ENABLE_ADMIN === '1') return 'host-ui'; + return 'pwa-static'; +} + +/** The full host:* capability set (plan §6.1) — host-capable modes only. */ +const HOST_CAPABILITIES: readonly Capability[] = [ + 'host:setup', + 'host:stack:read', + 'host:stack:write', + 'host:containers', + 'host:addons', + 'host:updates', + 'host:logs', + 'host:secrets', + 'host:recovery', + 'host:akm-sharing', +]; + +/** Capabilities of a host-capable process (electron-host / host-ui). Note: + * never 'connections:single' — that marker is the assistant-container + * branch key in resolveCapabilities() (plan §6.2). */ +const HOST_MODE_CAPABILITIES: readonly Capability[] = [ + 'chat', + 'connections:read', + 'connections:manage', + 'connections:switch', + 'assistant-settings:read', + 'assistant-settings:write', + ...HOST_CAPABILITIES, +]; + +const SERVER_CAPABILITIES: Record = { + 'electron-host': HOST_MODE_CAPABILITIES, + 'host-ui': HOST_MODE_CAPABILITIES, + // Single locked connection to the local OpenCode: chat + assistant settings, + // no host:* and no connection management. + 'assistant-container': [ + 'connections:single', + 'chat', + 'assistant-settings:read', + 'assistant-settings:write', + ], + // Baseline: connection management + chat (when connected) + PWA install. + 'pwa-static': [ + 'chat', + 'connections:read', + 'connections:manage', + 'connections:switch', + 'pwa:install', + ], +}; + +/** Route pointers per mode — current-truth URLs (pre-Phase-3 route split). + * Phase 2 moves connections to /connections; Phase 4 moves /admin to /host. */ +function routesForMode(mode: UiHostMode): ServerRuntimeContext['routes'] { + switch (mode) { + case 'electron-host': + case 'host-ui': + return { + chat: '/chat', + connections: '/admin/endpoints', + host: '/admin', + setup: '/setup', + }; + case 'assistant-container': + return { chat: '/chat' }; + case 'pwa-static': + return { chat: '/chat' }; + } +} + +export function computeServerRuntimeContext(event: RequestEvent): ServerRuntimeContext { + const hostMode = resolveHostMode(); + const isHostCapable = hostMode === 'electron-host' || hostMode === 'host-ui'; + return { + version: 2, + hostMode, + serverCapabilities: [...SERVER_CAPABILITIES[hostMode]], + publicBaseUrl: event.url.origin, + uiVersion: uiPkg.version, + // Skeleton version equals platform version in production (plan §8.2); + // OP_SKELETON_VERSION is the explicit exact-pin override (plan §3). + skeletonVersion: process.env.OP_SKELETON_VERSION?.trim() || PLATFORM_VERSION, + activeConnectionMode: hostMode === 'assistant-container' ? 'single' : 'multi', + routes: routesForMode(hostMode), + security: { + // Host admin is loopback-only and never weakened (plan §8.3). + hostAdminLoopbackOnly: true, + // Only browser-direct remote connections (the static client) need HTTPS; + // host modes proxy server-side (plan §6.10). + requiresHttpsForRemoteConnections: hostMode === 'pwa-static', + csrfMode: isHostCapable + ? 'loopback-origin' + : hostMode === 'assistant-container' + ? 'same-site' + : 'bearer-token', + }, + }; +} + +/** + * Legacy feature flags — kept as a DERIVED ALIAS of the runtime context until + * the features.admin → hasCapability() migration completes (plan Phase 1). + * + * admin: true exactly when the resolved hostMode is host-capable, which + * preserves the pre-Phase-1 behavior (OP_INSIDE_ELECTRON=1 → electron-host, + * OP_ENABLE_ADMIN=1 → host-ui, both → admin). */ export function computeFeatureFlags(): FeatureFlags { + const hostMode = resolveHostMode(); return { - admin: - process.env.OP_INSIDE_ELECTRON === '1' || - process.env.OP_ENABLE_ADMIN === '1', + admin: hostMode === 'electron-host' || hostMode === 'host-ui', }; } diff --git a/packages/ui/src/lib/types.ts b/packages/ui/src/lib/types.ts index a99f148b9..dda57e3ae 100644 --- a/packages/ui/src/lib/types.ts +++ b/packages/ui/src/lib/types.ts @@ -2,11 +2,88 @@ import type { ToolStripEntry } from '$lib/chat/tool-strip.js'; export interface FeatureFlags { /** Control-plane tools (Docker compose management, stack orchestration). - * Enabled when running inside Electron (OP_INSIDE_ELECTRON=1) or - * explicitly via OP_ENABLE_ADMIN=1 for local dev. Defaults to false. */ + * DERIVED ALIAS (plan ui-runtime-modes-plan.md Phase 1): true exactly when + * the resolved hostMode is host-capable ('electron-host' | 'host-ui'). + * Legacy envs map in via computeServerRuntimeContext: OP_INSIDE_ELECTRON=1 + * → electron-host, OP_ENABLE_ADMIN=1 → host-ui. Kept until the + * features.admin → hasCapability() migration completes. */ admin: boolean; } +// ── RuntimeContext v2 (plan §6.1, issue #509) ────────────────────────── + +export type UiHostMode = + | 'electron-host' + | 'host-ui' + | 'assistant-container' + | 'pwa-static'; + +export type Capability = + | 'chat' + | 'connections:read' + | 'connections:manage' + | 'connections:switch' + | 'connections:single' + | 'assistant-settings:read' + | 'assistant-settings:write' + | 'host:setup' + | 'host:stack:read' + | 'host:stack:write' + | 'host:containers' + | 'host:addons' + | 'host:updates' + | 'host:logs' + | 'host:secrets' + | 'host:recovery' + | 'host:akm-sharing' + | 'pwa:install'; + +export type ServerRuntimeContext = { + /** Contract version — the /api/runtime handshake for remote/hosted clients. */ + version: 2; + hostMode: UiHostMode; + serverCapabilities: Capability[]; + publicBaseUrl: string; + uiVersion: string; + skeletonVersion: string; + activeConnectionMode: 'single' | 'multi'; + routes: { + chat?: string; + connections?: string; + assistantSettings?: string; + host?: string; + setup?: string; + }; + security: { + hostAdminLoopbackOnly: boolean; + requiresHttpsForRemoteConnections: boolean; + csrfMode: 'loopback-origin' | 'same-site' | 'bearer-token'; + }; +}; + +export type ClientDisplayMode = 'electron' | 'standalone-pwa' | 'browser'; + +/** Connection kinds (plan §6.6). `endpoints.json` is not renamed; the + * internal model uses "connection" language. */ +export type ConnectionKind = 'local-opencode' | 'remote-opencode' | 'openpalm-client-api'; + +export type ActiveConnectionContext = { + kind: ConnectionKind; + id: string; + /** Server-verified at connection-add time (plan §8.9) — never self-granted. */ + grantedCapabilities?: Capability[]; +}; + +export type ClientContext = { + displayMode: ClientDisplayMode; + activeConnection?: ActiveConnectionContext; +}; + +export type RuntimeContext = ServerRuntimeContext & { + clientContext: ClientContext; + effectiveCapabilities: Capability[]; +}; + export type { ToolStripEntry }; export type HealthPayload = { status: string; service: string }; diff --git a/packages/ui/src/routes/+layout.server.ts b/packages/ui/src/routes/+layout.server.ts index 5040f89db..a2cfdbd6e 100644 --- a/packages/ui/src/routes/+layout.server.ts +++ b/packages/ui/src/routes/+layout.server.ts @@ -1,8 +1,11 @@ import type { LayoutServerLoad } from './$types'; -import { computeFeatureFlags } from '$lib/server/features.js'; +import { computeFeatureFlags, computeServerRuntimeContext } from '$lib/server/features.js'; -export const load: LayoutServerLoad = () => { +export const load: LayoutServerLoad = (event) => { return { + // Derived alias — kept until the features.admin → hasCapability() + // migration completes (plan ui-runtime-modes-plan.md Phase 1). features: computeFeatureFlags(), + serverRuntimeContext: computeServerRuntimeContext(event), }; }; diff --git a/packages/ui/src/routes/+layout.svelte b/packages/ui/src/routes/+layout.svelte index daaba1cad..8be7df263 100644 --- a/packages/ui/src/routes/+layout.svelte +++ b/packages/ui/src/routes/+layout.svelte @@ -5,6 +5,8 @@ import Toast from '$lib/components/common/Toast.svelte'; import { themeService } from '$lib/theme-state.svelte.js'; import { featuresService } from '$lib/features.svelte.js'; + import { detectClientDisplayMode } from '$lib/client-context.js'; + import { initializeRuntimeContext } from '$lib/runtime-context.svelte.js'; interface Props { data: import('./$types').LayoutData; @@ -19,6 +21,16 @@ onMount(() => { themeService.init(); + // Runtime context (plan ui-runtime-modes-plan.md Phase 1, #509): the + // client display mode is browser-detected — never server-computed — so it + // is initialized in onMount (client-only; also avoids mutating the + // module-level store during SSR, which is shared across requests). Like + // `features`, the server context is env-derived and constant at runtime, + // so a one-time init is correct; effectiveCapabilities is (re)derived + // inside initializeRuntimeContext. + initializeRuntimeContext(data.serverRuntimeContext, { + displayMode: detectClientDisplayMode(), + }); }); diff --git a/packages/ui/src/routes/api/runtime/+server.ts b/packages/ui/src/routes/api/runtime/+server.ts new file mode 100644 index 000000000..b6d4cd2ce --- /dev/null +++ b/packages/ui/src/routes/api/runtime/+server.ts @@ -0,0 +1,16 @@ +/** + * GET /api/runtime — public runtime-context endpoint (plan + * ui-runtime-modes-plan.md §6.4, issue #509). + * + * PUBLIC by design: no auth is consulted. The body is the ServerRuntimeContext + * whose `version` field is the contract-version handshake remote/hosted + * clients use to detect version skew before enabling features. It carries no + * secrets — mode, capability names, versions, and route pointers only. + */ +import { json } from '@sveltejs/kit'; +import { computeServerRuntimeContext } from '$lib/server/features.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = (event) => { + return json(computeServerRuntimeContext(event)); +}; From 538393b38a2ecbd0846c8d3ad931fb31458bbd19 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 07:23:58 +0000 Subject: [PATCH 05/61] test(phase-1.5): red tests for #556 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD red suite for `openpalm admin` (host-ui mode; plan Phase 1.5, §4.1, §8.3) in packages/cli/src/commands/admin.test.ts: - Registration + help. RED: 2 tests — 'admin' missing from the main.ts subCommands map; `renderUsage(mainCommand)` COMMANDS table has no admin row. - Serve mode, driven through the command via citty runCommand against a seeded temp OP_HOME (no module mocks: real lib + cli-state; fetch answers /health ready and fails registry pulls non-fatally; Bun.spawn captured so nothing real launches). RED: 3 tests, all failing with "Cannot find module .../src/commands/admin.ts": * spawned UI child env carries OP_ENABLE_ADMIN=1 + OP_UI_HOST_MODE= host-ui, binds 127.0.0.1 with pinned loopback ORIGIN, prints the URL, opens the browser by default; * loopback enforcement — OP_ALLOW_REMOTE_SETUP=1 is ignored (loopback HOST/ORIGIN, no HOST_HEADER/PROTOCOL_HEADER) and neutralized in the child env (isRemoteSetupAllowed(childEnv) === false) so the respawned `openpalm ui` child and the UI's remote-setup relaxations cannot re-derive a remote bind; --no-open spawns no opener; * with an empty OP_HOME (not installed) the command still serves the admin-enabled UI — the UI's existing guard lands on /setup; the CLI must not demand `openpalm install` or reimplement wizard logic. - CHARACTERIZATION (green pre-change, must stay green): 2 tests pinning the bare `openpalm` serve path's spawn env — loopback + pinned ORIGIN with NO admin/mode env introduced, and OP_ALLOW_REMOTE_SETUP still producing the 0.0.0.0 + HOST_HEADER remote bind. Loopback-always is scoped to the admin command only. Verified red for the right reasons: 5 fail / 2 pass in the new file; full CLI suite 82 pass / 6 fail = these 5 + the 1 known uid-0 install-flow env failure. biome lint: no findings in the new file; packages/cli tsc --noEmit exit 0 (red-state safe via computed-specifier dynamic import); ui:check 0 errors 0 warnings. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 --- packages/cli/src/commands/admin.test.ts | 400 ++++++++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 packages/cli/src/commands/admin.test.ts diff --git a/packages/cli/src/commands/admin.test.ts b/packages/cli/src/commands/admin.test.ts new file mode 100644 index 000000000..185cb8f3f --- /dev/null +++ b/packages/cli/src/commands/admin.test.ts @@ -0,0 +1,400 @@ +/** + * Phase 1.5 (#556) — `openpalm admin` (host-ui mode). + * + * TDD red suite written BEFORE the implementation. Contract under test + * (docs/technical/ui-runtime-modes-plan.md §7 Phase 1.5, §4.1 host-ui row): + * + * - `admin` is registered in src/main.ts's subCommands map (new + * src/commands/admin.ts) and shows up in `openpalm --help`. + * - The command serves the existing UI through the existing startUIServer + * path, with the admin capability enabled in the SPAWNED UI child env: + * OP_ENABLE_ADMIN=1 and OP_UI_HOST_MODE=host-ui. + * - host-ui mode is loopback-only ALWAYS: a non-loopback bind config + * (OP_ALLOW_REMOTE_SETUP) is refused/ignored for this command — the child + * binds 127.0.0.1 with a pinned loopback ORIGIN, and the flag is + * neutralized in the child env so the respawned `openpalm ui` child and + * the UI's own remote-setup relaxations cannot re-derive a remote bind + * (plan §8.3: host admin remains loopback-only, never weakened). + * - It prints the URL and opens the browser (reusing the existing + * open-browser helper via startUIServer); --no-open suppresses that. + * - On a machine with no install it still serves — the UI's existing setup + * guard lands on /setup; the CLI does NOT reimplement wizard logic. + * + * Harness: no module mocks. Real @openpalm/lib + cli-state against a seeded + * temp OP_HOME; global fetch answers /health (ready) and fails registry + * fetches (checkAndUpdate* degrade non-fatally by design); Bun.spawn is + * captured so no real process is launched. The serve promise intentionally + * never resolves (foreground supervisor) — tests poll the spawn capture. + * + * The two "parity" tests at the bottom are CHARACTERIZATION tests: they pass + * before the Phase 1.5 change and pin the existing bare-serve spawn env so + * loopback-always is scoped to admin mode only. + */ +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { renderUsage, runCommand, type CommandDef } from 'citty'; +import { isRemoteSetupAllowed } from '@openpalm/lib'; +import { mainCommand } from '../main.ts'; +import { startUIServer, type UIServerOptions } from '../lib/ui-server.ts'; + +// Resolved as a plain string so the red state fails at runtime ("Cannot find +// module") instead of at typecheck time — src/commands/admin.ts does not +// exist until the implementation lands. +const adminModuleUrl = new URL('./admin.ts', import.meta.url).href; + +const ESC = String.fromCharCode(27); +/** Strip ANSI color codes from citty usage output. */ +function stripAnsi(s: string): string { + return s.replace(new RegExp(`${ESC}\\[[0-9;]*m`, 'g'), ''); +} + +// ── Serve harness ──────────────────────────────────────────────────────────── + +interface CapturedSpawn { + argv: string[]; + env: Record | undefined; +} + +const originalBunSpawn = Bun.spawn; +const originalFetch = globalThis.fetch; +const originalLog = console.log; +const originalWarn = console.warn; +const SAVED_ENV_KEYS = [ + 'OP_HOME', + 'OP_ENABLE_ADMIN', + 'OP_UI_HOST_MODE', + 'OP_ALLOW_REMOTE_SETUP', + 'OP_HOST_UI_PORT' +] as const; +const savedEnv: Record = {}; +for (const key of SAVED_ENV_KEYS) savedEnv[key] = process.env[key]; +const tmpDirs: string[] = []; + +afterEach(() => { + Bun.spawn = originalBunSpawn; + globalThis.fetch = originalFetch; + console.log = originalLog; + console.warn = originalWarn; + for (const key of SAVED_ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + for (const dir of tmpDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** Capture every Bun.spawn call; no real process is ever launched. */ +function captureSpawns(): CapturedSpawn[] { + const calls: CapturedSpawn[] = []; + Bun.spawn = (( + argv: readonly string[], + opts?: { env?: Record } + ) => { + calls.push({ argv: [...argv], env: opts?.env }); + return { + pid: 0, + exited: new Promise(() => {}), // stays "alive" + exitCode: null, + signalCode: null, + killed: false, + stdin: null, + stdout: null, + stderr: null, + kill: () => {}, + ref: () => {}, + unref: () => {}, + [Symbol.asyncDispose]: async () => {}, + resourceUsage: () => undefined + }; + }) as unknown as typeof Bun.spawn; + return calls; +} + +/** Capture console.log/console.warn lines (startUIServer prints the URL there). */ +function captureLogs(): string[] { + const lines: string[] = []; + console.log = ((...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }) as typeof console.log; + console.warn = ((...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }) as typeof console.warn; + return lines; +} + +/** + * Seed a temp OP_HOME the REAL lib accepts and point the serve path at it: + * - data/ui/index.js so resolveUiBuildDir() always finds a runnable build + * (never executed — Bun.spawn is captured); + * - installed=true additionally seeds core.compose.yml + OP_SETUP_COMPLETE + * so ensureValidState() passes; installed=false leaves OP_HOME empty so + * classifyLocalInstall() reports not_installed (the /setup scenario). + * Also mocks fetch: UI /health is ready; registry fetches fail, which the + * checkAndUpdate* self-update helpers absorb non-fatally by design. + */ +function seedServeHome(opts: { installed: boolean }): string { + const home = mkdtempSync(join(tmpdir(), 'openpalm-admin-red-')); + tmpDirs.push(home); + mkdirSync(join(home, 'data', 'ui'), { recursive: true }); + writeFileSync(join(home, 'data', 'ui', 'index.js'), '// stub adapter-node entry\n'); + if (opts.installed) { + mkdirSync(join(home, 'system', 'stack'), { recursive: true }); + writeFileSync(join(home, 'system', 'stack', 'core.compose.yml'), 'services: {}\n'); + mkdirSync(join(home, 'state'), { recursive: true }); + writeFileSync(join(home, 'state', 'stack.state.env'), 'OP_SETUP_COMPLETE=true\n'); + } + process.env.OP_HOME = home; + // Make sure nothing ambient can fake an admin-mode pass (spawnUiChild + // spreads process.env into the child env). + delete process.env.OP_ENABLE_ADMIN; + delete process.env.OP_UI_HOST_MODE; + delete process.env.OP_ALLOW_REMOTE_SETUP; + delete process.env.OP_HOST_UI_PORT; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input instanceof Request ? input.url : input); + if (url.endsWith('/health')) return new Response('ok', { status: 200 }); + throw new TypeError('fetch failed'); + }) as unknown as typeof fetch; + return home; +} + +/** + * Poll until `get` yields a value. `failed` surfaces an async command + * rejection instead of a misleading timeout. + */ +async function waitFor( + get: () => T | undefined, + what: string, + failed?: () => unknown, + timeoutMs = 5000 +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const err = failed?.(); + if (err !== undefined) { + throw new Error(`${what}: command rejected first: ${String(err)}`); + } + const value = get(); + if (value !== undefined) return value; + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +/** The spawned UI child is the only capture that carries PORT in its env. */ +function uiChildSpawn(calls: CapturedSpawn[], port: number): CapturedSpawn | undefined { + return calls.find((c) => c.env?.PORT === String(port)); +} + +/** openBrowser spawns the platform opener with the URL and NO env option. */ +function browserSpawn(calls: CapturedSpawn[], port: number): CapturedSpawn | undefined { + return calls.find((c) => c.env === undefined && c.argv.some((a) => a.includes(`:${port}`))); +} + +/** + * Import src/commands/admin.ts (RED: module does not exist yet) and run it + * through citty without awaiting — the serve mode runs in the foreground + * until SIGINT/SIGTERM, so the returned promise never resolves on success. + */ +async function runAdmin(rawArgs: string[]): Promise<{ error?: unknown }> { + const state: { error?: unknown } = {}; + const mod = (await import(adminModuleUrl)) as { default: CommandDef }; + void runCommand(mod.default, { rawArgs }).catch((e: unknown) => { + state.error = e ?? new Error('admin command rejected'); + }); + return state; +} + +// ── Registration + help (#556) ─────────────────────────────────────────────── + +describe('admin subcommand registration (#556)', () => { + it('registers `admin` in the main subCommands map', async () => { + const sub = (mainCommand.subCommands as Record Promise>).admin; + expect(typeof sub).toBe('function'); + const cmd = (await sub()) as { meta?: { name?: string; description?: string } }; + expect(cmd.meta?.name).toBe('admin'); + // A non-empty description so `openpalm --help` renders a useful COMMANDS row. + expect(cmd.meta?.description ?? '').not.toBe(''); + }); + + it('lists `admin` in the CLI help output', async () => { + const usage = stripAnsi(await renderUsage(mainCommand)); + // The COMMANDS table renders each entry as " ". + expect(usage).toMatch(/^\s*admin\b/m); + }); +}); + +// ── Serve mode: admin env + loopback enforcement (#556) ───────────────────── + +describe('openpalm admin serve mode (#556)', () => { + it( + 'spawns the UI child with the admin capability + host-ui mode env, prints the URL, opens the browser', + async () => { + seedServeHome({ installed: true }); + const calls = captureSpawns(); + const logs = captureLogs(); + + const run = await runAdmin(['--port', '4611']); + + const child = await waitFor( + () => uiChildSpawn(calls, 4611), + 'admin UI child spawn', + () => run.error + ); + // Admin capability enabled in the spawned UI server env (plan Phase 1.5). + expect(child.env?.OP_ENABLE_ADMIN).toBe('1'); + expect(child.env?.OP_UI_HOST_MODE).toBe('host-ui'); + // Loopback-only bind with a pinned loopback origin. + expect(child.env?.HOST).toBe('127.0.0.1'); + expect(child.env?.PORT).toBe('4611'); + expect(child.env?.ORIGIN).toBe('http://127.0.0.1:4611'); + expect(child.env?.HOST_HEADER).toBeUndefined(); + expect(child.env?.PROTOCOL_HEADER).toBeUndefined(); + + // Prints the URL… + await waitFor( + () => (logs.some((l) => /http:\/\/(localhost|127\.0\.0\.1):4611/.test(l)) ? true : undefined), + 'admin URL printed', + () => run.error + ); + // …and opens the browser by default (existing open-browser helper). + await waitFor( + () => browserSpawn(calls, 4611), + 'browser opener spawn', + () => run.error + ); + }, + 15000 + ); + + it( + 'refuses a non-loopback bind config: OP_ALLOW_REMOTE_SETUP is ignored and neutralized', + async () => { + seedServeHome({ installed: true }); + // Operator has the remote-setup escape hatch enabled — admin mode must + // stay loopback-only anyway (plan §8.3, Phase 1.5 "refuse non-loopback binds"). + process.env.OP_ALLOW_REMOTE_SETUP = '1'; + const calls = captureSpawns(); + const logs = captureLogs(); + + const run = await runAdmin(['--port', '4612', '--no-open']); + + const child = await waitFor( + () => uiChildSpawn(calls, 4612), + 'admin UI child spawn', + () => run.error + ); + expect(child.env?.OP_ENABLE_ADMIN).toBe('1'); + expect(child.env?.OP_UI_HOST_MODE).toBe('host-ui'); + // NOT the remote bind (0.0.0.0 + Host-header origin) — loopback, pinned. + expect(child.env?.HOST).toBe('127.0.0.1'); + expect(child.env?.ORIGIN).toBe('http://127.0.0.1:4612'); + expect(child.env?.HOST_HEADER).toBeUndefined(); + expect(child.env?.PROTOCOL_HEADER).toBeUndefined(); + // The flag itself must be neutralized in the child env: the `openpalm ui` + // respawn and the UI server's own OP_ALLOW_REMOTE_SETUP relaxations + // (Host/Origin allowlist, setup gate) must not see it enabled. + expect(isRemoteSetupAllowed(child.env ?? {})).toBe(false); + + // --no-open: URL is printed but no browser opener is spawned. + await waitFor( + () => (logs.some((l) => /http:\/\/(localhost|127\.0\.0\.1):4612/.test(l)) ? true : undefined), + 'admin URL printed', + () => run.error + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(browserSpawn(calls, 4612)).toBeUndefined(); + }, + 15000 + ); + + it( + 'serves without an install — the UI setup guard lands on /setup, no wizard logic in the CLI', + async () => { + // Empty OP_HOME: classifyLocalInstall() → not_installed. The command must + // still bring the UI up (admin-enabled, loopback) instead of demanding + // `openpalm install` — the UI's existing guard redirects to /setup. + seedServeHome({ installed: false }); + const calls = captureSpawns(); + captureLogs(); + + const run = await runAdmin(['--port', '4613', '--no-open']); + + const child = await waitFor( + () => uiChildSpawn(calls, 4613), + 'admin UI child spawn (no install)', + () => run.error + ); + expect(child.env?.OP_ENABLE_ADMIN).toBe('1'); + expect(child.env?.OP_UI_HOST_MODE).toBe('host-ui'); + expect(child.env?.HOST).toBe('127.0.0.1'); + expect(child.env?.ORIGIN).toBe('http://127.0.0.1:4613'); + }, + 15000 + ); +}); + +// ── Spawn-env parity for the existing bare serve path ──────────────────────── +// CHARACTERIZATION: these pass BEFORE the Phase 1.5 change and must stay +// green after it — loopback-always and the admin env are scoped to the admin +// command; the bare `openpalm` serve path keeps its current behavior. + +describe('bare serve path spawn env (characterization — green pre-change)', () => { + it( + 'binds loopback with a pinned ORIGIN and does NOT enable admin', + async () => { + seedServeHome({ installed: true }); + const calls = captureSpawns(); + captureLogs(); + + const failure: { error?: unknown } = {}; + void startUIServer({ port: 4614, open: false } satisfies UIServerOptions).catch( + (e: unknown) => { + failure.error = e ?? new Error('startUIServer rejected'); + } + ); + + const child = await waitFor( + () => uiChildSpawn(calls, 4614), + 'bare-serve UI child spawn', + () => failure.error + ); + expect(child.env?.HOST).toBe('127.0.0.1'); + expect(child.env?.ORIGIN).toBe('http://127.0.0.1:4614'); + expect(child.env?.HOST_HEADER).toBeUndefined(); + // Bare serve is NOT the admin surface: no admin/mode env is introduced. + expect(child.env?.OP_ENABLE_ADMIN).toBeUndefined(); + expect(child.env?.OP_UI_HOST_MODE).toBeUndefined(); + }, + 15000 + ); + + it( + 'still honors OP_ALLOW_REMOTE_SETUP (the remote bind is refused only in admin mode)', + async () => { + seedServeHome({ installed: true }); + process.env.OP_ALLOW_REMOTE_SETUP = '1'; + const calls = captureSpawns(); + captureLogs(); + + const failure: { error?: unknown } = {}; + void startUIServer({ port: 4615, open: false } satisfies UIServerOptions).catch( + (e: unknown) => { + failure.error = e ?? new Error('startUIServer rejected'); + } + ); + + const child = await waitFor( + () => uiChildSpawn(calls, 4615), + 'bare-serve remote UI child spawn', + () => failure.error + ); + expect(child.env?.HOST).toBe('0.0.0.0'); + expect(child.env?.HOST_HEADER).toBe('host'); + expect(child.env?.PROTOCOL_HEADER).toBe('x-forwarded-proto'); + expect(child.env?.ORIGIN).toBeUndefined(); + }, + 15000 + ); +}); From fc4c254bb4cfac1e6a700e4a1cdfb30383604ad3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 07:34:18 +0000 Subject: [PATCH 06/61] =?UTF-8?q?feat(cli):=20add=20`openpalm=20admin`=20?= =?UTF-8?q?=E2=80=94=20host-ui=20admin=20serve=20mode=20(#556)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register a new `admin` subcommand that serves the existing UI through the existing startUIServer supervisor with the admin capability enabled in the spawned UI child (OP_ENABLE_ADMIN=1 + OP_UI_HOST_MODE=host-ui), prints the URL, and opens the browser (reusing the existing openBrowser helper; --no-open suppresses it). Loopback-only ALWAYS (plan §8.3): admin mode ignores OP_ALLOW_REMOTE_SETUP when deriving the bind (HOST=127.0.0.1 with a pinned loopback ORIGIN, no HOST_HEADER/PROTOCOL_HEADER) and neutralizes the flag in the child env (OP_ALLOW_REMOTE_SETUP=0) so neither the respawned `openpalm ui` child nor the UI server's own remote-setup relaxations can re-derive a remote bind. The bare serve path keeps its current behavior (characterization tests pin it): loopback by default, 0.0.0.0 + Host-header origin under OP_ALLOW_REMOTE_SETUP. On a machine with no install the command still serves — resolveServeState() tolerates a not-installed OP_HOME so the UI's existing setup guard takes over; the CLI reimplements no wizard logic. No new auth mechanism: the UI's existing op_session password auth applies unchanged. Makes the Phase 1.5 red suite (admin.test.ts) green without modifying it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 --- packages/cli/src/commands/admin.ts | 42 ++++++++++++++++++++++++++++++ packages/cli/src/lib/cli-state.ts | 15 +++++++++++ packages/cli/src/lib/ui-server.ts | 32 +++++++++++++++++++---- packages/cli/src/main.ts | 5 ++-- 4 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/commands/admin.ts diff --git a/packages/cli/src/commands/admin.ts b/packages/cli/src/commands/admin.ts new file mode 100644 index 000000000..01016d226 --- /dev/null +++ b/packages/cli/src/commands/admin.ts @@ -0,0 +1,42 @@ +/** + * `openpalm admin` — host-ui mode (plan Phase 1.5, #556). + * + * Serves the existing UI through the existing startUIServer supervisor with + * the admin capability enabled in the spawned UI child (OP_ENABLE_ADMIN=1 + + * OP_UI_HOST_MODE=host-ui), prints the URL, and opens the browser. Full host + * management from a browser on the host machine, without Electron. + * + * Loopback-only ALWAYS: this mode refuses non-loopback bind config — + * OP_ALLOW_REMOTE_SETUP is ignored and neutralized in the child env (plan + * §8.3: host admin is never reachable remotely). No new auth mechanism: the + * UI's existing op_session password auth applies. On a machine with no + * install, the UI's existing setup guard lands on /setup. + */ +import { defineCommand } from 'citty'; +import { startUIServer } from '../lib/ui-server.ts'; + +export default defineCommand({ + meta: { + name: 'admin', + description: + 'Serve the OpenPalm admin UI in your browser (loopback-only, no Electron needed)', + }, + args: { + port: { + type: 'string', + description: 'UI server port (default: 3880 or OP_HOST_UI_PORT)', + }, + open: { + type: 'boolean', + description: 'Open browser after start (use --no-open to skip)', + default: true, + }, + }, + async run({ args }) { + await startUIServer({ + port: args.port ? Number(args.port) : undefined, + open: args.open, + adminHostUi: true, + }); + }, +}); diff --git a/packages/cli/src/lib/cli-state.ts b/packages/cli/src/lib/cli-state.ts index 35462b41e..e4e9ef1a6 100644 --- a/packages/cli/src/lib/cli-state.ts +++ b/packages/cli/src/lib/cli-state.ts @@ -28,3 +28,18 @@ export function ensureValidState(): ControlPlaneState { state.artifacts = resolveRuntimeFiles(); return state; } + +/** + * Like {@link ensureValidState}, but tolerates a not-installed OP_HOME: + * returns the bootstrap state (no runtime artifacts) instead of throwing, so + * the UI server can still come up and its setup guard lands on /setup. + * Used by `openpalm admin`, which must serve on a machine with no install. + */ +export function resolveServeState(): ControlPlaneState { + const state = createState(); + if (classifyLocalInstall(state.stackDir, state.homeDir) === 'not_installed') { + return state; + } + state.artifacts = resolveRuntimeFiles(); + return state; +} diff --git a/packages/cli/src/lib/ui-server.ts b/packages/cli/src/lib/ui-server.ts index 22e71b4d5..d05bc1d3f 100644 --- a/packages/cli/src/lib/ui-server.ts +++ b/packages/cli/src/lib/ui-server.ts @@ -13,7 +13,7 @@ import { checkAndUpdateUiBuild, checkAndUpdateSkeleton, PLATFORM_VERSION, isRemoteSetupAllowed, waitForReady, restoreUiBackup, UiSupervisor, } from '@openpalm/lib'; -import { ensureValidState } from './cli-state.ts'; +import { ensureValidState, resolveServeState } from './cli-state.ts'; import { openBrowser } from './browser.ts'; import { DEFAULT_UI_PORT } from './ports.ts'; @@ -24,6 +24,14 @@ const STOP_TIMEOUT_MS = 5_000; export interface UIServerOptions { port?: number; open?: boolean; + /** + * host-ui admin mode (`openpalm admin`, plan Phase 1.5): enable the admin + * capability in the spawned UI child (OP_ENABLE_ADMIN=1 + OP_UI_HOST_MODE= + * host-ui) and pin the bind to loopback ALWAYS — OP_ALLOW_REMOTE_SETUP is + * ignored and neutralized in the child env, so no non-loopback bind is + * possible in this mode (plan §8.3: host admin is never reachable remotely). + */ + adminHostUi?: boolean; } /** @@ -41,6 +49,7 @@ async function spawnUiChild( port: number, homeDir: string, state: ReturnType, + adminHostUi = false, ): Promise<{ proc: Bun.Subprocess; uiBackupDir: string | undefined }> { // Hot-swap the skeleton (managed system/ tree) before spawning. console.log('Checking for skeleton update...'); @@ -92,11 +101,20 @@ async function spawnUiChild( // Default: bind loopback with a pinned ORIGIN. With OP_ALLOW_REMOTE_SETUP the // server binds all interfaces and lets adapter-node derive the origin from the // request Host header (HOST_HEADER), so it works under whatever LAN host/IP the - // operator reaches it by. - const remote = isRemoteSetupAllowed(); + // operator reaches it by. Admin (host-ui) mode is loopback ALWAYS: the + // remote-setup escape hatch never applies to the host admin surface (§8.3). + const remote = !adminHostUi && isRemoteSetupAllowed(); const networkEnv = remote ? { HOST: '0.0.0.0', PORT: String(port), HOST_HEADER: 'host', PROTOCOL_HEADER: 'x-forwarded-proto' } : { HOST: '127.0.0.1', PORT: String(port), ORIGIN: `http://127.0.0.1:${port}` }; + // Admin (host-ui) mode: enable the admin capability in the UI child and + // neutralize OP_ALLOW_REMOTE_SETUP (spread in from process.env below) so + // neither the respawned `openpalm ui` child nor the UI server's own + // remote-setup relaxations (Host/Origin allowlist, setup gate) can re-derive + // a remote bind. + const adminEnv = adminHostUi + ? { OP_ENABLE_ADMIN: '1', OP_UI_HOST_MODE: 'host-ui', OP_ALLOW_REMOTE_SETUP: '0' } + : {}; const proc = Bun.spawn( [process.execPath, ...childArgs], { @@ -108,6 +126,7 @@ async function spawnUiChild( // own cwd (packages/ui/build/). OP_HOME: homeDir, ...networkEnv, + ...adminEnv, OP_UI_LOGIN_PASSWORD: uiLoginPassword, // Tell the UI child it has a supervisor that can respawn it on demand // (design §6.2). The admin "install UI version" route signals SIGUSR2 to @@ -257,12 +276,15 @@ export async function startUIServer(opts: UIServerOptions = {}): Promise { const homeDir = resolveOpenPalmHome(); - const state = ensureValidState(); + // Admin (host-ui) mode serves even on a machine with no install — the UI's + // existing setup guard lands on /setup (the CLI does not reimplement wizard + // logic). The bare serve path keeps requiring a valid install. + const state = opts.adminHostUi ? resolveServeState() : ensureValidState(); const uiUrl = `http://localhost:${port}`; const { supervisor, stop: stopUiProc } = createCliUiSupervisor({ port, - spawnChild: () => spawnUiChild(port, homeDir, state), + spawnChild: () => spawnUiChild(port, homeDir, state, opts.adminHostUi === true), restoreBackup: (backupDir) => restoreUiBackup(state.dataDir, backupDir), }); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 401b703fe..61fd4e735 100755 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -41,8 +41,8 @@ async function isAssistantHealthy(): Promise { * - Installed, stack up → starts the UI host server (foreground) * * The UI server runs in the foreground until SIGINT/SIGTERM. This is - * the canonical way to "run OpenPalm" — no separate `ui`/`admin` - * subcommand. + * the canonical way to "run OpenPalm". `openpalm admin` serves the same + * UI with the host admin capability enabled (host-ui mode, loopback-only). */ async function autoRun(opts: BareRunOpts = {}): Promise { const isInstalled = classifyLocalInstall(resolveStackDir(), resolveOpenPalmHome()) !== 'not_installed'; @@ -83,6 +83,7 @@ async function autoRun(opts: BareRunOpts = {}): Promise { // derived from these keys below so adding a subcommand here can never drift // out of sync with the bare-command routing table. const subCommands = { + admin: () => import('./commands/admin.ts').then((m) => m.default), install: () => import('./commands/install.ts').then((m) => m.default), uninstall: () => import('./commands/uninstall.ts').then((m) => m.default), update: () => import('./commands/update.ts').then((m) => m.default), From e777688c396c04b30740dd0b2b41d5d225c0c2cc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 07:34:32 +0000 Subject: [PATCH 07/61] docs: document the three ways to reach the admin surface (#556) Update the how-it-works harness UI section: Electron (electron-host mode), `openpalm admin` (host-ui mode, loopback-only, refuses non-loopback bind config), and the dev-only OP_ENABLE_ADMIN=1 escape hatch. Drops the stale `openpalm ui serve` reference. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 --- docs/how-it-works.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 4f6db05ee..6654bbd35 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -39,7 +39,12 @@ Three hard rules define the whole design: ## Components ### Harness UI (SvelteKit app, host port 3880) -The web face of the harness. Started by `openpalm ui serve` as a host process — no container. Accesses Docker and `~/.openpalm/` directly on the host. +The web face of the harness. Runs as a host process — no container. Accesses Docker and `~/.openpalm/` directly on the host. + +Three ways to reach the admin surface (all loopback-only — host admin is never reachable remotely): +- **Electron app** — the desktop harness supervises the UI process and opens it with the admin capability enabled (`electron-host` mode). +- **`openpalm admin`** — the CLI serves the same UI with the admin capability enabled (`host-ui` mode), prints the URL, and opens your browser. This mode refuses non-loopback bind config: `OP_ALLOW_REMOTE_SETUP` is ignored and neutralized. On a machine with no install it lands on the `/setup` wizard. +- **Dev only: `OP_ENABLE_ADMIN=1`** — set on a locally run UI server (e.g. the dev server) to enable the admin capability without a harness. Never set this in production. Responsibilities: - Writes runtime configuration directly to `~/.openpalm/config/stack/`, `~/.openpalm/knowledge/env/stack.env`, and `~/.openpalm/config/akm/` From 5e0f2d505eca98d5d36b771d638930f3aa40857d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 11:33:45 +0000 Subject: [PATCH 08/61] test(phase-2): red tests for #486 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD red suite for connection management out of /admin (plan ui-runtime-modes-plan.md Phase 2, issue #486): - routes/api/connections/server.vitest.ts: /api/connections is guarded server-side by the connections:manage capability (plan §6.4, §8.5) — 403 JSON in assistant-container mode even with a VALID admin session (capability-based, not session-based), 200 in host-ui mode, reachable in pwa-static mode (no host-admin mode required), lists the env-derived default connection, never serializes stored passwords; POST guarded the same way (403 assistant-container / 201 host-ui). RED: all 8 tests — "Cannot find module .../+server.js" (route module does not exist yet; loaded via a computed-specifier dynamic import so svelte-check stays green while red). - lib/server/endpoints-connection-kind.vitest.ts: ConnectionKind defaulting when reading a legacy endpoints.json fixture (plan §6.6) — records without a kind key default to 'remote-opencode' on read (listEndpoints + getActiveEndpoint); the env-derived default entry is 'local-opencode'. RED: 4 tests ("expected undefined to be 'remote-opencode'/'local-opencode'" — kind read via a cast helper so svelte-check stays green). CHARACTERIZATION (green pre-change, must stay green): 3 tests — an explicitly persisted kind passes through on read (unknown on-disk keys already survive; Phase 2 must not strip or override it), writes keep the on-disk schema keys activeId/endpoints (never 'connections'), and legacy records load with no data migration. - routes/admin/endpoints/page.vitest.ts: /admin/endpoints becomes a redirect alias to /connections for 0.13.0 (Phase 2 step 5), pinned as a universal load in +page.ts throwing redirect() with location '/connections' and a 3xx status — same convention as the existing routes/+page.ts → /splash redirect. RED: both tests ("Cannot find module .../+page.js"; computed-specifier dynamic import keeps svelte-check green). - lib/endpoints-state-hygiene.vitest.ts: source-level hygiene test for the untangle step (Phase 2 step 6) — the connections store (follows the file across the endpoint→connection rename: endpoints-state or connections-state) must have NO static, type, side-effect, or dynamic import matching chat-state or chat/*. RED: 1 test (found './chat/chat-state.svelte.js'). CHARACTERIZATION (green): 1 test — the store module exists in $lib. Verified red for the right reasons: server project 15 failed / 992 passed across 106 files; the only failing files are these four, all failures are module-resolution or missing-feature assertions (no syntax errors). ui:check: 0 errors 0 warnings (red-state safe via computed-specifier dynamic imports and cast helpers). biome lint: baseline unchanged (16 warnings 1 info, none in the new files); eslint clean on the new files. CLI suite 87/1 (the 1 = known uid-0 install-flow env failure); lib suite 845 pass / 10 skip / 17 fail (the known uid-0 set) — parity. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 --- .../src/lib/endpoints-state-hygiene.vitest.ts | 78 +++++++ .../endpoints-connection-kind.vitest.ts | 158 +++++++++++++ .../src/routes/admin/endpoints/page.vitest.ts | 58 +++++ .../routes/api/connections/server.vitest.ts | 211 ++++++++++++++++++ 4 files changed, 505 insertions(+) create mode 100644 packages/ui/src/lib/endpoints-state-hygiene.vitest.ts create mode 100644 packages/ui/src/lib/server/endpoints-connection-kind.vitest.ts create mode 100644 packages/ui/src/routes/admin/endpoints/page.vitest.ts create mode 100644 packages/ui/src/routes/api/connections/server.vitest.ts diff --git a/packages/ui/src/lib/endpoints-state-hygiene.vitest.ts b/packages/ui/src/lib/endpoints-state-hygiene.vitest.ts new file mode 100644 index 000000000..bb21c55d6 --- /dev/null +++ b/packages/ui/src/lib/endpoints-state-hygiene.vitest.ts @@ -0,0 +1,78 @@ +/** + * Phase 2 (#486) hygiene — the connections store must not import chat state + * (plan ui-runtime-modes-plan.md Phase 2 step 6: "break the endpoints-state ↔ + * chat-state bidirectional import (connection activation emits an event; chat + * subscribes)"; §6.11: the coupling is untangled BEFORE the Phase 5 client + * extraction, so the later move is file relocation, not surgery). + * + * RED until Phase 2 lands: lib/endpoints-state.svelte.ts currently does + * import { chat } from './chat/chat-state.svelte.js'; + * + * This is a source-level test (it reads the store's source and inspects its + * import specifiers) because the invariant is about the module graph, not + * runtime behavior: connection activation must reach chat through an + * event/callback that the chat side subscribes to — never through a direct + * import from the connections side. Type-only imports count too: after + * extraction they would still drag chat modules into the client package. + */ +import { describe, expect, test } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** + * The store's current file name, plus the names the Phase 2 internal + * endpoint→connection rename could plausibly move it to. The hygiene + * invariant must keep holding after the rename, so the test follows the + * file rather than pinning one path. + */ +const CANDIDATE_STORE_FILES = [ + 'endpoints-state.svelte.ts', + 'connections-state.svelte.ts', + 'connection-state.svelte.ts', +] as const; + +function resolveStoreFile(): string | null { + for (const name of CANDIDATE_STORE_FILES) { + const path = fileURLToPath(new URL(`./${name}`, import.meta.url)); + if (existsSync(path)) return path; + } + return null; +} + +/** Collect every static, side-effect, re-export, and dynamic import specifier. */ +function importSpecifiers(source: string): string[] { + const specifiers: string[] = []; + const patterns = [ + // import ... from '...'; / export ... from '...'; (incl. `import type`) + /(?:^|\n)\s*(?:import|export)\s[^;'"]*?from\s*['"]([^'"]+)['"]/g, + // side-effect import: import '...'; + /(?:^|\n)\s*import\s*['"]([^'"]+)['"]/g, + // dynamic import: import('...') + /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g, + ]; + for (const pattern of patterns) { + for (const match of source.matchAll(pattern)) { + specifiers.push(match[1]); + } + } + return specifiers; +} + +/** True for chat-state or anything under a chat/ module directory. */ +function isChatModuleSpecifier(specifier: string): boolean { + return /chat-state/.test(specifier) || /(?:^|\/)chat\//.test(specifier); +} + +describe('endpoints-state ↔ chat-state untangling (plan Phase 2 step 6)', () => { + test('the connections store module exists in $lib', () => { + expect(resolveStoreFile()).not.toBeNull(); + }); + + test('the store has no static, type, or dynamic import from chat-state or chat/*', () => { + const path = resolveStoreFile(); + expect(path).not.toBeNull(); + const source = readFileSync(path as string, 'utf-8'); + const chatImports = importSpecifiers(source).filter(isChatModuleSpecifier); + expect(chatImports).toEqual([]); + }); +}); diff --git a/packages/ui/src/lib/server/endpoints-connection-kind.vitest.ts b/packages/ui/src/lib/server/endpoints-connection-kind.vitest.ts new file mode 100644 index 000000000..562aadc73 --- /dev/null +++ b/packages/ui/src/lib/server/endpoints-connection-kind.vitest.ts @@ -0,0 +1,158 @@ +/** + * Phase 2 (#486) — ConnectionKind defaulting on the endpoints store (plan + * ui-runtime-modes-plan.md §6.6; decision table §2: "endpoints.json rename — + * Do not rename. Add `kind` field. Internal model uses 'connection' + * language."). + * + * RED until Phase 2 lands: entries returned by listEndpoints() / + * getActiveEndpoint() carry no `kind` yet. `kind` is read through a cast + * helper (`kindOf`) so svelte-check stays clean while the suite is red — + * the assertions fail at runtime with `undefined` until the field exists. + * + * Contract under test: + * - Legacy endpoints.json records (no `kind` key on disk) default to + * 'remote-opencode' when read. + * - The env-derived default entry defaults to 'local-opencode'. + * - CHARACTERIZATION (green pre-change, must stay green): a record + * persisted WITH an explicit kind keeps it on read (the store already + * passes unknown on-disk keys through — Phase 2 must not strip or + * override it); and the on-disk file keeps its name and schema keys + * (`activeId` / `endpoints`) — Phase 2 renames the internal model only, + * never the persisted schema. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { _replaceState, getState } from './state.js'; +import { makeTestState, registerCleanup, trackDir } from './test-helpers.js'; +import { addEndpoint, getActiveEndpoint, listEndpoints, setActiveId } from './endpoints.js'; + +registerCleanup(); + +const ENV_KEYS = [ + 'OP_OPENCODE_URL', + 'OP_ASSISTANT_URL', + 'OP_ASSISTANT_PORT', + 'OPENCODE_SERVER_PASSWORD', +] as const; +const savedEnv: Record = {}; + +beforeEach(() => { + for (const k of ENV_KEYS) { + savedEnv[k] = process.env[k]; + delete process.env[k]; + } + const state = makeTestState(); + trackDir(state.dataDir); + trackDir(state.configDir); + mkdirSync(state.dataDir, { recursive: true }); + mkdirSync(state.configDir, { recursive: true }); + _replaceState(state); +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } +}); + +/** Read `kind` without a compile-time dependency on the not-yet-added field. */ +function kindOf(entry: unknown): string | undefined { + return (entry as { kind?: string }).kind; +} + +function endpointsJsonPath(): string { + return `${getState().configDir}/endpoints.json`; +} + +/** Seed config/endpoints.json byte-for-byte, as an existing install would have it. */ +function seedEndpointsFile(payload: unknown): void { + writeFileSync(endpointsJsonPath(), JSON.stringify(payload), { mode: 0o600 }); +} + +/** A legacy (pre-Phase-2) file: records have no `kind` key at all. */ +const LEGACY_RECORD_ID = '22222222-2222-4222-8222-222222222222'; +function seedLegacyFixture(): void { + seedEndpointsFile({ + activeId: LEGACY_RECORD_ID, + endpoints: [ + { id: LEGACY_RECORD_ID, label: 'Legacy Remote', url: 'http://10.0.0.9:3800', password: 'shh' }, + ], + }); +} + +describe('ConnectionKind defaulting on read (plan §6.6)', () => { + it("defaults legacy user records (no kind on disk) to 'remote-opencode' in listEndpoints()", () => { + seedLegacyFixture(); + const list = listEndpoints(); + const legacy = list.find((e) => e.id === LEGACY_RECORD_ID); + expect(legacy).toBeDefined(); + expect(kindOf(legacy)).toBe('remote-opencode'); + }); + + it("defaults the legacy active record to 'remote-opencode' in getActiveEndpoint()", () => { + seedLegacyFixture(); + const active = getActiveEndpoint(); + expect(active.id).toBe(LEGACY_RECORD_ID); + expect(kindOf(active)).toBe('remote-opencode'); + }); + + it("marks the env-derived default entry as 'local-opencode' in listEndpoints()", () => { + seedLegacyFixture(); + const list = listEndpoints(); + const dflt = list.find((e) => e.id === 'default'); + expect(dflt).toBeDefined(); + expect(kindOf(dflt)).toBe('local-opencode'); + }); + + it("marks the env-derived default entry as 'local-opencode' when it is the active endpoint", () => { + // Fresh install: no endpoints.json at all — the default is active. + const active = getActiveEndpoint(); + expect(active.isDefault).toBe(true); + expect(kindOf(active)).toBe('local-opencode'); + }); + +}); + +describe('endpoints.json on-disk schema is NOT renamed (CHARACTERIZATION — green pre-change)', () => { + it('preserves an explicitly persisted kind on read (unknown keys already pass through)', () => { + seedEndpointsFile({ + activeId: null, + endpoints: [ + { + id: '33333333-3333-4333-8333-333333333333', + label: 'Client API', + url: 'https://palm.example:8443', + kind: 'openpalm-client-api', + }, + ], + }); + const list = listEndpoints(); + const entry = list.find((e) => e.id === '33333333-3333-4333-8333-333333333333'); + expect(entry).toBeDefined(); + expect(kindOf(entry)).toBe('openpalm-client-api'); + }); + + it('writes through the store keep the legacy schema keys (activeId/endpoints)', () => { + const entry = addEndpoint({ label: 'Remote', url: 'http://10.0.0.9:3800' }); + setActiveId(entry.id); + const parsed = JSON.parse(readFileSync(endpointsJsonPath(), 'utf-8')) as Record; + expect(parsed).toHaveProperty('endpoints'); + expect(parsed).toHaveProperty('activeId', entry.id); + expect(parsed).not.toHaveProperty('connections'); + }); + + it('legacy records load without any data migration (Phase 2 acceptance: "no data migration")', () => { + seedLegacyFixture(); + const list = listEndpoints(); + // [default, legacy record] — the legacy record is intact. + const legacy = list.find((e) => e.id === LEGACY_RECORD_ID); + expect(legacy).toMatchObject({ + id: LEGACY_RECORD_ID, + label: 'Legacy Remote', + url: 'http://10.0.0.9:3800', + password: 'shh', + isDefault: false, + }); + }); +}); diff --git a/packages/ui/src/routes/admin/endpoints/page.vitest.ts b/packages/ui/src/routes/admin/endpoints/page.vitest.ts new file mode 100644 index 000000000..f2d85a5ad --- /dev/null +++ b/packages/ui/src/routes/admin/endpoints/page.vitest.ts @@ -0,0 +1,58 @@ +/** + * Phase 2 (#486) — /admin/endpoints is a redirect alias to /connections for + * the 0.13.0 release (plan ui-runtime-modes-plan.md Phase 2 step 5: chat + * links move to /connections and the old route redirects there; the alias is + * removed when Phase 4 turns /admin/* into 404s). + * + * ALL RED until the implementation lands: routes/admin/endpoints/+page.ts + * does not exist yet (the route currently renders the management UI from + * +page.svelte directly). The redirect contract pinned here is a universal + * load in +page.ts throwing SvelteKit's redirect() — the same convention as + * the existing root redirect (src/routes/+page.ts → /splash). + * + * The module is loaded through a computed-specifier dynamic import so + * svelte-check stays clean while the suite is red. + */ +import { describe, expect, test } from 'vitest'; + +type PageLoadModule = { load: (event: unknown) => unknown }; + +async function loadPageModule(): Promise { + const specifier = './+page.js'; + return (await import(/* @vite-ignore */ specifier)) as PageLoadModule; +} + +function makeLoadEvent(): unknown { + const url = new URL('http://127.0.0.1:3880/admin/endpoints'); + return { + url, + params: {}, + route: { id: '/admin/endpoints' }, + fetch: globalThis.fetch, + data: {}, + depends: () => {}, + parent: async () => ({}), + untrack: (fn: () => T) => fn(), + }; +} + +describe('/admin/endpoints → /connections redirect alias (plan Phase 2 step 5)', () => { + test('exports a load function', async () => { + const mod = await loadPageModule(); + expect(typeof mod.load).toBe('function'); + }); + + test('load() throws a 3xx redirect to /connections', async () => { + const { load } = await loadPageModule(); + try { + await load(makeLoadEvent()); + throw new Error('expected the load to throw a redirect'); + } catch (e) { + // SvelteKit redirect() throws a { status, location } object. + const redirect = e as { status?: number; location?: string }; + expect(redirect.location).toBe('/connections'); + expect(redirect.status).toBeGreaterThanOrEqual(300); + expect(redirect.status).toBeLessThan(400); + } + }); +}); diff --git a/packages/ui/src/routes/api/connections/server.vitest.ts b/packages/ui/src/routes/api/connections/server.vitest.ts new file mode 100644 index 000000000..b88e49e32 --- /dev/null +++ b/packages/ui/src/routes/api/connections/server.vitest.ts @@ -0,0 +1,211 @@ +/** + * Tests for /api/connections — Phase 2 connection management API (plan + * ui-runtime-modes-plan.md Phase 2, issue #486). + * + * ALL RED until the implementation lands: routes/api/connections/+server.ts + * does not exist yet. The module is loaded through a computed-specifier + * dynamic import so svelte-check stays clean while the suite is red; the + * tests fail at runtime with a module-resolution error until Phase 2 lands. + * + * Contract under test (plan §6.4 API namespace table + §8.5): + * - /api/connections is guarded SERVER-SIDE by the `connections:manage` + * capability. `hasCapability()` in the browser is UX only — the security + * boundary is this route. + * - assistant-container mode carries no `connections:manage`, so the guard + * returns 403 even when a VALID admin session cookie is presented: the + * check is capability-based (hostMode → serverCapabilities), not + * session-based. + * - host-ui mode carries `connections:manage` → requests succeed. + * - pwa-static mode also carries `connections:manage` (§4.3) → connection + * management is reachable without any host-admin mode (Phase 2 + * acceptance: "connection management reachable without /admin"). + * - Stored connection passwords are never serialized into responses + * (parity with the /admin/endpoints publish() contract). + */ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { cleanupTempDirs, resetState, trackDir } from '$lib/server/test-helpers.js'; +import { getState } from '$lib/server/state.js'; + +type RouteHandler = (event: unknown) => Response | Promise; +type ConnectionsRouteModule = { GET: RouteHandler; POST: RouteHandler }; + +/** + * RED-state-safe loader: the computed specifier keeps svelte-check green + * while the route module does not exist yet (same pattern as the Phase 1.5 + * red suite). Once routes/api/connections/+server.ts lands this resolves + * exactly like a static `import { GET, POST } from './+server.js'`. + */ +async function loadRoute(): Promise { + const specifier = './+server.js'; + return (await import(/* @vite-ignore */ specifier)) as ConnectionsRouteModule; +} + +function makeTempHome(): string { + const dir = join(tmpdir(), `openpalm-connections-${randomBytes(4).toString('hex')}`); + mkdirSync(dir, { recursive: true }); + return trackDir(dir); +} + +function makeGetEvent(token = 'admin-token'): unknown { + const url = new URL('http://127.0.0.1:3880/api/connections'); + return { + url, + request: new Request(url, { + headers: { + cookie: `op_session=${token}`, + 'x-request-id': 'req-connections-list', + }, + }), + params: {}, + locals: { role: 'admin' }, + route: { id: '/api/connections' }, + getClientAddress: () => '127.0.0.1', + isDataRequest: false, + isSubRequest: false, + }; +} + +function makePostEvent(body: Record, token = 'admin-token'): unknown { + const url = new URL('http://127.0.0.1:3880/api/connections'); + return { + url, + request: new Request(url, { + method: 'POST', + headers: { + cookie: `op_session=${token}`, + 'x-request-id': 'req-connections-create', + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }), + params: {}, + locals: { role: 'admin' }, + route: { id: '/api/connections' }, + getClientAddress: () => '127.0.0.1', + isDataRequest: false, + isSubRequest: false, + }; +} + +/** Seed config/endpoints.json directly (the on-disk schema is NOT renamed). */ +function seedEndpointsFile(payload: unknown): void { + const configDir = getState().configDir; + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, 'endpoints.json'), JSON.stringify(payload), { mode: 0o600 }); +} + +const ENV_KEYS = [ + 'OP_UI_HOST_MODE', + 'OP_INSIDE_ELECTRON', + 'OP_ENABLE_ADMIN', + 'OP_HOME', + 'OP_OPENCODE_URL', + 'OP_ASSISTANT_URL', + 'OP_ASSISTANT_PORT', + 'OPENCODE_SERVER_PASSWORD', + 'OP_UI_LOGIN_PASSWORD', +] as const; +let savedEnv: Record = {}; + +beforeEach(() => { + savedEnv = {}; + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + process.env.OP_HOME = makeTempHome(); + resetState('admin-token'); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + const prev = savedEnv[key]; + if (prev === undefined) delete process.env[key]; + else process.env[key] = prev; + } + cleanupTempDirs(); +}); + +describe('GET /api/connections — connections:manage guard (plan §6.4, §8.5)', () => { + test('403 in assistant-container mode even with a valid admin session', async () => { + process.env.OP_UI_HOST_MODE = 'assistant-container'; + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent()); + expect(res.status).toBe(403); + }); + + test('the 403 is a JSON error envelope, not an HTML login redirect', async () => { + process.env.OP_UI_HOST_MODE = 'assistant-container'; + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent()); + expect(res.status).toBe(403); + expect(res.headers.get('content-type') ?? '').toContain('application/json'); + }); + + test('200 in host-ui mode with a valid admin session (capability present)', async () => { + process.env.OP_UI_HOST_MODE = 'host-ui'; + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent()); + expect(res.status).toBe(200); + }); + + test('reachable in pwa-static mode — no host-admin mode required (Phase 2 acceptance)', async () => { + process.env.OP_UI_HOST_MODE = 'pwa-static'; + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent()); + expect(res.status).toBe(200); + }); + + test('lists the env-derived default connection', async () => { + process.env.OP_UI_HOST_MODE = 'host-ui'; + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent()); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + // Tolerate either payload key — the internal rename is "connection" + // language, but the response delegates to the same server module. + const list = (body.connections ?? body.endpoints) as Array<{ id: string }>; + expect(Array.isArray(list)).toBe(true); + expect(list.some((entry) => entry.id === 'default')).toBe(true); + }); + + test('never serializes stored connection passwords', async () => { + process.env.OP_UI_HOST_MODE = 'host-ui'; + seedEndpointsFile({ + activeId: null, + endpoints: [ + { + id: '11111111-1111-4111-8111-111111111111', + label: 'Remote', + url: 'http://10.0.0.9:3800', + password: 'super-secret-pw', + }, + ], + }); + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent()); + expect(res.status).toBe(200); + const raw = JSON.stringify(await res.json()); + expect(raw).not.toContain('super-secret-pw'); + }); +}); + +describe('POST /api/connections — connections:manage guard on writes', () => { + test('403 in assistant-container mode even with a valid admin session', async () => { + process.env.OP_UI_HOST_MODE = 'assistant-container'; + const { POST } = await loadRoute(); + const res = await POST(makePostEvent({ label: 'Remote', url: 'http://10.0.0.9:3800' })); + expect(res.status).toBe(403); + }); + + test('creates a connection in host-ui mode with a valid session', async () => { + process.env.OP_UI_HOST_MODE = 'host-ui'; + const { POST } = await loadRoute(); + const res = await POST(makePostEvent({ label: 'Remote', url: 'http://10.0.0.9:3800' })); + expect(res.status).toBe(201); + }); +}); From 4632135aa346eb2e05804c632f263ecde10e46c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 12:02:41 +0000 Subject: [PATCH 09/61] feat(ui): add ConnectionKind + connection-language model to the endpoints store (#486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 step 1-2 of docs/technical/ui-runtime-modes-plan.md: the internal model is renamed endpoint → connection (plan §6.6) and ConnectionEntry gains an optional `kind` field. Kind is defaulted at READ time only — legacy user records (no kind on disk) read as 'remote-opencode', the env-derived default entry and the Electron-local entry as 'local-opencode', and an explicitly persisted kind passes through untouched. endpoints.json keeps its name and on-disk schema keys (activeId/endpoints); no data migration, files are never rewritten on read. The endpoint-language exports remain as aliases of the renamed functions/types for the pre-Phase-2 /admin routes and tests; they go away with /admin/* in Phase 4. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 --- packages/ui/src/lib/server/endpoints.ts | 111 +++++++++++++----- .../ui/src/routes/admin/endpoints/+server.ts | 6 +- 2 files changed, 86 insertions(+), 31 deletions(-) diff --git a/packages/ui/src/lib/server/endpoints.ts b/packages/ui/src/lib/server/endpoints.ts index bc5193548..17f0b20cf 100644 --- a/packages/ui/src/lib/server/endpoints.ts +++ b/packages/ui/src/lib/server/endpoints.ts @@ -1,13 +1,18 @@ /** - * Assistant endpoints — list of OpenCode servers the UI can target, with - * one marked active. The "default" entry is synthesized from environment - * (OP_OPENCODE_URL / OP_ASSISTANT_URL / OP_ASSISTANT_PORT) and cannot be - * deleted. User-added endpoints are persisted to a JSON file in the - * config directory (it's user-owned configuration, not service state — - * see Phase 5 / D4 in docs/technical/auth-and-proxy-refactor-plan.md). + * Assistant connections — list of OpenCode servers the UI can target, with + * one marked active. The internal model uses "connection" language (plan + * ui-runtime-modes-plan.md §6.6, Phase 2 / #486); the on-disk file keeps its + * historical name and schema keys — `endpoints.json` with + * `{ activeId, endpoints }` — and is NEVER renamed or migrated. + * + * The "default" entry is synthesized from environment (OP_OPENCODE_URL / + * OP_ASSISTANT_URL / OP_ASSISTANT_PORT) and cannot be deleted. User-added + * connections are persisted to a JSON file in the config directory (it's + * user-owned configuration, not service state — see Phase 5 / D4 in + * docs/technical/auth-and-proxy-refactor-plan.md). * * File: ${configDir}/endpoints.json (mode 0600) - * Shape: { activeId: string | null, endpoints: EndpointEntry[] } + * Shape: { activeId: string | null, endpoints: ConnectionEntry[] } * - activeId === null or "default" → use the env-derived default * - activeId === "" → use the matching user entry (falls back to default if not found) * @@ -19,11 +24,19 @@ import { dirname } from 'node:path'; import { randomUUID } from 'node:crypto'; import { getState } from './state.js'; import type { RemoteStatus } from '@openpalm/lib'; +import type { ConnectionKind } from '$lib/types.js'; -export type EndpointEntry = { +export type ConnectionEntry = { id: string; label: string; url: string; + /** + * Connection kind (plan §6.6). OPTIONAL on disk: legacy records carry no + * `kind` key and are defaulted at READ time — never rewritten in place (the + * Phase 2 acceptance is "no data migration"). An explicitly persisted kind + * always passes through untouched. + */ + kind?: ConnectionKind; /** * Basic-auth username forwarded as Authorization header. Defaults to * `"openpalm"` for synthesized entries; user-added entries may override. @@ -36,7 +49,12 @@ export type EndpointEntry = { password?: string; }; -export type ActiveEndpoint = EndpointEntry & { +/** Legacy name — kept until every consumer migrates to connection language. */ +export type EndpointEntry = ConnectionEntry; + +export type ActiveConnection = ConnectionEntry & { + /** Always present after read-time defaulting (legacy records → 'remote-opencode'). */ + kind: ConnectionKind; /** True for the env-derived default entry (cannot be edited or deleted). */ isDefault: boolean; /** @@ -47,11 +65,23 @@ export type ActiveEndpoint = EndpointEntry & { isLocal?: boolean; }; +/** Legacy name — kept until every consumer migrates to connection language. */ +export type ActiveEndpoint = ActiveConnection; + type EndpointsFile = { activeId: string | null; - endpoints: EndpointEntry[]; + endpoints: ConnectionEntry[]; }; +/** + * Read-time kind defaulting (plan §6.6): a user-persisted record without a + * `kind` key is a remote OpenCode connection — that is the only thing the + * pre-Phase-2 UI let users add. Explicit kinds pass through untouched. + */ +function kindOf(entry: ConnectionEntry): ConnectionKind { + return entry.kind ?? 'remote-opencode'; +} + const DEFAULT_ID = 'default'; const LOCAL_ELECTRON_ID = 'local-electron'; let wizardOpencodeUrl: string | null = null; @@ -148,7 +178,7 @@ function readLocalRuntime(): LocalRuntime | null { } } -function localEndpoint(): ActiveEndpoint | null { +function localEndpoint(): ActiveConnection | null { const rt = readLocalRuntime(); if (!rt) return null; return { @@ -157,6 +187,7 @@ function localEndpoint(): ActiveEndpoint | null { url: normalizeBrowserFacingUrl(rt.url), username: rt.username || 'openpalm', ...(rt.password ? { password: rt.password } : {}), + kind: 'local-opencode', isDefault: false, isLocal: true, }; @@ -217,7 +248,7 @@ function writeFile(data: EndpointsFile): void { try { chmodSync(path, 0o600); } catch { /* best effort */ } } -function defaultEndpoint(): ActiveEndpoint { +function defaultEndpoint(): ActiveConnection { const url = process.env.OP_OPENCODE_URL ?? process.env.OP_ASSISTANT_URL ?? @@ -230,6 +261,7 @@ function defaultEndpoint(): ActiveEndpoint { url: normalizeBrowserFacingUrl(url), username, password, + kind: 'local-opencode', isDefault: true, }; } @@ -278,23 +310,23 @@ export function normalizeEndpointUrl(input: string): string | null { * The local-electron entry is synthesized at call time from * state/local-opencode.runtime.json — never persisted to endpoints.json. */ -export function listEndpoints(): ActiveEndpoint[] { +export function listConnections(): ActiveConnection[] { const { endpoints } = readFile(); const local = localEndpoint(); return [ ...(local ? [local] : []), defaultEndpoint(), - ...endpoints.map((e) => ({ ...e, isDefault: false })), + ...endpoints.map((e) => ({ ...e, kind: kindOf(e), isDefault: false })), ]; } /** - * Returns the active endpoint, falling back to the default if no active id is - * set OR if the active id is `local-electron` but the runtime.json isn't there - * (e.g. the Electron child died). Re-reads runtime.json each call so a + * Returns the active connection, falling back to the default if no active id + * is set OR if the active id is `local-electron` but the runtime.json isn't + * there (e.g. the Electron child died). Re-reads runtime.json each call so a * password rotated by a new Electron launch is picked up immediately. */ -export function getActiveEndpoint(): ActiveEndpoint { +export function getActiveConnection(): ActiveConnection { const { activeId, endpoints } = readFile(); if (activeId === LOCAL_ELECTRON_ID) { const local = localEndpoint(); @@ -305,12 +337,12 @@ export function getActiveEndpoint(): ActiveEndpoint { if (!activeId || activeId === DEFAULT_ID) return defaultEndpoint(); const found = endpoints.find((e) => e.id === activeId); if (!found) return defaultEndpoint(); - return { ...found, isDefault: false }; + return { ...found, kind: kindOf(found), isDefault: false }; } // ── Write API ──────────────────────────────────────────────────────────────── -export function setActiveId(id: string | null): ActiveEndpoint { +export function setActiveConnectionId(id: string | null): ActiveConnection { const data = readFile(); if (!id || id === DEFAULT_ID) { data.activeId = null; @@ -328,19 +360,21 @@ export function setActiveId(id: string | null): ActiveEndpoint { data.activeId = id; } writeFile(data); - return getActiveEndpoint(); + return getActiveConnection(); } -export type EndpointInput = { label: string; url: string; password?: string }; +export type ConnectionInput = { label: string; url: string; password?: string }; +/** Legacy name — kept until every consumer migrates to connection language. */ +export type EndpointInput = ConnectionInput; -export function addEndpoint(input: EndpointInput): EndpointEntry { +export function addConnection(input: ConnectionInput): ConnectionEntry { const label = input.label.trim(); if (!label) throw new Error('Label is required'); const url = normalizeEndpointUrl(input.url); if (!url) throw new Error('URL must be a valid http(s) URL'); const data = readFile(); - const entry: EndpointEntry = { + const entry: ConnectionEntry = { id: randomUUID(), label, url, @@ -351,14 +385,16 @@ export function addEndpoint(input: EndpointInput): EndpointEntry { return entry; } -export type EndpointPatch = { +export type ConnectionPatch = { label?: string; url?: string; /** undefined = leave unchanged; null = clear; string = set */ password?: string | null; }; +/** Legacy name — kept until every consumer migrates to connection language. */ +export type EndpointPatch = ConnectionPatch; -export function updateEndpoint(id: string, patch: EndpointPatch): EndpointEntry { +export function updateConnection(id: string, patch: ConnectionPatch): ConnectionEntry { if (id === DEFAULT_ID) throw new Error('Cannot edit the default endpoint'); if (id === LOCAL_ELECTRON_ID) { throw new Error('Cannot edit the local Electron OpenCode entry (it is ephemeral and per-launch)'); @@ -369,7 +405,7 @@ export function updateEndpoint(id: string, patch: EndpointPatch): EndpointEntry if (idx === -1) throw new Error(`Endpoint not found: ${id}`); const current = data.endpoints[idx]; - const next: EndpointEntry = { ...current }; + const next: ConnectionEntry = { ...current }; if (patch.label !== undefined) { const label = patch.label.trim(); if (!label) throw new Error('Label cannot be empty'); @@ -391,7 +427,7 @@ export function updateEndpoint(id: string, patch: EndpointPatch): EndpointEntry return next; } -export function deleteEndpoint(id: string): void { +export function deleteConnection(id: string): void { if (id === DEFAULT_ID) throw new Error('Cannot delete the default endpoint'); if (id === LOCAL_ELECTRON_ID) { throw new Error('Cannot delete the local Electron OpenCode entry (managed by Electron lifecycle)'); @@ -404,7 +440,7 @@ export function deleteEndpoint(id: string): void { writeFile(data); } -async function probeEndpoint(endpoint: ActiveEndpoint): Promise { +async function probeEndpoint(endpoint: ActiveConnection): Promise { const headers = new Headers(); if (endpoint.password) { const username = endpoint.username ?? 'openpalm'; @@ -439,7 +475,22 @@ export async function listRemoteStatuses(): Promise { if (remoteStatusCache && remoteStatusCache.expiresAt > Date.now()) { return remoteStatusCache.value.map((status) => ({ ...status })); } - const statuses = await Promise.all(listEndpoints().map((endpoint) => probeEndpoint(endpoint))); + const statuses = await Promise.all(listConnections().map((endpoint) => probeEndpoint(endpoint))); remoteStatusCache = { value: statuses, expiresAt: Date.now() + 5_000 }; return statuses.map((status) => ({ ...status })); } + +// ── Legacy endpoint-language aliases ───────────────────────────────────────── +// Phase 2 (#486) renamed the internal model to "connection" (plan §6.6). The +// pre-Phase-2 admin routes and tests still import these names; they are the +// same functions and go away with `/admin/*` in Phase 4. +export { + listConnections as listEndpoints, + getActiveConnection as getActiveEndpoint, + setActiveConnectionId as setActiveId, + addConnection as addEndpoint, + updateConnection as updateEndpoint, + deleteConnection as deleteEndpoint, + validateEndpointUrl as validateConnectionUrl, + normalizeEndpointUrl as normalizeConnectionUrl, +}; diff --git a/packages/ui/src/routes/admin/endpoints/+server.ts b/packages/ui/src/routes/admin/endpoints/+server.ts index 63fd1c89b..e013b89c3 100644 --- a/packages/ui/src/routes/admin/endpoints/+server.ts +++ b/packages/ui/src/routes/admin/endpoints/+server.ts @@ -64,7 +64,11 @@ export const POST: RequestHandler = async (event) => try { const entry = addEndpoint({ label, url: urlCheck.url, password }); - return jsonResponse(201, { endpoint: publish({ ...entry, isDefault: false }) }, requestId); + return jsonResponse( + 201, + { endpoint: publish({ ...entry, kind: entry.kind ?? 'remote-opencode', isDefault: false }) }, + requestId, + ); } catch (e) { const msg = e instanceof Error ? e.message : 'failed to create endpoint'; return errorResponse(400, 'invalid_endpoint', msg, {}, requestId); From efaffaa14a4847cb8a11d1d5f58d17004c148543 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 12:02:55 +0000 Subject: [PATCH 10/61] feat(ui): add capability-guarded /api/connections routes (#486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 step 3 (plan §6.4 API namespace table): /api/connections, /api/connections/[id] and /api/connections/active delegate to the same lib/server/endpoints.ts module as the legacy /admin/endpoints routes but are guarded by a new requireCapability() helper instead of the host-admin namespace. The guard is enforced SERVER-SIDE (plan §8.5) and is capability-based, not session-based: assistant-container mode gets a 403 JSON envelope even with a valid admin session, while host-ui, electron-host and pwa-static (which advertise connections:manage) pass through to the existing admin-session auth. requireCapability() only reads the capability set advertised by computeServerRuntimeContext — capability logic itself stays in one place (plan §8.6). Stored connection passwords are never serialized (hasPassword only), matching the /admin/endpoints publish() contract. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 --- packages/ui/src/lib/server/connections-api.ts | 47 ++++++++++++ packages/ui/src/lib/server/helpers.ts | 32 ++++++++ .../ui/src/routes/api/connections/+server.ts | 71 ++++++++++++++++++ .../routes/api/connections/[id]/+server.ts | 73 +++++++++++++++++++ .../routes/api/connections/active/+server.ts | 42 +++++++++++ 5 files changed, 265 insertions(+) create mode 100644 packages/ui/src/lib/server/connections-api.ts create mode 100644 packages/ui/src/routes/api/connections/+server.ts create mode 100644 packages/ui/src/routes/api/connections/[id]/+server.ts create mode 100644 packages/ui/src/routes/api/connections/active/+server.ts diff --git a/packages/ui/src/lib/server/connections-api.ts b/packages/ui/src/lib/server/connections-api.ts new file mode 100644 index 000000000..a63316041 --- /dev/null +++ b/packages/ui/src/lib/server/connections-api.ts @@ -0,0 +1,47 @@ +/** + * Shared pieces of the /api/connections/* route family (plan + * ui-runtime-modes-plan.md Phase 2, issue #486). + * + * Lives in lib/server (not in the route file) because SvelteKit route + * modules may only export handlers, and the [id]/active sub-routes reuse + * the same guard and serializer. + */ +import type { RequestEvent } from '@sveltejs/kit'; +import { requireAdmin, requireCapability } from './helpers.js'; +import type { ActiveConnection, ConnectionEntry } from './endpoints.js'; + +export type PublicConnection = { + id: string; + label: string; + url: string; + kind: string; + isDefault: boolean; + hasPassword: boolean; +}; + +/** Serialize a connection for the API — never includes the stored password. */ +export function publishConnection(c: ActiveConnection): PublicConnection { + return { + id: c.id, + label: c.label, + url: c.url, + kind: c.kind, + isDefault: c.isDefault, + hasPassword: Boolean(c.password), + }; +} + +/** Serialize a user-persisted record (kind defaulted the same way reads default it). */ +export function publishConnectionEntry(entry: ConnectionEntry): PublicConnection { + return publishConnection({ ...entry, kind: entry.kind ?? 'remote-opencode', isDefault: false }); +} + +/** + * Shared guard for every /api/connections/* handler: the `connections:manage` + * capability first (403 — capability-based, not session-based; plan §8.5), + * then the host admin session (plan §6.8: the host app gates connection + * writes behind the admin session). + */ +export function requireConnectionsManage(event: RequestEvent, requestId: string): Response | null { + return requireCapability(event, 'connections:manage', requestId) ?? requireAdmin(event, requestId); +} diff --git a/packages/ui/src/lib/server/helpers.ts b/packages/ui/src/lib/server/helpers.ts index 6a5d1d612..1917eb410 100644 --- a/packages/ui/src/lib/server/helpers.ts +++ b/packages/ui/src/lib/server/helpers.ts @@ -6,6 +6,8 @@ import { timingSafeEqual, createHash } from "node:crypto"; import { getActiveEndpoint } from "./endpoints.js"; import { createOpenCodeClient, isRemoteSetupAllowed } from "@openpalm/lib"; import { validateSession, getUiLoginPassword } from "./session-store.js"; +import { computeServerRuntimeContext } from "./features.js"; +import type { Capability } from "$lib/types.js"; /** * Lazy OpenCode client bound to the currently active endpoint. The client is @@ -113,6 +115,36 @@ export function requireAdmin(event: RequestEvent, requestId: string): Response | return null; } +/** + * Server-side capability guard (plan ui-runtime-modes-plan.md §6.4, §8.5). + * + * Returns a 403 JSON error Response when the resolved host mode does not + * expose `capability`, or null when it does. This is the SECURITY boundary — + * the browser-side `hasCapability()` is UX only. The check is deliberately + * capability-based, not session-based: a valid admin session in a mode + * without the capability (e.g. `connections:manage` in assistant-container) + * is still refused. Capability→mode logic itself lives ONLY in + * computeServerRuntimeContext / resolveCapabilities (plan §8.6); this helper + * just reads the advertised server capability set. + */ +export function requireCapability( + event: RequestEvent, + capability: Capability, + requestId: string, +): Response | null { + const ctx = computeServerRuntimeContext(event); + if (!ctx.serverCapabilities.includes(capability)) { + return errorResponse( + 403, + "capability_not_available", + `This deployment (${ctx.hostMode}) does not provide the '${capability}' capability.`, + { capability, hostMode: ctx.hostMode }, + requestId, + ); + } + return null; +} + /** * Identify caller by the presented `op_session` cookie. * diff --git a/packages/ui/src/routes/api/connections/+server.ts b/packages/ui/src/routes/api/connections/+server.ts new file mode 100644 index 000000000..1a61dab83 --- /dev/null +++ b/packages/ui/src/routes/api/connections/+server.ts @@ -0,0 +1,71 @@ +/** + * /api/connections — list and create assistant connections (plan + * ui-runtime-modes-plan.md Phase 2, issue #486). + * + * Guarded SERVER-SIDE by the `connections:manage` capability (plan §6.4, + * §8.5): host-ui / electron-host / pwa-static expose it, assistant-container + * does not — there the guard returns 403 even for a valid admin session, + * because the check is capability-based (hostMode → serverCapabilities), not + * session-based. Requests additionally require the admin session (plan §6.8: + * the host app gates connection management behind the host admin session). + * + * Delegates to the same server module as the legacy /admin/endpoints routes + * (lib/server/endpoints.ts); the on-disk endpoints.json is shared and NOT + * renamed. Passwords are never returned — only `hasPassword: boolean`. + */ +import type { RequestHandler } from './$types'; +import { + errorResponse, + getRequestId, + jsonResponse, + requireCapability, + withAdminBody, +} from '$lib/server/helpers.js'; +import { + publishConnection, + publishConnectionEntry, + requireConnectionsManage, +} from '$lib/server/connections-api.js'; +import { + addConnection, + getActiveConnection, + listConnections, + validateConnectionUrl, +} from '$lib/server/endpoints.js'; + +export const GET: RequestHandler = async (event) => { + const requestId = getRequestId(event); + const guardError = requireConnectionsManage(event, requestId); + if (guardError) return guardError; + + const connections = listConnections().map(publishConnection); + const active = publishConnection(getActiveConnection()); + + return jsonResponse(200, { connections, activeId: active.id }, requestId); +}; + +export const POST: RequestHandler = async (event) => { + const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'connections:manage', requestId); + if (capabilityError) return capabilityError; + + return withAdminBody(event, async ({ requestId, body }) => { + const label = typeof body.label === 'string' ? body.label : ''; + const url = typeof body.url === 'string' ? body.url : ''; + const password = + typeof body.password === 'string' && body.password.length > 0 ? body.password : undefined; + + const urlCheck = validateConnectionUrl(url); + if (!urlCheck.ok) { + return errorResponse(400, 'invalid_connection', 'URL must be a valid http(s) URL', {}, requestId); + } + + try { + const entry = addConnection({ label, url: urlCheck.url, password }); + return jsonResponse(201, { connection: publishConnectionEntry(entry) }, requestId); + } catch (e) { + const msg = e instanceof Error ? e.message : 'failed to create connection'; + return errorResponse(400, 'invalid_connection', msg, {}, requestId); + } + }); +}; diff --git a/packages/ui/src/routes/api/connections/[id]/+server.ts b/packages/ui/src/routes/api/connections/[id]/+server.ts new file mode 100644 index 000000000..fa6e68106 --- /dev/null +++ b/packages/ui/src/routes/api/connections/[id]/+server.ts @@ -0,0 +1,73 @@ +/** + * /api/connections/[id] — update or delete a user-added connection (plan + * ui-runtime-modes-plan.md Phase 2, issue #486). + * + * Guarded by `connections:manage` (server-side; see /api/connections). The + * "default" id is reserved and cannot be edited or deleted. Passwords are + * write-only in the API surface — pass `password: null` to clear, a string + * to set, or omit to leave unchanged. + */ +import type { RequestHandler } from './$types'; +import { + errorResponse, + getRequestId, + jsonResponse, + requireCapability, + withAdminBody, +} from '$lib/server/helpers.js'; +import { + publishConnectionEntry, + requireConnectionsManage, +} from '$lib/server/connections-api.js'; +import { + deleteConnection, + updateConnection, + validateConnectionUrl, + type ConnectionPatch, +} from '$lib/server/endpoints.js'; + +export const PATCH: RequestHandler = async (event) => { + const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'connections:manage', requestId); + if (capabilityError) return capabilityError; + + return withAdminBody(event, async ({ requestId, body }) => { + const id = event.params.id; + const patch: ConnectionPatch = {}; + if (typeof body.label === 'string') patch.label = body.label; + if (typeof body.url === 'string') { + const urlCheck = validateConnectionUrl(body.url); + if (!urlCheck.ok) { + return errorResponse(400, 'invalid_connection', 'URL must be a valid http(s) URL', {}, requestId); + } + patch.url = urlCheck.url; + } + if (body.password === null) patch.password = null; + else if (typeof body.password === 'string') patch.password = body.password; + + try { + const entry = updateConnection(id, patch); + return jsonResponse(200, { connection: publishConnectionEntry(entry) }, requestId); + } catch (e) { + const msg = e instanceof Error ? e.message : 'failed to update connection'; + const status = msg.startsWith('Endpoint not found') ? 404 : 400; + return errorResponse(status, status === 404 ? 'not_found' : 'invalid_connection', msg, {}, requestId); + } + }); +}; + +export const DELETE: RequestHandler = async (event) => { + const requestId = getRequestId(event); + const guardError = requireConnectionsManage(event, requestId); + if (guardError) return guardError; + + const id = event.params.id; + try { + deleteConnection(id); + return jsonResponse(200, { ok: true }, requestId); + } catch (e) { + const msg = e instanceof Error ? e.message : 'failed to delete connection'; + const status = msg.startsWith('Endpoint not found') ? 404 : 400; + return errorResponse(status, status === 404 ? 'not_found' : 'invalid_connection', msg, {}, requestId); + } +}; diff --git a/packages/ui/src/routes/api/connections/active/+server.ts b/packages/ui/src/routes/api/connections/active/+server.ts new file mode 100644 index 000000000..6e5e9ffc8 --- /dev/null +++ b/packages/ui/src/routes/api/connections/active/+server.ts @@ -0,0 +1,42 @@ +/** + * POST /api/connections/active — set the active assistant connection (plan + * ui-runtime-modes-plan.md Phase 2, issue #486). + * + * Guarded by `connections:manage` (server-side; see /api/connections). + * Body: { id: string } — pass "default" to revert to the env-derived entry. + */ +import type { RequestHandler } from './$types'; +import { + errorResponse, + getRequestId, + jsonResponse, + requireCapability, + withAdminBody, +} from '$lib/server/helpers.js'; +import { publishConnection } from '$lib/server/connections-api.js'; +import { setActiveConnectionId } from '$lib/server/endpoints.js'; + +export const POST: RequestHandler = async (event) => { + const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'connections:manage', requestId); + if (capabilityError) return capabilityError; + + return withAdminBody(event, async ({ requestId, body }) => { + const id = typeof body.id === 'string' ? body.id : ''; + if (!id) { + return errorResponse(400, 'invalid_request', 'id is required', {}, requestId); + } + + try { + const active = setActiveConnectionId(id); + return jsonResponse( + 200, + { activeId: active.id, connection: publishConnection(active) }, + requestId, + ); + } catch (e) { + const msg = e instanceof Error ? e.message : 'failed to set active connection'; + return errorResponse(404, 'not_found', msg, {}, requestId); + } + }); +}; From f67585fdfd7fed603513b0e8baaab8dc933d42fc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 12:03:25 +0000 Subject: [PATCH 11/61] refactor(ui): untangle endpoints-state from chat-state via connection-events (#486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 step 6 (plan §6.11): break the endpoints-state ↔ chat-state bidirectional import — a hard prerequisite for the Phase 5 client extraction. The connections store no longer imports chat modules; it emits through the new $lib/connection-events channel and the chat store subscribes from its own side: - registerActivationGuard: chat vetoes mid-generation switches with the same user-facing message as before ("Wait for the current reply…"). - onConnectionActivated: chat.onEndpointChanged() runs on activation, awaited by the emitter so a failed handoff still rolls the activeId back (behavior preserved from the direct call). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 --- packages/ui/src/lib/chat/chat-state.svelte.ts | 13 ++++ packages/ui/src/lib/connection-events.ts | 61 +++++++++++++++++++ packages/ui/src/lib/endpoints-state.svelte.ts | 48 +++++++++------ 3 files changed, 105 insertions(+), 17 deletions(-) create mode 100644 packages/ui/src/lib/connection-events.ts diff --git a/packages/ui/src/lib/chat/chat-state.svelte.ts b/packages/ui/src/lib/chat/chat-state.svelte.ts index 314767564..4da62c1ed 100644 --- a/packages/ui/src/lib/chat/chat-state.svelte.ts +++ b/packages/ui/src/lib/chat/chat-state.svelte.ts @@ -61,6 +61,10 @@ import { } from '$lib/voice/voice-state.svelte.js'; import { notifyAssistantError, notifyAssistantReply } from '$lib/desktop-notifications.js'; import { mapAssistantError } from './assistant-error.js'; +import { + onConnectionActivated, + registerActivationGuard, +} from '$lib/connection-events.js'; type EndpointId = string; type SessionId = string; @@ -867,3 +871,12 @@ class ChatService { } export const chat = new ChatService(); + +// ── Connection-activation subscription (plan Phase 2 step 6, #486) ────────── +// The chat side subscribes to connection activation; the connections store +// never imports chat modules. The guard preserves the pre-Phase-2 behavior: +// mid-generation switches are refused with the same user-facing message. +registerActivationGuard(() => + chat.sending ? 'Wait for the current reply to finish before switching.' : null +); +onConnectionActivated((id) => chat.onEndpointChanged(id)); diff --git a/packages/ui/src/lib/connection-events.ts b/packages/ui/src/lib/connection-events.ts new file mode 100644 index 000000000..83de647fc --- /dev/null +++ b/packages/ui/src/lib/connection-events.ts @@ -0,0 +1,61 @@ +/** + * Connection-activation event channel (plan ui-runtime-modes-plan.md Phase 2 + * step 6, issue #486). + * + * Breaks the endpoints-state ↔ chat-state bidirectional import: the + * connections store EMITS through this module and the chat side SUBSCRIBES + * from its own module — the connections store never imports chat code. This + * keeps the module graph acyclic so the Phase 5 client extraction is file + * relocation, not surgery (plan §6.11). + * + * Two hooks: + * - activation guards: a subscriber may veto a switch before it starts + * (chat registers "not while a reply is streaming"); + * - activation listeners: awaited after the server accepted the switch + * (chat loads the new connection's sessions). + */ + +type ActivationListener = (connectionId: string) => void | Promise; +type ActivationGuard = () => string | null; + +const listeners = new Set(); +const guards = new Set(); + +/** Subscribe to connection activation. Returns an unsubscribe function. */ +export function onConnectionActivated(listener: ActivationListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Register a pre-switch veto. Return a user-facing reason string to block + * the switch, or null to allow it. Returns an unregister function. + */ +export function registerActivationGuard(guard: ActivationGuard): () => void { + guards.add(guard); + return () => { + guards.delete(guard); + }; +} + +/** First non-null guard reason blocks the switch; null means proceed. */ +export function activationBlockReason(): string | null { + for (const guard of guards) { + const reason = guard(); + if (reason) return reason; + } + return null; +} + +/** + * Notify subscribers that `connectionId` is now active. Awaited by the + * emitter: activation is not complete until every subscriber's handoff + * (e.g. the chat store loading the connection's sessions) has settled. + */ +export async function emitConnectionActivated(connectionId: string): Promise { + for (const listener of listeners) { + await listener(connectionId); + } +} diff --git a/packages/ui/src/lib/endpoints-state.svelte.ts b/packages/ui/src/lib/endpoints-state.svelte.ts index f18c7b2f3..1aedfb780 100644 --- a/packages/ui/src/lib/endpoints-state.svelte.ts +++ b/packages/ui/src/lib/endpoints-state.svelte.ts @@ -1,18 +1,29 @@ /** - * Client-side store for the assistant endpoints list + active selection. + * Client-side store for the assistant connections list + active selection + * (internal model renamed endpoint → connection in Phase 2 / #486; plan + * ui-runtime-modes-plan.md §6.6). * * Loaded lazily on first access. Other components ($lib/components/Navbar) - * and pages (admin/endpoints) share this state so a change anywhere is - * reflected everywhere without a full reload. + * and pages share this state so a change anywhere is reflected everywhere + * without a full reload. + * + * NOTE on naming: the file name and the `endpointsService` export (and its + * `endpoints`/`activeId` fields) are pinned by existing components and their + * browser tests; they migrate with the Phase 5 client extraction. New code + * is written in connection language. + * + * Untangled from chat (plan Phase 2 step 6): this store NEVER imports chat + * modules. Activation emits through $lib/connection-events; the chat store + * subscribes (and registers its "not while sending" guard) from its own side. */ import { fetchEndpoints, setActiveEndpoint, type AssistantEndpoint, } from './api.js'; -import { chat } from './chat/chat-state.svelte.js'; +import { activationBlockReason, emitConnectionActivated } from './connection-events.js'; -class EndpointsService { +class ConnectionsService { endpoints = $state([]); activeId = $state('default'); loading = $state(false); @@ -37,7 +48,7 @@ class EndpointsService { const err = e as { message?: string; status?: number }; // 401 is the auth gate's responsibility — don't surface here if (err.status !== 401) { - this.error = err.message ?? 'Failed to load endpoints'; + this.error = err.message ?? 'Failed to load connections'; } } finally { this.loading = false; @@ -46,27 +57,30 @@ class EndpointsService { async activate(id: string): Promise { if (id === this.activeId) return; - // Mid-generation switches are blocked at the chat layer; surface the - // refusal here so the switcher doesn't silently flip the activeId. - if (chat.sending) { - this.error = 'Wait for the current reply to finish before switching.'; - throw new Error(this.error); + // A subscriber may veto the switch (the chat side blocks mid-generation + // switches); surface the refusal here so the switcher doesn't silently + // flip the activeId. + const blocked = activationBlockReason(); + if (blocked) { + this.error = blocked; + throw new Error(blocked); } const previous = this.activeId; this.activeId = id; try { await setActiveEndpoint(id); - // Hand off to the per-endpoint chat state: load this endpoint's - // sessions, restore the previously-open one (or the newest), and - // fetch its messages. See docs/technical/multi-endpoint-session-ux.md. - await chat.onEndpointChanged(id); + // Hand off to subscribers (the chat store loads this connection's + // sessions, restores the previously-open one, and fetches messages — + // see docs/technical/multi-endpoint-session-ux.md). Awaited so a + // failed handoff rolls the switch back. + await emitConnectionActivated(id); } catch (e) { this.activeId = previous; const err = e as { message?: string }; - this.error = err.message ?? 'Failed to switch endpoint'; + this.error = err.message ?? 'Failed to switch connection'; throw e; } } } -export const endpointsService = new EndpointsService(); +export const endpointsService = new ConnectionsService(); From b7b8fb304b30c7e5543d4288c63788a29219b835 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 12:03:56 +0000 Subject: [PATCH 12/61] feat(ui): move connection management to /connections; alias /admin/endpoints (#486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 steps 3-5 (plan ui-runtime-modes-plan.md): - New /connections page — the existing endpoint management UI restyled minimally in connection language, backed by the capability-guarded /api/connections/* routes so it is reachable in every mode that advertises connections:manage (host-ui, electron-host, pwa-static), without the host-admin /admin namespace. - lib/api/endpoints.ts rewritten in connection language and pointed at /api/connections/*; the connections store consumes it. AssistantEndpoint stays as a type alias until Phase 5 relocates the module. - /admin/endpoints is now a 0.13.0 redirect alias: a universal load in +page.ts throws redirect(302, '/connections') (same convention as the routes/+page.ts → /splash redirect); the sibling JSON +server routes stay for out-of-process callers this release. The hooks /admin gate honors the alias in non-admin modes too, so stale links land on /connections instead of /chat. - Chat garden + settings drawer links now point at /connections. (EndpointList's link is left on /admin/endpoints deliberately — its browser test pins the href and cannot run in this environment; the alias redirect keeps it working. Migrate with Phase 3 chrome work.) - hooks.server.ts exempts /connections from launch routing; the runtime context route pointers advertise connections: '/connections' for host-capable and pwa-static modes. Host mode behavior is unchanged: host admin stays loopback-only and the admin session still gates the connection API after the capability check. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 --- packages/ui/src/hooks.server.ts | 7 +- packages/ui/src/lib/api/endpoints.ts | 55 ++- .../components/chrome/SettingsDrawer.svelte | 2 +- packages/ui/src/lib/endpoints-state.svelte.ts | 20 +- packages/ui/src/lib/server/features.ts | 9 +- .../src/routes/admin/endpoints/+page.svelte | 444 +----------------- .../ui/src/routes/admin/endpoints/+page.ts | 16 + packages/ui/src/routes/chat/+page.svelte | 2 +- .../ui/src/routes/connections/+page.svelte | 440 +++++++++++++++++ 9 files changed, 520 insertions(+), 475 deletions(-) create mode 100644 packages/ui/src/routes/admin/endpoints/+page.ts create mode 100644 packages/ui/src/routes/connections/+page.svelte diff --git a/packages/ui/src/hooks.server.ts b/packages/ui/src/hooks.server.ts index 372388301..1ad89a27c 100644 --- a/packages/ui/src/hooks.server.ts +++ b/packages/ui/src/hooks.server.ts @@ -167,8 +167,10 @@ export const handle: Handle = async ({ event, resolve }) => { // Feature gate: /admin/* requires admin flag (Electron or OP_ENABLE_ADMIN=1). // Redirect to /chat so the user lands somewhere useful instead of a 404/403. + // /admin/endpoints is the Phase 2 alias for /connections (#486) — honor the + // alias in every mode so stale links land on the capability-guarded page. if (path.startsWith('/admin') && !computeFeatureFlags().admin) { - redirect(302, '/chat'); + redirect(302, path === '/admin/endpoints' ? '/connections' : '/chat'); } const isSetupPath = SETUP_PATHS.some(p => path === p || path.startsWith(`${p}/`)); @@ -199,7 +201,8 @@ export const handle: Handle = async ({ event, resolve }) => { : launch.recommendedRoute === 'chat' ? '/chat' : '/splash'; - const usageRoute = path.startsWith('/chat') || path.startsWith('/advanced'); + const usageRoute = path.startsWith('/chat') || path.startsWith('/advanced') + || path.startsWith('/connections'); const exempt = path.startsWith('/api/') || path.startsWith('/proxy/') || path.startsWith('/login') || path.startsWith('/health') || path.startsWith('/guardian/health') || path.startsWith('/admin') || path.startsWith('/splash') diff --git a/packages/ui/src/lib/api/endpoints.ts b/packages/ui/src/lib/api/endpoints.ts index 8d7d64269..8b15bd78d 100644 --- a/packages/ui/src/lib/api/endpoints.ts +++ b/packages/ui/src/lib/api/endpoints.ts @@ -1,49 +1,64 @@ import { request, requireOk } from './core.js'; +import type { ConnectionKind } from '$lib/types.js'; -// ── Assistant Endpoints ─────────────────────────────────────────────────────── +// ── Assistant Connections ───────────────────────────────────────────────────── +// +// Phase 2 (#486): the internal model uses "connection" language (plan +// ui-runtime-modes-plan.md §6.6) and the client talks to /api/connections/*, +// which is guarded by the `connections:manage` capability instead of the +// host-admin-only /admin/* namespace — so connection management works in +// every mode that advertises the capability (host-ui, electron-host, +// pwa-static). This module keeps its historical file name (api/endpoints.ts) +// until the Phase 5 client extraction relocates it. -export type AssistantEndpoint = { +export type AssistantConnection = { id: string; label: string; url: string; + kind: ConnectionKind; isDefault: boolean; hasPassword: boolean; }; -export type EndpointListResponse = { - endpoints: AssistantEndpoint[]; +/** Legacy name — kept until every consumer migrates to connection language. */ +export type AssistantEndpoint = AssistantConnection; + +export type ConnectionListResponse = { + connections: AssistantConnection[]; activeId: string; }; -export async function fetchEndpoints(): Promise { - const res = await requireOk(await request('GET', '/admin/endpoints')); - return (await res.json()) as EndpointListResponse; +export async function fetchConnections(): Promise { + const res = await requireOk(await request('GET', '/api/connections')); + return (await res.json()) as ConnectionListResponse; } -export async function createEndpoint(input: { +export async function createConnection(input: { label: string; url: string; password?: string; -}): Promise<{ endpoint: AssistantEndpoint }> { - const res = await requireOk(await request('POST', '/admin/endpoints', input)); - return (await res.json()) as { endpoint: AssistantEndpoint }; +}): Promise<{ connection: AssistantConnection }> { + const res = await requireOk(await request('POST', '/api/connections', input)); + return (await res.json()) as { connection: AssistantConnection }; } -export async function updateEndpoint( +export async function updateConnection( id: string, patch: { label?: string; url?: string; password?: string | null } -): Promise<{ endpoint: AssistantEndpoint }> { +): Promise<{ connection: AssistantConnection }> { const res = await requireOk( - await request('PATCH', `/admin/endpoints/${encodeURIComponent(id)}`, patch) + await request('PATCH', `/api/connections/${encodeURIComponent(id)}`, patch) ); - return (await res.json()) as { endpoint: AssistantEndpoint }; + return (await res.json()) as { connection: AssistantConnection }; } -export async function deleteEndpoint(id: string): Promise { - await requireOk(await request('DELETE', `/admin/endpoints/${encodeURIComponent(id)}`)); +export async function deleteConnection(id: string): Promise { + await requireOk(await request('DELETE', `/api/connections/${encodeURIComponent(id)}`)); } -export async function setActiveEndpoint(id: string): Promise<{ activeId: string; endpoint: AssistantEndpoint }> { - const res = await requireOk(await request('POST', '/admin/endpoints/active', { id })); - return (await res.json()) as { activeId: string; endpoint: AssistantEndpoint }; +export async function setActiveConnection( + id: string +): Promise<{ activeId: string; connection: AssistantConnection }> { + const res = await requireOk(await request('POST', '/api/connections/active', { id })); + return (await res.json()) as { activeId: string; connection: AssistantConnection }; } diff --git a/packages/ui/src/lib/components/chrome/SettingsDrawer.svelte b/packages/ui/src/lib/components/chrome/SettingsDrawer.svelte index f26b16329..6139cef18 100644 --- a/packages/ui/src/lib/components/chrome/SettingsDrawer.svelte +++ b/packages/ui/src/lib/components/chrome/SettingsDrawer.svelte @@ -59,7 +59,7 @@ Manage this assistant... {/if} - (open = false)}> + (open = false)}> Manage assistant connections... diff --git a/packages/ui/src/lib/endpoints-state.svelte.ts b/packages/ui/src/lib/endpoints-state.svelte.ts index 1aedfb780..88f8c5a8c 100644 --- a/packages/ui/src/lib/endpoints-state.svelte.ts +++ b/packages/ui/src/lib/endpoints-state.svelte.ts @@ -4,8 +4,8 @@ * ui-runtime-modes-plan.md §6.6). * * Loaded lazily on first access. Other components ($lib/components/Navbar) - * and pages share this state so a change anywhere is reflected everywhere - * without a full reload. + * and pages (/connections) share this state so a change anywhere is + * reflected everywhere without a full reload. * * NOTE on naming: the file name and the `endpointsService` export (and its * `endpoints`/`activeId` fields) are pinned by existing components and their @@ -17,20 +17,20 @@ * subscribes (and registers its "not while sending" guard) from its own side. */ import { - fetchEndpoints, - setActiveEndpoint, - type AssistantEndpoint, + fetchConnections, + setActiveConnection, + type AssistantConnection, } from './api.js'; import { activationBlockReason, emitConnectionActivated } from './connection-events.js'; class ConnectionsService { - endpoints = $state([]); + endpoints = $state([]); activeId = $state('default'); loading = $state(false); loaded = $state(false); error = $state(''); - active = $derived( + active = $derived( this.endpoints.find((e) => e.id === this.activeId) ?? this.endpoints[0] ?? null ); @@ -40,8 +40,8 @@ class ConnectionsService { this.loading = true; this.error = ''; try { - const { endpoints, activeId } = await fetchEndpoints(); - this.endpoints = endpoints; + const { connections, activeId } = await fetchConnections(); + this.endpoints = connections; this.activeId = activeId; this.loaded = true; } catch (e) { @@ -68,7 +68,7 @@ class ConnectionsService { const previous = this.activeId; this.activeId = id; try { - await setActiveEndpoint(id); + await setActiveConnection(id); // Hand off to subscribers (the chat store loads this connection's // sessions, restores the previously-open one, and fetches messages — // see docs/technical/multi-endpoint-session-ux.md). Awaited so a diff --git a/packages/ui/src/lib/server/features.ts b/packages/ui/src/lib/server/features.ts index 3861152a8..8cc050876 100644 --- a/packages/ui/src/lib/server/features.ts +++ b/packages/ui/src/lib/server/features.ts @@ -85,21 +85,24 @@ const SERVER_CAPABILITIES: Record = { }; /** Route pointers per mode — current-truth URLs (pre-Phase-3 route split). - * Phase 2 moves connections to /connections; Phase 4 moves /admin to /host. */ + * Phase 2 (#486) moved connections to /connections (the /admin/endpoints + * alias redirects there for 0.13.0); Phase 4 moves /admin to /host. */ function routesForMode(mode: UiHostMode): ServerRuntimeContext['routes'] { switch (mode) { case 'electron-host': case 'host-ui': return { chat: '/chat', - connections: '/admin/endpoints', + connections: '/connections', host: '/admin', setup: '/setup', }; case 'assistant-container': return { chat: '/chat' }; case 'pwa-static': - return { chat: '/chat' }; + // Connection management is capability-guarded, not host-admin-gated — + // reachable in pwa-static (plan §4.3). + return { chat: '/chat', connections: '/connections' }; } } diff --git a/packages/ui/src/routes/admin/endpoints/+page.svelte b/packages/ui/src/routes/admin/endpoints/+page.svelte index 5a181b79e..664ebfae0 100644 --- a/packages/ui/src/routes/admin/endpoints/+page.svelte +++ b/packages/ui/src/routes/admin/endpoints/+page.svelte @@ -1,438 +1,6 @@ - - - - Assistant Endpoints — OpenPalm - - - - -
- - - {#if endpointsService.error} - - {/if} - -
- {#each endpoints as ep (ep.id)} -
-
-
- {ep.label} - {#if ep.isDefault}Default{/if} - {#if ep.id === active?.id}Active{/if} - {#if ep.hasPassword}{/if} -
-
{ep.url}
-
-
- {#if ep.id !== active?.id} - - {/if} - {#if !ep.isDefault} - - - {/if} -
-
- {/each} -
- - {#if formMode === 'idle'} - - {:else} -
-

{formMode === 'add' ? 'Add endpoint' : 'Edit endpoint'}

- - - - - - - - {#if formMode === 'edit'} - - {/if} - - {#if formError} - - {/if} - -
- - -
-
- {/if} -
- - + diff --git a/packages/ui/src/routes/admin/endpoints/+page.ts b/packages/ui/src/routes/admin/endpoints/+page.ts new file mode 100644 index 000000000..b1a8ab12f --- /dev/null +++ b/packages/ui/src/routes/admin/endpoints/+page.ts @@ -0,0 +1,16 @@ +/** + * /admin/endpoints → /connections redirect alias (plan + * ui-runtime-modes-plan.md Phase 2 step 5, issue #486). + * + * Connection management moved to the capability-guarded /connections surface. + * This alias is kept for the 0.13.0 release only — Phase 4 turns /admin/* + * into 404s. Same convention as the routes/+page.ts → /splash redirect. + * (The sibling +server.ts JSON API remains for out-of-process callers this + * release; SvelteKit content-negotiates HTML navigations to this page.) + */ +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = () => { + redirect(302, '/connections'); +}; diff --git a/packages/ui/src/routes/chat/+page.svelte b/packages/ui/src/routes/chat/+page.svelte index 3c5de8fe2..52ea8eabe 100644 --- a/packages/ui/src/routes/chat/+page.svelte +++ b/packages/ui/src/routes/chat/+page.svelte @@ -487,7 +487,7 @@
- + {/if} - onChosen?.()}> + onChosen?.()}> Manage assistant connections…
diff --git a/packages/ui/src/lib/components/chat/EndpointList.svelte.vitest.ts b/packages/ui/src/lib/components/chat/EndpointList.svelte.vitest.ts index c4d513870..bbc3498b2 100644 --- a/packages/ui/src/lib/components/chat/EndpointList.svelte.vitest.ts +++ b/packages/ui/src/lib/components/chat/EndpointList.svelte.vitest.ts @@ -26,7 +26,7 @@ describe('EndpointList', () => { test('renames the connections management link', async () => { render(EndpointList); - await expect.element(page.getByRole('link', { name: 'Manage assistant connections…' })).toHaveAttribute('href', '/admin/endpoints'); + await expect.element(page.getByRole('link', { name: 'Manage assistant connections…' })).toHaveAttribute('href', '/connections'); }); test('shows the local assistant management link above assistant connections', async () => { From e039fae66e82a2a81835724a47001d300d624316 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 13:03:18 +0000 Subject: [PATCH 14/61] test(phase-3): red tests for plan Phase 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD red suite for capability-driven landing + navigation + chrome untangle (plan ui-runtime-modes-plan.md Phase 3, §6.5; issue #555): - lib/resolve-landing.vitest.ts: resolveLanding(ctx, launchState) full §6.5 matrix — host:setup rows (migration pending → /attention with precedence over every local state; not_installed/setup_incomplete → /setup; installed_offline → /admin; installed_broken → /admin?tab=diagnostics; running → /chat), assistant-container → /chat always (local state, connections, and migration ignored), pwa-static → /connections/new when 0 connections else /chat, and the gate being CAPABILITY-driven (host-ui ctx without host:setup in effectiveCapabilities falls through to /chat). Pins that resolveLanding is PURE (reads ctx.effectiveCapabilities, never the global store) so hooks.server can call it per-request. Phase 3 keeps the host landing at /admin — HOST_ADMIN_LANDING in the test mirrors the implementation's TODO constant; Phase 4 flips both to /host. RED: all 17 tests ("resolveLanding module not found" — module does not exist; computed-specifier dynamic import keeps svelte-check green while red). - hooks.server.landing.vitest.ts: root + /splash document navigations route through the resolved landing — / with not_installed → /setup and installed_offline → /admin (both currently /splash); /splash is no longer exempt and redirects pre-auth to the resolved landing (/chat when running, /admin when offline, /setup when not_installed); the /splash route files are deleted; /attention exists. RED: 7 tests (redirect location mismatches — currently /splash or /login?redirectTo=%2Fsplash — plus the two route-split file assertions). CHARACTERIZATION (green pre-change, must stay green): 1 test — / with a healthy running stack redirects to /chat. - lib/features-admin-hygiene.vitest.ts: no .svelte component reads features.admin / featuresService.admin (plan §8.6; the alias may survive only in hooks.server pending Phase 4). RED: 1 test (offender lib/components/chrome/Navbar.svelte:70). CHARACTERIZATION (green): 1 walker-sanity test. - lib/components/chrome/chrome-untangle-hygiene.vitest.ts: the chrome module(s) the admin surface mounts (resolved from routes/{admin,host}/+{page,layout}.svelte imports, so the test follows the Phase 3 chrome split and the Phase 4 rename) import no chat components or chat stores. RED: 1 test (4 offenders: Navbar.svelte → components/chat/{EndpointSwitcher,SessionPicker, VoiceControl}.svelte + $lib/chat/navigation.js). CHARACTERIZATION (green): admin surface mounts a chrome module. - routes/chat/page-imports.vitest.ts: chat/+page.svelte does not import the $lib/api.js barrel (direct domain-client imports like $lib/api/chat.js stay allowed). RED: 1 test (offender '$lib/api.js'). CHARACTERIZATION (green): the chat page exists. Verified red for the right reasons: server project 27 failed / 1011 passed across 111 files; the only failing files are these five and every failure is a missing-module or missing-feature assertion (no syntax errors). ui:check: 0 errors 0 warnings (red-state safe). biome: clean on the new files (baseline elsewhere unchanged); eslint: clean on the new files. CLI suite 87 pass / 1 known uid-0 env failure; lib suite 845 pass / 10 skip / 17 known uid-0 failures — parity. No production code changed. Playwright e2e specs are deliberately not added here: browsers cannot run in this container and the Phase 3 route split they would smoke-test lands with the implementation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 --- .../ui/src/hooks.server.landing.vitest.ts | 196 ++++++++++++ .../chrome/chrome-untangle-hygiene.vitest.ts | 110 +++++++ .../src/lib/features-admin-hygiene.vitest.ts | 54 ++++ packages/ui/src/lib/resolve-landing.vitest.ts | 293 ++++++++++++++++++ .../ui/src/routes/chat/page-imports.vitest.ts | 62 ++++ 5 files changed, 715 insertions(+) create mode 100644 packages/ui/src/hooks.server.landing.vitest.ts create mode 100644 packages/ui/src/lib/components/chrome/chrome-untangle-hygiene.vitest.ts create mode 100644 packages/ui/src/lib/features-admin-hygiene.vitest.ts create mode 100644 packages/ui/src/lib/resolve-landing.vitest.ts create mode 100644 packages/ui/src/routes/chat/page-imports.vitest.ts diff --git a/packages/ui/src/hooks.server.landing.vitest.ts b/packages/ui/src/hooks.server.landing.vitest.ts new file mode 100644 index 000000000..ba015f989 --- /dev/null +++ b/packages/ui/src/hooks.server.landing.vitest.ts @@ -0,0 +1,196 @@ +/** + * Phase 3 — root + /splash routing through resolveLanding (plan + * ui-runtime-modes-plan.md §6.5, Phase 3 step 2). + * + * Contract pinned here: + * - hooks.server.ts derives the landing for document navigations from + * resolveLanding(ctx, launchState) instead of the binary + * recommendedRoute → '/chat' | '/splash' table. + * - The /splash ROUTE is removed (its files are gone from routes/splash/), + * but the /splash PATH keeps redirecting to the resolved landing for this + * release. The redirect is issued by the hooks launch-routing guard — + * i.e. /splash is no longer exempt — so it fires BEFORE the auth guard, + * exactly like the existing '/' redirect (a stale /splash bookmark must + * not bounce through /login first). + * - /attention exists as the new migration/blocking surface split out of + * /splash. /host is NOT created in Phase 3 — until Phase 4 renames + * /admin, the installed_offline/broken landing stays '/admin' (a TODO + * constant in the implementation; flip HOST_ADMIN_LANDING here with it). + * + * RED until Phase 3 lands, EXCEPT the test explicitly labeled + * CHARACTERIZATION (healthy stack → /chat), which passes today and must + * keep passing. + * + * Deterministic like hooks.server.vitest.ts: the host probes (composePs, + * detectRuntime, listRemoteStatuses) are stubbed so results don't depend on + * what happens to be running on the dev machine. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import type { RequestEvent } from '@sveltejs/kit'; +import { resetState } from '$lib/server/test-helpers.js'; + +vi.mock('$lib/server/endpoints.js', async (orig) => ({ + ...(await orig()), + listRemoteStatuses: vi.fn(async () => []), +})); +vi.mock('@openpalm/lib', async (orig) => ({ + ...(await orig()), + composePs: vi.fn(async () => ({ ok: false, stdout: '', stderr: '', code: 1 })), + detectRuntime: vi.fn(async () => ({ dockerPresent: false, composeAvailable: false })), +})); + +import { composePs } from '@openpalm/lib'; +import { handle, _resetLaunchCache } from './hooks.server.js'; + +// Phase 3 value. TODO(phase-4): flip to '/host' when /admin is renamed. +const HOST_ADMIN_LANDING = '/admin'; + +// ── helpers (same conventions as hooks.server.vitest.ts) ───────────────────── + +function seedStackEnv(stackDir: string, setupComplete: boolean): void { + const kvDir = join(stackDir, '..', '..', 'knowledge', 'env'); + mkdirSync(kvDir, { recursive: true }); + writeFileSync(join(kvDir, 'stack.env'), `OP_SETUP_COMPLETE=${setupComplete}\n`); +} + +/** compose ps --format json output for a single healthy running service. */ +const RUNNING_PS = '{"Service":"assistant","State":"running","Health":"healthy"}\n'; + +function makeEvent(path: string): RequestEvent { + const url = new URL(`http://localhost:3880${path}`); + const headers: Record = { + host: 'localhost:3880', + accept: 'text/html', + }; + return { + url, + request: new Request(url.toString(), { headers }), + params: {}, + locals: {} as App.Locals, + route: { id: path }, + platform: undefined, + getClientAddress: () => '127.0.0.1', + isDataRequest: false, + isSubRequest: false, + } as unknown as RequestEvent; +} + +const resolve = () => Promise.resolve(new Response('ok', { status: 200 })); + +const MODE_ENV_KEYS = ['OP_UI_HOST_MODE', 'OP_INSIDE_ELECTRON'] as const; + +describe('hooks.server — landing routing through resolveLanding (plan Phase 3)', () => { + let home = ''; + let prevHome: string | undefined; + let savedModeEnv: Record = {}; + + beforeEach(() => { + process.env.PORT = '3880'; + // host-ui mode: host:setup is in the server capabilities, so the host + // rows of the §6.5 matrix apply. + process.env.OP_ENABLE_ADMIN = '1'; + savedModeEnv = {}; + for (const key of MODE_ENV_KEYS) { + savedModeEnv[key] = process.env[key]; + delete process.env[key]; + } + prevHome = process.env.OP_HOME; + home = mkdtempSync(join(tmpdir(), 'op-hooks-landing-')); + process.env.OP_HOME = home; + _resetLaunchCache(); // the 5s launch cache is module-level — resolve fresh per test + vi.mocked(composePs).mockResolvedValue({ ok: false, stdout: '', stderr: '', code: 1 }); + resetState('test-admin-pw'); + }); + + afterEach(() => { + delete process.env.PORT; + delete process.env.OP_ENABLE_ADMIN; + for (const key of MODE_ENV_KEYS) { + const prev = savedModeEnv[key]; + if (prev === undefined) delete process.env[key]; + else process.env[key] = prev; + } + if (prevHome === undefined) delete process.env.OP_HOME; + else process.env.OP_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); + }); + + // ── root ('/') ────────────────────────────────────────────────────────────── + + test('CHARACTERIZATION: / with a healthy running stack redirects to /chat', async () => { + const state = resetState('test-admin-pw'); + seedStackEnv(state.stackDir, true); + vi.mocked(composePs).mockResolvedValue({ ok: true, stdout: RUNNING_PS, stderr: '', code: 0 }); + + await expect(handle({ event: makeEvent('/'), resolve })).rejects.toMatchObject({ + location: '/chat', + }); + }); + + test('/ with nothing installed redirects to /setup (was /splash)', async () => { + const state = resetState('test-admin-pw'); + seedStackEnv(state.stackDir, false); // no core.compose.yml → not_installed + + await expect(handle({ event: makeEvent('/'), resolve })).rejects.toMatchObject({ + location: '/setup', + }); + }); + + test(`/ with an installed-but-offline stack redirects to ${HOST_ADMIN_LANDING} (was /splash)`, async () => { + const state = resetState('test-admin-pw'); + seedStackEnv(state.stackDir, true); // setup complete, composePs fails → installed_offline + + await expect(handle({ event: makeEvent('/'), resolve })).rejects.toMatchObject({ + location: HOST_ADMIN_LANDING, + }); + }); + + // ── /splash → resolved landing (kept as a redirect this release) ─────────── + + test('/splash with a healthy running stack redirects to /chat', async () => { + const state = resetState('test-admin-pw'); + seedStackEnv(state.stackDir, true); + vi.mocked(composePs).mockResolvedValue({ ok: true, stdout: RUNNING_PS, stderr: '', code: 0 }); + + await expect(handle({ event: makeEvent('/splash'), resolve })).rejects.toMatchObject({ + location: '/chat', + }); + }); + + test(`/splash with an installed-but-offline stack redirects to ${HOST_ADMIN_LANDING}`, async () => { + const state = resetState('test-admin-pw'); + seedStackEnv(state.stackDir, true); + + await expect(handle({ event: makeEvent('/splash'), resolve })).rejects.toMatchObject({ + location: HOST_ADMIN_LANDING, + }); + }); + + test('/splash with nothing installed redirects to /setup', async () => { + const state = resetState('test-admin-pw'); + seedStackEnv(state.stackDir, false); + + await expect(handle({ event: makeEvent('/splash'), resolve })).rejects.toMatchObject({ + location: '/setup', + }); + }); +}); + +// ── route split: /splash removed, /attention created (Phase 3 step 2) ──────── + +describe('route split — /splash removed, /attention exists (plan Phase 3)', () => { + const routesDir = fileURLToPath(new URL('./routes/', import.meta.url)); + + test('the /splash route files are removed (redirect lives in hooks, not a route)', () => { + expect(existsSync(join(routesDir, 'splash', '+page.svelte'))).toBe(false); + expect(existsSync(join(routesDir, 'splash', '+page.server.ts'))).toBe(false); + }); + + test('the /attention route exists (migration/blocking surface split from /splash)', () => { + expect(existsSync(join(routesDir, 'attention', '+page.svelte'))).toBe(true); + }); +}); diff --git a/packages/ui/src/lib/components/chrome/chrome-untangle-hygiene.vitest.ts b/packages/ui/src/lib/components/chrome/chrome-untangle-hygiene.vitest.ts new file mode 100644 index 000000000..e564d5685 --- /dev/null +++ b/packages/ui/src/lib/components/chrome/chrome-untangle-hygiene.vitest.ts @@ -0,0 +1,110 @@ +/** + * Phase 3 hygiene — the admin-surface chrome must not import chat + * components/stores (plan ui-runtime-modes-plan.md Phase 3 step 4, issue + * #555: "Navbar must stop importing chat components/stores into the admin + * surface — split chrome so admin surface uses a chrome without chat state; + * chat surface gets its own chrome composition"). + * + * RED until Phase 3 lands: the admin surface (routes/admin/+page.svelte) + * mounts chrome/Navbar.svelte, which imports EndpointSwitcher, SessionPicker + * and VoiceControl from $lib/components/chat/ plus $lib/chat/navigation.js. + * + * Source-level test, same rationale as endpoints-state-hygiene.vitest.ts: + * the invariant is about the module graph — after the Phase 5 extraction the + * admin surface must not drag chat modules into the host bundle, so the + * coupling has to be gone BEFORE the move. The test follows the files rather + * than pinning names: it resolves whichever module(s) under + * $lib/components/chrome/ the admin surface imports (routes/admin today, + * routes/host after Phase 4; +page.svelte or +layout.svelte) and inspects + * THOSE modules' import specifiers. Type-only imports count too. + */ +import { describe, expect, test } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SRC_ROOT = fileURLToPath(new URL('../../../', import.meta.url)); + +/** Admin-surface entry files, in both pre- and post-Phase-4 locations. */ +const ADMIN_SURFACE_CANDIDATES = [ + 'routes/admin/+layout.svelte', + 'routes/admin/+page.svelte', + 'routes/host/+layout.svelte', + 'routes/host/+page.svelte', +] as const; + +function adminSurfaceFiles(): string[] { + return ADMIN_SURFACE_CANDIDATES.map((rel) => join(SRC_ROOT, rel)).filter((abs) => + existsSync(abs), + ); +} + +/** Collect every static, side-effect, re-export, and dynamic import specifier. */ +function importSpecifiers(source: string): string[] { + const specifiers: string[] = []; + const patterns = [ + // import ... from '...'; / export ... from '...'; (incl. `import type`) + /(?:^|\n)\s*(?:import|export)\s[^;'"]*?from\s*['"]([^'"]+)['"]/g, + // side-effect import: import '...'; + /(?:^|\n)\s*import\s*['"]([^'"]+)['"]/g, + // dynamic import: import('...') + /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g, + ]; + for (const pattern of patterns) { + for (const match of source.matchAll(pattern)) { + specifiers.push(match[1]); + } + } + return specifiers; +} + +/** Resolve a $lib/ or relative specifier to an absolute file path. */ +function resolveImportPath(fromFile: string, specifier: string): string | null { + if (specifier.startsWith('$lib/')) return join(SRC_ROOT, 'lib', specifier.slice('$lib/'.length)); + if (specifier.startsWith('.')) return resolve(dirname(fromFile), specifier); + return null; +} + +function isChromeSpecifier(specifier: string): boolean { + return specifier.includes('components/chrome/') && specifier.endsWith('.svelte'); +} + +/** True for chat state or anything under a chat/ module directory — + * $lib/chat/* (stores/navigation) and $lib/components/chat/* alike. */ +function isChatModuleSpecifier(specifier: string): boolean { + return /chat-state/.test(specifier) || /(?:^|\/)chat\//.test(specifier); +} + +/** The chrome modules the admin surface actually mounts. */ +function adminChromeModules(): string[] { + const files = new Set(); + for (const surfaceFile of adminSurfaceFiles()) { + const source = readFileSync(surfaceFile, 'utf-8'); + for (const specifier of importSpecifiers(source).filter(isChromeSpecifier)) { + const abs = resolveImportPath(surfaceFile, specifier); + if (abs && existsSync(abs)) files.add(abs); + } + } + return [...files]; +} + +describe('admin-surface chrome ↔ chat untangling (plan Phase 3 step 4, #555)', () => { + test('the admin surface exists and mounts a chrome module from $lib/components/chrome/', () => { + // CHARACTERIZATION (green today): guards the resolver — if the admin + // surface stopped importing any chrome module, the hygiene assertion + // below would pass vacuously. + expect(adminSurfaceFiles().length).toBeGreaterThan(0); + expect(adminChromeModules().length).toBeGreaterThan(0); + }); + + test('the admin-surface chrome imports no chat components or chat stores', () => { + const offenders: string[] = []; + for (const chromeFile of adminChromeModules()) { + const source = readFileSync(chromeFile, 'utf-8'); + for (const specifier of importSpecifiers(source).filter(isChatModuleSpecifier)) { + offenders.push(`${basename(chromeFile)} → ${specifier}`); + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/packages/ui/src/lib/features-admin-hygiene.vitest.ts b/packages/ui/src/lib/features-admin-hygiene.vitest.ts new file mode 100644 index 000000000..85a1a9bb7 --- /dev/null +++ b/packages/ui/src/lib/features-admin-hygiene.vitest.ts @@ -0,0 +1,54 @@ +/** + * Phase 3 hygiene — no component reads `features.admin` (plan + * ui-runtime-modes-plan.md Phase 3 step 3, §8.6: "hasCapability(cap) is the + * only check components call. No `if (features.admin)` anywhere"). + * + * RED until Phase 3 lands: chrome/Navbar.svelte still branches on + * `featuresService.admin`. + * + * Source-level test (scans every .svelte file under src/ for the read + * pattern) because the invariant is about where capability logic lives, not + * runtime behavior: components must consult runtimeContext.routes + + * hasCapability() so that adding a capability rule stays a one-function + * change (plan §11). The legacy alias may survive Phase 3 only in + * hooks.server.ts (computeFeatureFlags) pending Phase 4 — that is server + * code, deliberately outside this scan. + */ +import { describe, expect, test } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SRC_ROOT = fileURLToPath(new URL('../', import.meta.url)); + +function svelteFiles(): string[] { + return (readdirSync(SRC_ROOT, { recursive: true }) as string[]) + .filter((rel) => rel.endsWith('.svelte')) + .map((rel) => join(SRC_ROOT, rel)); +} + +/** Matches reads of the legacy admin flag: `features.admin`, + * `featuresService.admin`, `data.features.admin`, optional-chained forms. */ +const FEATURES_ADMIN_READ = /\bfeatures(?:Service)?\??\.admin\b/; + +describe('no features.admin reads in .svelte components (plan §8.6, Phase 3 step 3)', () => { + test('the scan sees the component tree (sanity)', () => { + // Guards the walker itself: if this ever returns [], the hygiene + // assertion below would pass vacuously. + expect(svelteFiles().length).toBeGreaterThan(50); + }); + + test('no .svelte file reads features.admin / featuresService.admin', () => { + const offenders: string[] = []; + for (const file of svelteFiles()) { + const source = readFileSync(file, 'utf-8'); + const lines = source.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (FEATURES_ADMIN_READ.test(lines[i])) { + offenders.push(`${file.slice(SRC_ROOT.length)}:${i + 1}`); + } + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/packages/ui/src/lib/resolve-landing.vitest.ts b/packages/ui/src/lib/resolve-landing.vitest.ts new file mode 100644 index 000000000..e96a957e1 --- /dev/null +++ b/packages/ui/src/lib/resolve-landing.vitest.ts @@ -0,0 +1,293 @@ +/** + * Phase 3 — resolveLanding(ctx, launchState) landing matrix (plan + * ui-runtime-modes-plan.md §6.5, Phase 3 step 1). + * + * ALL RED until the implementation lands: the module under test does not + * exist yet. The contract pinned here: + * + * - `resolveLanding` lives in its own module at $lib/resolve-landing.ts + * (see CANDIDATE_MODULE_BASES for the accepted names), together with the + * LaunchState type derived from the existing splash launch-state logic: + * `local.state` is @openpalm/lib's LocalStackState, `connections` is the + * connection list, and `migration.status` is the blocking-migration gate + * ('pending' blocks; anything else does not). + * - `resolveLanding` is PURE: it consults ctx.effectiveCapabilities — never + * the global runtimeContext store — so hooks.server.ts can call it + * per-request on the server, where no client store exists. Capability + * RESOLUTION still lives only in resolveCapabilities() (plan §8.6); + * resolveLanding merely reads the already-resolved list. + * + * Landing matrix (plan §6.5, exactly): + * host:setup capability present: + * migration pending → /attention + * local not_installed → /setup + * local setup_incomplete → /setup + * local installed_offline → host admin landing + * local installed_broken → host admin landing + ?tab=diagnostics + * otherwise (running) → /chat + * no host:setup capability: + * assistant-container → /chat (always) + * pwa-static, 0 connections → /connections/new + * pwa-static, ≥1 connection → /chat + * anything else → /chat + * + * Phase 3 does NOT create /host (that is Phase 4): the host admin landing + * stays at the existing /admin, encoded in the implementation as a TODO + * constant so Phase 4 flips exactly one value. When Phase 4 lands, update + * HOST_ADMIN_LANDING below to '/host' (and nothing else in this file). + * + * The module is loaded through a computed-specifier dynamic import so + * svelte-check stays clean while the suite is red (same convention as the + * Phase 2 red tests). + */ +import { describe, expect, test } from 'vitest'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import type { Capability, RuntimeContext, UiHostMode } from '$lib/types.js'; + +// Phase 3 value. TODO(phase-4): flip to '/host' when /admin is renamed. +const HOST_ADMIN_LANDING = '/admin'; + +// ── module loading (red-state safe) ────────────────────────────────────────── + +/** Accepted file names for "its own module" in $lib (implementation picks one). */ +const CANDIDATE_MODULE_BASES = ['resolve-landing', 'landing', 'resolve-landing.svelte'] as const; + +type ResolveLandingFn = (ctx: RuntimeContext, launchState: unknown) => string; + +async function loadResolveLanding(): Promise { + for (const base of CANDIDATE_MODULE_BASES) { + if (!existsSync(fileURLToPath(new URL(`./${base}.ts`, import.meta.url)))) continue; + const specifier = `./${base}.js`; + const mod = (await import(/* @vite-ignore */ specifier)) as { resolveLanding?: unknown }; + if (typeof mod.resolveLanding !== 'function') { + throw new Error(`$lib/${base}.ts exists but does not export resolveLanding()`); + } + return mod.resolveLanding as ResolveLandingFn; + } + throw new Error( + `resolveLanding module not found — expected packages/ui/src/lib/<${CANDIDATE_MODULE_BASES.join( + '|', + )}>.ts exporting resolveLanding(ctx, launchState) per plan §6.5`, + ); +} + +// ── fixtures ────────────────────────────────────────────────────────────────── + +/** Effective capabilities of a host-capable session (electron / host-ui browser). */ +const HOST_EFFECTIVE: Capability[] = [ + 'chat', + 'connections:read', + 'connections:manage', + 'connections:switch', + 'assistant-settings:read', + 'assistant-settings:write', + 'host:setup', + 'host:stack:read', + 'host:stack:write', + 'host:containers', + 'host:addons', + 'host:updates', + 'host:logs', + 'host:secrets', + 'host:recovery', + 'host:akm-sharing', +]; + +/** assistant-container effective set (resolveCapabilities output, plan §4.3). */ +const ASSISTANT_CONTAINER_EFFECTIVE: Capability[] = [ + 'chat', + 'assistant-settings:read', + 'assistant-settings:write', +]; + +/** pwa-static effective baseline (resolveCapabilities output, plan §4.3). */ +const PWA_EFFECTIVE: Capability[] = [ + 'chat', + 'connections:read', + 'connections:manage', + 'connections:switch', + 'pwa:install', +]; + +function makeCtx(hostMode: UiHostMode, effectiveCapabilities: Capability[]): RuntimeContext { + return { + version: 2, + hostMode, + serverCapabilities: [...effectiveCapabilities], + publicBaseUrl: 'http://127.0.0.1:3880', + uiVersion: '0.0.0-test', + skeletonVersion: '0.0.0-test', + activeConnectionMode: hostMode === 'assistant-container' ? 'single' : 'multi', + routes: {}, + security: { + hostAdminLoopbackOnly: true, + requiresHttpsForRemoteConnections: hostMode === 'pwa-static', + csrfMode: 'loopback-origin', + }, + clientContext: { displayMode: 'browser' }, + effectiveCapabilities, + }; +} + +type TestLocalState = + | 'not_installed' + | 'setup_incomplete' + | 'installed_offline' + | 'installed_broken' + | 'running'; + +type TestLaunchState = { + migration: { status: 'pending' | 'none' }; + local: { state: TestLocalState }; + connections: Array<{ id: string }>; +}; + +function makeLaunchState(overrides: Partial = {}): TestLaunchState { + return { + migration: { status: 'none' }, + local: { state: 'running' }, + connections: [{ id: 'local' }], + ...overrides, + }; +} + +// ── module contract ─────────────────────────────────────────────────────────── + +describe('resolveLanding — module contract', () => { + test('exports resolveLanding(ctx, launchState) from its own $lib module', async () => { + const resolveLanding = await loadResolveLanding(); + expect(typeof resolveLanding).toBe('function'); + }); +}); + +// ── host:setup rows (electron-host / host-ui with full capabilities) ────────── + +describe('resolveLanding — host:setup capability present (plan §6.5)', () => { + const hostCtx = makeCtx('host-ui', HOST_EFFECTIVE); + + test('pending migration lands on /attention', async () => { + const resolveLanding = await loadResolveLanding(); + const state = makeLaunchState({ migration: { status: 'pending' } }); + expect(resolveLanding(hostCtx, state)).toBe('/attention'); + }); + + test('pending migration takes precedence over not_installed', async () => { + const resolveLanding = await loadResolveLanding(); + const state = makeLaunchState({ + migration: { status: 'pending' }, + local: { state: 'not_installed' }, + }); + expect(resolveLanding(hostCtx, state)).toBe('/attention'); + }); + + test('pending migration takes precedence over installed_broken', async () => { + const resolveLanding = await loadResolveLanding(); + const state = makeLaunchState({ + migration: { status: 'pending' }, + local: { state: 'installed_broken' }, + }); + expect(resolveLanding(hostCtx, state)).toBe('/attention'); + }); + + test('not_installed lands on /setup', async () => { + const resolveLanding = await loadResolveLanding(); + const state = makeLaunchState({ local: { state: 'not_installed' }, connections: [] }); + expect(resolveLanding(hostCtx, state)).toBe('/setup'); + }); + + test('setup_incomplete lands on /setup', async () => { + const resolveLanding = await loadResolveLanding(); + const state = makeLaunchState({ local: { state: 'setup_incomplete' } }); + expect(resolveLanding(hostCtx, state)).toBe('/setup'); + }); + + test(`installed_offline lands on the host admin surface (${HOST_ADMIN_LANDING} until Phase 4)`, async () => { + const resolveLanding = await loadResolveLanding(); + const state = makeLaunchState({ local: { state: 'installed_offline' } }); + expect(resolveLanding(hostCtx, state)).toBe(HOST_ADMIN_LANDING); + }); + + test('installed_broken lands on the host admin diagnostics tab', async () => { + const resolveLanding = await loadResolveLanding(); + const state = makeLaunchState({ local: { state: 'installed_broken' } }); + expect(resolveLanding(hostCtx, state)).toBe(`${HOST_ADMIN_LANDING}?tab=diagnostics`); + }); + + test('running (healthy) lands on /chat', async () => { + const resolveLanding = await loadResolveLanding(); + expect(resolveLanding(hostCtx, makeLaunchState())).toBe('/chat'); + }); + + test('electron-host resolves through the same host rows', async () => { + const resolveLanding = await loadResolveLanding(); + const electronCtx = makeCtx('electron-host', HOST_EFFECTIVE); + expect(resolveLanding(electronCtx, makeLaunchState())).toBe('/chat'); + expect( + resolveLanding(electronCtx, makeLaunchState({ local: { state: 'installed_offline' } })), + ).toBe(HOST_ADMIN_LANDING); + }); + + test('the gate is CAPABILITY-driven, not hostMode-driven: a host-capable server viewed without host:setup falls through to /chat', async () => { + const resolveLanding = await loadResolveLanding(); + // host-ui × standalone-pwa display: resolveCapabilities strips host:* per + // plan §4.2, so even a broken local stack must not land this session on + // the host admin surface it cannot use. + const restrictedCtx = makeCtx('host-ui', [ + 'chat', + 'connections:read', + 'connections:manage', + 'connections:switch', + ]); + const state = makeLaunchState({ local: { state: 'installed_broken' } }); + expect(resolveLanding(restrictedCtx, state)).toBe('/chat'); + }); +}); + +// ── assistant-container row ─────────────────────────────────────────────────── + +describe('resolveLanding — assistant-container (plan §6.5)', () => { + const ctx = makeCtx('assistant-container', ASSISTANT_CONTAINER_EFFECTIVE); + + test('always lands on /chat', async () => { + const resolveLanding = await loadResolveLanding(); + expect(resolveLanding(ctx, makeLaunchState())).toBe('/chat'); + }); + + test('lands on /chat regardless of local stack state or connection count', async () => { + const resolveLanding = await loadResolveLanding(); + // The co-process has no view of a host stack — local state is meaningless. + const state = makeLaunchState({ local: { state: 'not_installed' }, connections: [] }); + expect(resolveLanding(ctx, state)).toBe('/chat'); + }); + + test('a pending migration does not divert it (no host:setup → gate skipped)', async () => { + const resolveLanding = await loadResolveLanding(); + const state = makeLaunchState({ migration: { status: 'pending' } }); + expect(resolveLanding(ctx, state)).toBe('/chat'); + }); +}); + +// ── pwa-static row ──────────────────────────────────────────────────────────── + +describe('resolveLanding — pwa-static (plan §6.5)', () => { + const ctx = makeCtx('pwa-static', PWA_EFFECTIVE); + + test('zero connections lands on /connections/new', async () => { + const resolveLanding = await loadResolveLanding(); + const state = makeLaunchState({ connections: [] }); + expect(resolveLanding(ctx, state)).toBe('/connections/new'); + }); + + test('one or more connections lands on /chat', async () => { + const resolveLanding = await loadResolveLanding(); + const state = makeLaunchState({ connections: [{ id: 'r1' }] }); + expect(resolveLanding(ctx, state)).toBe('/chat'); + }); + + test('a pending migration does not divert it (no host:setup → gate skipped)', async () => { + const resolveLanding = await loadResolveLanding(); + const state = makeLaunchState({ migration: { status: 'pending' }, connections: [] }); + expect(resolveLanding(ctx, state)).toBe('/connections/new'); + }); +}); diff --git a/packages/ui/src/routes/chat/page-imports.vitest.ts b/packages/ui/src/routes/chat/page-imports.vitest.ts new file mode 100644 index 000000000..9d7b784ec --- /dev/null +++ b/packages/ui/src/routes/chat/page-imports.vitest.ts @@ -0,0 +1,62 @@ +/** + * Phase 3 hygiene — the chat page must not import the $lib/api.js barrel + * (plan ui-runtime-modes-plan.md Phase 3 step 4, issue #555: "Chat stops + * importing the $lib/api.js barrel (direct domain-client imports only)"). + * + * RED until Phase 3 lands: chat/+page.svelte still does + * import { probeChatBackend } from '$lib/api.js'; + * + * Source-level test because the invariant is about the module graph: the + * barrel re-exports every admin domain client (containers, versions, + * backups, secrets, akm, …), so one barrel import drags the entire admin API + * surface into the chat chunk — exactly what the Phase 5 client extraction + * (and the "chat chunk imports no admin API clients" acceptance) forbids. + * Direct domain-client imports (e.g. $lib/api/chat.js) remain allowed. + */ +import { describe, expect, test } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const CHAT_PAGE = fileURLToPath(new URL('./+page.svelte', import.meta.url)); + +/** Collect every static, side-effect, re-export, and dynamic import specifier. */ +function importSpecifiers(source: string): string[] { + const specifiers: string[] = []; + const patterns = [ + // import ... from '...'; / export ... from '...'; (incl. `import type`) + /(?:^|\n)\s*(?:import|export)\s[^;'"]*?from\s*['"]([^'"]+)['"]/g, + // side-effect import: import '...'; + /(?:^|\n)\s*import\s*['"]([^'"]+)['"]/g, + // dynamic import: import('...') + /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g, + ]; + for (const pattern of patterns) { + for (const match of source.matchAll(pattern)) { + specifiers.push(match[1]); + } + } + return specifiers; +} + +/** The $lib/api barrel, in alias or relative form — NOT the $lib/api/ domain + * modules, which stay allowed. */ +function isApiBarrelSpecifier(specifier: string): boolean { + return ( + /^\$lib\/api(?:\.(?:js|ts))?$/.test(specifier) || + /^(?:\.\.?\/)+lib\/api(?:\.(?:js|ts))?$/.test(specifier) + ); +} + +describe('chat page ↔ admin API barrel untangling (plan Phase 3 step 4, #555)', () => { + test('the chat page exists (sanity)', () => { + // CHARACTERIZATION (green today): guards the path this hygiene scan pins. + expect(existsSync(CHAT_PAGE)).toBe(true); + }); + + test('chat/+page.svelte does not import the $lib/api.js barrel', () => { + const offenders = importSpecifiers(readFileSync(CHAT_PAGE, 'utf-8')).filter( + isApiBarrelSpecifier, + ); + expect(offenders).toEqual([]); + }); +}); From d4cd80f3d45fa943ca0739378709c6948a9022f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 13:30:43 +0000 Subject: [PATCH 15/61] feat(ui): capability-driven landing via resolveLanding(); split /splash into /attention (#555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan ui-runtime-modes-plan.md Phase 3 steps 1-2 (§6.5): - New $lib/resolve-landing.ts: pure resolveLanding(ctx, launchState) — reads ctx.effectiveCapabilities (never the global store) so hooks.server can call it per-request. LaunchState derives from the launch-status probes that fed /splash (LocalStackState + connections + a migration gate). The host landing stays '/admin' behind the HOST_ADMIN_LANDING TODO constant; Phase 4 flips that one value to '/host'. One documented delta from the §6.5 pseudocode: the host not_installed row lands on /chat when an accessible connection exists, preserving the authoritative #440 remote-only rule (nothing local to set up is no reason to block a working remote) — mirrors the pwa-static row's shape. - New $lib/server/landing.ts: resolveRequestLanding(event) collects the launch facts (moved from hooks.server.ts, same 5s cache; _resetLaunchCache is re-exported from hooks for the existing test import site), resolves capabilities with the 'browser' baseline (display mode is client-only by design, §6.3 — resolveCapabilities() remains the only capability logic, §8.6), and runs resolveLanding(). The pre-Phase-3 nuance is preserved: an interrupted install with running containers lands on /setup, not chat. - hooks.server.ts: the launch-routing guard now redirects document navigations through the resolved landing instead of the binary recommendedRoute → '/chat'|'/splash' table. /splash is no longer exempt: the ROUTE is removed and the path 302s pre-auth to the resolved landing for this release only. - Route split: routes/splash/* deleted; routes/attention/+page.svelte is the migration/blocking surface (nothing produces migration 'pending' yet — the gate is wired ahead of the first blocking migration); /connections/new aliases to /connections?new=1 for the pwa-static zero-connections landing; routes/+page.ts's static /splash redirect becomes +page.server.ts resolving the same landing for client-side navigations. - hooks.server.vitest.ts: 'first-run document navigation routes to splash instead of setup' pinned the pre-Phase-3 landing; the ratified Phase 3 matrix (red-suite hooks.server.landing.vitest.ts pins the same scenario) sends not_installed to /setup, so the expectation is updated — the companion rule (not_installed + accessible remote → /chat) still passes unmodified. e2e/setup-guard.pw.ts's loose landing regex swaps splash for the new landings (Playwright cannot run in this container; not executed). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 --- packages/ui/e2e/setup-guard.pw.ts | 9 +- packages/ui/src/hooks.server.ts | 80 ++------ packages/ui/src/hooks.server.vitest.ts | 7 +- packages/ui/src/lib/resolve-landing.ts | 75 +++++++ packages/ui/src/lib/server/landing.ts | 124 ++++++++++++ packages/ui/src/lib/server/state.ts | 3 +- packages/ui/src/routes/+page.server.ts | 16 ++ packages/ui/src/routes/+page.svelte | 4 +- packages/ui/src/routes/+page.ts | 6 - .../ui/src/routes/admin/endpoints/+page.ts | 2 +- packages/ui/src/routes/attention/+page.svelte | 89 +++++++++ .../src/routes/connections/new/+page.svelte | 6 + .../ui/src/routes/connections/new/+page.ts | 13 ++ packages/ui/src/routes/splash/+page.server.ts | 39 ---- packages/ui/src/routes/splash/+page.svelte | 188 ------------------ 15 files changed, 353 insertions(+), 308 deletions(-) create mode 100644 packages/ui/src/lib/resolve-landing.ts create mode 100644 packages/ui/src/lib/server/landing.ts create mode 100644 packages/ui/src/routes/+page.server.ts delete mode 100644 packages/ui/src/routes/+page.ts create mode 100644 packages/ui/src/routes/attention/+page.svelte create mode 100644 packages/ui/src/routes/connections/new/+page.svelte create mode 100644 packages/ui/src/routes/connections/new/+page.ts delete mode 100644 packages/ui/src/routes/splash/+page.server.ts delete mode 100644 packages/ui/src/routes/splash/+page.svelte diff --git a/packages/ui/e2e/setup-guard.pw.ts b/packages/ui/e2e/setup-guard.pw.ts index ef2eb8543..4d9733981 100644 --- a/packages/ui/e2e/setup-guard.pw.ts +++ b/packages/ui/e2e/setup-guard.pw.ts @@ -24,10 +24,11 @@ test.describe('Setup guard — fresh instance (mocked-lib)', () => { test('GET / never serves raw admin/chat content unauthenticated', async ({ page }) => { const res = await page.goto('/'); expect(res?.status()).toBeLessThan(400); - // Whatever the guard's recommended landing page is (splash, setup, or a - // bounce through chat straight to the login gate), it must be one of - // these — never a bare admin page rendered with no session. - await expect(page).toHaveURL(/\/(splash|setup|login)/); + // Whatever landing resolveLanding() picks (plan §6.5: setup, attention, + // connections, or a bounce through chat/admin straight to the login + // gate), it must be one of these — never a bare admin page rendered with + // no session. + await expect(page).toHaveURL(/\/(setup|attention|connections|login)/); }); test('GET /setup is directly reachable, unauthenticated, from localhost', async ({ page }) => { diff --git a/packages/ui/src/hooks.server.ts b/packages/ui/src/hooks.server.ts index 1ad89a27c..7508119cd 100644 --- a/packages/ui/src/hooks.server.ts +++ b/packages/ui/src/hooks.server.ts @@ -18,37 +18,23 @@ import { sessionCookieHeader, SESSION_COOKIE_NAME } from "$lib/server/session-co import { computeFeatureFlags } from '$lib/server/features.js'; import { createLogger, - composePs, - deriveLaunchStatus, - deriveLocalStackState, isSetupComplete, resolveOpenPalmHome, readStackRuntimeEnv, readSecret, - classifyLocalInstall, - detectRuntime, - buildComposeOptions, collectBindAddressWarnings, isRemoteSetupAllowed, - type ComposeServiceStatus, } from "@openpalm/lib"; -import { listRemoteStatuses } from "$lib/server/endpoints.js"; +import { resolveRequestLanding } from "$lib/server/landing.js"; + +// Launch-fact collection + the 5s cache live in $lib/server/landing.ts; the +// reset hook is re-exported here so tests keep one import site. +export { _resetLaunchCache } from "$lib/server/landing.js"; const logger = createLogger("admin"); let startupApplyDone = false; let setupCompleteMemo = false; -type LaunchRouting = { - installState: ReturnType; - launch: ReturnType; -}; - -let localStatusCache: { expiresAt: number; value: LaunchRouting } | null = null; - -/** Test-only: clear the 5s launch-routing cache so each test resolves fresh. */ -export function _resetLaunchCache(): void { - localStatusCache = null; -} // Load the process-level config the UI needs to serve, READ-ONLY w.r.t. OP_HOME. // install/update own every OP_HOME write (via applyHome), so merely serving @@ -119,44 +105,6 @@ function isLocalhostAddress(ip: string): boolean { return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"; } -function parseComposePsServices(stdout: string): ComposeServiceStatus[] { - const services: ComposeServiceStatus[] = []; - for (const line of stdout.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const parsed = JSON.parse(trimmed) as Record; - services.push({ - service: String(parsed.Service ?? parsed.Name ?? ''), - state: String(parsed.State ?? ''), - health: String(parsed.Health ?? ''), - }); - } catch { - } - } - return services; -} - -async function resolveLaunchRouting(): Promise { - if (localStatusCache && localStatusCache.expiresAt > Date.now()) return localStatusCache.value; - const state = getState(); - const installState = classifyLocalInstall(state.stackDir, state.homeDir); - const composeResult = await composePs(buildComposeOptions(state)); - const services = composeResult.ok ? parseComposePsServices(composeResult.stdout) : []; - const localState = deriveLocalStackState(installState, services); - const launch = deriveLaunchStatus({ - local: { - state: localState, - runtime: installState === 'not_installed' ? await detectRuntime() : undefined, - detail: { installState }, - }, - remotes: await listRemoteStatuses(), - }); - const value = { installState, launch }; - localStatusCache = { value, expiresAt: Date.now() + 5_000 }; - return value; -} - export const handle: Handle = async ({ event, resolve }) => { const hostError = checkHostHeader(event.request); if (hostError) return hostError; @@ -194,21 +142,21 @@ export const handle: Handle = async ({ event, resolve }) => { } } + // ── Launch routing: document navigations land where resolveLanding() says + // (plan ui-runtime-modes-plan.md §6.5, Phase 3). Fires BEFORE the auth guard + // so `/` and stale `/splash` bookmarks never bounce through /login first. + // The /splash ROUTE is gone (Phase 3 split it into /attention + the §6.5 + // landings); the /splash PATH keeps redirecting here for this release. if (!isSetupPath) { - const { installState, launch } = await resolveLaunchRouting(); - const desiredPath = installState === 'setup_incomplete' && launch.local.state === 'running' - ? '/splash' - : launch.recommendedRoute === 'chat' - ? '/chat' - : '/splash'; + const landing = await resolveRequestLanding(event); + const [landingPath] = landing.split('?'); const usageRoute = path.startsWith('/chat') || path.startsWith('/advanced') || path.startsWith('/connections'); const exempt = path.startsWith('/api/') || path.startsWith('/proxy/') || path.startsWith('/login') || path.startsWith('/health') || path.startsWith('/guardian/health') || path.startsWith('/admin') - || path.startsWith('/splash') || usageRoute; - if (path === '/' || (path !== desiredPath && !exempt)) { - redirect(302, desiredPath); + if (path === '/' || (path !== landingPath && !exempt)) { + redirect(302, landing); } } diff --git a/packages/ui/src/hooks.server.vitest.ts b/packages/ui/src/hooks.server.vitest.ts index 4e497c28d..d96dac153 100644 --- a/packages/ui/src/hooks.server.vitest.ts +++ b/packages/ui/src/hooks.server.vitest.ts @@ -119,7 +119,10 @@ describe('hooks.server — sliding renewal', () => { expect(setCookie).not.toContain(`${SESSION_COOKIE_NAME}=`); }); - test('first-run document navigation routes to splash instead of setup', async () => { + test('first-run document navigation routes to /setup (resolveLanding, plan §6.5)', async () => { + // Pre-Phase-3 this pinned '/' → /splash; the Phase 3 landing matrix sends + // not_installed straight to /setup (same scenario is also pinned in + // hooks.server.landing.vitest.ts). const state = resetState('test-admin-pw'); const kvDir = join(state.stackDir, '..', '..', 'knowledge', 'env'); mkdirSync(kvDir, { recursive: true }); @@ -127,7 +130,7 @@ describe('hooks.server — sliding renewal', () => { const event = makeEvent('/', null, 'text/html'); - await expect(handle({ event, resolve })).rejects.toMatchObject({ location: '/splash' }); + await expect(handle({ event, resolve })).rejects.toMatchObject({ location: '/setup' }); }); test('not_installed + an accessible remote skips splash → /chat', async () => { diff --git a/packages/ui/src/lib/resolve-landing.ts b/packages/ui/src/lib/resolve-landing.ts new file mode 100644 index 000000000..cc74407d6 --- /dev/null +++ b/packages/ui/src/lib/resolve-landing.ts @@ -0,0 +1,75 @@ +/** + * Landing resolver — the single place that decides where a session lands + * (plan ui-runtime-modes-plan.md §6.5, Phase 3 step 1). + * + * PURE by contract: reads `ctx.effectiveCapabilities` — never the global + * `runtimeContext` store — so hooks.server.ts can call it per-request on the + * server (where no client store exists) and +layout/svelte code can call it + * with the store's value. Capability RESOLUTION stays in + * `resolveCapabilities()` (plan §8.6); this function only reads the + * already-resolved list. + * + * Landing matrix (plan §6.5): + * host:setup capability present: + * migration pending → /attention (precedence over local state) + * local not_installed → /setup — unless an accessible connection + * exists, then /chat (the authoritative #440 + * remote-only rule: nothing local to set up + * is no reason to block a working remote) + * local setup_incomplete → /setup + * local installed_offline → HOST_ADMIN_LANDING + * local installed_broken → HOST_ADMIN_LANDING?tab=diagnostics + * otherwise (running) → /chat + * no host:setup capability: + * assistant-container → /chat (always — no host stack in view) + * pwa-static, 0 connections → /connections/new + * pwa-static, ≥1 connection → /chat + * anything else → /chat + */ +import type { LocalStackState } from '@openpalm/lib'; +import type { RuntimeContext } from '$lib/types.js'; + +/** + * Where the host admin surface lives. Phase 3 keeps the existing /admin. + * TODO(phase-4): flip to '/host' when /admin is renamed — this constant is + * the only value that changes. + */ +export const HOST_ADMIN_LANDING = '/admin'; + +/** Blocking-migration gate: 'pending' blocks; anything else does not. + * Nothing produces 'pending' yet — the gate (and the /attention surface it + * routes to) is wired ahead of the first blocking OP_HOME migration. */ +export type MigrationStatus = 'pending' | 'none'; + +/** + * Launch facts, derived from the launch-status probes that used to feed the + * /splash page (`local.state` is @openpalm/lib's LocalStackState). On the + * server, `connections` is the list of connections usable right now + * (accessible remotes, plus the local assistant when it is running); the + * static client feeds its own stored connection list. + */ +export type LaunchState = { + migration: { status: MigrationStatus }; + local: { state: LocalStackState }; + connections: ReadonlyArray<{ id: string }>; +}; + +/** Resolve the landing path for a session (plan §6.5). Pure — no I/O, no + * global state; the gate is CAPABILITY-driven, not hostMode-driven. */ +export function resolveLanding(ctx: RuntimeContext, state: LaunchState): string { + if (ctx.effectiveCapabilities.includes('host:setup')) { + if (state.migration.status === 'pending') return '/attention'; + if (state.local.state === 'not_installed') { + return state.connections.length === 0 ? '/setup' : '/chat'; + } + if (state.local.state === 'setup_incomplete') return '/setup'; + if (state.local.state === 'installed_offline') return HOST_ADMIN_LANDING; + if (state.local.state === 'installed_broken') return `${HOST_ADMIN_LANDING}?tab=diagnostics`; + return '/chat'; + } + if (ctx.hostMode === 'assistant-container') return '/chat'; + if (ctx.hostMode === 'pwa-static') { + return state.connections.length === 0 ? '/connections/new' : '/chat'; + } + return '/chat'; +} diff --git a/packages/ui/src/lib/server/landing.ts b/packages/ui/src/lib/server/landing.ts new file mode 100644 index 000000000..0de425198 --- /dev/null +++ b/packages/ui/src/lib/server/landing.ts @@ -0,0 +1,124 @@ +/** + * Server-side landing resolution (plan ui-runtime-modes-plan.md §6.5, + * Phase 3 step 2). + * + * Collects the launch facts (local install state, container health, remote + * reachability — the probes that used to feed the /splash page) and runs them + * through the pure `resolveLanding()`. Used by hooks.server.ts for document + * navigations to `/` and `/splash`, and by routes/+page.server.ts for + * client-side navigations to `/`. + * + * The server cannot detect the client display mode (client-only by design, + * plan §6.3), so capabilities are resolved with the 'browser' baseline — + * which yields the correct host:setup gating for every hostMode: host-capable + * modes keep their host:* capabilities in a browser, and the other modes + * never had them. + */ +import type { RequestEvent } from '@sveltejs/kit'; +import { + buildComposeOptions, + classifyLocalInstall, + composePs, + deriveLaunchStatus, + deriveLocalStackState, + detectRuntime, + type ComposeServiceStatus, +} from '@openpalm/lib'; +import { getState } from '$lib/server/state.js'; +import { listRemoteStatuses } from '$lib/server/endpoints.js'; +import { computeServerRuntimeContext } from '$lib/server/features.js'; +import { resolveCapabilities } from '$lib/runtime-context.svelte.js'; +import { resolveLanding, type LaunchState } from '$lib/resolve-landing.js'; +import type { ClientContext, RuntimeContext } from '$lib/types.js'; + +type LaunchRouting = { + installState: ReturnType; + launch: ReturnType; +}; + +let launchRoutingCache: { expiresAt: number; value: LaunchRouting } | null = null; + +/** Test-only: clear the 5s launch-routing cache so each test resolves fresh. */ +export function _resetLaunchCache(): void { + launchRoutingCache = null; +} + +function parseComposePsServices(stdout: string): ComposeServiceStatus[] { + const services: ComposeServiceStatus[] = []; + for (const line of stdout.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed) as Record; + services.push({ + service: String(parsed.Service ?? parsed.Name ?? ''), + state: String(parsed.State ?? ''), + health: String(parsed.Health ?? ''), + }); + } catch { + // Skip malformed compose ps lines — health derives from the valid ones. + } + } + return services; +} + +async function resolveLaunchRouting(): Promise { + if (launchRoutingCache && launchRoutingCache.expiresAt > Date.now()) { + return launchRoutingCache.value; + } + const state = getState(); + const installState = classifyLocalInstall(state.stackDir, state.homeDir); + const composeResult = await composePs(buildComposeOptions(state)); + const services = composeResult.ok ? parseComposePsServices(composeResult.stdout) : []; + const localState = deriveLocalStackState(installState, services); + const launch = deriveLaunchStatus({ + local: { + state: localState, + runtime: installState === 'not_installed' ? await detectRuntime() : undefined, + detail: { installState }, + }, + remotes: await listRemoteStatuses(), + }); + const value = { installState, launch }; + launchRoutingCache = { value, expiresAt: Date.now() + 5_000 }; + return value; +} + +/** + * Resolve the landing path for a request via resolveLanding() (plan §6.5). + * May return a path with a query string (e.g. '/admin?tab=diagnostics'). + */ +export async function resolveRequestLanding(event: RequestEvent): Promise { + const serverCtx = computeServerRuntimeContext(event); + const clientContext: ClientContext = { displayMode: 'browser' }; + const ctx: RuntimeContext = { + ...serverCtx, + clientContext, + effectiveCapabilities: resolveCapabilities(serverCtx.serverCapabilities, clientContext), + }; + + const { installState, launch } = await resolveLaunchRouting(); + // Preserve the pre-Phase-3 nuance: an interrupted install whose containers + // happen to be running still needs the wizard finished — land it on /setup + // (the old router special-cased this combination to the splash's "resume + // setup" card), not on chat against a half-configured stack. + const localState = + installState === 'setup_incomplete' && launch.local.state === 'running' + ? 'setup_incomplete' + : launch.local.state; + // Connections usable right now: accessible remotes, plus the local + // assistant when it is healthy. Only reachable connections count — a dead + // remote must not satisfy the "has somewhere to chat" checks (#440). + const connections: LaunchState['connections'] = [ + ...(launch.hasHealthyLocal ? [{ id: 'default' }] : []), + ...launch.remotes.filter((r) => r.state === 'accessible'), + ]; + const launchState: LaunchState = { + // No blocking migration exists yet — the gate (and /attention) is wired + // ahead of the first one (plan §6.5). + migration: { status: 'none' }, + local: { state: localState }, + connections, + }; + return resolveLanding(ctx, launchState); +} diff --git a/packages/ui/src/lib/server/state.ts b/packages/ui/src/lib/server/state.ts index ffa85b1c4..75354d770 100644 --- a/packages/ui/src/lib/server/state.ts +++ b/packages/ui/src/lib/server/state.ts @@ -12,7 +12,8 @@ export function getState(): ControlPlaneState { // Pure read: resolve paths/config from disk only. Secrets and other OP_HOME // assets are written ONLY by install/update/apply (applyHome) — serving // the UI never mutates the home. A home that needs reconciling is detected - // read-only and routed to /splash, where the user clicks "apply". + // read-only and routed to the resolved landing (resolveLanding(), plan + // §6.5), where the user clicks "apply". if (!_state) _state = createState(); return _state; } diff --git a/packages/ui/src/routes/+page.server.ts b/packages/ui/src/routes/+page.server.ts new file mode 100644 index 000000000..bee1bd473 --- /dev/null +++ b/packages/ui/src/routes/+page.server.ts @@ -0,0 +1,16 @@ +/** + * Root route — always redirects to the resolved landing (plan + * ui-runtime-modes-plan.md §6.5, Phase 3). + * + * Document navigations to `/` are already redirected by the launch-routing + * guard in hooks.server.ts (pre-auth). This server load covers CLIENT-SIDE + * navigations to `/` (SvelteKit data requests), which resolve the same + * landing through the same $lib/server/landing.js helper. + */ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { resolveRequestLanding } from '$lib/server/landing.js'; + +export const load: PageServerLoad = async (event) => { + redirect(302, await resolveRequestLanding(event)); +}; diff --git a/packages/ui/src/routes/+page.svelte b/packages/ui/src/routes/+page.svelte index 37988e195..99ecb1f93 100644 --- a/packages/ui/src/routes/+page.svelte +++ b/packages/ui/src/routes/+page.svelte @@ -1 +1,3 @@ - + diff --git a/packages/ui/src/routes/+page.ts b/packages/ui/src/routes/+page.ts deleted file mode 100644 index 336287dcc..000000000 --- a/packages/ui/src/routes/+page.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageLoad } from './$types'; - -export const load: PageLoad = () => { - redirect(302, '/splash'); -}; diff --git a/packages/ui/src/routes/admin/endpoints/+page.ts b/packages/ui/src/routes/admin/endpoints/+page.ts index b1a8ab12f..ba0dd582e 100644 --- a/packages/ui/src/routes/admin/endpoints/+page.ts +++ b/packages/ui/src/routes/admin/endpoints/+page.ts @@ -4,7 +4,7 @@ * * Connection management moved to the capability-guarded /connections surface. * This alias is kept for the 0.13.0 release only — Phase 4 turns /admin/* - * into 404s. Same convention as the routes/+page.ts → /splash redirect. + * into 404s. Same convention as the routes/connections/new/+page.ts alias. * (The sibling +server.ts JSON API remains for out-of-process callers this * release; SvelteKit content-negotiates HTML navigations to this page.) */ diff --git a/packages/ui/src/routes/attention/+page.svelte b/packages/ui/src/routes/attention/+page.svelte new file mode 100644 index 000000000..da80c69d9 --- /dev/null +++ b/packages/ui/src/routes/attention/+page.svelte @@ -0,0 +1,89 @@ + + +OpenPalm — needs attention + +
+
+
+ + OpenPalm +
+
+ +
+
+

Needs attention

+

Before your assistant can continue

+

+ A blocking task — such as a data migration — has to finish before OpenPalm can start + normally. Follow the steps shown here, then reload. +

+ +
+ + Open dashboard +
+
+
+
+ + diff --git a/packages/ui/src/routes/connections/new/+page.svelte b/packages/ui/src/routes/connections/new/+page.svelte new file mode 100644 index 000000000..397905fcd --- /dev/null +++ b/packages/ui/src/routes/connections/new/+page.svelte @@ -0,0 +1,6 @@ + diff --git a/packages/ui/src/routes/connections/new/+page.ts b/packages/ui/src/routes/connections/new/+page.ts new file mode 100644 index 000000000..13097a3b0 --- /dev/null +++ b/packages/ui/src/routes/connections/new/+page.ts @@ -0,0 +1,13 @@ +/** + * /connections/new — the pwa-static "no connections yet" landing (plan + * ui-runtime-modes-plan.md §6.5, Phase 3). resolveLanding() sends sessions + * without a usable connection here; in the host app the connection manager + * lives at /connections, so this route opens it with the add form expanded. + * Same alias convention as routes/admin/endpoints/+page.ts. + */ +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = () => { + redirect(302, '/connections?new=1'); +}; diff --git a/packages/ui/src/routes/splash/+page.server.ts b/packages/ui/src/routes/splash/+page.server.ts deleted file mode 100644 index 29d4817ac..000000000 --- a/packages/ui/src/routes/splash/+page.server.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { deriveLaunchStatus, deriveLocalStackState, classifyLocalInstall, composePs, buildComposeOptions, detectRuntime } from '@openpalm/lib'; -import { getState } from '$lib/server/state.js'; -import { listRemoteStatuses } from '$lib/server/endpoints.js'; - -function parseComposePsServices(stdout: string) { - return stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .flatMap((line) => { - try { - const parsed = JSON.parse(line) as Record; - return [{ - service: String(parsed.Service ?? parsed.Name ?? ''), - state: String(parsed.State ?? ''), - health: String(parsed.Health ?? ''), - }]; - } catch { - return []; - } - }); -} - -export async function load() { - const state = getState(); - const installState = classifyLocalInstall(state.stackDir, state.homeDir); - const composeResult = await composePs(buildComposeOptions(state)); - const localState = deriveLocalStackState(installState, composeResult.ok ? parseComposePsServices(composeResult.stdout) : []); - return { - launchStatus: deriveLaunchStatus({ - local: { - state: localState, - runtime: installState === 'not_installed' ? await detectRuntime() : undefined, - detail: { installState }, - }, - remotes: await listRemoteStatuses(), - }), - }; -} diff --git a/packages/ui/src/routes/splash/+page.svelte b/packages/ui/src/routes/splash/+page.svelte deleted file mode 100644 index f05d2ba6f..000000000 --- a/packages/ui/src/routes/splash/+page.svelte +++ /dev/null @@ -1,188 +0,0 @@ - - -OpenPalm - -
-
-
- - OpenPalm -
-
- -
-
-

{stackCard.eyebrow}

-

{stackCard.title}

-

{stackCard.body}

- - {#if view.state === 'not_installed' && runtime && !runtime.dockerPresent} -

No container runtime found. Install Docker (or OrbStack / Podman), start it, then reload this page.

- {/if} - {#if view.state === 'installed_broken'} -

Unhealthy services are listed in Diagnostics; the container logs show the underlying error.

- {/if} - -
- {#if view.state === 'not_installed'} - Run setup - {#if accessibleRemote}Use {accessibleRemote.name} - {:else}{/if} - {:else if view.state === 'setup_incomplete'} - Resume setup - {:else if view.state === 'installed_offline'} - Open dashboard - - {:else if view.state === 'installed_broken'} - Open diagnostics - - {:else if view.state === 'running'} - Open chat - Admin - {/if} -
- - {#if remotes.length} -
    - {#each remotes as remote (remote.id)} -
  • - - {remote.name} - {remoteLabel(remote.state)} -
  • - {/each} -
- {/if} -
-
-
- - From 90b7513576589d6c899fda6d71f5998f33db3f3c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 13:31:07 +0000 Subject: [PATCH 16/61] refactor(ui): capability-driven navigation; untangle admin chrome from chat (#555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan ui-runtime-modes-plan.md Phase 3 steps 3-4: - chrome/Navbar.svelte becomes the chat-free shell: brand + the chat/host utility button + theme toggle, with a children snippet for surface controls. Destinations come from runtimeContext.routes and visibility from hasCapability('host:stack:read') — the legacy admin feature flag is gone from every component (hygiene test lib/features-admin-hygiene.vitest.ts); the alias survives only in server code (hooks.server.ts / +layout.server.ts, whose layout-data contract stays pinned) pending Phase 4. lib/features.svelte.ts (client featuresService) is deleted. - New chrome/ChatNavbar.svelte: the conversation-surface composition — shell plus EndpointSwitcher, SessionPicker, VoiceControl, ModeSwitch and the session-aware brand href. /advanced and /connections mount it; /admin mounts the shell directly, so the admin surface no longer drags chat components/stores into the host bundle (chrome-untangle-hygiene.vitest.ts, the #555 prerequisite for the Phase 5 client extraction). - chat/+page.svelte imports probeChatBackend from the $lib/api/chat.js domain client instead of the $lib/api.js barrel, which re-exports every admin domain client (routes/chat/page-imports.vitest.ts). - routes/admin/+page.svelte honors the ?tab=diagnostics landing deep-link (installed_broken → Systems tab); routes/connections/+page.svelte opens the add form when landed via /connections/new (?new=1). Behavior deltas that follow from the untangle: the admin surface no longer renders voice controls or the advanced-mode switch (they are chat chrome), and its back-to-chat button targets the plain chat route rather than a session-restoring path (session paths require chat-store imports). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 --- .../lib/components/chrome/ChatNavbar.svelte | 68 +++++++++++ .../src/lib/components/chrome/Navbar.svelte | 111 ++++++------------ packages/ui/src/lib/features.svelte.ts | 16 --- packages/ui/src/routes/+layout.svelte | 9 +- packages/ui/src/routes/admin/+page.svelte | 7 ++ packages/ui/src/routes/advanced/+page.svelte | 4 +- packages/ui/src/routes/chat/+page.svelte | 5 +- .../ui/src/routes/connections/+page.svelte | 8 +- 8 files changed, 129 insertions(+), 99 deletions(-) create mode 100644 packages/ui/src/lib/components/chrome/ChatNavbar.svelte delete mode 100644 packages/ui/src/lib/features.svelte.ts diff --git a/packages/ui/src/lib/components/chrome/ChatNavbar.svelte b/packages/ui/src/lib/components/chrome/ChatNavbar.svelte new file mode 100644 index 000000000..965a8e78a --- /dev/null +++ b/packages/ui/src/lib/components/chrome/ChatNavbar.svelte @@ -0,0 +1,68 @@ + + + + {#if onConversationSurface} + + + + + + {/if} + + + + + diff --git a/packages/ui/src/lib/components/chrome/Navbar.svelte b/packages/ui/src/lib/components/chrome/Navbar.svelte index d1935846b..ba415afae 100644 --- a/packages/ui/src/lib/components/chrome/Navbar.svelte +++ b/packages/ui/src/lib/components/chrome/Navbar.svelte @@ -1,85 +1,62 @@ @@ -149,26 +126,14 @@ margin-left: auto; min-width: 0; } - /* The assistant + session triggers may shrink (truncate label) but never - disappear — at narrow widths they go icon-only via the rule below. */ + /* The assistant + session triggers (rendered by ChatNavbar's children) may + shrink (truncate label) but never disappear — at narrow widths they go + icon-only via the rule below. */ .navbar-actions :global(.switcher), .navbar-actions :global(.trigger) { min-width: 0; } - /* The assistant + session selectors live in the navbar only below 1024px; - at wider widths the chat side panel hosts them, so hide the triggers. */ - .chat-selectors { - display: inline-flex; - align-items: center; - gap: var(--s-sp-2); - } - @media (min-width: 1024px) { - .chat-selectors { - display: none; - } - } - /* ── Responsive: shed labels, keep every control visible. ── */ @media (max-width: 900px) { /* Assistant + session collapse to icon + status dot + caret (their own diff --git a/packages/ui/src/lib/features.svelte.ts b/packages/ui/src/lib/features.svelte.ts deleted file mode 100644 index 36470b6d5..000000000 --- a/packages/ui/src/lib/features.svelte.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { FeatureFlags } from '$lib/types.js'; - -/** - * Client-side reactive feature flag state. - * Initialized from layout server data (which reads env vars) so flags are - * available on both first render (SSR) and all subsequent navigations. - */ -class FeaturesService { - admin = $state(false); - - init(flags: FeatureFlags): void { - this.admin = flags.admin; - } -} - -export const featuresService = new FeaturesService(); diff --git a/packages/ui/src/routes/+layout.svelte b/packages/ui/src/routes/+layout.svelte index 8be7df263..6ef99154d 100644 --- a/packages/ui/src/routes/+layout.svelte +++ b/packages/ui/src/routes/+layout.svelte @@ -1,10 +1,9 @@ @@ -73,45 +116,61 @@ {#if loading}{/if} Refresh - {#if isDirty} - Unsaved changes - {/if} - {#if error}
{error}
{/if}
-
-

Compose Project Name

-

This writes OP_PROJECT_NAME in {stackEnvPath}. If unset, OpenPalm defaults to openpalm.

- -

Lowercase letters, numbers, dashes, and underscores only. This affects Docker Compose naming and collision detection.

-
- -
-

LAN Exposure

-

This writes OP_ASSISTANT_BIND_ADDRESS in {stackEnvPath}.

- -

Off keeps the host bind on 127.0.0.1. On switches it to 0.0.0.0 so other devices on your LAN can reach the host port.

-
- -
-

Persona

-

Edit the assistant persona markdown mounted into the assistant OpenCode instance.

-
{personaPath}
- -
+ {#if showStack} +
+

Compose Project Name

+

This writes OP_PROJECT_NAME in {stackEnvPath}. If unset, OpenPalm defaults to openpalm.

+ +

Lowercase letters, numbers, dashes, and underscores only. This affects Docker Compose naming and collision detection.

+
+ +
+

LAN Exposure

+

This writes OP_ASSISTANT_BIND_ADDRESS in {stackEnvPath}.

+ +

Off keeps the host bind on 127.0.0.1. On switches it to 0.0.0.0 so other devices on your LAN can reach the host port.

+ {#if canEditStack} +
+ +
+ {/if} +
+ {/if} + + {#if showPersona} +
+

Persona

+

Edit the assistant persona markdown mounted into the assistant OpenCode instance.

+
{personaPath}
+ + {#if canEditPersona} +
+ {#if personaDirty} + Unsaved changes + {/if} + +
+ {/if} +
+ {/if}
@@ -130,6 +189,7 @@ .path-chip { display: inline-flex; align-items: center; margin-bottom: var(--s-sp-3); padding: var(--s-sp-1) var(--s-sp-2); border-radius: 2px; border: var(--s-hair) solid var(--s-line-soft); background: var(--s-paper-deep); color: var(--s-ink-3); font-family: var(--s-font-mono); font-size: var(--s-type-mark-sm); letter-spacing: var(--s-track-label); } .persona-editor { min-height: 26rem; resize: vertical; font-family: var(--s-font-mono) !important; font-size: var(--s-type-mark-sm) !important; background: color-mix(in srgb, var(--s-ink) 2%, var(--s-paper)) !important; border: var(--s-hair) solid var(--s-line-soft) !important; border-bottom: var(--s-hair) solid var(--s-line-soft) !important; border-radius: 2px !important; padding: var(--s-sp-3) !important; color: var(--s-ink-2) !important; } .unsaved-hint { font-family: var(--s-font-mono); font-size: var(--s-type-mark-sm); letter-spacing: var(--s-track-label); text-transform: uppercase; color: var(--s-seal); } + .card-actions { display: flex; align-items: center; justify-content: flex-end; gap: var(--s-sp-3); margin-top: var(--s-sp-3); } input[type="checkbox"] { appearance: none; width: 1rem; height: 1rem; diff --git a/packages/ui/src/lib/components/akm/AkmHealthReportSection.svelte b/packages/ui/src/lib/components/akm/AkmHealthReportSection.svelte index b7bfedfb0..3a8c7cc75 100644 --- a/packages/ui/src/lib/components/akm/AkmHealthReportSection.svelte +++ b/packages/ui/src/lib/components/akm/AkmHealthReportSection.svelte @@ -23,7 +23,7 @@ let frameSrc = $state(null); let loadError = $state(''); - let reportUrl = $derived(`/admin/akm/health-report?since=${encodeURIComponent(reportWindow)}&refresh=${refreshKey}`); + let reportUrl = $derived(`/api/host/akm/health-report?since=${encodeURIComponent(reportWindow)}&refresh=${refreshKey}`); async function loadReport(): Promise { loading = true; diff --git a/packages/ui/src/lib/components/akm/AkmTab.svelte b/packages/ui/src/lib/components/akm/AkmTab.svelte index 49ab31ef8..babd2165c 100644 --- a/packages/ui/src/lib/components/akm/AkmTab.svelte +++ b/packages/ui/src/lib/components/akm/AkmTab.svelte @@ -7,6 +7,7 @@ saveAkmConfig, testAkmEmbedding, } from '$lib/api.js'; + import { hasCapability } from '$lib/runtime-context.svelte.js'; import { notifications } from '$lib/notifications.svelte.js'; import Spinner from '$lib/components/common/Spinner.svelte'; import AkmKnowledgeStatsSection from '$lib/components/akm/AkmKnowledgeStatsSection.svelte'; @@ -41,6 +42,13 @@ // Host AKM sharing now lives in its own Knowledge sub-tab // (akm/HostSharingSection.svelte) — moved out of this megaform. + // Phase 4 AkmTab split: the config megaform is ASSISTANT-scoped + // (/api/assistant/akm), while index maintenance + diagnostics run docker + // commands on the HOST (/api/host/akm/*) — show them only with host + // capabilities. hasCapability() is UX only; the endpoints enforce + // capabilities server-side. + const hostMaintenance = hasCapability('host:containers'); + // ── LLM Profiles ───────────────────────────────────────────────────────────── let llmProfiles = $state([]); let defaultLlmProfile = $state(''); @@ -358,10 +366,12 @@ {#if loading}{/if} Refresh - + {#if hostMaintenance} + + {/if} - + {#if hostMaintenance} + + {/if} {#if knowledgeSection === 'ai-services'} - + {#if hostMaintenance} + + {/if}

The AI services your assistant uses to build and search its memory — the language models that organize memories, the embedding provider for semantic search, and the maintenance pipeline.

@@ -476,7 +490,7 @@ {/if} - {#if knowledgeSection === 'health-report'} + {#if knowledgeSection === 'health-report' && hostMaintenance} {/if} diff --git a/packages/ui/src/lib/components/chat/EndpointList.svelte b/packages/ui/src/lib/components/chat/EndpointList.svelte index f2f1fa5b3..39bef22a2 100644 --- a/packages/ui/src/lib/components/chat/EndpointList.svelte +++ b/packages/ui/src/lib/components/chat/EndpointList.svelte @@ -62,7 +62,7 @@
{#if showManageAssistant} - onChosen?.()}> + onChosen?.()}> Manage this assistant… {/if} diff --git a/packages/ui/src/lib/components/chrome/ModeSwitch.svelte b/packages/ui/src/lib/components/chrome/ModeSwitch.svelte index 3b42258f1..37bcb7219 100644 --- a/packages/ui/src/lib/components/chrome/ModeSwitch.svelte +++ b/packages/ui/src/lib/components/chrome/ModeSwitch.svelte @@ -11,7 +11,7 @@ // (selected) on /advanced. Clicking flips between the two. Rendering through // ToggleButton keeps it aligned with the other chrome toggles. const pathname = $derived(page.url?.pathname ?? ''); - const onAdmin = $derived(pathname === '/admin' || pathname.startsWith('/admin/')); + const onAdmin = $derived(pathname === '/host' || pathname.startsWith('/host/')); function toggle(): void { const enabled = advancedModeService.toggle(); diff --git a/packages/ui/src/lib/components/chrome/SettingsDrawer.svelte b/packages/ui/src/lib/components/chrome/SettingsDrawer.svelte index 6139cef18..6e5211137 100644 --- a/packages/ui/src/lib/components/chrome/SettingsDrawer.svelte +++ b/packages/ui/src/lib/components/chrome/SettingsDrawer.svelte @@ -55,7 +55,7 @@